OneSwap Series 9- Troubles of ERC20

ERC20 is a standard of smart contract tokens introduced as an Ethereum Improvement Protocol (EIP-20) on Ethereum. It has formulated a set of token functions, and aims at standardizing token functions and helping improve tokens’ compatibility among wallets and decentralized exchanges.

The functions included in the ERC20 specification are as follows:

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);
}

Some read-only methods are defined in the IERC20interface, such as obtaining the name, symbol, precision, and total supply of the token as well as the balance of an account and the allowance of an owner account to another spending account. In addition, this interface also defines methods for token transfer: the transfer()method is used to transfer amounttokens from the message sender to the toaddress, approve()to set the allowance of the message sender to spender, and the transferFrom()method to spend valuetokens out of the allowance of the message sender from fromaddress and transfer them to the toaccount.

The interface for ERC20 looks simple, and everyone can issue their tokens independently based on ERC20. However, ERC20 specification only defines the token interface and has never strictly restricted the implementation or function. Also, in the early development of smart contracts, there is no standard template for the implementation of ERC20 tokens for reference. As a result, the implementation for ERC20 tokens varies from token to token. Although there is already a relatively mature ERC20 contract template that saves the trouble of independently writing the smart contract code, functional differences in the external legacy ERC20 token contracts still bring along great difficulties to the new contract development when it comes to interacting with an unknown ERC20 token contract. This article will elaborate on these issues.

The issue of the return value

Although ERC20 specification stipulates the names, parameters, and return values of the functional methods that the token needs to implement, some contracts do not strictly follow the requirements. At the same time, since the function signature in Solidity does not depend on the return value of the function, the token contracts that do not essentially implement the ERC20 specification can still be called by other contracts as ERC20 tokens.

Just take one simple example. In the ERC20 specification, it is agreed that the name() and symbol() methods return the token name and symbol as string type, but in the implementation of the Maker project, the return value is defined as bytes type. Although this may only cause failures in token name and symbol parsing, similar return value problems may lead to other serious consequences.

In the early implementation of ERC20 given by OpenZeppelin, the transfer()method was defined with no return value. At this time, if a contract calls the transfer()method of such ERC20 contracts, the call can be executed normally, but in the end, since the ERC20 contract does not return any value for this call, the caller takes the unpredictable value obtained in the memory as the transfer result. However, the 32-byte value will be parsed as false if it only contains zeros, so the problem is temporarily hidden.

That has changed when a new bytecode instruction ReturnDataSizeis introduced in the Byzantine fork of Ethereum for EVM, which is used to obtain the return value of external contract calls. After the Solidity 0.4.22 version, if a contract is called in the form of IERC20(Contract).Method()and return value is decoded, Solidtiy automatically inserts a check on the return value. If the size of the return value obtained during execution does not match the expectations, the entire transaction reverts. However, some problematic contracts that have been deployed before will be affected because of the introduction of this default behavior. Someone has scanned the ERC20 token list displayed on Etherscan and found that more than 130 token contracts were affected by this behavior, including BinanceCoin and OmiseGO returnvalue.

In order to achieve compatibility with these token contracts that have no return value in the transfer()method, the contract that calls the ERC20 transfer()method needs to be implemented as follows:

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");
}

Using the low-level calling method of address.call(function_selector), we can avoid checking the return value introduced in the calling method of IERC20(Contract).Method(), get the result and the return value of the call, and then check whether the return value is true if there is a return value. If there is not, the transfer is deemed successful by default. In the OneSwap project, the above implementation method is applied for transfers involving ERC20 tokens.

In addition, although some ERC20 contracts define the transfer()method to have a return value of booltype, it does not return any value explicitly in the implementation of the method. The USDT contract on the Tron network usdt_tron is implemented this way:

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);
}
}

For this situation where the return value is defined but no value is explicitly returned in the method implementation, Solidity compiler will return the zero value for each returned parameter by default. As a result, the return value of the transfer() method is always false regardless of whether the transfer is successful or not.

In addition to the return value problem, for some token contracts, the transfer(), transferFrom()method of USDTusdt(after the charging function is turned on) and DEGOdegodeduct a certain part from the transferred amount as transaction fee, which makes the transfer amount parameter entered by the user/contract inconsistent with the actual amount received. To be compatible with this behavior, when a contract interacts with ERC20 tokens, it needs to actively obtain the balance of the recipient's account before and after the call with the transfer(), transferFrom()method, and use the gap between them as the actual transferred amount for the subsequent calculation. The OneSwapPair contract of the OneSwap project checks the actual credited amount of the user’s transfer and require that the amount is not less than the input token amount parameter which is set for the addLimitOrder(), addMarketOrder()method when a user places an order. But if we want the entire OneSwap project to be compatible with such tokens, it is still necessary to modify the relevant logic in the OneSwapRouter contract using the above method.

approve method

The approve() and transferFrom() methods allow users to set the total allowance for the contract from themselves when they need to interact with certain contracts (such as decentralized exchanges), and then the contract can call the transferFrom( ) method to transfer tokens from users' accounts. In this way, they reduce the user's overall Gas consumption on the one hand, and on the other, save users from the manual transfer, realizing the operation atomicity of transfers and contract calls. There are not too many restrictions on this method in the ERC20 specification. Users can set an arbitrarily large allowance for an address, even larger than the their current balance. In addition, when executed, this method updates the allowance by resetting.

In response to this behavior, someone proposed an attack method: suppose User A initially approves 10 tokens to User B, and after a while, User A wants to reduce the limit to 5 tokens. Since User B has not yet withdrawn the 10 tokens, User A reissues an approve transaction of 5 tokens to User B. Assume the transaction is detected by User B before it is packaged into block and he immediately sends a transferFromtransaction and withdraws 10 tokens from User A. If User B’s transaction is packaged before User A’s second transaction, then after these two transactions are executed in sequence, User B successfully withdraws 10 tokens from User A while still keeping the allowanace of 5 tokens approved by User A.

For this attack method, there are currently two mainstream solutions.

One is the reference implementation given by OpenZepplin:

function increaseAllowance(address spender, uint256 addedValue) external returns (bool);
function decreaseAllowance(address spender, uint256 subtractedValue) external returns (bool);

This implementation introduces another two methods. Compared to the previous resetting of allowed limits, these two methods aim to increase or decrease the allowance incrementally. Under this implementation, in the above attack, if the second transaction sent by User A calls the decreaseAllowancemethod, then after User B successfully withdraws 10 tokens, User A's transaction will fail due to the negative allowance to be updated.

Another method is to implement the following approve()method in a similar way like USDT:

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;
}

When executed, this method will first determine whether the allowance set this time is 0 or whether the current allowance is 0. If it is not, the execution fails. So it is required to reset the limit to 0 before an update.

Although both methods can avoid the above attacks, they have their limitations when they are compatible with other contracts. The implementation given by OpenZepplin adds two methods, but they do not fall in the function set of the standard ERC20 interface. Other contracts (such as DEX) and external users cannot easily know whether the methods are implemented for the current token when interacting with it, thus increasing the complexity of the interaction logic.

In the second method, approveforces the user to reset the allowance to 0 before updating it every time, which increases the number of transactions. As for the second method, when the user/contract interacts with the ERC20 contract, they need to first execute the call of approve(addr,0). However, the fact that the ERC20 specification only regulates interfaces brings along new problems. Before being aware of this attack against the approve()method, some ERC20 token contracts, such as CET, require that the input parameter _valuein the implementation of approve(address _spender, uint256 _value)cannot be 0 or exceed the current account balance. If a contract encodes the approvelogic in the code and relies on this logic to process all ERC20 tokens, then when it interacts with ERC20 tokens such as CET, it fails to set the allowable limit to 0, or even affect the normal operation of the entire contract function for the worst case.

##weth

As the native token of Ethereum, ETH is not compatible with the ERC20 standard. But as the most important asset on Ethereum, ETH has inevitably been supported by smart contracts with various functions, such as the decentralized exchanges. To facilitate the contract to interact with ETH like ERC20 tokens, WETH is issued on Ethereum as an ERC20 token 1:1 anchored to ETH. To generate a WETH, the user/contract first needs to deposit ETH to the contract. After that, the user/contract can use WETH according to the ERC20 standard interface, and in the end the user can withdraw the same amount of ETH assets after depositing WETH. WETH tokens reduce the complexity of ETH compatibility for various contract applications, but in the meantime, users need to perform a conversion between ETH and WETH both before and after interacting with such contracts, making operations more complex. Although the two conversions are automatically performed for users in some contracts, compared to transactions that only contain standard ERC20 tokens, they increase the gas consumption of ETH transactions.

To be compatible with ETH while minimizing the complexity caused by the difference between ETH and ERC20, OneSwap tries to natively support ETH from the contract level, which is to avoid the conversion between ETH and WETH. To realize this function, we need to distinguish between ETH and ERC20 tokens in the internal logic of the contract. The OneSwap project associates all zero addresses with ETH by default. When users create a trading pair, if one of the tokens in the trading pair is an all-zero address, it will be regarded as ETH. When users add liquidity or swap in a trading pair containing ETH, the corresponding transfer logic needs to be executed on the basis of whether the input token is ETH or ERC20 token. Moreover, to obtain the account balance, special processing of ETH is also required. Take the OneSwapPair contract as an example. These two operations that require special treatment are encapsulated in the _safeTransfer()method and the _myBalance()method, while the remaining code do not need to consider the difference between ETH and ERC20 tokens but call these two methods when needed.

// 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");
}
}

At the same time, in the _safeTransfer() method, OneSwap deliberately discarded the check for the transfer result. That is to prevent the failure of orders in the order book due to the transfer failure. For details, please refer to the "Safety Verification, Foolproof and Friction" in the Oneswap series.

Conclusion

Simple as the token function defined by the ERC20 specification may seem, the specification only defines the functional interface, yet does not give more detailed guidance on the behavior of the interface and implementation details, which leads to the various implementation behaviors of the ERC20 token contract and brings along great difficulties to the development of contracts that need to interact with ERC20 contracts. This article sorts out the return value problem in the implementation of the ERC20 contract, the approve() attack‘s solutions and the possible incompatibility issues arising therefrom, the principle and function of the ERC20 token WETH anchored to ETH, and how to achieve compatibility between ETH and ERC20 tokens without introducing WETH, in an attempt to offer some help to smart contract developers.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store