JavaScript 不可用。

我们检测到浏览器禁用了 JavaScript。请启用 JavaScript 或改用支持的浏览器来继续访问

波场网络(Tron)上使用 @openzeppelin/contracts 对 USDT 做 safeTransfer 操作时,交易永远失败

作者:Anban Chu

发表日期:2022年12月01日

所属目录:Bug总结

场景介绍

这是一个刚接触波场网络的合约开发者,经常碰到的问题。波场网络的 USDT 信息如下:

这个错误场景的描述:使用的库是:@openzeppelin/contracts 中的 IERC20SafeERC20。用 using SafeERC20 for IERC20; 将 SafeERC20 内的方法赋给 IERC20。此时 IERC20 已经拥有了 SafeERC20 上的 safeTransferFromsafeTransfer 方法。

✅ 用户向合约储存 USDT 时候是可以正常完成的:

  1. 向合约 approve(授权) USDT 的操作权。
  2. 合约内使用 safeTransferFrom 方法,将钱从用户的地址拿到指定的地方。

❌ 但是用户取款时候报错 失败-交易REVERT / FAILED -TRANSACTION REVERT 或者收到 SafeERC20: ERC20 operation did not succeed 错误,波场项目方给出的提示是

导致交易Revert的原因可能如下:
1.调用throw
2.如果你调用require的参数(表达式)最终结果为false
3.如果你通过消息调用某个函数,但该函数没有正确结束
4.如果你使用new关键字创建合约,但合约没有正确创建
5.如果你的合约通过一个没有payable修饰符的公有函数接收TRX
6.transfer() 失败            // ❌ 此时属于这个错误
7.调用revert()
8.到达最大函数栈深64
9.了解更多请前往:https://developers.tron.network/docs/exception-handling

错误总结

造成这种事情的原因是波场 USDT 的 transfer 函数没有遵守代码规范,灾难由它产生。

波场的 USDT 主合约 TetherToken.transfer 函数继承的父合约的 transfer,。但是父合约的 transfer 虽然声明了返回值,但却没有真正的做返回,因此会导致主合约的 transfer 会永久返回 false。从而导致在使用 safeTransfer 调用 USDT 的 transfer 时永远都只返回 false,或许会导致可以存 USDT,但是无法提 USDT。

提醒:在波场上部署 USDT 相关合约的时候,需要针对 USDT 做适配,做好充足的测试再推出。

合约分析

这个代币是基于 pragma solidity ^0.4.18; 开发的,使用 solidity 0.4.25编译,合约依赖关系如下。

文件 1(共 10 个):BasicToken.sol
文件 8(共 10 个):StandardToken.sol
文件 9(共 10 个):StandardTokenWithFees.sol
文件 10(共 10 个):TetherToken.sol

下面逐个分析

第一、TetherToken.sol

以太坊网络:核心代码

transfertransferFrom 方法均没有return标记。transfer 函数虽然有返回值,但是函数声明时却没有声明,导致后续没有返回值。核心代码如下:

contract TetherToken is Pausable, StandardToken, BlackList {
    // Forward ERC20 methods to upgraded contract if this one is deprecated
    function transfer(address _to, uint _value) public whenNotPaused {
        require(!isBlackListed[msg.sender]);
        if (deprecated) {
            return UpgradedStandardToken(upgradedAddress).transferByLegacy(msg.sender, _to, _value);
        } else {
            return super.transfer(_to, _value);
        }
    }

    // Forward ERC20 methods to upgraded contract if this one is deprecated
    function transferFrom(address _from, address _to, uint _value) public whenNotPaused {
        require(!isBlackListed[_from]);
        if (deprecated) {
            return UpgradedStandardToken(upgradedAddress)
                    .transferFromByLegacy(msg.sender, _from, _to, _value);
        } else {
            return super.transferFrom(_from, _to, _value);
        }
    }
}

以太坊上的代码可以在浏览器那查看到: https://etherscan.io/token/0xdac17f958d2ee523a2206206994597c13d831ec7#code

波场网络:核心代码

transfertransferFrom 方法均有 returns 标记。主合约中的 transfer 函数继承的父合约的 transfer。但是父合约的 transfer 虽然有声明返回值,但是函数内部却没有写 return,因此会导致主合约的 transfer 会永久返回 false。

contract TetherToken is Pausable, StandardTokenWithFees, BlackList {
    // Forward ERC20 methods to upgraded contract if this one is deprecated
    function transfer(address _to, uint _value) public whenNotPaused returns (bool) {
        require(!isBlackListed[msg.sender]);
        if (deprecated) {
            return UpgradedStandardToken(upgradedAddress).transferByLegacy(msg.sender, _to, _value);
        } else {
            return super.transfer(_to, _value);
        }
    }

    // Forward ERC20 methods to upgraded contract if this one is deprecated
    function transferFrom(address _from, address _to, uint _value) public whenNotPaused
    returns (bool) {
        require(!isBlackListed[_from]);
        if (deprecated) {
            return UpgradedStandardToken(upgradedAddress)
                        .transferFromByLegacy(msg.sender, _from, _to, _value);
        } else {
            return super.transferFrom(_from, _to, _value);
        }
    }
}

上面代码 return super.transfer(_to, _value); 会将 super.transfer() 函数的运行结果返回出去,运行 StandardTokenWithFees.transfer

波场上的代码可以在浏览器那查看到: https://tronscan.org/#/token20/TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t/code

第二、StandardTokenWithFees.sol

StandardTokenWithFees.transfer 也确实标记了 returns (bool)。但是函数内部只写了 super.transfer 却没有写 retuen super.transfer。此函数的返回值永远是 false

contract StandardTokenWithFees is StandardToken, Ownable {

  // ❌ 函数内部没有 return;
  function transfer(address _to, uint _value) public returns (bool) {
    uint fee = calcFee(_value);
    uint sendAmount = _value.sub(fee);

    super.transfer(_to, sendAmount);
    if (fee > 0) {
      super.transfer(owner, fee);
    }
  }

  // ✅ 函数内部做了 return true;
  function transferFrom(address _from, address _to, uint256 _value) public returns (bool) {
    require(_to != address(0));
    require(_value <= balances[_from]);
    require(_value <= allowed[_from][msg.sender]);

    uint fee = calcFee(_value);
    uint sendAmount = _value.sub(fee);

    balances[_from] = balances[_from].sub(_value);
    balances[_to] = balances[_to].add(sendAmount);
    if (allowed[_from][msg.sender] < MAX_UINT) {
        allowed[_from][msg.sender] = allowed[_from][msg.sender].sub(_value);
    }
    Transfer(_from, _to, sendAmount);
    if (fee > 0) {
      balances[owner] = balances[owner].add(fee);
      Transfer(_from, owner, fee);
    }
    return true;
  }
}

我们忽略上面的没有写 retrun 的操作,接着再寻找父级 super.transfer,会运行 StandardToken.transfer

第三、StandardToken.sol

由代码 contract StandardToken is ERC20, BasicToken 得知,此时会运行 BasicToken.transfer

/**
 * @title ERC20 interface
 * @dev see https://github.com/ethereum/EIPs/issues/20
 */
contract ERC20 is ERC20Basic {
  function allowance(address owner, address spender) public view returns (uint256);
  function transferFrom(address from, address to, uint256 value) public returns (bool);
  function approve(address spender, uint256 value) public returns (bool);
  event Approval(address indexed owner, address indexed spender, uint256 value);
}

contract StandardToken is ERC20, BasicToken {
  /**
   * @dev Transfer tokens from one address to another
   * @param _from address The address which you want to send tokens from
   * @param _to address The address which you want to transfer to
   * @param _value uint256 the amount of tokens to be transferred
   */
  function transferFrom(address _from, address _to, uint256 _value) public returns (bool) {
    require(_to != address(0));
    require(_value <= balances[_from]);
    require(_value <= allowed[_from][msg.sender]);

    balances[_from] = balances[_from].sub(_value);
    balances[_to] = balances[_to].add(_value);
    allowed[_from][msg.sender] = allowed[_from][msg.sender].sub(_value);
    Transfer(_from, _to, _value);
    return true;
  }
}

第四、BasicToken.sol

接着查看这个 BasicToken.transfer,发现这里确实写了 return true

/**
 * @title ERC20Basic
 * @dev Simpler version of ERC20 interface
 * @dev see https://github.com/ethereum/EIPs/issues/179
 */
contract ERC20Basic {
  function totalSupply() public constant returns (uint);
  function balanceOf(address who) public view returns (uint256);
  function transfer(address to, uint256 value) public returns (bool);
  event Transfer(address indexed from, address indexed to, uint256 value);
}

/**
 * @title Basic token
 * @dev Basic version of StandardToken, with no allowances.
 */
contract BasicToken is ERC20Basic {

  /**
  * @dev transfer token for a specified address
  * @param _to The address to transfer to.
  * @param _value The amount to be transferred.
  */
  function transfer(address _to, uint256 _value) public returns (bool) {
    require(_to != address(0));
    require(_value <= balances[msg.sender]);

    // SafeMath.sub will throw if there is not enough balance.
    balances[msg.sender] = balances[msg.sender].sub(_value);
    balances[_to] = balances[_to].add(_value);
    Transfer(msg.sender, _to, _value);
    return true;
  }
}

所以 StandardTokenWithFees.transfer 内部的 super.transfer(owner, fee); 是会收到 true 的。但是在函数内没有对外做 return

USDT 的合约开发者大概率是想写 return super.transfer(); 这样的风格。但是写代码的时候却忘记了这件事情。

为什么要使用 SafeTransfer

关于 ERC20 的 transfer 是否需要返回值。按照标准来说并不做要求,规范是 function transfer(address to, uint value) external; 没有 returns (bool success)

因为规范没有要求,所以主流分为两种操作方法

  1. transfer 函数默认表示交易成功。如果遇到失败,ERC20 合约内部做 revert ,直接回滚交易。
  2. transfer 函数通过返回值表示交易状态,而不是暴力的直接 revert 交易。由调用合约内部灵活处理该部分的逻辑。返回 false 值代表交易失败,返回 true 表示交易成功。

这些都是符合 ERC20 标准的,所以 @openzeppelin/contracts 使用 SafeERC20 进行兼容。核心代码如下

library SafeERC20 {
  function safeTransfer(IERC20 token, address to, uint256 value) internal {
      _callOptionalReturn(token, abi.encodeWithSelector(token.transfer.selector, to, value));
  }

  function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal {
      _callOptionalReturn(token, abi.encodeWithSelector(token.transferFrom.selector, from, to, value));
  }

  function _callOptionalReturn(IERC20 token, bytes memory data) private {
      bytes memory returndata = address(token).functionCall(data, "SafeERC20: low-level call failed");
      if (returndata.length > 0) { // Return data is optional
          // solhint-disable-next-line max-line-length
          require(abi.decode(returndata, (bool)), "SafeERC20: ERC20 operation did not succeed");
      }
  }
}

上面代码是如果没有返回值,则直接完成,如果交易失败,Token 内部做 revert 会导致交易失败。但是如果有返回值,就使用 bool 解析返回值,返回值必须为 true,否则报错 SafeERC20: ERC20 operation did not succeed。这个兼容处理是没有任何问题的。

但是 USDT 没有按照约定走,它的代码逻辑是想对外抛出返回值,但是开发者自己忘记返回了。所以合约永远返回 false

该问题的解决办法

使用默认 transfer 即可。如果拥有很多资金池模型的合约,可以通过属性来判断,比如引入 erc20IsStandard 变量,来判断否为标准的 ERC20 合约。

if (erc20IsStandard) {
    IERC20(erc20Token).safeTransfer(_to_user, _amount);
} else {
    IERC20(erc20Token).transfer(_to_user, _amount);
}

开发者的反思

需要严格做好单元测试和 E2E 测试,很多开发者有写单元测试,但是几乎不做生产环境的 E2E 测试。如果合约内部牵扯到其他合约或者 ERC20 资产,一定要做好代码的 review。





以上就是本篇文章的全部内容了,希望对你有帮助。

>> 本站不提供评论服务,技术交流请在 Twitter 上找我 @anbang_account