你可能认为在 ERC-20 调用几个函数非常简单,对吗?很不幸,不是的。有些事情我们必须要考虑,而且还可能出现一些很常见的问题。
我们从最简单的开始,下面我们要处理一个非常普通的 token 交易,下面的代码会导入并直接使用 IERC20.sol。
// 不正确的版本
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[4]这样的 token 来说这段代码是很完美的,调用 transfer 函数并在出错的时候回退调用。
但是,如果我们调用的是 0x(ZRX)会发生什么?ZRX代码在这里[5]。
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 (代码[6])实现中这样做,也是我建议这样做的原因。
而对于返回值的做法,是有争议的。如果你知道正在交易的 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[7]。
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 合约做错误处理。
现在你已经支持了 ERC-20 标准的 token, 然而有相当多的 token 看起来像 ERC-20 ,但是它的有些行为却不像,有些出现缺少返回值的错误[8]。
有一段时间,OpenZeppelin 有一个bug,他们在失败的时候回退交易,但没有在成功时返回 true(即缺少返回值)。这个 bug 让很多 token 都受到了影响,包括 USDT、OmiseGo 和 BNB 。你期望返回一个布尔值,却没有任何值返回,这种情况,如果用 Solidity 0.4.22 或更高版本编译,会回退交易,这个 bug 甚至影响到了Uniswap[9]。
那么其他项目是如何处理这个问题的呢?我们看看下面的Compound 版本[10]。
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是这样做的[11]:
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[12]来实现。
这是一个围绕 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[13] 赞助支持。
来源:https://soliditydeveloper.com/safe-erc20
[1]
登链翻译计划: https://github.com/lbc-team/Pioneer
[2]
翻译小组: https://learnblockchain.cn/people/412
[3]
Tiny 熊: https://learnblockchain.cn/people/15
[4]
DAI: https://etherscan.io/address/0x6b175474e89094c44da98b954eedeac495271d0f#code
[5]
这里: https://etherscan.io/address/0xe41d2489571d322189246dafa5ebde1f4699f498#code
[6]
代码: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol
[7]
try/catch: https://solidity.readthedocs.io/en/latest/control-structures.html#try-catch
[8]
缺少返回值的错误: https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca
[9]
影响到了Uniswap: https://twitter.com/UniswapProtocol/status/1072286773554876416
[10]
Compound 版本: https://github.com/compound-finance/compound-money-market/blob/241541a62d0611118fb4e7eb324ac0f84bb58c48/contracts/SafeToken.sol#L97
[11]
Uniswap是这样做的: https://github.com/Uniswap/uniswap-lib/blob/9642a0705fdaf36b477354a4167a8cd765250860/contracts/libraries/TransferHelper.sol#L13-L17
[12]
OpenZeppelin SafeERC20: https://docs.openzeppelin.com/contracts/3.x/api/token/erc20#SafeERC20
[13]
Cell Network: https://www.cellnetwork.io/?utm_souce=learnblockchain