0x00 问题
最近在做智能合约开发,需要转出合约里的 USDT。在以太坊网络里,USDT 是 ERC20 Token, 调用以下函数:
function transfer(address to, uint value) external returns (bool);
得到如下报错信息:
Error: Improper return (may be an unexpected self-destruct)
at TetherToken [address 0xdAC17F958D2ee523a2206206994597C13D831ec7] (TetherToken.sol:340:5)
0x01 原因
为啥转出报错了呢,看看 Etherscan 合约源码1,发现猫腻:
function transfer(address to, uint value) public;
没有返回值!!这不是标准的 ERC20。
标准 ERC20 会返回 bool
值,以区分是否转账成功。智能合约可以根据返回值,对错误做处理,这样的好处是,即使 Token 转移失败,仍然允许交易成功。
而 USDT 转移操作失败时,直接回退交易,没有返回值。
0x02 深入分析
一番搜索后,找到这篇文章2 讲的很详细,有兴趣的看看原文。
原来,ERC20 标准制定的时候就两种方案讨论了很久:
- 有返回值:返回
bool
是否成功,让合约去做异常处理 - 没有返回值: 失败时 revert 以确保安全性
现在,这两种都被认为服从 ERC20 标准,但前者更常见,很大一部分原因是 2017/3/17~2017/8/13 这段时间 OpenZeppelin 实现了前者3,之后才改为后者。
由于 Solidity 的函数选择器只由 函数名和入参决定,如:
selector = bytes4(sha3("transfer(address,uint)"))
也就是和函数返回值没关,所以两种实现的 transfer
函数调用都没问题。
第一种实现没有返回值,函数声明有返回 bool
, EVM 会把 内存slot 相应位置 的任何东西作为返回值。以前,这个 相应位置 刚好是函数选择器的内存slot, 所以总会被 EVM 解释为返回 true
。这隐藏了个巨大的坑,只是刚好表现正常。
果然,2017年10月的 拜占庭硬分叉 引入的新opcode RETURNDATASIZE
, 让这个问题再次浮出水面:当使用 Solidity 0.4.22 或更高版本编译合约调用时,EVM 会检查函数返回值的大小,如果比预计的要小,交易就会 revert。也就是所有用到有返回值的 interface 调用 ERC20 的 transfer函数,都会回退。这也就是我文章开头遇到的问题。
那究竟这个问题影响了多少 Token 呢?原文发表于18年6月,所列的名单有130个 Tokens。粗略来看,除了 USDT, BNB也在列表里。当然,除了影响 Token, 对智能合约也有影响,如Uniswap V1, 就因为这个问题,把刚上线的 BNB-ETH 交易对给停了4。
1/ WARNING: BNB providers
— Uniswap Labs 🦄 (@Uniswap) December 11, 2018
Due to a bug in the @binance BNB contract it’s possible to add liquidity to the Uniswap BNB<>ETH liquidity pool but not remove it.
This bug is a variation on the “missing return value” ERC20 bug which affects several tokens.https://t.co/x77ccBjAn9
0x03 解决办法
既然知道了原因,问题就好解决了,根据返回值的大小判断是否有返回值,以下代码是 Uniswap V3 实现的 TransferHelper5:
/// @title TransferHelper
/// @notice Contains helper methods for interacting with ERC20 tokens that do not consistently return true/false
library TransferHelper {
/// @notice Transfers tokens from msg.sender to a recipient
/// @dev Calls transfer on token contract, errors with TF if transfer fails
/// @param token The contract address of the token which will be transferred
/// @param to The recipient of the transfer
/// @param value The value of the transfer
function safeTransfer(
address token,
address to,
uint256 value
) internal {
(bool success, bytes memory data) =
token.call(abi.encodeWithSelector(IERC20Minimal.transfer.selector, to, value));
require(success && (data.length == 0 || abi.decode(data, (bool))), 'TF');
}
}
现在不要直接调用 ERC20 的 transfer
函数,改用上面的 safeTransfer
函数。
0x04 总结
有些 Token 早已发布,要兼容老 Token, 需要同时支持两种 ERC20 实现。
充分测试,还好不是发布主网才遇到问题 🙂
参考: