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:
- Free: Paste your contract into
slitherandaderyn. Most of Tier 1–2 will be flagged. Tier 3 you check manually. - 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 →.
- 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.