AMMs and Weird ERC-20 Tokens

The danger of non-standard ERC-20 tokens to AMM protocols.

Introduction

Many protocols allow for the usage of ERC-20 tokens with non-standard behavior (e.g., > 18 decimals), also called “weird ERC-20 tokens”, which commonly lead to unexpected behavior.

In this post, I will walk through the dangers of the most common types of weird ERC-20 tokens, and the impact they can have, especially to AMMs, and real-world exploits to learn from.

1. Fee-on-transfer

Fee-on-transfer (FoT) tokens (e.g., PAXG) deduct a fee from the transfer amount before sending it to the recipient, which is normally implemented in the transfer/transferFrom function:

contract FeeToken {
    mapping(address => uint256) public balanceOf;
    uint256 public constant FEE_BPS = 100; // 1%
    address public feeCollector;

    function transfer(address to, uint256 amount) external returns (bool) {
        uint256 fee = amount * FEE_BPS / 10_000;

        balanceOf[msg.sender] -= amount;    // sender loses 100
        balanceOf[to] += amount - fee;      // recipient gets 99
        balanceOf[feeCollector] += fee;     // 1 goes elsewhere
        return true;
    }
}

Info

USDT currently has a fee-on-transfer functionality implemented but not yet activated.

The danger, therefore, lies in the recipient assuming that they have received the entire amount, rather than the fee-deducted amount.

Case study: Balancer STA/STONK pools, ~$523k drained

In June 2020, Balancer v1’s swap function didn’t properly check how many tokens were actually received after transfers, allowing an attacker to inflate the internal accounting of each pool containing a FoT token. The attack method was as follows:

STA/WETH pool

  1. Repeatedly swap STA out of the pool until its actual balance is tiny, making it very expensive.
  2. Swap a dust amount of STA in, with the amount recorded in full by the pool, but in actuality the entire amount sent was deducted by the fee (due to FoT). Since it’s expensive, it buys a large amount of WETH, even though no STA was received.
  3. Call gulp(STA) (as shown in the snippet below), which resets the STA balance back to its tiny balance (since none was actually sent in step 2), so STA remains expensive.
  4. Repeat steps 2-3 until WETH is drained.
function swapExactAmountIn(
    address tokenIn, uint tokenAmountIn,
    address tokenOut, uint minAmountOut, uint maxPrice
) external _lock_ returns (uint tokenAmountOut, uint spotPriceAfter) {
    Record storage inRecord  = _records[tokenIn];
    Record storage outRecord = _records[tokenOut];

    // Price is calculated from the internal accounting, not balanceOf
    tokenAmountOut = calcOutGivenIn(
        inRecord.balance, inRecord.denorm,
        outRecord.balance, outRecord.denorm,
        tokenAmountIn, _swapFee
    );
    require(tokenAmountOut >= minAmountOut, "ERR_LIMIT_OUT");

    // Credit full tokenIn specified
    inRecord.balance  = badd(inRecord.balance, tokenAmountIn);
    outRecord.balance = bsub(outRecord.balance, tokenAmountOut);

    // but STA's 1% fee means the pool receives less
    _pullUnderlying(tokenIn, msg.sender, tokenAmountIn);
    _pushUnderlying(tokenOut, msg.sender, tokenAmountOut);
}

function _pullUnderlying(address erc20, address from, uint amount) internal {
    bool xfer = IERC20(erc20).transferFrom(from, address(this), amount);
    require(xfer, "ERR_ERC20_FALSE");
    // Missing check of what actually arrived
}

function gulp(address token) external _lock_ {
    require(_records[token].bound, "ERR_NOT_BOUND");
    _records[token].balance = IERC20(token).balanceOf(address(this));
}

The solution: Always check the contract’s balance before and after receiving tokens:

uint256 balanceBefore = token.balanceOf(address(this));
token.safeTransferFrom(msg.sender, address(this), amount);
uint256 received = token.balanceOf(address(this)) - balanceBefore;

// use actual `received` amount
reserve += received;

2. Rebasing/airdrops

Rebasing tokens (e.g., yield-bearing tokens such as Lido stETH) and airdrops automatically change the balance of the token held. If the internal accounting is not up to date, then that can create a mismatch with the actual balance stored in the contract, leading to dangerous states such as stale reserves.

Case study: ElasticSwap exploit, ~$850k drained

In December 2022, ElasticSwap suffered from an accounting bug where removeLiquidity calculated the payout from live balanceOf, then subtracted it from cached internalBalances.

function removeLiquidity(uint256 _liquidityTokenQty, ...) external nonReentrant {
    // Payout is computed from live balances
    uint256 baseTokenReserveQty  = IERC20(baseToken).balanceOf(address(this));
    uint256 quoteTokenReserveQty = IERC20(quoteToken).balanceOf(address(this));

    uint256 baseTokenQtyToReturn  = (_liquidityTokenQty * baseTokenReserveQty)  / totalSupply;
    uint256 quoteTokenQtyToReturn = (_liquidityTokenQty * quoteTokenReserveQty) / totalSupply;

    // but subtracted from the cached reserve internalBalances
    if (quoteTokenQtyToReturn > internalBalances.quoteTokenReserveQty) {
        internalBalances.quoteTokenReserveQty = 0;
    } else {
        internalBalances.quoteTokenReserveQty -= quoteTokenQtyToReturn;
    }

    internalBalances.kLast =
        internalBalances.baseTokenReserveQty * internalBalances.quoteTokenReserveQty;

    _burn(msg.sender, _liquidityTokenQty);
    IERC20(baseToken).safeTransfer(_tokenRecipient, baseTokenQtyToReturn);
    IERC20(quoteToken).safeTransfer(_tokenRecipient, quoteTokenQtyToReturn);
}

The protocol specifically used live balances with a cache to detect rebasing. But the issue is, also allowing this for non-rebasing tokens can lead to price manipulation via donation attacks.

The attack method was as follows:

AMPL/USDC pool

  1. Flash loan AMPL and USDC from Uniswap v2 pools.
  2. Add liquidity with both tokens to create an LP position.
  3. Transfer USDC directly to the pool, which makes balanceOf(USDC) increase, while internalBalances[USDC] remains the same.
  4. Call removeLiquidity. The payout is calculated from the inflated live USDC balance, then subtracted from the smaller cached reserve. The cached USDC reserve ends up close to zero.
  5. Swap a small amount of USDC for AMPL. Since the price is based on the near-zero cached USDC reserve, a small amount of USDC buys most of the pool’s AMPL.
  6. Use all of the stolen AMPL and a tiny amount of USDC to add liquidity, minting as many shares as possible. Then, call removeLiquidity, which transfers the entire AMPL balance plus almost all USDC in the contract, as removeLiquidity uses the live balance:
USDC payout = attacker's LP share × balanceOf(USDC)

The solution: Avoid mixing cached reserves and live balances in the same calculation. Consider using the wrapped, non-rebasing version of rebasing tokens, and watch out for donation attacks.

3. ERC-777 hooks

ERC-777 tokens (e.g., LUKSO) include transfer hooks, which allow for the execution of additional logic when sending/receiving these tokens, introducing reentrancy attack vectors.

Case study: Uniswap v1 imBTC pool, ~$300k stolen

In April 2020, a Uniswap v1 imBTC (ERC-777)/ETH pool suffered from a reentrancy bug, where an attacker called the tokenToEthInput function to swap imBTC to ETH, and then reentered the same function within the transferFrom hook, before imBTC is actually sent:

# uniswap_exchange.vy (v1)
def tokenToEthInput(tokens_sold, min_eth, deadline, buyer, recipient):
    token_reserve: uint256 = self.token.balanceOf(self)           # live balance
    eth_bought: uint256 = self.getInputPrice(tokens_sold, token_reserve, self.balance)
    send(recipient, eth_bought)                                   # 1. ETH out
    assert self.token.transferFrom(buyer, self, tokens_sold)      # 2. tokens in → hook fires here

The result is that upon each tokenToEthInput reentrancy, ETH is sent out but imBTC remains, making imBTC appear more expensive than it should. For example:

Real state: 110 imBTC / 90.91 ETH → imBTC is worth 0.83 ETH
What the pool sees: 100 imBTC / 90.91 ETH → imBTC is worth 0.91 ETH

This allows the attacker to take far more ETH for a lesser amount of imBTC than they should have been able to.

The solution: Always follow checks-effects-interactions (e.g., update all states and receive tokens before sending tokens out). For extra protection, add a reentrancy lock to each function that changes reserves.

4. Multi-entrypoint

Multi-entrypoint tokens are tokens with more than one address that share and update the same underlying balance.

For example, CELO token acts as both a native token and ERC-20 token on the CELO blockchain. Both represent the same balance, and changes in one affect the other.

The danger here is when a smart contract treats both tokens as separate assets.

Case study: Uniswap v4 C-01 (OpenZeppelin audit, 2024)

Vulnerable code:

function sync(Currency currency) public returns (uint256 balance) {
    balance = currency.balanceOfSelf();
    currency.setReserves(balance);              // snapshot the balance
}

function settle(Currency currency) external payable onlyWhenUnlocked returns (uint256 paid) {
    if (currency.isNative()) {
        paid = msg.value;                       // if native currency: take msg.value
    } else {
        uint256 reservesBefore = currency.getReserves();
        uint256 reservesNow = sync(currency);
        paid = reservesNow - reservesBefore;    // ERC-20: measure the balance delta
    }
    _accountDelta(currency, paid.toInt128(), msg.sender);
}

Using CELO token (which can be accessed through both native and ERC-20 interfaces), an attacker can execute the following:

  1. sync(CELO_ERC20): snapshot the Pool Manager’s CELO balance.
  2. settle{value: 1000}(CELO_NATIVE): sends 1,000 native CELO from the attacker to the Pool Manager (PM) contract. The PM then credits the attacker with 1,000 native CELO.
  3. settle(CELO_ERC20): The settle function sees that the CELO ERC-20 balance has increased by 1,000, because the reservesNow has increased by 1,000 since the last sync (from step 1). Therefore, paid = reservesNow - reservesBefore. The attacker is credited an extra 1,000 tokens.
  4. Call take(CELO_ERC20, attacker, 1000) and take(CELO_NATIVE, attacker, 1000). The PM proceeds to send the attacker a total of 2,000 CELO, since it interprets them differently.

Starting with 1,000 CELO, the attacker could end up with 2,000. They can repeat the above attack until the entire balance of CELO is drained.

The solution: Ensure multiple representations of the same asset are credited together. For example, in the above case study, the solution was to require each sync() to be followed by a settle() for the same token only.

5. High and low decimal tokens

Normally, most commonly used ERC-20 tokens have 18 decimals (except USDC and WBTC, which have 6 and 8 decimals respectively on most chains). However, some tokens can have very low decimals (e.g., GUSD with 2 decimals) or high decimals (e.g., YAM-V2 with 24 decimals).

The issue is that this can lead to unexpected behavior such as incorrect calculations, rounding issues, and overflow (e.g., with high decimal tokens).

The solution: Normalize every token to a common precision and always test with extreme decimals. For example:

uint256 usdcNorm = usdcBalance * 10**(18 - 6);   // 1e9 * 1e12 = 1e21 (usdc has 6 decimals)
uint256 daiNorm  = daiBalance  * 10**(18 - 18);  // 1e21 (dai has 18 decimals)

6. Blocklists/pause

Some tokens such as USDT and USDC contain blocklist/pause ability, which can revert transfers. Any function that needs that transfer can therefore be blocked, which can lead to issues such as locked funds or DoS.

The solution: Ensure that in the case a token is paused, any other important operation can still execute. Consider using pull-over-push when handling payments. For example:

function swap(...) external {
    ...
    owed[feeRecipient] += fee;              // fee payment cannot block the swap
}

function claim() external {
    uint256 amount = owed[msg.sender];
    owed[msg.sender] = 0;
    usdc.safeTransfer(msg.sender, amount);  // if this reverts, only the claim fails, rather than the swap
}

7. Missing/false return values

According to the ERC-20 standard, the transfer() and transferFrom() functions should return a boolean indicating whether the transfer has succeeded. However, some tokens, such as USDT (on Ethereum Mainnet), do not return a value, whereas others may return false instead of reverting if the transfer fails. The danger is that if a contract expects each token to follow the ERC-20 standard, then transfers may silently fail or unexpectedly revert.

The solution: Consider using OpenZeppelin’s SafeERC20. Alternatively, use a low-level call, following Uniswap v2’s implementation:

bytes4 private constant SELECTOR = bytes4(keccak256(bytes("transfer(address,uint256)")));
...
(bool success, bytes memory data) =
    token.call(abi.encodeWithSelector(SELECTOR, to, value));
require(success && (data.length == 0 || abi.decode(data, (bool))), "TRANSFER_FAILED");

8. Zero-value transfer reverts

According to the ERC-20 standard, transfers of 0 amount should succeed. However, some tokens, such as LEND, instead revert on 0 amount transfers.

The danger here is that this can lead to DoS. For example, when a contract attempts to transfer a fee or reward that has rounded down to 0, the transfer can revert.

The solution: Skip transfers if the amount is 0, as done by Uniswap v2:

if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out);

Conclusion

Weird ERC-20 tokens create unexpected behavior throughout all of DeFi. AMMs commonly allow these tokens, as supporting many different assets increases trading opportunities and potential fee revenue for LPs. Understanding their unique behaviors can help prevent vulnerabilities that lead to catastrophic losses.

Test your understanding

Are you ready to handle weird ERC-20 tokens safely? Test your knowledge with the questions below.

Alice swaps 100 fee-on-transfer tokens into an AMM, but the pool only receives 99. What happens if the AMM calculates the output using 100?

An AMM uses live balances for withdrawals but cached reserves for pricing to support rebasing tokens. What happens if someone directly donates a non-rebasing token to the pool?

An AMM sends ETH to a trader before calling transferFrom() to receive their ERC-777 tokens. Why can this be dangerous?

A pool manager supports both native CELO and its ERC-20 representation, which share the same underlying balance. What vulnerability could arise?

An AMM interacts with ERC-20 tokens that may return false or no value when transferring. What can happen if these cases aren’t handled correctly?