ERC-20 Token Security: What You Need to Consider

Thesis Defense Team
Thesis Defense
Published in
9 min readFeb 22, 2024

By Ahmad Jawid Jamiulahmadi & Mukesh Jaiswal
Security Auditors & Engineers

Writing robust and secure smart contracts requires careful consideration of security best practices and recommended coding guidelines.

To claim with validity that security has been considered in a smart contract implementation and to avoid some of the most common security issues, the smart contract should adhere to security best practices. In addition, a smart contract with good code quality demands the implementation of recommended code quality guidelines and gas optimization practices.

One of the situations that requires particularly careful consideration is the handling of ERC-20 tokens.

In this blog post, we will outline some of the most common security best practices and recommendations that must be considered when interacting with ERC-20 tokens.

1. Check for Handling Support For Fee on Transfer Tokens and Deflationary Tokens

Different ERC-20 token implementations behave differently regarding the actual amount received when transferring tokens. USDT on Ethereum, for example, can charge a fee when transferring ERC-20 tokens, as you can see in the below code snippet.

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

Deflationary tokens like STA, meanwhile, burn a certain percentage of the transferred amount, which subsequently decreases the token supply, as in the below example:

function transfer(address to, uint256 value) public returns (bool) {
require(value <= _balances[msg.sender]);
require(to != address(0));

uint256 tokensToBurn = cut(value);

uint256 tokensToTransfer = value.sub(tokensToBurn);
_balances[msg.sender] = _balances[msg.sender].sub(value);
_balances[to] = _balances[to].add(tokensToTransfer);

_totalSupply = _totalSupply.sub(tokensToBurn);

emit Transfer(msg.sender, to, tokensToTransfer);
emit Transfer(msg.sender, address(0), tokensToBurn);
return true;
}

As a result, the transfer of STA and USDT, and other tokens like them, to a smart contract can result in incorrect token accounting. Such a situation is simulated in the code snippet below.

contract Test {
mapping(address => uint256) public balances;


function deposit(uint256 inputAmount) public {
//the contract might receive an amount less than the inputAmount
token.transferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount;
}
function withdraw() public {
uint256 toSend = balances[msg.sender];
balances[msg.sender] = 0
token.transfer(msg.sender, toSend);
}
}

As you can see above, there is no check made on whether a fee has been charged on token transfer, or on whether the token was a deflationary token. As a result, the balance of msg.sender stored (i.e. the deposited amount) in the contract might deviate from the actual amount received (i.e., the contract might have received a smaller amount).

To mitigate this issue, the contract should check the balance before and after a transfer, updating the balance of the msg.sender based on the amount subtracted for fees or burnt tokens.

function Deposit(uint256 amount) public {

uint balance_before = token.balanceOf(address(this)
token.transferFrom(msg.sender, address(this), amount);
uint balance_after = token.balanceOf(address(this);
require(balance_after >= balance_before );
uint amount_to_add = balance_after — balance_before;
balances[msg.sender] += amount_to_add;


}

2. Use a Wrapper Smart Contract for ERC-20 Token Transfers

The challenges related to the ERC-20 transfer function primarily revolve around its reliance on a boolean return value to indicate success or failure. This poses potential risks as it might not always provide accurate feedback or compatibility with all ERC-20 tokens, especially those that deviate from the standard by not returning a boolean value. This discrepancy can lead to complications or failures in handling token transfers within contracts.

Standard ERC-20 token implementations expect a boolean return value on successful token transfers for transfer and transferFrom functions, as can be seen in the token interface below:

interface ERC20Interface {

function totalSupply() external constant returns (uint);
function balanceOf(address tokenOwner) external constant returns (uint balance);
function allowance(address tokenOwner, address spender) external constant returns (uint remaining);
function transfer(address to, uint tokens) external returns (bool success);
function approve(address spender, uint tokens) external returns (bool success);
function transferFrom(address from, address to, uint tokens) external returns (bool success);
event Transfer(address indexed from, address indexed to, uint tokens);
event Approval(address indexed tokenOwner, address indexed spender, uint tokens);
}

However, there are significant number of ERC-20 token implementations that do not comply with the above interface, including BNB (0xB8c77482e45F1F44dE1745F52C74426C631bDD52) and USDT (0xdac17f958d2ee523a2206206994597c13d831ec7) on Ethereum.

A crucial aspect of token standards is their ability to facilitate interaction between various token contracts using a shared interface. When a contract that expects an ERC-20 interface is trying to interact with a token that is not ERC-20 compliant (i.e. call the transfer or transferFrom functions on the non-compliant contract), the calling contract sends an external call. The receiving contract executes the call, performs the transfer, but does not provide a boolean return value. Because the receiving token does not provide this value, when the calling contract checks the memory for a return value, it interprets whatever data it finds in that memory position as the return value.

Before Solidity version 0.4.22, the slot in memory where the caller anticipated the return value coincided with the memory slot containing the function selector of the call. The EVM interpreted this overlap as the return value being “true”. As a result, the return value was — coincidently — the expected value.

However, since the Byzantium hard fork, a new opcode RETURNDATASIZE has been introduced and adopted by Solidity, starting from version 0.4.22. The RETURNDATASIZE opcode stores the size of the returned data of an external call. As a result of the introduction of this opcode, the Solidity compiler now checks the size of the returned value following an external call and reverts the transaction if the returned data is smaller than anticipated. This means a smart contract compiled using Solidity version 0.4.22 or later, and designed to interact with an ERC-20 interface, won’t successfully interact with a non-standard token. Consequently, any tokens sent to this contract, even if they contain a function to transfer ERC-20 tokens, will be locked forever.

Additionally, some ERC-20 tokens return false on transfer in case of failure — for example when insufficient tokens are present. Therefore, it is crucial to check the return value of ECR20 transfer and transferFrom functions. Otherwise, the transaction will go through without successfully sending the tokens. Moreover, ERC-20 Tokens can behave differently on different chains based on their return values. For example, the USDT transfer() function returns boolean values on the Polygon chain, but not on Ethereum. Look at the code snippets below for Ethereum and Polygon implementations of transfer function:

Ethereum:

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

Polygon:

function transfer(address recipient, uint256 amount) public virtual override returns (bool) 
{
_transfer(_msgSender(), recipient, amount);
return true;
}

It is safer and more efficient in all these situations to use a wrapper smart contract that will handle such issues without any extra effort. A wrapper smart contract can wrap a non-standard token, making it behave like a standard token, while at the same time checking the return value of transfer and transferFrom functions and acting accordingly.

OpenZeppelin’s SafeERC20 library can be used as a wrapper for this purpose. This library provides compatibility with certain non-standard ERC-20 tokens that lack boolean return values. Additionally, its transfer functions check the boolean return values of ERC-20 operations and revert the transaction in case of failure.

3. Avoid HardCoding Token Addresses in Case of Multichain Deployment

EVM and Solidity are supported on multiple networks and chains. However, tokens may not have the same address on each chain. As a result, hardcoding a token address into a Solidity contract might work on one chain but not on another. Therefore, dynamically fetching the address based on the environment ensures better interoperability.

For example, the address of WETH on Ethereum is 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, and 0x7ceb23fd6bc0add59e62ac25578270cff1b9f619 on Polygon.

To mitigate this issue, a factory contract or a registry that dynamically tracks and provides the latest contract addresses can be used. This way, contract upgradeability is also facilitated without changing the source code.

Environment-based configuration can also be implemented or conditional statements can be utilized to select the appropriate contract address based on the deployment network when running the deployment script.

4. Consider Security Measures For Tokens Implementing Hooks

ERC-777 stands out as a token standard on the Ethereum blockchain, providing enhanced fungibility, integrated hooks, and callbacks. Notably, ERC-777 maintains backward compatibility with ERC-20 tokens. The core innovation of ERC-777 revolves around receive hook, a function in a contract that is called when tokens are sent to it, meaning accounts and contracts can react to receivingTokens.

While these hooks can be helpful for specific applications, they can be exploited and are prone to both Reentrancy and DOS attacks.

The following example illustrates their vulnerability: A smart contract acting as a malicious token receiver is programmed to revert the transaction, thereby impeding any subsequent execution within the smart contract or any reentry into the contract. This is what is known as a reentrancy attack. Consequently, the contract now has the ability to reject token receptions by reverting transactions during the hook call, causing a Denial of Service attack or a reentrancy attack.

One of the victims of ERC-777 hooks’ abuse was imBTC, which suffered a reentrancy attack in 2020.

To mitigate potential reentrancy or DoS attacks when using these hooks, it is necessary to check the implementation of the tokens needed to interact with and to add security measures according to the token integration.

5. Check the Existence of Optional Methods When Dealing with ERC-20 Tokens

Methods such as name(), symbol() and decimals() are optional in ERC-20 contracts, which means they are not required to be present for an ECR-20 contract to be ERC-20 compliant.

function name() public view returns (string)
function symbol() public view returns (string)
function decimals() public view returns (uint8)

If a contract depends upon these methods and the token has not implemented them, a call for these methods will be reverted.

To mitigate this issue, the use of a try/catch structure is recommended to make sure these functions are present in the token implementation.

6. Consider Measures to Handle Tokens That Have Different Decimal Values

While ERC-20 tokens generally have 18 decimal values, it is important to understand that this is not universally true since dealing with tokens with different decimal values can cause errors in accounting if not handled carefully. Tokens with many decimal values may cause issues due to overflow, while tokens with few decimal values may result in a loss of precision.

Below, you can see some common tokens with their corresponding decimal values:

WBTC — 8 decimal values

USDT — 6 decimals

GUSD — 2 decimals

YAMv2–24 decimals

To mitigate this issue, it is recommended that the decimal values of all tokens be checked, and appropriate handling measures implemented.

7. Prevent Race Condition for ERC-20 Approvals

For the ERC-20 approve function, the procedure through which a user updates the allowance of a particular address to a non-zero value is susceptible to a race condition. Consider the following illustration:

  • Initial Approval: Alice approves Bob to transfer 20 tokens
  • Second Approval: Alice attempts to change the allowance to 10 tokens by calling approve()
  • Bob’s Exploitation: Before Alice’s second transaction is mined, Bob quickly sends a transaction to call transferFrom() with a higher fee to prioritize his transaction.
  • Subsequent Transfer: After Alice’s second transaction, Bob calls transferFrom() again.

If Bob’s first transaction is mined before Alice’s second transaction, Bob could transfer 20 tokens even though Alice intended to reduce the allowance to 10. This race condition allows Bob to gain more tokens than Alice had initially approved.

In order to mitigate this situation, a user must first set the allowance to zero, and then update the allowance, thus preventing updates between non-zero allowances. This allows the user to detect if the allowance was used by the approved user before the new approval.

This mitigation is illustrated in the below example:

function approve(address spender, uint256 amount) external virtual override
returns (bool) {
require(allowances[msg.sender][_spender] == 0);
_approve(msg.sender, spender, amount);
return true;
}

At Thesis Defense, we pride ourselves on our expertise. Our team of security auditors have carried out hundreds of security audits for decentralized systems across a number of technologies including smart contracts, wallets + browser extensions, bridges, node implementations, cryptographic protocols, and dApps. We offer our services within a variety of ecosystems including Bitcoin, Ethereum + EVMs, Stacks, Cosmos / Cosmos SDK, NEAR and more.

To learn more about our services and get a free quote, schedule a call or email us @ defense@thesis.co. For more information about Thesis Defense, visit us on our website, blog and X (Twitter).

--

--