← All posts
May 25, 2026 · 4 min read · by AEDSC team

The ERC-20 audit checklist you should run before mainnet

The 23 checks every ERC-20 token contract should pass before public sale. Centralization, supply caps, fee math, ERC-2612 permit gotchas, the works.

erc20auditchecklisttokentokenomicssolidity

ERC-20 looks simple. Six functions, two events, done. And yet ~30% of all bugs we catch at AEDSC are in ERC-20 contracts — including some of the most expensive ones (USDT-style return values, owner mints with no cap, fee-on-transfer composability breaks).

Here's the 23-point checklist we run on every ERC-20 token contract before it touches mainnet, broken down by impact tier.

Tier 1 — ship-blockers (must fix)

1. Total supply has a hardcoded cap

If mint() can be called by an owner with no upper bound, the token is one compromised key away from infinite dilution. Etherscan auto-flags this and most aggregators downrank you.

uint256 public constant MAX_SUPPLY = 1_000_000_000 ether;

function mint(address to, uint256 amount) external {
    require(totalSupply() + amount <= MAX_SUPPLY, "cap");
    _mint(to, amount);
}

2. Mint, pause, blacklist functions are behind a timelock

A multisig is not enough. A 48h TimelockController (OpenZeppelin) is the standard. This gives users time to exit if you announce a hostile action.

3. Decimals declared and consistent everywhere

decimals() returning 18 but your math using 6-decimal fixed-point will tank your token's USDC liquidity. Use OpenZeppelin's ERC20 base and override decimals() if you need non-18.

4. transferFrom decrements the allowance correctly

If you override _spendAllowance, do it the OpenZeppelin way — type(uint256).max is the infinite-approval sentinel and skipping that check breaks DEX integrations.

5. No reentrancy in fee-collection or rebase logic

Custom fee mechanisms often call out to a Treasury contract or a swap router. Treat every transfer like an external call. Either keep fee math purely internal or guard with nonReentrant.

Tier 2 — high-priority (catch them now or pay later)

6. _burn doesn't underflow totalSupply

Solidity 0.8 reverts on underflow, but a token with unchecked blocks can still bug out. Test that burning more than balanceOf reverts cleanly.

7. permit (ERC-2612) DOMAIN_SEPARATOR is correct

If you implement permit, the EIP-712 domain separator must be initialized on chainId change (handle hard forks). Most implementations cache it. We've caught 2 token deploys where a typo here let permit forgery in 2026.

8. No block.timestamp in critical conditionals

Snapshot dates, vesting unlocks, fee rate changes — all OK with block.timestamp because miners can only shift by ~15s. But if (block.timestamp == X) is a code smell.

9. transfer(address(0)) reverts or burns explicitly

Implicit "send to zero" is how tokens get permanently lost. Either revert in _transfer if to == address(0) or document that it's a burn.

10. approve race condition mitigation present

approve(spender, X) to approve(spender, Y) is the classic race. OpenZeppelin's ERC20 doesn't fix this — you need increaseAllowance / decreaseAllowance for sensitive use cases.

11. Fee-on-transfer or rebase semantics documented

If your token deducts a fee, aggregators (1inch, Paraswap) need to know. Most break silently. If you intend to ship fee-on-transfer, set _BURN_FEE_BPS as a public constant.

12. Owner can't suddenly enable fees mid-life

A constructor-only bool feesEnabled = false that an owner can flip is a soft rug. Either commit at deploy time (immutable) or put behind a 7-day timelock.

Tier 3 — operational hygiene (cheap to fix, prevents bug tickets later)

13. Pragma pinned

pragma solidity 0.8.24; not ^0.8.0. Compiler bug-fix releases shouldn't surprise you.

14. License declared in SPDX

// SPDX-License-Identifier: MIT (or your choice). Required for Etherscan verification.

15. NatSpec on every external function

/// @notice and /// @param for each. Wallets render these in transaction confirmations.

16. Events for ownership transfers and role grants

OwnershipTransferred, RoleGranted. Off-chain indexers need them.

17. No selfdestruct reachable

Even with a multisig owner, a reachable selfdestruct is a one-shot drain. Don't include it unless you have a very specific upgrade pattern that requires it.

18. Constructor mints to a known address, not msg.sender

_mint(initialReceiver, MAX_SUPPLY) where initialReceiver is the multisig, not whoever deploys. Prevents leak of all tokens if deployer key is compromised.

19. Public functions that should be external are marked external

Slither flags this; ~5 gas saved per call. Trivial but auditors notice.

20. No floating constants

uint256 fee = amount * 100 / 10000; should be FEE_BPS = 100; with a named constant.

21. recoverERC20(token, amount) exists for stuck-token recovery

If users accidentally send other ERC-20s to your contract, you need a way to return them. Single owner-only function, well-known pattern.

22. Tests cover at minimum: transfer, approve, transferFrom, mint cap, fee math

Use Foundry. 10 tests is enough for confidence on a basic ERC-20.

23. Etherscan verification ready

Single-file or hardhat-flatten output, license set, optimizer settings match. If you can't verify, no one will integrate.

How to actually run this checklist

Three options, ordered by cost:

  1. Free: Paste your contract into slither and aderyn. Most of Tier 1–2 will be flagged. Tier 3 you check manually.
  2. AEDSC ($19/mo founding seat): we run all of the above, plus Mythril, and you get a markdown report with fix diffs. Drop a contract here →.
  3. Human audit ($10k–$50k): Trail of Bits, Cyfrin, Halborn. For tokens with significant TVL, this is non-negotiable.

For most pre-launch tokens, AEDSC + a focused human audit at the architectural level is the right combo. AEDSC catches the 23-point list above; the auditor focuses on economic + governance design.

Check your token now — free → · See a sample report →