安全的处理 ERC20 转账(解决非标准 ERC20 问题)

解决非标准 ERC20 问题

> * 原文链接:https://soliditydeveloper.com/safe-erc20 作者:[Markus](https://soliditydeveloper.com/about) > * 译文出自:[登链翻译计划](https://github.com/lbc-team/Pioneer) > * 译者:[翻译小组](https://learnblockchain.cn/people/412) > * 校对:[Tiny 熊](https://learnblockchain.cn/people/15) > * 本文永久链接:[learnblockchain.cn/article…](https://learnblockchain.cn/article/3074) 你可能认为在 ERC-20 调用几个函数非常简单,对吗?很不幸,不是的。有些事情我们必须要考虑,而且还可能出现一些很常见的问题。 我们从最简单的开始,下面我们要处理一个非常普通的 token 交易,下面的代码会导入并直接使用 IERC20.sol。 ![](https://img.learnblockchain.cn/2020/09/04/15991890349006.jpg) ## 怎样安全的处理 ERC20 转账 ```js // 不正确的版本 import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/IERC20.sol"; function interactWithToken(uint256 sendAmount) { // some code IERC20 token = IERC20(tokenAddress); token.transferFrom(msg.sender, address(this), sendAmount); } ``` 对于像[DAI](https://etherscan.io/address/0x6b175474e89094c44da98b954eedeac495271d0f#code)这样的 token 来说这段代码是很完美的,调用 transfer 函数并在出错的时候回退调用。 但是,如果我们调用的是 0x(ZRX)会发生什么?ZRX代码在[这里](https://etherscan.io/address/0xe41d2489571d322189246dafa5ebde1f4699f498#code)。 ``` function transferFrom(address _from, address _to, uint _value) returns (bool) { if (balances[_from] >= _value && allowed[_from][msg.sender] >= _value && balances[_to] + _value >= balances[_to]) { balances[_to] += _value; balances[_from] -= _value; allowed[_from][msg.sender] -= _value; Transfer(_from, _to, _value); return true; } else { return false; } } ``` 我们可以看到,与DAI不同,当出错时 0x 不会回退交易,而是返回 false,但是我们在代码中不管这个返回值。本质上,任何人都可以与我们合约的interactWithToken交易,合约会认为成功交易了一个 token ,但实际上什么也没有做。很糟糕! ZRX 仍然符合 ERC-20 标准,因为没有任何地方规定 ERC-20 合约必须在发生失败时回退交易。这两种方法都有优点和缺点。在上面的例子中,很明显我们只需要检查返回值就知道是否成功,一段简单的代码 `require(token.transferFrom(msg.sender, address(this), sendAmount), "Token transfer failed!");` 就可以修复。合约所有函数都是这样,执行失败的时候返回 false 或者回退,所以,一定要处理好这两种情况。 ## 合约内部的错误处理 大多数情况下,token 会在失败时回退交易。这样做的好处是,即使是像我们的第一个例子那样的代码,仍然可以安全地交易。这就是为什么 OpenZeppelin 的 ERC20 ([代码](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol))实现中这样做,也是我建议这样做的原因。 而对于返回值的做法,是有争议的。如果你知道正在交易的 token 在失败时返回 false,或许你只会想为这些 token 添加额外的功能,则可以像下面的例子一样处理: ``` function interactWithToken(uint256 sendAmount) { IERC20 token = IERC20(tokenAddress); bool success = token.transferFrom(msg.sender, address(this), sendAmount); if (success) { // handle success case } else { // handle failure case without reverting } } ``` 这样的好处显然是,即使 token 转移失败,我们仍然允许交易成功。 **如果 token 在失败时回退交易,错误如何处理?** 这在以前是比较复杂的,但从 Solidity 0.6 之后,就已经不那么困难了,现在 Solidity 支持[try/catch](https://solidity.readthedocs.io/en/latest/control-structures.html#try-catch)。 ![](https://img.learnblockchain.cn/2020/09/04/15991891334722.jpg) ```js function interactWithToken(uint256 sendAmount) { IERC20 token = IERC20(tokenAddress); bool success; try token.transferFrom(msg.sender, address(this), sendAmount) returns (bool _success) { success = _success; } catch Error(string memory /*reason*/) { success = false; // special handling depending on error message possible } catch (bytes memory /*lowLevelData*/) { success = false; } if (success) { // handle success case } else { // handle failure case without reverting } } ``` 这样你就可以为两个版本的 ERC-20 合约做错误处理。 ## 怎样支持所有 token 现在你已经支持了 ERC-20 标准的 token, 然而有相当多的 token 看起来像 ERC-20 ,但是它的有些行为却不像,有些出现[缺少返回值的错误](https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca)。 有一段时间,OpenZeppelin 有一个bug,他们在失败的时候回退交易,但没有在成功时返回 true(即缺少返回值)。这个 bug 让很多 token 都受到了影响,包括 USDT、OmiseGo 和 BNB 。你期望返回一个布尔值,却没有任何值返回,这种情况,如果用 Solidity 0.4.22 或更高版本编译,会回退交易,这个 bug 甚至[影响到了Uniswap](https://twitter.com/UniswapProtocol/status/1072286773554876416)。 那么其他项目是如何处理这个问题的呢?我们看看下面的[Compound 版本](https://github.com/compound-finance/compound-money-market/blob/241541a62d0611118fb4e7eb324ac0f84bb58c48/contracts/SafeToken.sol#L97)。 ```js function doTransferOut(address payable to, uint amount) internal { EIP20NonStandardInterface token = EIP20NonStandardInterface(underlying); token.transfer(to, amount); bool success; assembly { switch returndatasize() case 0 { // This is a non-standard ERC-20 success := not(0) // set success to true } case 32 { // This is a complaint ERC-20 returndatacopy(0, 0, 32) success := mload(0) // Set `success = returndata` of external call } default { // This is an excessively non-compliant ERC-20, revert. revert(0, 0) } } require(success, "TOKEN_TRANSFER_OUT_FAILED"); } ``` 其先检查返回数据的大小,如果是 0 ,我们就假定它是行为不正常的 token 。如果调用没有回退交易,那就意味着交易成功了,应该返回 true 。 随着 Solidity 的版本更新,我们可以简化这段代码,像[Uniswap是这样做的](https://github.com/Uniswap/uniswap-lib/blob/9642a0705fdaf36b477354a4167a8cd765250860/contracts/libraries/TransferHelper.sol#L13-L17): ```js function safeTransfer(address token, address to, uint value) internal { // bytes4(keccak256(bytes('transfer(address,uint256)'))); (bool success, bytes memory data) = token.call(abi.encodeWithSelector(0xa9059cbb, to, value)); require(success && (data.length == 0 || abi.decode(data, (bool))), 'TransferHelper: TRANSFER_FAILED'); } ``` 这种实现方法只是稍有不同而已,因为 abi.decode 也会对其他 data.lengths 起作用,不是只有32 字节,但是这没关系,可以很容易修改以支持错误处理: ```js function safeTransferNoRevert(address token, address to, uint value) internal returns (bool) { (bool success, bytes memory data) = token.call(abi.encodeWithSelector(0xa9059cbb, to, value)); return success && (data.length == 0 || abi.decode(data, (bool)); } ``` ![](https://img.learnblockchain.cn/2020/09/04/15991892241026.jpg) ## 你应该怎么做? 那么,现在最好的方法是什么呢?一个很简单的方法就是,使用[OpenZeppelin SafeERC20](https://docs.openzeppelin.com/contracts/3.x/api/token/erc20#SafeERC20)来实现。 这是一个围绕 ERC-20 调用的包装库。不要感到困惑,这不是为了创建自己的 token ,而是为了安全地交易。SafeERC20 的实现基本上就是像上面的 Uniswap 版本一样,你可以像下面这样用它: ```js import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/SafeERC20.sol"; import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/IERC20.sol"; contract TestContract { using SafeERC20 for IERC20; function safeInteractWithToken(uint256 sendAmount) external { IERC20 token = IERC20(address(this)); token.safeTransferFrom(msg.sender, address(this), sendAmount); } } ``` --- 本翻译由 [Cell Network](https://www.cellnetwork.io/?utm_souce=learnblockchain) 赞助支持。

  • 原文链接:https://soliditydeveloper.com/safe-erc20 作者:Markus
  • 译文出自:登链翻译计划
  • 译者:翻译小组
  • 校对:Tiny 熊
  • 本文永久链接:learnblockchain.cn/article…

你可能认为在 ERC-20 调用几个函数非常简单,对吗?很不幸,不是的。有些事情我们必须要考虑,而且还可能出现一些很常见的问题。

我们从最简单的开始,下面我们要处理一个非常普通的 token 交易,下面的代码会导入并直接使用 IERC20.sol。

怎样安全的处理 ERC20 转账

// 不正确的版本
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/IERC20.sol";

function interactWithToken(uint256 sendAmount) {
  // some code
  IERC20 token = IERC20(tokenAddress);
  token.transferFrom(msg.sender, address(this), sendAmount);
}

对于像DAI这样的 token 来说这段代码是很完美的,调用 transfer 函数并在出错的时候回退调用。

但是,如果我们调用的是 0x(ZRX)会发生什么?ZRX代码在这里。

function transferFrom(address _from, address _to, uint _value) returns (bool) {
        if (balances[_from] >= _value && allowed[_from][msg.sender] >= _value && balances[_to] + _value >= balances[_to]) {
            balances[_to] += _value;
            balances[_from] -= _value;
            allowed[_from][msg.sender] -= _value;
            Transfer(_from, _to, _value);
            return true;
        } else { return false; }
}

我们可以看到,与DAI不同,当出错时 0x 不会回退交易,而是返回 false,但是我们在代码中不管这个返回值。本质上,任何人都可以与我们合约的interactWithToken交易,合约会认为成功交易了一个 token ,但实际上什么也没有做。很糟糕!

ZRX 仍然符合 ERC-20 标准,因为没有任何地方规定 ERC-20 合约必须在发生失败时回退交易。这两种方法都有优点和缺点。在上面的例子中,很明显我们只需要检查返回值就知道是否成功,一段简单的代码 require(token.transferFrom(msg.sender, address(this), sendAmount), "Token transfer failed!"); 就可以修复。合约所有函数都是这样,执行失败的时候返回 false 或者回退,所以,一定要处理好这两种情况。

合约内部的错误处理

大多数情况下,token 会在失败时回退交易。这样做的好处是,即使是像我们的第一个例子那样的代码,仍然可以安全地交易。这就是为什么 OpenZeppelin 的 ERC20 (代码)实现中这样做,也是我建议这样做的原因。

而对于返回值的做法,是有争议的。如果你知道正在交易的 token 在失败时返回 false,或许你只会想为这些 token 添加额外的功能,则可以像下面的例子一样处理:

function interactWithToken(uint256 sendAmount) {
  IERC20 token = IERC20(tokenAddress);
  bool success = token.transferFrom(msg.sender, address(this), sendAmount);

  if (success) {
    // handle success case
  } else {
     // handle failure case without reverting
  }
}

这样的好处显然是,即使 token 转移失败,我们仍然允许交易成功。

如果 token 在失败时回退交易,错误如何处理?

这在以前是比较复杂的,但从 Solidity 0.6 之后,就已经不那么困难了,现在 Solidity 支持try/catch。

function interactWithToken(uint256 sendAmount) {
  IERC20 token = IERC20(tokenAddress);
  bool success;

  try token.transferFrom(msg.sender, address(this), sendAmount) returns (bool _success) {
    success = _success;
  } catch Error(string memory /*reason*/) {
    success = false;
    // special handling depending on error message possible
  } catch (bytes memory /*lowLevelData*/) {
    success = false;
  }

  if (success) {
    // handle success case
  } else {
     // handle failure case without reverting
  }
}

这样你就可以为两个版本的 ERC-20 合约做错误处理。

怎样支持所有 token

现在你已经支持了 ERC-20 标准的 token, 然而有相当多的 token 看起来像 ERC-20 ,但是它的有些行为却不像,有些出现缺少返回值的错误。

有一段时间,OpenZeppelin 有一个bug,他们在失败的时候回退交易,但没有在成功时返回 true(即缺少返回值)。这个 bug 让很多 token 都受到了影响,包括 USDT、OmiseGo 和 BNB 。你期望返回一个布尔值,却没有任何值返回,这种情况,如果用 Solidity 0.4.22 或更高版本编译,会回退交易,这个 bug 甚至影响到了Uniswap。

那么其他项目是如何处理这个问题的呢?我们看看下面的Compound 版本。

function doTransferOut(address payable to, uint amount) internal {
    EIP20NonStandardInterface token = EIP20NonStandardInterface(underlying);
    token.transfer(to, amount);

    bool success;
    assembly {
        switch returndatasize()
            case 0 {                      // This is a non-standard ERC-20
                success := not(0)          // set success to true
            }
            case 32 {                     // This is a complaint ERC-20
                returndatacopy(0, 0, 32)
                success := mload(0)        // Set `success = returndata` of external call
            }
            default {                     // This is an excessively non-compliant ERC-20, revert.
                revert(0, 0)
            }
    }
    require(success, "TOKEN_TRANSFER_OUT_FAILED");
}

其先检查返回数据的大小,如果是 0 ,我们就假定它是行为不正常的 token 。如果调用没有回退交易,那就意味着交易成功了,应该返回 true 。

随着 Solidity 的版本更新,我们可以简化这段代码,像Uniswap是这样做的:

function safeTransfer(address token, address to, uint value) internal {
  // bytes4(keccak256(bytes('transfer(address,uint256)')));
  (bool success, bytes memory data) = token.call(abi.encodeWithSelector(0xa9059cbb, to, value));
  require(success && (data.length == 0 || abi.decode(data, (bool))), 'TransferHelper: TRANSFER_FAILED');
}

这种实现方法只是稍有不同而已,因为 abi.decode 也会对其他 data.lengths 起作用,不是只有32 字节,但是这没关系,可以很容易修改以支持错误处理:

function safeTransferNoRevert(address token, address to, uint value) internal returns (bool) {
  (bool success, bytes memory data) = token.call(abi.encodeWithSelector(0xa9059cbb, to, value));
  return success && (data.length == 0 || abi.decode(data, (bool));
}

你应该怎么做?

那么,现在最好的方法是什么呢?一个很简单的方法就是,使用OpenZeppelin SafeERC20来实现。

这是一个围绕 ERC-20 调用的包装库。不要感到困惑,这不是为了创建自己的 token ,而是为了安全地交易。SafeERC20 的实现基本上就是像上面的 Uniswap 版本一样,你可以像下面这样用它:

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/SafeERC20.sol";
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/IERC20.sol";

contract TestContract {
    using SafeERC20 for IERC20;

    function safeInteractWithToken(uint256 sendAmount) external {
        IERC20 token = IERC20(address(this));
        token.safeTransferFrom(msg.sender, address(this), sendAmount);
    }
}

本翻译由 Cell Network 赞助支持。

本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

  • 发表于 2021-09-29 14:32
  • 阅读 ( 846 )
  • 学分 ( 80 )
  • 分类:Solidity
  • 专栏:全面掌握Solidity智能合约开发

评论