Weird things to know about some ERC20 tokens
A list of some quirks that some ERC20 tokens have, that you should be aware of if ever writing smart contracts that interact with erc20 tokens
Table of Contents for Weird things to know about some ERC20 tokens
ERC-20 tokens are extremely popular. A quick search on Google shows that there are some ERC20 tokens in the top 10 crypto currencies (USDC, USDT).
Here is a list of ‘gotchas’ to be aware of if you are working with ERC-20 tokens.
Calling transfer()
or transferFrom()
may transfer a different amount of the token
Some tokens, such as USDT have support to add a fee to transfers. This is not currently enabled, but it could be in the future.
Here is the USDT implementation of the transfer function. You can see the logic to add fees.
function transfer(address _to, uint _value) public onlyPayloadSize(2 * 32) {
uint fee = (_value.mul(basisPointsRate)).div(10000);
if (fee > maximumFee) {
fee = maximumFee;
}
uint sendAmount = _value.sub(fee);
balances[msg.sender] = balances[msg.sender].sub(_value);
balances[_to] = balances[_to].add(sendAmount);
if (fee > 0) {
balances[owner] = balances[owner].add(fee);
Transfer(msg.sender, owner, fee);
}
Transfer(msg.sender, _to, sendAmount);
}
This is known as fee on transfer. Not accounting for this feature can easily lead to incorrect balances being listed. There was a famous hack that exploited this (with the STA token, which uses fee on transfer).
The name()
and symbol()
for some tokens doesn’t return a string
The specs define them as:
function symbol() public view returns (string)
function name() public view returns (string)
But some tokens define them and return something else (such as bytes)
Some commonly used functions are optional in the specs
Apart from the fact that some tokens won’t implement functions that are required in the specs, you also have to remember that some commonly used functions are optional in the specs.
Here is a list of all optional functions in the erc20 specs :
function name() public view returns (string)
function symbol() public view returns (string)
function decimals() public view returns (uint8)
Not all tokens use 18 decimals of precision
We sometimes assume that all tokens use 18 decimals of precision.
It is often the case, but it isn’t rare to have something else. For example USDC has 6 decimals. Some tokens have num of decimals higher than 18 too.
It is important to bear this in mind if you’re ever hard coding the decimals or make assumptions about the number of decimals.
Some tokens require approving 0
first
- Some ERC20 tokens require setting the amount of approved to
0
- This is to avoid a security issue.
- If Alice allows Bob to transfer 200 tokens, she could call approve(bobsAddress, 200). Then Alice decides to lower it to 100. Bob could see this change in the mempool, and could therefore spend 200 just before she sets it to 100, then straight after that could spend 100 (total 300, when at most Alice set it as 200).
- The best workaround/fix for this is to ensure when changing approved amount to always set it back to 0 first.
- full write up
- Because of this issue, some tokens such as USDT implement this logic, so you have to ensure you approve 0 tokens first
They don’t all return a boolean on success, like the ERC-20 specs says they should
The ERC-20 spec says that some functions (such as the transfer function) should return a boolean to indicate if it was successful or not.
However, not all ERC-20 tokens implement that feature or do not correctly implement it.
For example this is the transfer()
function for USDT (link). Notice how it has no return type.
function transfer(address _to, uint _value) public onlyPayloadSize(2 * 32) {
// ...
}
Some tokens (such as BNB
) return booleans (like the spec says) on some functions, but not all.
Some tokens (such as XAUt
) will return false
even if the function was successful.
The best way to know if a transfer was successful is to call balanceOf()
before and after, and check it changed as expected.
Tokens can be blocked
Some wallets have an extremely high balance of certain tokens, but they are unable to call transfer()
or transferFrom()
as the token contract has added it to a list of blocked/banned wallets.
Here is the logic from USDT:
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);
}
}
Tokens cab be paused
This is a very common thing to see in contracts, but still something to think about. While testing things out, everything might work fine, but you may need to add in support for when tokens have paused things!
OpenZeppelin have a well known ‘pausable’ contract (link). You can check out its implementation to understand it.
Some tokens will revert if you try and transfer 0
tokens
While you might think it is a valid error, we might also sometimes code things and expect that a transfer of anything < some balance
is valid… and of course 0
seems valid sometimes.
But some contracts will revert if you try and transfer 0
.
Recommended reading/links
You should check out the EIP-20 spec
Spotted a typo or have a suggestion to make this crypto dev article better? Please let me know!
📙 Solidity Auditing online quiz
Learn how to audit smart contracts by looking at some example code and trying to find the bugs
⛽ Solidity Gas Optimizations Guide
How to optimize and reduce gas usage in your smart contracts in Solidity
🧪 Guide to testing with Foundry
Guide to adding testing for your Solidity contracts, using the Foundry and Forge tools
📌 Guide to UTXO
UTXO and the UTXO set (used by blockchains such as Bitcoin) explained
📐 Solidity Assembly Guide
Introduction guide to using assembly in your Solidity smart contracts
📦 Ethereum EOF format explained
Information explaining what the upcoming Ethereum EOF format is all about