1
月
在社区成长 1 月
LEVEL 1
1 XP
很多刚接触 Web3 的朋友会遇到这样的疑问:钱包一直在自己手上,助记词也没有给过别人,为什么某些代币仍然可能被其他地址转走?
关键往往不在于有没有主动点击“转账”,而在于过去是否给某个合约留下了代币使用授权。这篇文章以 EVM 兼容链和 ERC-20 代币为范围,带大家理解授权机制,并使用一个不需要私钥的脚本,核对余额、授权额度与 spender 地址的链上代码。
全文只进行公开链上数据的只读查询,不要求连接陌生网站,不要求导入助记词,也不包含任何针对他人的资产操作。示例适用于以太坊及采用相似账户、合约接口的网络,但具体代币实现和浏览器功能可能存在差异。
一、背景:代币余额和原生币余额不是一回事
在以太坊上,ETH 是原生币;常见的 ERC-20 代币则由各自的智能合约维护余额。钱包界面虽然把它们放在同一个资产列表里,底层记账方式并不相同。
用户与兑换、借贷等应用交互时,应用合约通常不能直接扣走用户的 ERC-20 代币,因此会先要求用户向某个地址授予额度,再由对应地址发起后续调用。
这个被允许使用代币的地址通常叫 spender。它可能是路由合约、金库、其他合约,也可能只是一个普通地址。不能因为一个地址被称作 spender,就默认它属于可信协议。
本文的排查对象可以用一个四元组表示:网络、代币合约、资产持有人 owner、被授权方 spender。少了其中任何一项,都容易查错。
二、原理:approve、allowance 与 transferFrom
标准 ERC-20 接口中,与授权最相关的是以下三个方法:
1. approve(spender, amount):持币人设置某个 spender 可以使用的额度。
2. allowance(owner, spender):查询 owner 给予 spender 的当前剩余额度。
3. transferFrom(from, to, amount):调用者尝试从 from 的余额中划转代币,通常需要满足余额和授权等条件。
假设小明持有 100 枚某代币,向合约 A 授权 30 枚。在常见实现下,A 最多可以在这项授权允许的范围内划走代币,不一定需要小明再次弹窗确认。
这里有三个特别容易误解的地方:
第一,断开网站连接,不等于撤销链上授权。断开连接通常只是取消网站对钱包账户信息的访问权限,已经写入代币合约的 allowance 不会因此自动归零。
第二,当前余额为零,不等于授权无风险。如果授权仍然有效,未来转入同一种代币后,新增余额仍可能暴露在这项授权之下。
第三,ERC-20 授权通常不授予 spender 转走原生 ETH 的权限,也不自动覆盖其他代币。若出现多种资产同时异常,需要扩大排查范围,不能只盯着一个 approve。
所谓“无限授权”,常见做法是把额度设为 uint256 的最大值,即 2^256−1。它不是无限资产,而是极大的额度。一些实现不会在使用这种最大额度授权后递减 allowance,因此不能假设使用一次后额度一定会减少。
三、前置条件:先准备信息,不准备私钥
我们只需要:
• 当前仍受支持的 Node.js LTS 环境。
• 对应网络的可信 JSON-RPC 地址。
• 需要检查的 owner 地址。
• 代币合约地址。
• 需要核验的 spender 地址。
• 从可信来源核实的预期 chain ID。
这些地址不是秘密,但地址组合、查询时间和 IP 可能形成隐私线索。公共 RPC 服务商通常能够观察你的查询请求;对隐私要求较高时,可以使用自建节点或经过评估的服务。
spender 可以从历史授权交易、可信协议文档或区块浏览器的授权记录中寻找。不要从搜索广告、私信或陌生人提供的“安全入口”直接复制。
还要明确一个限制:ERC-20 标准没有提供“列出某个 owner 的全部 spender”的通用方法。因此,下面的脚本只能核验已知组合,不能自动证明整个钱包没有其他授权。
四、分步骤实践:不用连接钱包,直接读取链上状态
步骤 1:新建独立目录并安装依赖。
mkdir allowance-check
cd allowance-check
npm init -y
npm install ethers@6
建议使用独立目录,核对 npm 包名,避免安装拼写相似的包。正式项目应保留锁文件、审查依赖版本;不要在同一目录存放助记词或私钥。
步骤 2:创建 check.mjs,写入以下内容。
import { JsonRpcProvider, getAddress, formatUnits, MaxUint256, Interface } from 'ethers';
const required = ['RPC_URL', 'EXPECTED_CHAIN_ID', 'OWNER', 'TOKEN', 'SPENDER'];
for (const name of required) {
if (!process.env[name]) throw new Error(`缺少环境变量:${name}`);
}
const provider = new JsonRpcProvider(process.env.RPC_URL);
const owner = getAddress(process.env.OWNER);
const token = getAddress(process.env.TOKEN);
const spender = getAddress(process.env.SPENDER);
const network = await provider.getNetwork();
if (network.chainId !== BigInt(process.env.EXPECTED_CHAIN_ID)) {
throw new Error('chain ID 与预期不符,请停止并核实 RPC');
}
const block = await provider.getBlockNumber();
const abi = new Interface([
'function balanceOf(address) view returns (uint256)',
'function allowance(address,address) view returns (uint256)',
'function decimals() view returns (uint8)'
]);
async function read(name, args) {
const data = abi.encodeFunctionData(name, args);
const result = await provider.call({ to: token, data, blockTag: block });
return abi.decodeFunctionResult(name, result)[0];
}
const tokenCode = await provider.getCode(token, block);
if (tokenCode === '0x') throw new Error('该区块下代币地址没有代码,请核对网络和地址');
const [balance, allowance, spenderCode] = await Promise.all([
read('balanceOf', [owner]),
read('allowance', [owner, spender]),
provider.getCode(spender, block)
]);
let decimals;
try {
decimals = Number(await read('decimals', []));
} catch {
console.log('decimals() 读取失败,仅展示原始整数');
}
console.log('chainId:', network.chainId.toString());
console.log('block:', block);
console.log('owner:', owner);
console.log('token:', token);
console.log('spender:', spender);
console.log('balanceRaw:', balance.toString());
console.log('allowanceRaw:', allowance.toString());
if (decimals !== undefined) {
console.log('balance:', formatUnits(balance, decimals));
console.log('allowance:', formatUnits(allowance, decimals));
}
console.log('是否为 uint256 最大额度:', allowance === MaxUint256);
console.log('spender 在该区块是否有代码:', spenderCode !== '0x');
步骤 3:设置环境变量并运行。下面是 macOS、Linux 等常见 shell 的写法,所有占位内容都需要替换;Windows PowerShell 的环境变量语法不同。
export RPC_URL='替换为对应网络的可信RPC地址'
export EXPECTED_CHAIN_ID='替换为预期chain ID的十进制数字'
export OWNER='替换为资产持有人地址'
export TOKEN='替换为代币合约地址'
export SPENDER='替换为待核验的被授权地址'
node check.mjs
脚本没有创建签名器,没有发送交易,也没有接触私钥。它调用的是读取接口,不消耗链上 gas;RPC 服务本身仍可能计费或限流。
脚本把读取固定在同一个区块高度,避免余额和额度分别来自不同高度而产生明显的对照偏差。不过,区块高度快照并不等于最终确定性:链重组、节点响应异常仍可能影响结果,重要判断应等待确认并使用独立可信来源复核。
五、结果怎么读:不要把一个数字当成安全结论
假设输出为:
balance: 125.0
allowance: 1000.0
是否为 uint256 最大额度: false
spender 在该区块是否有代码: true
这意味着在所查询区块,授权额度高于当前余额。对普通 ERC-20 而言,如果 spender 能够执行相应调用,当前余额通常都可能处于这项授权的覆盖范围内。
但实际能否划转、能划多少,还取决于代币的暂停、冻结、转账限制,以及 spender 的代码逻辑等条件,不能简单把 allowance 当作必然损失金额。
再看两个边界:
• allowance 为 0:只说明这一个代币、这一个 owner、这一个 spender,在该快照下没有可用额度,不代表整个钱包安全。
• spender 没有代码:不是安全证明。普通地址也可以成为 spender;在某些机制下,没有部署代码的地址也可能与未来的合约部署安排有关。
同样,spender 有代码也不是安全证明。已验证源码通常只说明浏览器展示的源码与相应部署代码经过匹配,不等于完成审计。若它是代理合约,还需要核查实际实现、升级权限、管理员及相关变更记录。
六、发现可疑授权后,怎样处理更稳妥
先确认异常的层级。
如果只是发现不再使用的历史授权,可以从独立核验的入口,通过钱包或可信浏览器向对应代币合约提交 approve(spender, 0),并准备该网络需要的手续费。
交易确认后,再读取一次 allowance,确认结果确实为 0。仅看到钱包弹出“提交成功”不够,交易可能还在等待、失败或被替换。
部分代币要求将非零额度修改为另一个非零额度之前先归零;某些代币还可能具有非标准行为。不要反复盲签,先核对具体实现和交易模拟结果。
如果助记词或私钥已经泄露,撤销授权不能解决根本问题。攻击者仍可能直接签名转账或重新授权。此时应在干净环境下建立使用全新密钥的钱包,并针对剩余资产制定迁移方案;不要把泄露过的助记词再次导入新设备,就误以为获得了新的安全边界。
如果已经出现正在进行的异常转出,不要持续向受影响地址充值手续费试错。自动化攻击者可能持续转走资金,应优先保存交易证据并寻求可信的事件响应支持。
七、风险与注意事项:本文查不到什么
1. 离线签名风险。某些 permit 或类似授权方案允许先签署消息、以后再由其他地址提交。在签名尚未上链生效时,普通 allowance 查询未必能显示这项潜在风险。撤销当前额度也不应被当成所有未使用签名都已失效,必须结合具体协议的 nonce、期限和失效机制判断。
2. 多层授权风险。部分授权管理协议既包含代币对管理合约的授权,也包含管理合约内部的权限。只查一层容易漏项。
3. NFT 权限。ERC-721、ERC-1155 的授权接口与 ERC-20 不同,本脚本不检查单个 NFT 授权或 setApprovalForAll。
4. 智能账户权限。会话密钥、模块、恢复机制等权限不在 ERC-20 allowance 的覆盖范围内。
5. 数据源可信度。chain ID 校验可以减少误连网络的情况,但不能让恶意 RPC 自动变可信。重要结果应交叉核验。
6. 模拟与硬件钱包的边界。交易模拟能够帮助发现问题,但不能保证未来状态与模拟一致;硬件钱包能降低密钥被直接窃取的风险,也不能阻止用户主动确认危险授权。
八、常见问题
问:为什么代币精度要单独查询?
答:链上余额和额度是整数。例如某代币精度为 6,原始值 1000000 通常展示为 1。decimals 主要影响展示,不会改变授权本身的整数值,而且它是可选元数据接口,并非所有代币都保证正常实现。
问:只用区块浏览器看 Approval 事件可以吗?
答:事件适合寻找候选 spender 和还原历史,但不能把某条历史事件的数值直接当作当前剩余额度。当前状态应通过 allowance 复核,日志检索还要考虑网络、时间范围与数据完整性。
问:每次都只授权本次使用金额,是否绝对安全?
答:不是,但通常能缩小额度暴露。它仍无法消除恶意合约、错误网络、错误地址、危险签名或密钥泄露带来的风险。
问:撤销授权需要付费,这是不是骗局?
答:真正的链上撤销通常需要发送交易,因此可能产生网络手续费。但“需要手续费”不能证明网站可信。若页面要求输入助记词、私钥,或要求先向所谓客服地址付款,应立即停止。
九、总结:把安全检查变成可重复的流程
授权排查最重要的不是找一个显示绿色对勾的页面,而是建立可核验的顺序:确认网络与地址,理解 spender 的身份,在明确区块读取余额和额度,再结合合约逻辑判断暴露范围。
我的建议是把本次检查记录成一个小清单:网络、owner、代币、spender、查询区块、余额、额度、授权用途、是否保留、撤销交易哈希。以后每次完成一次不再需要长期权限的链上交互,都按清单复查。
钱包安全不是一次点击就完成的动作。减少不必要的长期授权、认真核对签名内容、隔离高风险交互与主要资产,比任何未经验证的“绝对安全方案”都更值得坚持。本文用于技术学习与防御性自查,不构成投资建议或资产安全保证。
关键往往不在于有没有主动点击“转账”,而在于过去是否给某个合约留下了代币使用授权。这篇文章以 EVM 兼容链和 ERC-20 代币为范围,带大家理解授权机制,并使用一个不需要私钥的脚本,核对余额、授权额度与 spender 地址的链上代码。
全文只进行公开链上数据的只读查询,不要求连接陌生网站,不要求导入助记词,也不包含任何针对他人的资产操作。示例适用于以太坊及采用相似账户、合约接口的网络,但具体代币实现和浏览器功能可能存在差异。
一、背景:代币余额和原生币余额不是一回事
在以太坊上,ETH 是原生币;常见的 ERC-20 代币则由各自的智能合约维护余额。钱包界面虽然把它们放在同一个资产列表里,底层记账方式并不相同。
用户与兑换、借贷等应用交互时,应用合约通常不能直接扣走用户的 ERC-20 代币,因此会先要求用户向某个地址授予额度,再由对应地址发起后续调用。
这个被允许使用代币的地址通常叫 spender。它可能是路由合约、金库、其他合约,也可能只是一个普通地址。不能因为一个地址被称作 spender,就默认它属于可信协议。
本文的排查对象可以用一个四元组表示:网络、代币合约、资产持有人 owner、被授权方 spender。少了其中任何一项,都容易查错。
二、原理:approve、allowance 与 transferFrom
标准 ERC-20 接口中,与授权最相关的是以下三个方法:
1. approve(spender, amount):持币人设置某个 spender 可以使用的额度。
2. allowance(owner, spender):查询 owner 给予 spender 的当前剩余额度。
3. transferFrom(from, to, amount):调用者尝试从 from 的余额中划转代币,通常需要满足余额和授权等条件。
假设小明持有 100 枚某代币,向合约 A 授权 30 枚。在常见实现下,A 最多可以在这项授权允许的范围内划走代币,不一定需要小明再次弹窗确认。
这里有三个特别容易误解的地方:
第一,断开网站连接,不等于撤销链上授权。断开连接通常只是取消网站对钱包账户信息的访问权限,已经写入代币合约的 allowance 不会因此自动归零。
第二,当前余额为零,不等于授权无风险。如果授权仍然有效,未来转入同一种代币后,新增余额仍可能暴露在这项授权之下。
第三,ERC-20 授权通常不授予 spender 转走原生 ETH 的权限,也不自动覆盖其他代币。若出现多种资产同时异常,需要扩大排查范围,不能只盯着一个 approve。
所谓“无限授权”,常见做法是把额度设为 uint256 的最大值,即 2^256−1。它不是无限资产,而是极大的额度。一些实现不会在使用这种最大额度授权后递减 allowance,因此不能假设使用一次后额度一定会减少。
三、前置条件:先准备信息,不准备私钥
我们只需要:
• 当前仍受支持的 Node.js LTS 环境。
• 对应网络的可信 JSON-RPC 地址。
• 需要检查的 owner 地址。
• 代币合约地址。
• 需要核验的 spender 地址。
• 从可信来源核实的预期 chain ID。
这些地址不是秘密,但地址组合、查询时间和 IP 可能形成隐私线索。公共 RPC 服务商通常能够观察你的查询请求;对隐私要求较高时,可以使用自建节点或经过评估的服务。
spender 可以从历史授权交易、可信协议文档或区块浏览器的授权记录中寻找。不要从搜索广告、私信或陌生人提供的“安全入口”直接复制。
还要明确一个限制:ERC-20 标准没有提供“列出某个 owner 的全部 spender”的通用方法。因此,下面的脚本只能核验已知组合,不能自动证明整个钱包没有其他授权。
四、分步骤实践:不用连接钱包,直接读取链上状态
步骤 1:新建独立目录并安装依赖。
mkdir allowance-check
cd allowance-check
npm init -y
npm install ethers@6
建议使用独立目录,核对 npm 包名,避免安装拼写相似的包。正式项目应保留锁文件、审查依赖版本;不要在同一目录存放助记词或私钥。
步骤 2:创建 check.mjs,写入以下内容。
import { JsonRpcProvider, getAddress, formatUnits, MaxUint256, Interface } from 'ethers';
const required = ['RPC_URL', 'EXPECTED_CHAIN_ID', 'OWNER', 'TOKEN', 'SPENDER'];
for (const name of required) {
if (!process.env[name]) throw new Error(`缺少环境变量:${name}`);
}
const provider = new JsonRpcProvider(process.env.RPC_URL);
const owner = getAddress(process.env.OWNER);
const token = getAddress(process.env.TOKEN);
const spender = getAddress(process.env.SPENDER);
const network = await provider.getNetwork();
if (network.chainId !== BigInt(process.env.EXPECTED_CHAIN_ID)) {
throw new Error('chain ID 与预期不符,请停止并核实 RPC');
}
const block = await provider.getBlockNumber();
const abi = new Interface([
'function balanceOf(address) view returns (uint256)',
'function allowance(address,address) view returns (uint256)',
'function decimals() view returns (uint8)'
]);
async function read(name, args) {
const data = abi.encodeFunctionData(name, args);
const result = await provider.call({ to: token, data, blockTag: block });
return abi.decodeFunctionResult(name, result)[0];
}
const tokenCode = await provider.getCode(token, block);
if (tokenCode === '0x') throw new Error('该区块下代币地址没有代码,请核对网络和地址');
const [balance, allowance, spenderCode] = await Promise.all([
read('balanceOf', [owner]),
read('allowance', [owner, spender]),
provider.getCode(spender, block)
]);
let decimals;
try {
decimals = Number(await read('decimals', []));
} catch {
console.log('decimals() 读取失败,仅展示原始整数');
}
console.log('chainId:', network.chainId.toString());
console.log('block:', block);
console.log('owner:', owner);
console.log('token:', token);
console.log('spender:', spender);
console.log('balanceRaw:', balance.toString());
console.log('allowanceRaw:', allowance.toString());
if (decimals !== undefined) {
console.log('balance:', formatUnits(balance, decimals));
console.log('allowance:', formatUnits(allowance, decimals));
}
console.log('是否为 uint256 最大额度:', allowance === MaxUint256);
console.log('spender 在该区块是否有代码:', spenderCode !== '0x');
步骤 3:设置环境变量并运行。下面是 macOS、Linux 等常见 shell 的写法,所有占位内容都需要替换;Windows PowerShell 的环境变量语法不同。
export RPC_URL='替换为对应网络的可信RPC地址'
export EXPECTED_CHAIN_ID='替换为预期chain ID的十进制数字'
export OWNER='替换为资产持有人地址'
export TOKEN='替换为代币合约地址'
export SPENDER='替换为待核验的被授权地址'
node check.mjs
脚本没有创建签名器,没有发送交易,也没有接触私钥。它调用的是读取接口,不消耗链上 gas;RPC 服务本身仍可能计费或限流。
脚本把读取固定在同一个区块高度,避免余额和额度分别来自不同高度而产生明显的对照偏差。不过,区块高度快照并不等于最终确定性:链重组、节点响应异常仍可能影响结果,重要判断应等待确认并使用独立可信来源复核。
五、结果怎么读:不要把一个数字当成安全结论
假设输出为:
balance: 125.0
allowance: 1000.0
是否为 uint256 最大额度: false
spender 在该区块是否有代码: true
这意味着在所查询区块,授权额度高于当前余额。对普通 ERC-20 而言,如果 spender 能够执行相应调用,当前余额通常都可能处于这项授权的覆盖范围内。
但实际能否划转、能划多少,还取决于代币的暂停、冻结、转账限制,以及 spender 的代码逻辑等条件,不能简单把 allowance 当作必然损失金额。
再看两个边界:
• allowance 为 0:只说明这一个代币、这一个 owner、这一个 spender,在该快照下没有可用额度,不代表整个钱包安全。
• spender 没有代码:不是安全证明。普通地址也可以成为 spender;在某些机制下,没有部署代码的地址也可能与未来的合约部署安排有关。
同样,spender 有代码也不是安全证明。已验证源码通常只说明浏览器展示的源码与相应部署代码经过匹配,不等于完成审计。若它是代理合约,还需要核查实际实现、升级权限、管理员及相关变更记录。
六、发现可疑授权后,怎样处理更稳妥
先确认异常的层级。
如果只是发现不再使用的历史授权,可以从独立核验的入口,通过钱包或可信浏览器向对应代币合约提交 approve(spender, 0),并准备该网络需要的手续费。
交易确认后,再读取一次 allowance,确认结果确实为 0。仅看到钱包弹出“提交成功”不够,交易可能还在等待、失败或被替换。
部分代币要求将非零额度修改为另一个非零额度之前先归零;某些代币还可能具有非标准行为。不要反复盲签,先核对具体实现和交易模拟结果。
如果助记词或私钥已经泄露,撤销授权不能解决根本问题。攻击者仍可能直接签名转账或重新授权。此时应在干净环境下建立使用全新密钥的钱包,并针对剩余资产制定迁移方案;不要把泄露过的助记词再次导入新设备,就误以为获得了新的安全边界。
如果已经出现正在进行的异常转出,不要持续向受影响地址充值手续费试错。自动化攻击者可能持续转走资金,应优先保存交易证据并寻求可信的事件响应支持。
七、风险与注意事项:本文查不到什么
1. 离线签名风险。某些 permit 或类似授权方案允许先签署消息、以后再由其他地址提交。在签名尚未上链生效时,普通 allowance 查询未必能显示这项潜在风险。撤销当前额度也不应被当成所有未使用签名都已失效,必须结合具体协议的 nonce、期限和失效机制判断。
2. 多层授权风险。部分授权管理协议既包含代币对管理合约的授权,也包含管理合约内部的权限。只查一层容易漏项。
3. NFT 权限。ERC-721、ERC-1155 的授权接口与 ERC-20 不同,本脚本不检查单个 NFT 授权或 setApprovalForAll。
4. 智能账户权限。会话密钥、模块、恢复机制等权限不在 ERC-20 allowance 的覆盖范围内。
5. 数据源可信度。chain ID 校验可以减少误连网络的情况,但不能让恶意 RPC 自动变可信。重要结果应交叉核验。
6. 模拟与硬件钱包的边界。交易模拟能够帮助发现问题,但不能保证未来状态与模拟一致;硬件钱包能降低密钥被直接窃取的风险,也不能阻止用户主动确认危险授权。
八、常见问题
问:为什么代币精度要单独查询?
答:链上余额和额度是整数。例如某代币精度为 6,原始值 1000000 通常展示为 1。decimals 主要影响展示,不会改变授权本身的整数值,而且它是可选元数据接口,并非所有代币都保证正常实现。
问:只用区块浏览器看 Approval 事件可以吗?
答:事件适合寻找候选 spender 和还原历史,但不能把某条历史事件的数值直接当作当前剩余额度。当前状态应通过 allowance 复核,日志检索还要考虑网络、时间范围与数据完整性。
问:每次都只授权本次使用金额,是否绝对安全?
答:不是,但通常能缩小额度暴露。它仍无法消除恶意合约、错误网络、错误地址、危险签名或密钥泄露带来的风险。
问:撤销授权需要付费,这是不是骗局?
答:真正的链上撤销通常需要发送交易,因此可能产生网络手续费。但“需要手续费”不能证明网站可信。若页面要求输入助记词、私钥,或要求先向所谓客服地址付款,应立即停止。
九、总结:把安全检查变成可重复的流程
授权排查最重要的不是找一个显示绿色对勾的页面,而是建立可核验的顺序:确认网络与地址,理解 spender 的身份,在明确区块读取余额和额度,再结合合约逻辑判断暴露范围。
我的建议是把本次检查记录成一个小清单:网络、owner、代币、spender、查询区块、余额、额度、授权用途、是否保留、撤销交易哈希。以后每次完成一次不再需要长期权限的链上交互,都按清单复查。
钱包安全不是一次点击就完成的动作。减少不必要的长期授权、认真核对签名内容、隔离高风险交互与主要资产,比任何未经验证的“绝对安全方案”都更值得坚持。本文用于技术学习与防御性自查,不构成投资建议或资产安全保证。