ERC20 & its quirks
The Ethereum ecosystem has ushered in a new era of decentralized applications, smart contracts, and blockchain-based solutions. With such diversity and decentralization, the need for a standard arises that allows all the current tokens and the ones to come, to conform to a structure that is similar on a higher level.
At the heart of cryptocurrencies lies the ERC-20 standard, a widely accepted one which brings us to:
What is an ERC-20 token?
To quote from the official documentation:
A standard interface for tokens.
You can think of ERC20 as a baseline, for fungible tokens. A blueprint of what a fungible token should be like on the Ethereum Blockchain. This blueprint includes a set of functions that are required to be defined for the token that a developer intends to develop and if it satisfies the criteria of having all of those functions, it is said to be ERC20-compliant token.
For a fungible token to be ERC20 compliant, the following is the set of functions that the dev should implement:
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.0.0) (token/ERC20/IERC20.sol)
pragma solidity ^0.8.20;
interface IERC20 {
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
Use of Functions:
totalSupply(): The function is used to return the total supply of the token that is circulating in the market.
balanceOf(): The function returns the balance of an ERC20 token for a given address. This address could belong to any user, it can be considered similar to a getter function to fetch the balance of a target user.
transfer(): The
transfer()
function is used to transfer_someValue
amount of tokens to_someAddress
address which can belong to anyone. When we intend to burn or destroy the circulating supply of tokens, thetransfer
function is used to send the tokens to0x0
address.allowance(): The function returns the
amount
of tokens that aspender
is allowed to spend from anowner
's account, this allowance is set by using theapprove
function.approve(): As the idea suggests, the function is used to approve a
spender
to spendsome
amount of tokens from theowners
balance.transferFrom(): Once an
owner
has approved a certain amount of tokens to be spent from their account, for an external address (another contract/user), thetransferFrom
function is used to transfer that_amount
from theowner
's address to thetarget
address.
Quirks:
ERC20 is a very simple template in its most basic sense which allows huge flexibility for the developer to play around with. However, if not handled with a sense of security, this flexibility can prove to be harmful to the project/organization. In the past, there have been several cases where these inconsistencies have caused the projects to lose millions.
Here are some of the most common issues that projects run across:
Unchecked Return Values: ERC20 tokens are required to implement the transfer(), approve() and the transferFrom() functions. Now, the issue here arises from the fact that the definition of these functions can be inconsistent across various tokens, where some tokens revert on a failed call of token transfer, some might simply return boolean values.
For example, Token A might return a boolean value 'True' on a successful call of the transfer() function whereas another Token B might not, this inconsistency is the root cause of such issues. On the other hand, tokens such as USDT returns no value at all and the only way to ensure a successful transfer is to use the
balanceOf()
function and match the balances.To ensure that your project is not vulnerable to this issue, we need to implement OpenZeppelin's SafeERC20 library for all token-related transactions.
A good list of tokens that do not return any boolean, can be found here.
Fee-On-Transfer Tokens: As ERC20 provides the flexibility of defining the logic of the functions to the protocol developers, some tokens have implemented a feature to charge a fee for every transfer that takes place.
For example, if a user transfers 100 tokens of such a token which charges a fee of 10% then the actual transfer will leave the user with 100 - 10% = 90 tokens.
When this difference is not accounted for while conducting the transfers, this can lead to an accounting mismatch for the protocol/project that implements it thus allowing an attacker to leverage the inconsistent balances and withdrawing more tokens than they can.
Quoting from my friend's blog which you may find here: (33Audits )
FEE token contract takes 10% of the tokens (10 FEE).
90 FEE tokens actually get deposit in contract.
_updatePoolBalance(poolId, pools[poolId].poolBalance + amount);
will equal 100.Attacker then sends 100 FEE tokens to the contract
The contract now has 180 FEE tokens but each user has an accounting of 100 FEE.
The attacker then tries to redeem his collateral for the full amount 100 FEE tokens.
The contract will transfer 100 FEE tokens to Bob taking 10 of Alice's tokens with him.
Bob can then deposit back into the pool and repeat this until he drains all of Alice's funds.
When Alice attempts to withdraw the transaction will revert due to insufficient funds.
To ensure your protocol is safe from this issue, you need to check the balance of the target address before and after the transfer and then store that difference as their balance.
Rebasing Tokens: Now imagine a pool of a token whose total circulating supply can adjust itself. If the token intends to maintain a target price (stable coins) then as the token's price increases, the supply will increase thus stabilizing the value of the token. Conversely, if the token's price goes below the desired value, the supply decreases thus pushing the price up. These changes can happen daily or even on an hourly basis.
While working with such tokens the developers need to take special care and ensure the rebasing is accounted for in all instances where these balances are interacted with or taken into account and if they do not intend to interact with such tokens due to their complexity then implementing a blacklist is a good way to ensure such tokens do not disturb the core functionalities of the protocol.
A great read for rebasing tokens on UNISWAP protocol, here.
ERC20s with Blacklists: Some ERC20s implement a blacklist to block addresses that might seem malicious. One very good example of this is the
USDC
token which implements a blacklist and thus any interaction (sending/receiving) with an address that exists on the USDC blacklist will be reverted.If a participant is on the USDC blacklist then this can cause different kind of issues depending upon the implementation. In my experience, I came across an implementation shared below which I have simplified for the sake of an example:
function distribute(){
for (uint i = 0; i < rewards.length; i++) {
-- snip --
success = success && IERC20(USDC).transfer(receipt.owner, transferAmount);
}
}
In this example, the loop iterates over the list of investors to distribute their rewards. However, a malicious user may intentionally get an address blacklisted and then participate in this protocol and as a result, DOS the functionality that distributes the awards because every time this function will be executed - the function will fail because of that single blacklisted address.
An approach to prevent such an issue would be to:
* allow the users to withdraw their balances individually.
* introduce a check for USDC pools to deny an address that is on the blacklist.
* allow the user to provide a secondary address to withdraw their funds to.
- Approval to Zero: A Push payment method refers to the one where a
payer
sends an amount to thereceiver
. A Pull payment method is one where areceiver
instructs thepayer
to send an amount. In this bug case, an attacker makes front runs an approval to steal funds. Quoting an example from SmartDec's blog:
Alice decides to allow Bob to spend some of her funds, for example, 1000 tokens. She calls the
approve
function with the argument equal to 1000.Alice rethinks her previous decision and now she wants to allow Bob to spend only 300 tokens. She calls the
approve
function again with the argument value equal to 300.Bob notices the second transaction before it is actually mined. He quickly sends the transaction that calls the
transferFrom
function and spends 1000 tokens.Since Bob is smart, he sets very high fee for his transaction, so that miner will definitely want to include his transaction in the block. If Bob is as quick as he is generous, his transaction will be executed before the Alice’s one.
In that case, Bob has already spent 1000 Alice’s tokens. The number of Alice’s tokens that Bob can transfer is equal to zero.
Then the Alice’s second transaction is mined. That means, that the Bob’s allowance is set to 300.
Now Bob can spend 300 more tokens by calling the
transferFrom
function.
Some tokens like the USDT
revert if the tokens' approval is not set to 0. The reason is to prevent the front run attacks like the one described above. In order to prevent this issue, the protocol should approve to 0
first and then increment the allowance.
Earlier the ERC20's safeApprove()
function was recommended but it was depreciated as it wasn't checking the allowance to 0 properly.
Subscribe to my newsletter
Read articles from hexbyte directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by