ERC-20 支付即使在发送合约拥有足够 token 时也可能失败;也可能看起来成功,却没有真正移动 token;第三种变体则更隐蔽——token 成功移动了,但协议记录了错误的金额。
这三种情况并非同一个 bug 的三个名称,而是三项独立的证明义务:
- 权限:合约选择的转移操作是否与谁拥有 token、谁在花费 token 相匹配?
- 调用成功:token 调用是否按照 token 的实际返回行为完成?
- 记账:余额变化是否与协议记入、铸造、结算或释放的金额匹配?
区分这三层至关重要,因为针对一层的修复并不能证明另外两层。将 transferFrom 替换为 transfer 可以纠正权限不匹配,但未检查的返回值问题依然存在;添加 SafeERC20 能规范化返回处理,却保留了错误的 owner-spender 模型;即使两者都检查了,如果协议信任请求的金额而非实际收到的余额增量,fee-on-transfer 存款仍可能被超额记入。
本文围绕这三个边界构建一个范围较窄的比较模型。锚点是 Bastion 案例研究 中描述的 Bastion Wallet 订阅公开发现,随后将 ERC-20 规范、OpenZeppelin 实现指南以及 PoolTogether、ENS、Tigris 的公开审计记录纳入对比。[1][2][4][5][6]
方法与主张边界
如果某条公开记录描述了以下三个边界之一处的不匹配,该记录就会进入比较:
- token 托管与所选转移原语之间的不匹配;
- EVM 调用完成与 ERC-20 成功语义之间的不匹配;
- 请求的转移金额与协议产生的记账之间的不匹配。
这是一份以机制为导向的目的性样本,并非流行率研究。这些记录使用不同的评审系统、token 假设和协议上下文,样本不能推导出频率、行业损失或相对严重性主张。
全文使用四个标签:
- 来源事实:链接来源中陈述的行为、标签、状态或建议。
- Zealynx 分类:我们根据哪项证明义务失败对记录进行分组。
- 推论:跨记录推导出的可复用工程结论。
- 局限:公开材料未确立的内容(生产环境暴露、修复部署、利用或资金影响等)。
模型:证明权限、成功和记账
只有当以下三个陈述全部成立时,协议级 token 移动才是正确的:
正确的转移
= 已授权的移动
AND 已确认的 token 调用成功
AND 协议记账与实际移动相匹配
这些层是顺序的,但不可互换。
证明 1:权限与托管匹配
ERC-20 将 transfer(to, value) 定义为从消息调用者的余额移动 token;将 transferFrom(from, to, value) 定义为委托提款工作流,并明确要求 from 账户应已刻意授权调用者。[1]
由此得到一条简单的决策规则:
如果 address(this) 拥有这些 token:
token.transfer(recipient, amount)
如果另一个地址拥有这些 token,且该合约是已批准的 spender:
token.transferFrom(owner, recipient, amount)
OpenZeppelin 的实现明确区分了这一点:transfer 从调用者移动价值,transferFrom 在移动 token 之前检查并花费调用者对 from 的 allowance。[2]
常见的抽象错误是将这两个函数解读为“发送 ERC-20”的同义词。它们不是——函数选择的是授权模型。
证明 2:调用完成意味着 token 成功
ERC-20 规范明确警告调用者处理 false,并要求不要假设它永远不会被返回。[1] 也就是说,低级调用完全可以在没有 revert 的情况下完成,而实际 ERC-20 操作却报告了失败。
OpenZeppelin 的 SafeERC20 工具处理两种兼容性情形:返回 false 的操作,以及不返回值但不 revert 的 token。其包装器在操作返回除 true 以外的值时 revert,同时将没有返回数据的非 revert 调用视为成功。[2]
这是调用语义证明。它并不决定 transfer 还是 transferFrom 是正确的权限模型。
证明 3:记账跟随观察到的移动
即使一次调用已正确授权并报告成功,也仍然不能证明精确的余额增量。Token 实现可能施加转移费用或其他余额行为。如果协议根据输入参数发行份额、信用、债务偿还或收据,那么它隐含了一个明确假设——收到的金额等于该参数。
更强的不变量是:
记入金额 <= 接收者之后余额 - 接收者之前余额
是否需要严格相等取决于产品设计。一个 vault 可能拒绝具有非精确转移语义的 token,另一个协议则可能接受它们并只记入实际到达的金额。危险状态是意外支持——接口看起来通用,记账却默默假设精确移动。
Bastion:余额充足,权限模型错误
Bastion Wallet 审查覆盖了一个面向 ERC-4337 账户抽象系统的订阅合约。公开报告记录,SubExecutor 持有订阅者的 ERC-20 资金,并在结算前检查自己的余额。[3]
被审查的路径随后调用:
token.transferFrom(address(this), sub.initiator, sub.amount);
源地址和消息调用者都是 SubExecutor。然而 transferFrom 却在这两个角色之间引入了 allowance 证明。报告的 fuzz 测试触发了 ERC20: insufficient allowance,尽管合约本身持有足够的 token。[3]
Zealynx 将此分类为权限-托管不匹配:
实际所有者: SubExecutor
实际调用者: SubExecutor
所选模型: 委托 spender
缺失证明: 从 SubExecutor 到 SubExecutor 的 allowance
报告建议改用 transfer(sub.initiator, sub.amount),并记录了团队回应为已确认并修复。[3] 案例研究 追踪了完整的托管和执行路径。
来源并未确立受影响代码已部署、用户实际遇到过该失败,或修复已进入生产环境。当前可见的公开仓库也没有暴露被审查的订阅代码以供独立验证。[3]
PoolTogether:调用返回了,转移没有成功
一个针对 PoolTogether 的公开 Code4rena 发现项描述了在两个 yield-source 合约中未检查 transfer 和 transferFrom 的返回值。该发现以 BADGER 为例,说明 token 在转移失败时可能返回 false 而非 revert。[4]
附加在该 issue 上的讨论对影响和可能性提出了争议,一位 judge 在考虑这些限制后将其标记为 Medium Risk。[4] 这种评审上下文不应被简化为普遍严重性主张。
Zealynx 将该机制分类为调用成功不匹配。所选的转移原语本身可能是合适的,但调用合约将"非 revert"误当作了转移证明:
// 不安全的结论:外部调用没有 revert,因此 token 移动了。
token.transfer(recipient, amount);
_accountAsPaid(amount);
ERC-20 并不支持这个结论。标准明确要求处理 false。[1]
这一记录与 Bastion 有一个重要区别:Bastion 选择了错误的授权原语并在测试 token 下 revert;PoolTogether 的问题则涉及一次可能以失败值正常返回的 token 调用。前者在结算前因不必要的许可要求而失败,后者则可能让本地执行在结算失败后继续推进。
ENS:返回 false 的转移与合成信用发生偏离
一个公开的 Code4rena ENS 发现项报告,未检查的 transferFrom 结果可能让 ERC-1155 表示更新,即使底层 ERC-20 转移返回了 false。概念验证使用了一个 mock token,其第一次 transferFrom 返回 false,随后展示了合成余额在没有相应 ERC-20 移动的情况下更新。[5]
该 issue 在公开记录中带有 Medium Risk 标签、judge 降级标签以及与重复项相关的元数据。[5] 这些标签是来源事实,并非 Zealynx 的重新评估。
Zealynx 将此分类为调用成功不匹配并演变为记账偏离:
底层移动报告失败
+ 调用者忽略了布尔结果
+ 表示记账继续
= 债权可能与支撑资产发生偏离
这一点很有价值,因为它连接了第二和第三个证明。直接缺陷是未检查的成功语义,但协议后果之所以产生,只是因为后续记账效果假定了转移已经发生。
公开发现项描述了概念验证和潜在路径,本文不主张存在已实现的利用、生产损失或未解决的部署。
Tigris:一个接口,两种兼容性失败
一个公开的 Tigris 竞赛提交描述了稳定币 vault 中裸用的 IERC20.transfer 和 transferFrom 调用。提交者识别了两种行为:token 可能返回 false 而调用者忽略它,或者省略预期的返回值,导致 ABI 解码的接口调用 revert。[6]
该 issue 的历史同样重要——judge 最终将该提交标记为超出范围。[6] 因此,它不是已接受的范围内漏洞的证据,但仍然是 SafeERC20 旨在规范化的两种兼容性假设的公开技术示例。
Zealynx 将其分类为支持策略歧义,而不是协议损失的证据。如果协议声称支持任意 ERC-20 资产,裸接口调用可能让这种支持依赖于返回数据约定;如果协议仅支持严格控制的 token 列表,那么相关任务就是记录并测试这些特定于 token 的假设。
可复用的教训不是“每个 token 都必须被支持”,而是:
通用接口主张需要通用行为测试。受限支持需要明确的 allowlist 和明确的假设。
比较矩阵
| 记录 |
来源报告的行为 |
Zealynx 分类 |
该记录不能证明什么 |
| Bastion H-02 |
持有合约对自己的余额使用 transferFrom;报告测试因 allowance 不足而 revert;记录为已修复 [3] |
权限-托管不匹配 |
部署、用户影响或经独立验证的生产修复 |
| PoolTogether #112 |
transfer 和 transferFrom 的返回值未检查;评审讨论涉及严重性 [4] |
调用成功不匹配 |
对每个受支持 token 的普遍结果 |
| ENS #313 |
返回 false 的 transferFrom 之后可能跟随 ERC-1155 记账更新 [5] |
成功不匹配导致记账偏离 |
已实现的利用或生产损失 |
| Tigris #6 |
提交描述了返回 false 和无返回 token 的兼容性;被判定为超出范围 [6] |
支持策略歧义 |
被接受的竞赛发现项或已确认的协议影响 |
| ERC-20 规范 |
调用者必须处理 false;transferFrom 是委托提款工作流 [1] |
规范性基线 |
每个已部署 token 的精确行为 |
| OpenZeppelin ERC20 和 SafeERC20 |
transferFrom 花费 allowance;SafeERC20 规范化可选返回 [2] |
参考实现模式 |
调用后的正确协议记账 |
这些记录不具有相同的严重性或证据权重。该表比较的是机制,而不是结果。
为什么 SafeERC20 是必要的但不充分
使用 safeTransfer 和 safeTransferFrom 是一个强默认选择,因为它消除了一类返回值错误,但并不能解决完整的转移问题。
它不能选择 owner-spender 模型
如果合约自己拥有这些 token,以下调用依然是错误的:
asset.safeTransferFrom(address(this), recipient, amount);
包装器可以忠实执行错误的原语。如果 token 强制要求持有合约对自身的 allowance,调用就会像 Bastion 被审查的路径一样 revert。
它不能定义记入金额
对于接收者收到的金额少于 amount 的 token,以下代码依然可能是错误的:
asset.safeTransferFrom(msg.sender, address(this), amount);
shares[msg.sender] += amount;
SafeERC20 证明 token 调用符合其接受的返回约定,但并不能证明 address(this) 收到了恰好 amount。
它不能定义 token 支持
协议仍然需要针对 rebasing token、fee-on-transfer token、带 hook 的 token、暂停的 token、blocklist、可升级 token 逻辑以及其他行为制定策略。没有任何包装器能够推断出周围系统的经济假设。
用于设计评审的转移证明表
在审查代码之前,先将预期移动写成一张表:
| 问题 |
存款或拉取 |
提款或支付 |
| 调用前谁拥有 token? |
通常是用户 |
通常是协议合约 |
| 谁调用 token? |
协议合约 |
协议合约 |
| 哪个原语匹配? |
safeTransferFrom(user, protocol, amount) |
safeTransfer(recipient, amount) |
| 什么证明调用成功? |
SafeERC20 或显式检查的返回值 |
SafeERC20 或显式检查的返回值 |
| 记入什么金额? |
观察到的收款或明确支持的精确金额 |
在需要时观察到的发送者和接收者增量 |
| 允许什么 token 行为? |
记录的 allowlist 和假设 |
相同策略,包括 hook 和费用 |
“通常”是刻意保留的——Router、智能账户、vault、回调和 permit 系统都可能改变调用者和所有者。这张表的意义在于强制命名这些角色,而不是从名为 deposit 或 pay 的函数名中盲目推断。
独立测试每个证明
使用传统 mock token 的单个 happy-path 测试是弱证据。更好的做法是构建隔离每个假设的对抗性 token 或 mock。
权限测试
- 直接为协议合约提供资金。
- 将 self-allowance 保持为零。
- 执行支付。
- 断言协议余额下降且接收者余额上升。
这能捕获 Bastion 被审查的模式,因为直接所有者支付不应需要自我批准。
返回 false 测试
使用一个返回 false 但不 revert 的 token,断言完整协议操作 revert,且没有份额、债权、收据或支付标记发生变化:
function transferFrom(address, address, uint256)
external
returns (bool)
{
return false;
}
无返回测试
使用一个 transfer 函数不返回数据的 token。先决定它是否受支持:如果支持,验证包装器接受非 revert 调用且发生预期的余额移动;如果不支持,验证准入控制在记账开始前将其拒绝。
余额增量测试
使用一个从发送者扣除 amount 但向接收者记入更少的 token,断言以下任一情况:
- 协议拒绝该 token 或转移;或
- 协议信用等于观察到的收款。
在这里,balance delta 成为安全断言,而不仅仅是记账便利。
原子性测试
对于每种失败模式,验证所有相邻状态都回滚。失败的 token 移动不得留下支付时间戳、已铸造份额、委托余额、债务减少或已完成订单标志。
属性测试 可以表达核心规则:
如果协议信用增加了 C,
那么收到的已认证 token 资产必须至少增加 C,
除非有记录的转换规则解释了差异。
fuzzing 术语表条目 提供了测试上下文。该属性应变化调用者、余额、allowance、返回行为、转移费用和顺序。
审查清单
- 命名所有者、调用者、spender 和接收者——在确切的 token 调用边界处执行此操作。
- 根据这些角色选择原语:对调用者的 token 使用直接转移,对另一个所有者的 token 使用委托转移。
- 使用返回安全的包装器。不要仅将非 revert 视为 ERC-20 成功。
- 定义受支持的 token 行为。通用 ERC-20 支持是一个测试矩阵,而不是导入一个接口。
- 当无法保证精确转移时,根据观察到的移动进行记账。测量之前和之后的余额。
- 保持 token 移动和协议记账原子化。不要在未验证的调用之后更新债权。
- 使用零 self-allowance 测试自托管支付。这隔离了权限-托管混淆。
- 测试
false、无返回数据和 revert——它们是单独的调用结果。
- 测试带费用的移动。确认协议是拒绝它还是正确记账。
- 保留修复测试。一行原语变更值得一个永久回归测试。
局限
该比较有意保持狭窄。它不深入覆盖 allowance 抢跑、permit 签名验证、ERC-777 hook、回调重入、token blocklist、rebase 供应或升级风险。
公开审计记录在权威性上也各不相同。Bastion 的规范报告记录了被审查的发现项和团队回应。[3] Code4rena issue 保留了提交和评审元数据,包括争议、降级、重复项和超出范围决定。[4][5][6] 将它们纳入此处的目的在于确立一个有记录的机制,而非同等的验证级别或严重性。
本语料库中没有任何来源确立流行率。本文不声称每个裸 ERC-20 调用都可被利用、每个非标准 token 都应受支持,或每个记账不匹配都造成了资金损失。
结论:token 调用不是结算证明
Bastion 的持久教训不仅仅是“使用 transfer 而不是 transferFrom”,而是 token 结算存在多个独立边界。
Bastion 展示了托管-权限不匹配:合约拥有 token,却选择了委托转移。[3] PoolTogether 的公开发现项说明当 token 可以返回 false 时,非 revert 远远不够。[4] ENS 将忽略的失败结果与可能偏离底层移动的合成记账连接起来。[5] Tigris 则说明为什么声称的 token 兼容性需要刻意的返回数据策略,即使该提交被判定为超出范围。[6]
简洁规则是:
证明谁可以移动 token,证明 token 报告了成功,然后证明协议记账与实际移动的内容匹配。
任何少于这些的,都只是一次 token 调用——它还不是结算的证据。
来源
[1] https://learnblockchain.cn/docs/eips/EIPS/eip-20 - ERC-20 Token 标准
[2] https://docs.openzeppelin.com/contracts/5.x/api/token/ERC20 - OpenZeppelin Contracts 5.x ERC20 和 SafeERC20 文档
[3] https://raw.githubusercontent.com/ZealynxSecurity/audits/main/web3/2024-03-zealynx-shieldify-bastion-wallet.pdf - Bastion Wallet 安全审查
[4] https://github.com/code-423n4/2021-06-pooltogether-findings/issues/112 - PoolTogether 发现项 #112
[5] https://github.com/code-423n4/2023-10-ens-findings/issues/313 - ENS 发现项 #313
[6] https://github.com/code-423n4/2022-12-tigris-findings/issues/6 - Tigris 提交 #6
常见问题
合约何时应使用 transfer 而不是 transferFrom?
当调用合约拥有被发送的 token 时,使用 transfer;当调用合约正在花费另一个地址拥有的 token、且基于 allowance 时,使用 transferFrom。在执行时确认角色,而不是根据操作的业务标签来选择。
SafeERC20 能修复每个 ERC-20 转移问题吗?
不能。SafeERC20 规范化返回值行为,包括不返回值的 token。它不选择正确的 owner-spender 模型,不定义受支持的 token 经济,也不证明收到的金额等于请求的金额。
为什么在成功转移后还要检查余额增量?
成功调用证明的是可接受的执行语义,不一定是精确移动。余额增量可以显示接收者实际收到了什么,当协议根据该收款创建份额或信用时,这一点至关重要。
合约转移自己的 token 前需要自我批准吗?
对于标准直接 transfer,不需要。只有当合约不必要地为自己已经拥有的 token 选择委托 transferFrom 路径时,才会出现自我批准。
最小有用的 ERC-20 集成测试矩阵是什么?
测试一个标准成功 token、一个返回 false 的 token、一个无返回 token(如果受支持)、一个 revert 的 token,以及一个带费用的 token。对于每种情况,断言 token balance deltas 和所有协议侧记账。
术语表
原文链接:zealynx.io/blogs/erc20-t...
本文由云栈社区编译整理,仅供技术学习与交流。