USDT.transfer self-destruct? 非标准ERC20问题和解决方案

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 标准制定的时候就两种方案讨论了很久:

  1. 有返回值:返回 bool 是否成功,让合约去做异常处理
  2. 没有返回值: 失败时 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

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 实现。

充分测试,还好不是发布主网才遇到问题 🙂

参考:

  1. USDT 0xdac17f958d2ee523a2206206994597c13d831ec7 合约源码
  2. missing-return-value-bug-at-least-130-tokens-affected by lukas-berlin
  3. OpenZeppelin 当时的 ERC20实现
  4. Uniswap’s Twitter: remove BNB from Uniswap V1
  5. TransferHelper.sol from Uniswap V3

Leave a Reply

Your email address will not be published. Required fields are marked *