描述
这是一个合约新手非常容易犯的失误,分为两种情况
- 转账的时候不判断返回值
- 转账的时候不判断余额是否真正的按照预期值增加/减少
在生产环境的合约中,如果你的合约涉及资金池的概念,而且可能上未知的任何 Token,那么这两个操作都是必须做的。核心是由于 Token 不规范导致的。
第一种情况的解决办法
第一个 转账的时候不判断返回值 的解决办法是使用 '@openzeppelin/contracts'
的 SafeERC20
来解决。转账的时候使用 erc20Token.safeTransfer
和 erc20Token.safeTransferFrom
来进行。
因为 ERC20 的规范里并没有对返回值做要求。关于 ERC20 的 transfer 是否需要返回值。按照标准来说并不做要求,规范是 function transfer(address to, uint value) external;
没有 returns (bool success)
因为规范没有要求,所以主流分为两种操作方法
- transfer 函数默认表示交易成功。如果遇到失败,ERC20 合约内部做 revert ,直接回滚交易。
- transfer 函数通过返回值表示交易状态,而不是暴力的直接 revert 交易。由调用合约内部灵活处理该部分的逻辑。返回
false
值代表交易失败,返回true
表示交易成功。
这些都是符合 ERC20 标准的,所以 @openzeppelin/contracts
使用 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
。这个兼容处理是很棒的。
第二种情况的解决办法
一定要判断余额是否按照预期进行,它可能是内部扣除 10% 手续费的垃圾币,也可能是只转了一半的金额,其他都销毁了。也可能是内部逻辑出错了,比如下面的代码
// 如果用户要转账的 amount 大于用户当前余额,则要转账的 amount 重写为用户当前的余额
if (amount > balanceOf(from)) {
amount = balanceOf(from);
}
此时 Token 合约内不抛错,也不返回 false
,而是直接执行了。
尤其是做 erc20Token.safeTransferFrom
的相关业务的时候,对应合约的方法可能是 deposit(amount)
这种方法。 用户在做 deposit
的时候,会先将 Token 授权给合约,授权后用户存钱,此时合约从用户地址将需要储存的 amount 用 safeTransferFrom
拿到指定地址。但是此时我们没办法知道该 Token 是是否为作恶的币。
以上几种行为,可能都是该代币项目方的刻意那么做的,代码程序员也是按照需求做事。但是他和大家正常认为的 token 转账并不相同,所以一旦不做余额判断,而合约又是对外开放的,这将是一个非常危险的操作。
建议做余额判断,比如下面的演示
uint256 balBefore = erc20Token.balanceOf(address(this));
erc20Token.safeTransferFrom(depositer, address(this), amount);
uint256 balAfter = erc20Token.balanceOf(address(this));
require((balAfter - balBefore) == amount, 'depositer error'); // 这里使用 assert 也很推荐
反思
第一种操作是很多合约开发者都会做的,第二种操作可能是被遗漏的。如果你的合约代码没有做第二种判断,那么恰好你的资金池是需要 owner 权限才能创建的,创建不对外开放;那么每次上资金池的时候都需要合约开发者 review 代币代码后再上架,不要直接上架,否则可能会承受损失。
写代码的时候建议两种操作都做。