We manage your security to weather the AI storm →

← Resource Hub

ERC-20 Vulnerabilities: Approve Races, Mint Bugs and Hooks

By Owen Thurm Published 6 min read

ERC-20 Vulnerabilities: Approve Races, Mint Bugs and Hooks

Quick answer. ERC-20 bugs cluster in eight places. They are the approve race, unguarded mint and burn paths, supply drifting from balances, bad return values, fee and rebase accounting, reentrant hooks, broken permits and unchecked admin powers.

Watch: ERC-20 Vulnerabilities: Approve Races, Mint Bugs and Hooks 1:43
Video transcript

ERC-20 is a small standard. The bugs come from what teams add on top: fees, mints, burns, blacklists, hooks and signed approvals.

Those bugs cluster in eight places: the approve race, permits, mint and burn paths, supply drift, return values, fee and rebase accounting, hooks and admin powers.

Approve overwrites an allowance, so a front-runner can spend old and new amounts. Leave that fix to user interfaces, and always emit Approval.

Permit breaks in setup and use. A Synthetix vault skipped its initializer, a Manifest token declared the wrong version, and at ULLR, a front-run permit could lock tokens.

Every mint path needs a role and an onchain cap. Supply drifts three ways: different math at Bracket, flash mints at Peapods, and an unchecked setup mint at Clanker.

Hooks hand control to another contract mid-transfer. In April 2020, an attacker used imBTC, an ERC-777 token, to re-enter Lendf.Me and take nearly 25 million dollars.

One token let a blacklisted spender still spend an allowance. Check from, to and the spender, and keep admin roles behind a multisig and a timelock.

A Guardian audit runs three passes over the same code: a manual team, a competing team driving frontier AI, and invariant fuzzing beneath both.

Read the full article for code examples and a review checklist, or talk to Guardian before your token launches.

ERC-20 is the standard interface for fungible tokens on Ethereum. It requires six functions and two events.

The standard is small. The bugs come from what teams add on top: fees, mints, burns, blacklists, hooks and signed approvals.

This guide is for teams writing a token. If you integrate tokens someone else wrote, start with our weird ERC-20 tokens checklist.

Approve Races and Permit

approve overwrites an allowance. A spender who front-runs a change can spend both the old and the new amount.

EIP-20 puts the fix in user interfaces: set the allowance to 0 before setting a new value. It says the contract itself "shouldn't enforce it", for backwards compatibility.

So keep approve standard. Tokens such as USDT and KNC reject a nonzero to nonzero change, and every integrator must work around that.

Emit Approval on every allowance you set; EIP-20 says it MUST fire on any successful approve.

A g8keep token wrote an allowance in its constructor with no event (L-18).

Permit

EIP-2612 adds permit: an allowance set by a signed message instead of a transaction. Each signature carries a deadline and the owner's nonce.

The domain separator ties a signature to one contract and one chain. If it is fixed at deployment, EIP-2612 warns of replay between chains after a chain split.

Setup is where permit breaks:

  • A Synthetix vault never called its permit initializer, leaving an incorrect domain separator (M-03).
  • A Manifest token hashed version "1" but declared "1k", so correctly built permits failed (L-01).

Permits can also be front-run, as EIP-2612 notes. At ULLR, an attacker could front-run a victim's permit and lock their tokens with a near-infinite lock time (High).

Mint, Burn and Supply Bugs

Every token mints and burns somewhere. Each path needs an owner, a limit and a test.

In OpenZeppelin's ERC-20, _mint and _burn are internal, and all changes to transfers, mints and burns belong in _update.

The risk is in how you expose them:

function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
    if (totalSupply() + amount > cap) revert CapExceeded();
    _mint(to, amount);
}

// Never burn another holder's tokens without their allowance
function burnFrom(address from, uint256 amount) external {
    _spendAllowance(from, msg.sender, amount);
    _burn(from, amount);
}

Burns go wrong when the wrong address pays. Peapods' flash mint charged its burn fee to the recipient, not the caller (Critical).

An attacker could name any contract with a fallback as the recipient and burn its whole balance. That raised the value of every remaining token, the attacker's included.

Supply goes wrong in three ways:

  • Different math. Bracket's balances and totalSupply used different per-share rates (High).
  • Flash mints. Peapods' let anyone move totalSupply, and anything priced from it, in one transaction (High).
  • Unchecked setup mints. Clanker checked one balance, not the real totalSupply, so a hidden mint passed (High).

Vault shares are the common case of pricing from supply: see our ERC-4626 security checklist. For incidents, see how infinite mints happen.

Return Values and Events

In EIP-20, transfer, transferFrom and approve return a bool. Callers "MUST handle false", but not all of them do.

So make failure loud: revert on failure and return true on success.

Never drop the return value. USDT, BNB and OMG return none, which breaks interfaces that expect a bool.

Events are part of the interface. Get them exact:

  • Emit Transfer on every transfer, including zero-value ones (EIP-20: MUST).
  • Emit Transfer from the zero address on mint (EIP-20: SHOULD).
  • With a fee, make the events add up to the balance changes.

A g8keep token's Transfer event showed the after-fee amount, which can mislead consumers tracking what left the sender (L-16).

Fees, Rebases and Hooks

A transfer fee or a rebase means the amount sent differs from the amount received. Every protocol that lists your token must then measure balances, not trust amounts.

Many do not. Guardian's public reports repeatedly flag protocols that cannot handle fee-on-transfer or rebasing tokens.

If you need either, document it. Put the fee in _update so no transfer route skips it.

Hooks

ERC-777 added hooks. tokensToSend MUST run before the sender's balance drops, and tokensReceived after the recipient's rises.

Both hand control to another contract mid-transfer. In April 2020, an attacker used imBTC, an ERC-777 token, to re-enter Lendf.Me and take nearly $25 million, Quantstamp reported.

OpenZeppelin deprecated ERC-777 in version 4.9 and removed it in 5.0. The same risk applies to any token that calls recipients, such as ERC-1363.

Integrators carry it too. At Impermax, a bounty paid in an ERC-777 token let the receiver re-enter before the reinvested liquidity reached the position (M-05).

If your token must call out, do it after every state change. Say so in your docs.

Admin and Blacklist Powers

Mint roles, pausers, blacklists and upgrades are features until a key leaks. Then each is a power the attacker inherits.

Blacklists have a classic gap: the spender. transferFrom moves tokens for from, but the caller is a third address.

At raac.io, the token's _update checked only from and to (L-10). A blacklisted spender could still spend an allowance, or get a fresh one through permit.

Check all three addresses:

function _update(address from, address to, uint256 value) internal override {
    if (blacklisted[from] || blacklisted[to] || blacklisted[msg.sender]) {
        revert Blacklisted();
    }
    super._update(from, to, value);
}

If you also block approve for blacklisted addresses, the report suggests still allowing approve(spender, 0) so holders can revoke.

Put mint, pause, blacklist and upgrade roles on a multisig with a timelock. Our token launch security checklist covers keys and roles before a TGE.

ERC-20 Review Checklist

  • approve is standard and emits Approval on every allowance set.
  • permit initializes its domain with the real name, version and chain.
  • Every mint path has a role and an onchain cap.
  • No burn touches another holder's balance without an allowance.
  • Balances sum to totalSupply, as a fuzzed invariant.
  • Transfers and approvals revert on failure and return true.
  • Every transfer, mint and burn emits a matching Transfer.
  • Fees sit in _update, and any fee or rebase is documented.
  • External calls happen only after state changes.
  • Blacklists check from, to and the spender.
  • Admin roles sit behind a multisig and a timelock.

How Guardian Reviews Tokens

A Guardian smart contract audit runs three passes over the same code: two competing teams, one manual and one driving frontier AI, with an invariant suite fuzzing beneath both.

Core safety properties become executable invariants. For a token, that means supply matching balances and every mint staying authorized and capped.

Vanguard adds a Defender Contest, a bug bounty, Web2 security and auditing of post-launch updates.

Frequently Asked Questions

What is the ERC-20 approve race condition?

When an owner changes a nonzero allowance, the spender can front-run it and spend both the old and the new amounts. EIP-20 asks user interfaces to reset it to 0 first, and says the token itself shouldn't enforce that.

Should my token return false or revert when a transfer fails?

Revert, and return true on success. EIP-20 requires callers to handle false, but a revert stops a caller that forgets to check. Never omit the return value: integrations that expect a bool will revert.

Why are token transfer hooks risky?

Hooks such as ERC-777's tokensToSend and tokensReceived hand control to another contract during a transfer, which can then re-enter a protocol mid-update. In April 2020, an attacker used imBTC, an ERC-777 token, to take nearly $25 million from Lendf.Me.

How do I implement permit safely?

Initialize the EIP-712 domain with the token's real name and version, include the chain ID, and enforce the deadline and nonce. Contracts that accept permits should bind them to the user's intent and tolerate a permit that was already used.

Sources

Get your token reviewed before launch

Guardian tests your token's mint paths, allowances and hooks, and leaves you an invariant suite for every later change.