ERC 与 EIP 标准深入:ERC-20/721/1155/4626/4337 的实现陷阱与组合风险

深入 ERC-20、ERC-721、ERC-1155、ERC-4626 与 ERC-4337 的接口语义与实现陷阱,覆盖重入回调、舍入攻击、授权风险与 gas 成本对比。

标准是链上世界的接口宪法。同一个 transfer 签名背后可能藏着返回值缺失、转账扣税、余额弹性变化三种截然不同的语义,而 ERC-4626 的舍入方向、ERC-1155 的接收钩子、ERC-4337 的验证阶段存储限制,又分别在组合层引入了新的攻击面。本文从接口语义出发,逐条拆解主流代币与金库标准在真实代码里的实现陷阱。

多数漏洞不是「写错了函数」,而是「误判了标准承诺的边界」:集成方默认 transfer 返回 bool,默认 balanceOf 单调不减,默认 approve 不会被抢跑,默认回调是安全的——每一次默认,都是一次被利用的机会。

前置:账户模型与交易执行基础、Solidity 合约开发与部署流程。


目录


1. ERC-20 的实现陷阱

1.1 返回值与 approve 竞态

EIP-20 明确规定 transfer / transferFrom / approve 必须返回 bool,但 USDT 等早期合约并未返回值。若集成方直接写 require(token.transfer(to, amt)),在无返回值的合约上会因解码空数据而回滚。安全做法是用低层调用并兼容空返回:

function safeTransfer(address token, address to, uint256 amount) internal {
    (bool ok, bytes memory data) = token.call(
        abi.encodeWithSelector(0xa9059cbb, to, amount)
    );
    require(ok && (data.length == 0 || abi.decode(data, (bool))), "TRANSFER_FAILED");
}

approve 的经典问题是「先改额度再消费」的竞态:用户把额度从 100 改为 50,攻击者在两笔交易之间用掉 100。EIP-20 建议客户端先 approve(spender, 0) 再设新值,OpenZeppelin 则提供 increaseAllowance / decreaseAllowance 规避。另一条路线是 EIP-2612 的 permit,用链下签名一次性授权,省掉一次 approve 交易。

1.2 fee-on-transfer 与 rebasing 陷阱

「余额可变化」的代币是集成方最容易踩的坑。fee-on-transfer 代币(如部分反射型代币)实际到账少于传入金额,若合约记账用 amount 而非前后余额差,就会产生坏账:

uint256 before = token.balanceOf(address(this));
token.safeTransferFrom(msg.sender, address(this), amount);
uint256 received = token.balanceOf(address(this)) - before;
// 必须用 received 记账,而不是 amount

rebasing 代币(如 stETH 早期形态)则让 balanceOf 随质押收益自动变动,任何把「余额 == 记账」当作不变量的合约都会在 rebase 后失衡。集成前必须确认代币是否属于 fee-on-transfer、rebasing、或可暂停/可黑名单的「非标准三件套」。


2. ERC-721 与元数据安全

2.1 safeTransferFrom 的接收回调

safeTransferFrom 与 transferFrom 的唯一区别,是前者会对合约接收方调用 onERC721Received,并校验返回值等于 0x150b7a02:

function _checkOnERC721Received(address from, address to, uint256 tokenId, bytes memory data) private {
    if (to.code.length > 0) {
        try IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, data)
            returns (bytes4 retval) {
            require(retval == IERC721Receiver.onERC721Received.selector, "UNSAFE_RECIPIENT");
        } catch {
            revert("UNSAFE_RECIPIENT");
        }
    }
}

这个回调是外部调用,意味着状态更新必须在回调之前完成(checks-effects-interactions)。若在回调之后才写 ownerOf[tokenId] = to,接收方就能在回调里再次调用 safeTransferFrom 实现重入,把同一个 NFT 转移两次。OpenZeppelin 的 _safeTransfer 正是先 _transfer 更新所有权、再执行回调。

2.2 tokenURI 与元数据可变性

元数据的三种存储路线各有权衡:ipfs:// 内容寻址但依赖网关、ar:// 永久存储但写入昂贵、data:application/json;base64,... 全链上但 gas 极高。ERC-721 允许 tokenURI 在 baseURI 未设置时返回空字符串,但市场方通常要求可解析。

可变元数据(reveal 机制)会带来稀有度泄露与 front-running:若 tokenURI 在揭示前就暴露特征,用户可在快照前挑选稀有 token。稳妥做法是揭示前统一返回占位 URI,并冻结 baseURI 后放弃修改权限。


3. ERC-1155 多代币与批量操作

3.1 批量转账与 gas 节省

ERC-1155 用 id 区分同质与非同质资产,safeBatchTransferFrom 一次调用可转移多个 id:

function safeBatchTransferFrom(
    address from, address to,
    uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data
) public {
    require(from == msg.sender || isApprovedForAll(from, msg.sender), "NOT_AUTHORIZED");
    require(ids.length == amounts.length, "LENGTH_MISMATCH");
    _beforeTokenTransfer(msg.sender, from, to, ids, amounts, data);
    for (uint256 i = 0; i < ids.length; ++i) {
        _balances[ids[i]][from] -= amounts[i];
        _balances[ids[i]][to] += amounts[i];
    }
    emit TransferBatch(msg.sender, from, to, ids, amounts);
    _doSafeBatchTransferAcceptanceCheck(msg.sender, from, to, ids, amounts, data);
}

批量省 gas 的来源是「共享交易基础成本」:21000 的交易基础费、21k 左右的外部调用开销、以及事件与循环的固定部分被多个 id 摊薄。转移 10 个不同 id 时,逐个 safeTransferFrom 约需 10 次外部调用与 10 次事件,批量版本可省下约 30%~50% 的 gas;id 越多、节省比例越接近摊销上限。

3.2 接收钩子与重入防护

ERC-1155 的接收方若为合约,必须实现 onERC1155Received / onERC1155BatchReceived 并返回 0xf23a6e61 / 0xbc197c81。与 ERC-721 同理,钩子在状态更新之后调用;但 1155 的批量循环里如果中途做外部调用,就会出现「部分状态已更新、部分未更新」的中间态,被重入者利用。OpenZeppelin 的 ERC1155Supply 与 _update 钩子配合 nonReentrant 修饰器是最小防护。

另一个易忽略点是数组长度校验:ids.length != amounts.length 若不检查,会因越界访问 amounts[i] 直接 revert(新版本 Solidity 有边界检查),但旧编译器或 unchecked 块里则可能读到脏数据。


4. ERC-4626:代币化金库与舍入攻击

4.1 convertToShares 与舍入方向

ERC-4626 把收益金库标准化为「用资产换份额」:deposit 存入资产换份额,redeem 烧份额取资产。核心是 convertToShares / convertToAssets 两个换算函数,以及它们的预览版本 previewDeposit / previewRedeem。

舍入方向必须始终对金库有利:用户存入时份额向下取整(mulDiv(assets, totalSupply, totalAssets)),用户赎回时资产向下取整。方向写反,攻击者就能反复存取把金库的零头「磨」出来。

function convertToShares(uint256 assets) public view returns (uint256) {
    uint256 supply = totalSupply();
    // 向下取整:金库不亏
    return supply == 0 ? assets : assets.mulDiv(supply, totalAssets(), Math.Rounding.Down);
}

function convertToAssets(uint256 shares) public view returns (uint256) {
    uint256 supply = totalSupply();
    return supply == 0 ? shares : shares.mulDiv(totalAssets(), supply, Math.Rounding.Down);
}

4.2 通胀攻击与虚拟份额

ERC-4626 最著名的攻击是「首存通胀攻击」(inflation / donation attack)。攻击者先存入 1 wei 得到 1 份额,然后直接向金库转账(捐赠)大量资产抬高 totalAssets。此时后进用户存入 X 资产,因 shares = X * 1 / (X + donation) 向下取整为 0,攻击者独吞全部资产。

三种缓解手段:一是 OpenZeppelin 的 decimals offset(虚拟份额),在换算中人为放大 totalSupply 与 totalAssets 的基准;二是初始化时铸造最小流动性份额并永久锁仓;三是要求首次存款金额下限。虚拟份额方案最通用:

function _convertToShares(uint256 assets, Math.Rounding rounding) internal view returns (uint256) {
    return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding);
}

+1 让 totalAssets 永不为零,10 ** offset 让攻击者需要付出指数级成本才能把后来者的份额压到 0。


5. ERC-4337 与账户抽象接口

5.1 UserOperation 与 EntryPoint 流程

EIP-4337 不修改共识层,而是引入 UserOperation 结构体与单例 EntryPoint 合约。用户把操作签名后交给 Bundler,Bundler 打包成交易调用 EntryPoint.handleOps:

struct PackedUserOperation {
    address sender;
    uint256 nonce;
    bytes initCode;
    bytes callData;
    bytes32 accountGasLimits;
    bytes32 preVerificationGas;
    bytes32 gasFees;
    bytes paymasterAndData;
    bytes signature;
}

流程分四步:validateUserOp 验证签名与预付款、可选的 Paymaster 验证、执行 callData、最后结算 gas。Paymaster 可代付 gas 实现「无感 Web3」,但必须防止其被滥用——validatePaymasterUserOp 中要校验用户身份与额度。

5.2 validateUserOp 与签名验证

账户需实现 IAccount.validateUserOp,校验失败时返回 SIG_VALIDATION_FAILED(1)而非 revert,避免 Bundler 被恶意操作 DoS:

function validateUserOp(
    PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds
) external returns (uint256 validationData) {
    require(msg.sender == entryPoint, "only entryPoint");
    if (missingAccountFunds > 0) {
        (bool ok, ) = msg.sender.call{value: missingAccountFunds}("");
        ok; // 允许失败,EntryPoint 会处理
    }
    return _validateSignature(userOp, userOpHash);
}

验证阶段有一条硬性规则:不得访问与操作自身相关的存储槽之外的状态(EIP-7562 的 storage access rules),否则同一账户的多笔操作无法在 bundler 模拟中并行验证。签名验证兼容 EOA 的 ecrecover、ERC-1271 的 isValidSignature(智能合约钱包),以及聚合签名方案。


6. 周边关键标准:165、1271、2981、2771、2612

6.1 ERC-165 与 ERC-1271

ERC-165 是接口探测标准:合约实现 supportsInterface(bytes4) 返回是否支持某接口,0x01ffc9a7 是 ERC-165 自身的标识。ERC-721 与 ERC-1155 都要求实现 165,市场方据此判断能否调用 safeTransferFrom。

ERC-1271 让合约也能「签名」:isValidSignature(bytes32 hash, bytes signature) 校验通过时返回魔数 0x1626ba7e。这是智能合约钱包(含 ERC-4337 账户)被 dApp 识别的关键——dApp 不能只靠 ecrecover,必须回退到 1271:

bytes4 constant MAGICVALUE = 0x1626ba7e;
function isValidSignature(bytes32 hash, bytes memory sig) external view returns (bytes4) {
    require(_verify(hash, sig), "invalid signature");
    return MAGICVALUE;
}

6.2 ERC-2981、2771 与 2612

ERC-2981 定义版税查询:royaltyInfo(uint256 tokenId, uint256 salePrice) 返回收款地址与版税金额。注意它只提供「查询」,并不强制市场执行,因此版税可被市场方绕过,实践中常配合链下白名单或 ERC-721 的 transfer 钩子。

ERC-2771 解决「元交易」的 msg.sender 伪造问题:受信任的 forwarder 在 calldata 尾部追加真实发送者地址,合约通过 _msgSender() 读取。风险在于若合约接受任意 forwarder 追加的数据,攻击者可自行拼接地址冒充任何人,因此必须硬编码可信 forwarder。

EIP-2612 的 permit 用 EIP-712 结构化签名做授权,签名字段含 owner、spender、value、nonce、deadline 与 domainSeparator。domainSeparator 绑定 chainId 与合约地址,防止签名跨链或跨合约重放;nonce 防重放,deadline 限制时效。


7. 组合风险:再入、授权与钩子

7.1 再入与只读重入

外部回调是重入的温床。2020 年 imBTC 事件中,Uniswap V1 的 ETH-imBTC 池被 ERC-777 的 tokensToSend 钩子重入,攻击者在 balanceOf 更新前反复触发转账,套走约 30 万美元等值资产。教训是:不要假设被集成的代币没有钩子,ERC-777、ERC-1155、ERC-721、ERC-4626 的回调都可能成为入口。

只读重入(read-only reentrancy)更隐蔽:攻击者在状态不一致的中间态调用只读函数(如 getVirtualPrice、convertToAssets),把错误价格喂给依赖它的其他协议。防护手段是加 nonReentrant 到只读函数,或使用重入锁状态变量让外部协议可查询。

7.2 授权、钩子与闪电贷回调

无限授权(approve(type(uint256).max))让被盗私钥的损失从「当前余额」放大到「全部余额」。集成方应最小化授权额度并定期 revoke。permit 的钓鱼则利用用户对签名的低警惕:一个看似无害的签名请求可能包含 permit 或 setApprovalForAll,签完即被清空资产。

ERC-3156 标准化了闪电贷回调 onFlashLoan,攻击者可在回调中利用协议尚未更新的状态。ERC-6093 则统一了代币自定义错误(如 ERC20InsufficientAllowance、ERC721IncorrectOwner),降低集成方的错误解码成本,但尚未被所有旧合约采纳。


8. 标准实现的 gas 对比

8.1 存储与转账成本

EVM 的 gas 结构决定了优化方向:首次写入一个非零存储槽(SSTORE,0→非 0)花费 20000 gas,改写已有非零值(非 0→非 0)为 5000 gas,清零(非 0→0)退还部分 gas。ERC-20 的 transfer 在接收方余额槽首次写入时约 51k gas,热路径(槽已存在)约 29k gas;approve 因只改一个槽,约 46k / 24k。

ERC-1155 批量转移的边际成本远低于逐个 ERC-721 转移:每个新增 id 只需一次 SSTORE 加循环开销,没有额外的交易基础费与外部调用开销。存储打包(把多个 uint128 塞进一个 256 位槽)能把多次 SSTORE 合并为一次。

8.2 成本对照

操作大致 gas主要成本来源
ERC-20 transfer 首次51000接收方余额槽 SSTORE
ERC-20 transfer 热路径29000两个余额槽改写
ERC-20 approve46000授权槽 SSTORE
ERC-721 transferFrom85000所有权与余额槽
ERC-1155 单次转移52000一个余额槽对
ERC-1155 批量 10 个 id约 150000摊薄后的循环
ERC-4626 deposit90000份额与资产记账

减少 calldata、使用自定义错误替代字符串 revert(省下 require 的字符串存储与拷贝)、以及把不变量校验放进 unchecked 块,都是常见优化。


9. 审计清单与测试要点

9.1 常见漏洞清单

审计时按标准逐条过:ERC-20 检查返回值兼容、approve 竞态、fee-on-transfer 记账;ERC-721 检查回调重入、tokenURI 权限、ownerOf 与 balanceOf 一致性;ERC-1155 检查数组长度、批量钩子、uri 替换;ERC-4626 检查舍入方向、首存通胀、totalAssets 是否可被捐赠操纵;ERC-4337 检查验证阶段存储访问、missingAccountFunds 处理、Paymaster 额度。

跨标准的不变量同样重要:totalSupply 与所有余额之和是否始终相等、授权额度是否单调、convertToShares(convertToAssets(x)) <= x 是否成立。

9.2 Foundry 测试要点

单元测试覆盖正常路径,模糊测试(fuzz)覆盖边界值,不变量测试(invariant)覆盖跨函数组合:

function testFuzz_DepositRedeemNeverProfits(uint96 assets) public {
    assets = uint96(bound(assets, 1, type(uint96).max));
    uint256 shares = vault.deposit(assets, address(this));
    uint256 back = vault.redeem(shares, address(this), address(this));
    assertLe(back, assets, "round-trip must not profit");
}

function invariant_TotalSupplyMatchesBalances() public {
    assertEq(vault.totalSupply(), sumOfBalances());
}

分叉测试(fork test)在真实主网状态下验证与 Uniswap、Aave 等既有协议的组合行为;配合 Echidna 或 Medusa 做基于属性的状态探索,能覆盖手工用例想不到的调用序列。


10. 选型与扩展模式

10.1 标准选型决策

同质化代币选 ERC-20,需要元交易加 ERC-2771、需要签名授权加 EIP-2612;NFT 若每件独立且需独立元数据选 ERC-721,若存在同类多份(游戏道具、票券)选 ERC-1155;收益金库选 ERC-4626,但务必处理首存通胀与舍入;智能钱包选 ERC-4337,配合 ERC-1271 让 dApp 识别签名。

选型时还要看生态兼容性:市场方是否支持 2981 版税、钱包是否支持 4337、桥是否识别 2612 的 permit。标准之外的自定义扩展(如 ERC-7579 模块化账户、ERC-6900 模块化智能账户)仍在演进,采用前需评估审计成熟度。

10.2 扩展与组合模式

主流扩展模式有三类。其一是钩子(hooks):在 _beforeTokenTransfer / _update 中插入费用、白名单、暂停逻辑,但要警惕钩子本身引入重入。其二是代理升级:用 ERC-1967 存储槽配合 UUPS 或 Transparent 代理,升级时注意存储布局兼容与初始化函数只调用一次。其三是组合封装:把 ERC-4626 金库代币再包装成 ERC-20 抵押品接入借贷协议,此时必须复核两层换算的舍入方向与清算阈值。

组合越深,不变量越脆弱。每引入一层封装,都应重新验证「份额—资产—债务」三者的换算单调性与极端行情下的清算安全性。

10.3 速查表与一句话记忆

标准核心接口最易踩的坑
ERC-20transfer / approve / allowance无返回值、approve 竞态、fee-on-transfer
ERC-721safeTransferFrom / tokenURI接收回调重入、元数据可变
ERC-1155safeBatchTransferFrom / uri数组长度、批量钩子重入
ERC-4626deposit / redeem / convertToShares舍入方向、首存通胀攻击
ERC-4337validateUserOp / handleOps验证阶段存储限制、Paymaster 滥用
ERC-165supportsInterface接口 id 计算错误
ERC-1271isValidSignature魔数 0x1626ba7e 校验
ERC-2981royaltyInfo仅查询、市场可不执行
ERC-2771_msgSender可信 forwarder 必须硬编码
EIP-2612permitdomainSeparator 与 nonce 重放

一句话记忆:读标准先读「隐含假设」,写实现先写「舍入方向」,做集成先问「余额会不会变、回调会不会重入」。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「区块链 Web3」更多文章

  1. DeFi 风险管理与清算:抵押率、清算机制与坏账处置
  2. MPC 钱包与密钥管理:门限签名、2-of-3 架构与攻击面分析
  3. 形式化验证实战:Certora、Halmos 与 Echidna 的规约、边界与 CI 集成