ERC20[1]是以太坊上在以太坊改进协议(EIP-20)中引入的智能合约代币标准,制定了代币功能方法集合,其目的在于对代币功能进行规范,帮助钱包、去中心化交易所等更好地对代币进行兼容。
ERC20规范包含的功能接口如下:
interface IERC20 {
event Approval(address indexed owner, address indexed spender, uint value);
event Transfer(address indexed from, address indexed to, uint value);
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function decimals() external view returns (uint8);
function totalSupply() external view returns (uint);
function balanceOf(address owner) external view returns (uint);
function allowance(address owner, address spender) external view returns (uint);
function approve(address spender, uint value) external returns (bool);
function transfer(address to, uint value) external returns (bool);
function transferFrom(address from, address to, uint value) external returns (bool);
}
IERC20
接口中定义了一些只读方法,如获取代币的名字、符号、精度、总供应量,以及某个账户的余额和某个账户对另一个账户的准许花费额度。此外,该接口还定义了代币的转账方法:transfer()
方法从消息发送者转移到to
地址amount
数量的代币,approve()
用来设置消息发送者对spender
的准许花费额度,transferFrom()
方法用来从from
到消息发送者的准许额度中花费value
数量的代币,并将其转移给to
账户。
ERC20中包含的功能接口看起来很简单,每个人都可以基于该ERC20规范来发行自己的代币。但由于ERC20规范仅对代币接口进行了定义,并未从实现或者功能方面给出严格的约束,并且在智能合约发展早期,关于ERC20代币的实现并没有一个标准模板可供大家参考,各家自己的代币逻辑实现也不尽相同。尽管目前已经有相对成熟的ERC20合约模板,无需自己重新编写智能合约代码。但在涉及到与其他ERC20代币合约进行交互时,不同ERC20代币合约对外表现的功能差异也会给当前合约开发造成很多困扰,接下来本文会对这些问题进行详细介绍。
返回值问题
尽管ERC20规范中对代币需要实现的功能方法的名字、参数和返回值都进行了规定,但一些合约在实现时并未严格遵循该要求。同时由于Solidity中函数签名不依赖于函数的返回值,导致这些本质上并未实现ERC20规范的代币合约,仍然能作为ERC20合约而被其他合约调用。
一个简单的例子是在ERC20中约定了name()
和symbol()
方法返回字符串类型的代币名字和符号,但在Maker[^maker]项目的实现中,则将该方法的返回值定义为了bytes
类型,虽然这看起来仅会导致代币名字和符号的解析故障,但类似的返回值问题可能造成其他严重的后果。
在早期的OpenZeppelin给出的ERC20实现中,transfer()
方法被定义为不含返回值。此时,如果有一个合约按照ERC20规范的行为来调用该合约的transfer()
方法时,调用可以正常执行,但最终由于该ERC20合约没有为本次调用返回任何值,调用方将把从内存中拿到的不可预料的值作为转账结果,但由于32字节的值只有全零一种表示会被解析为false,所以该问题暂时被隐藏了起来。
直到在以太坊的拜占庭分叉中为EVM引入了一个新的字节码指令ReturnDataSize
,该指令用来获取外部合约调用的返回值大小。在Solidity 0.4.22版本之后,合约间如果采用IERC20(Contract).Method()
这种形式的调用并且尝试对方法的返回值进行解码,Solidtiy会自动插入对返回值大小的检查。如果在执行时得到的返回值大小与预期不符,则整笔交易执行失败。但在这之前已经部署的一些问题合约则会因该默认行为的引入而受到影响,有人通过扫描Etherscan上显示的ERC20 代币列表发现有130多个代币合约受到此行为的影响,其中比较知名的项目有BinanceCoin、OmiseGO等[2]。
而为了实现对这些transfer()
方法无返回值的代币合约的兼容性,一些会调用ERC20transfer()
方法的合约逻辑需要采用如下的实现方式:
bytes4 private constant _SELECTOR = bytes4(keccak256(bytes("transfer(address,uint256)")));
function _safeTransfer(address token, address to, uint value) internal {
(bool success, bytes memory data) = token.call(abi.encodeWithSelector(_SELECTOR, to, value));
require(success && (data.length == 0 || abi.decode(data, (bool))), "LockSend: TRANSFER_FAILED");
}
利用address.call(function_selector)
这种较底层的调用方式能够避免IERC20(Contract).Method()
调用方式下引入的返回值大小的检查,获得本次调用是否成功以及调用的返回值,然后针对有返回值的情况检查其返回值是否为真,对于无返回值的情况则默认转账成功。在OneSwap项目中,涉及到ERC20代币的转账行为即采用如上的实现方式。
此外,还有的ERC20合约虽然定义了transfer()
方法是有bool
类型的返回值,但在方法的实现中最后并未显式返回任何值,Tron网络上的USDT合约[3]即是这种实现方式:
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);
}
}
对于这种定义了返回值但并未在方法实现中显式返回任何值的情形,Solidity编译器会默认返回各个返回值的零值,从而导致不管内部转账是否成功,transfer()
方法对外的返回值始终为false
。
除了返回值问题之外,有一些代币合约,如开启了收费功能之后的USDT[4], DEGO[^dego]等的transfer(),transferFrom()
方法实现会从转账的金额中扣除一部分交易费用,这就导致用户/合约输入的转账金额参数与实际到账的金额不符。为了兼容此种行为,合约在与ERC20代币交互时,需要在transfer(),transferFrom()
方法调用前后主动获取接收者账户的余额,将两者之差作为实际到账的金额参与后续计算。在OneSwap项目的OneSwapPair合约中即采用了这种方式来检查用户转账实际到账的金额,并要求该金额不低于用户下单时为addLimitOrder(),addMarketOrder()
方法设置的输入代币金额参数,但如果需要OneSwap项目整体兼容此类代币仍需要按照此种方法来修改OneSwapRouter合约中的相关逻辑。
approve方法
approve()
和transferFrom()
方法允许用户在需要与某些特定功能合约(如去中心化交易所等)交互时,一次性设置合约的准许花费额度,随后合约可以调用transferFrom()
方法从用户的账户中转移代币,一方面降低用户的整体Gas
消耗,另一方面避免了用户的手动转账,实现了转账与调用合约的操作原子性。在ERC20规范中对该方法并未做太多限制,用户可以对某个地址设置任意大的准许额度,甚至超过用户当前的可用余额。另外,该方法在执行时会直接对准许额度以重新赋值的方式进行更新。
针对这种行为,有人提出了一种攻击方法 [^attack]:假设用户A起初向B approve了10个代币,过了一阵A想把该额度降为5个代币,由于B还未提取这10个代币,A重新发出了一笔对B的approve交易,数量为5。假如在该交易还未被打包时被B监测到了,B立即发送了一笔transferFrom
的交易从A提取了10个代币,如果B的交易被打包在A的第二笔交易之前,那么在这两个交易依次执行完毕后,B成功从A提取了10个代币,同时仍然保有在A上的5个准许额度。
针对这种攻击方法,目前主流的有两种解决方法。
一个是由OpenZepplin给出的参考实现:
function increaseAllowance(address spender, uint256 addedValue) external returns (bool);
function decreaseAllowance(address spender, uint256 subtractedValue) external returns (bool);
该实现额外增加了两个方法,相比于之前的重置式设置准许额度,这两个方法用来以增量化的方式分别对准许额度进行增减。在这种实现下,在上述攻击场景中如果A发送的第二笔交易调用的是decreaseAllowance
方法,那么在B成功提取10个代币之后,A的这笔交易会因待更新的准许额度为负而执行失败。
另外一种方法是与USDT[4]类似的如下的approve()
方法实现:
function approve(address _spender, uint256 _value) public returns (bool success) {
require(_value==0||allowed[msg.sender][_spender]==0,"allowance must be reset to zero firstly");
allowed[msg.sender][_spender] = _value;
emit Approval(msg.sender, _spender, _value); //solhint-disable-line indent, no-unused-vars
return true;
}
该方法实现在执行时会首先判断本次设置的额度是否为0或者当前的准许额度是否为0,如果否则执行失败,这也就要求在更新准许额度之前首先将其重置为0。
尽管这两种方式都能避免上述攻击,但这两种实现在被其他合约兼容时都存在各自的局限。OpenZepplin给出的实现增加了两个方法,但这两个方法并不属于标准的ERC20接口的功能集合,其他合约(如DEX类)/用户在与代币交互时无法很容易地获知当前代币是否实现了该方法,因此增加了交互/合约逻辑的复杂度。
第二种方式在approve
强制要求用户每次在进行准许额度的更新前,先要把该值重置为0,随后再进行更新,增加了交易的次数。并且对于第二种方法,用户/合约调用方在与该ERC20合约交互时,需要首先执行approve(addr,0)
的调用。但是呢,ERC20规范仅对接口进行规范的事实,在这里又引入了新的问题。在尚未意识到这种针对approve()
方法的攻击之前,一些ERC20代币合约,如CET[5]在approve(address _spender, uint256 _value)
的实现中要求输入的参数_value
不能为0,并且不能超过当前账户余额。如果一个合约将该approve
逻辑编码在代码中,并且依赖此逻辑处理所有ERC20代币,那么它在与CET这种ERC20代币进行交互时,首先将准许额度置为0的操作会失败,最恶劣的情况甚至会影响整个合约功能的正常运转。
weth
ETH作为以太坊的原生代币,其本身与ERC20标准并不兼容。但作为以太坊上最重要的资产,各种各样功能的智能合约如去中心化交易所等都不可避免地要对ETH进行支持。为了方便合约可以像与ERC20代币一样来与ETH进行交互,WETH作为与ETH 1:1锚定的ERC20代币在以太坊上发行。为了生成一个WETH,用户/合约首先需要向合约中质押一个ETH。此后用户/合约可以按照ERC20规范的接口来使用WETH,最终用户可以换回等量的ETH资产。WETH代币的发行,降低了各类合约应用兼容ETH的复杂度,但另一方面用户需要在与此类合约交互前后分别进行一次ETH与WETH的转换,增加了用户的操作复杂度。虽然在一些合约里会自动帮用户进行这两层转换,但相比于只包含标准ERC20代币的交易来讲,这两层转换增加了针对ETH的交易Gas消耗。
为了同时兼容ETH并尽可能降低ETH的特殊性带来的复杂度,OneSwap尝试从合约层面实现对ETH的原生支持,即无需在ETH和WETH之间进行转换。该功能需要在合约内部逻辑中对ETH和ERC20代币进行区分,OneSwap项目默认将全零地址与ETH相关联。具体来说,OneSwap项目实现了订单簿与AMM相结合的DEX,在创建交易对时,如果交易对中有一个代币为全零地址,则将其视为ETH。在向包含ETH的交易对添加流动性或者交易时,需要根据输入的代币是ETH还是ERC20代币来执行相应的转账逻辑。另外,在获取账户余额时,也需要对ETH进行特殊处理。以OneSwap项目的OneSwapPair合约为例,这两个需要特殊处理的地方分别被封装在_safeTransfer()
方法和_myBalance()
方法中,OneSwapPair合约的其他部分无需考虑对ETH的区别化处理,只在需要时调用这两个方法即可。
// get balance of current pair contract
function _myBalance(address token) internal view returns (uint) {
if(token==address(0)) {
return address(this).balance;
} else {
return IERC20(token).balanceOf(address(this));
}
}
// safely transfer ERC20 tokens, or ETH (when token==0)
function _safeTransfer(address token, address to, uint value, address ones) internal {
if(value==0) {return;}
if(token==address(0)) {
// limit gas to 9000 to prevent gastoken attacks
// solhint-disable-next-line avoid-low-level-calls
to.call{value: value, gas: 9000}(new bytes(0)); //we ignore its return value purposely
return;
}
// solhint-disable-next-line avoid-low-level-calls
(bool success, bytes memory data) = token.call(abi.encodeWithSelector(_SELECTOR, to, value));
success = success && (data.length == 0 || abi.decode(data, (bool)));
if(!success) { // for failsafe
address onesOwner = IOneSwapToken(ones).owner();
// solhint-disable-next-line avoid-low-level-calls
(success, data) = token.call(abi.encodeWithSelector(_SELECTOR, onesOwner, value));
require(success && (data.length == 0 || abi.decode(data, (bool))), "OneSwap: TRANSFER_FAILED");
}
}
同时,在_safeTransfer()
方法中,OneSwap故意丢弃了对转账结果的判断,这是为了防止由于转账失败造成的订单簿中订单无法成交的结果,详细内容将在Oneswap系列文章《安全校验、防呆和摩擦》一篇中进行介绍。
总结
ERC20规范定义的代币功能看似简单,但由于该规范只是对功能接口的定义,并未对接口的行为以及实现细节等给出更详细的指导,从而导致各家实现的ERC20代币合约行为迥异,也给其他需要与ERC20合约进行交互的合约开发带来了困扰。本文梳理了ERC20的合约实现中的返回值问题、approve()
方法的攻击、解决措施与可能导致的不兼容问题、与ETH相锚定的ERC20代币WETH的原理、作用,以及如何在不引入WETH的前提下在合约内部对ETH和ERC20代币实现兼容,希望能给智能合约开发者提供一些帮助。
[^attack]:https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
原文:《OneSwap Series 9- Troubles of ERC20》
链接:https://oneswap.medium.com/oneswap-series-8-troubles-of-erc20-fe179c21b765
翻译校对:OneSwap中文社区
-
https://eips.ethereum.org/EIPS/eip-20 ↩
-
https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca ↩
-
https://tronscan.io/#/contract/TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t/code ↩
-
https://etherscan.io/address/0xdac17f958d2ee523a2206206994597c13d831ec7#code ↩ ↩
-
https://etherscan.io/token/0x081f67afa0ccf8c7b17540767bbe95df2ba8d97f ↩