The Audit Checklist
Checklists exist for surgeons, pilots, and nuclear plant operators for one reason: under time pressure, experts skip steps they know they should do. A well-designed checklist does not replace expertise — it ensures that expertise is consistently applied. In competitive audits where you have 7 days and dozens of contracts, a checklist prevents you from spending all your time on reentrancy and forgetting to check oracle staleness on the last day. This lesson presents a comprehensive 60+ item checklist organized by category, with context for each item.
Do not use this as a replacement for understanding the protocol. Use it as a safety net during your final review pass. After you have read all the code and identified findings, run through the checklist to ensure you have not missed an entire category. For each item: (1) confirm it applies to this codebase, (2) check whether the issue exists, (3) mark it done or flag it as a finding.
Category 1: Access Control (10 Items)
// AC-1: Are all privileged functions protected by access control?
// grep for: function.*external without onlyOwner/onlyRole/require(msg.sender ==)
// AC-2: Is the access control modifier at the FUNCTION level, not just caller check inside?
function badExample() external {
// Modifier should be here ↑
if (msg.sender != owner) return; // ❌ Silent return instead of revert
}
// AC-3: Can roles be granted to address(0)? → Effective permanent loss of admin role
require(newOwner != address(0)); // ✅ Must exist
// AC-4: Is the initialize() function of upgradeable contracts protected?
// Unprotected initialize() on implementation contract → takeover
function initialize() external initializer { // ✅ initializer modifier required
// AC-5: Is there a two-step ownership transfer? (transferOwnership + acceptOwnership)
// One-step transfer can permanently brick if wrong address is set
// AC-6: Are role revocations possible? Can a role holder revoke their own role?
// AccessControl.renounceRole() — verify this is intentional behavior
// AC-7: Is there separation between admin roles?
// OPERATOR_ROLE (day-to-day), ADMIN_ROLE (parameter changes), EMERGENCY_ROLE
// AC-8: Can the timelock be bypassed via any admin function?
// grep for: onlyOwner functions that change critical state without timelock
// AC-9: Is msg.sender checked against the correct state variable?
// Common bug: checking against a stale cached admin address
// AC-10: For proxies, is there a storage collision between admin slot and logic slot?
// EIP-1967 specifies admin slot at: bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1)
Category 2: Arithmetic and Math (8 Items)
// MATH-1: Division before multiplication (precision loss)
// ❌ Wrong: (a / b) * c — loses precision if a < b
// ✅ Correct: a * c / b — multiply first, then divide
uint256 wrong = (amount / PRECISION) * rate;
uint256 right = amount * rate / PRECISION;
// MATH-2: Unchecked blocks — are all operations within actually safe?
unchecked {
totalSupply += amount; // ❌ Can overflow if totalSupply near max
i++; // ✅ Safe for loop counter that cannot overflow
}
// MATH-3: Integer truncation in critical calculations
// shares = assets * totalShares / totalAssets
// If totalAssets = 1e18 + 1, result truncated by up to 1 — is this a rounding attack?
// MATH-4: Rounding direction — does it favor the protocol or the user?
// Rounding up on debt (user owes more), rounding down on shares (user gets less)
// Both should favor protocol to prevent loss accumulation
// MATH-5: Type casting — does uint256 → uint128 safely check for overflow?
uint128 safeAmount = SafeCast.toUint128(largeAmount); // ✅
uint128 unsafeAmount = uint128(largeAmount); // ❌ Silently truncates
// MATH-6: Price oracle multiplication overflow
// amount (1e18) * price (1e8 Chainlink) = 1e26 — does this fit in uint256? Yes.
// But: amount (1e18) * price (1e18) * ratio (1e18) = 1e54 — OVERFLOW
// MATH-7: ERC4626 / vault share inflation attack
// If totalSupply = 0 and attacker sends 1 wei directly to vault:
// First depositor gets shares = amount * 0 / 1 = 0 shares! (if not handled)
// MATH-8: Compound interest precision — does borrowIndex use enough precision?
// Per-block accrual on low values can round to 0 for months before anything accrues
Category 3: External Calls and Reentrancy (10 Items)
// RE-1: Checks-Effects-Interactions pattern — all state updated before external calls?
// grep for: .call{value/ .transfer( / IERC20.transfer( AFTER state changes
// RE-2: nonReentrant modifier on all state-changing external functions?
// At minimum: deposit, withdraw, borrow, repay, liquidate
// RE-3: Cross-function reentrancy — can a reentrant call invoke a DIFFERENT function?
function withdraw() external nonReentrant {
// nonReentrant prevents re-entering withdraw()
// But can attacker reenter borrow() or liquidate()? — separate reentrancy state
}
// RE-4: ETH transfers — using .call{value:} with gas stipend check?
bool success = payable(recipient).call{value: amount}("");
require(success, "ETH transfer failed"); // ✅ Check return value
// RE-5: ERC721.safeTransferFrom() triggers onERC721Received — is this handled?
// Use transferFrom() if callback is not needed and reentrancy is a concern
// RE-6: Read-only reentrancy — does any view function read external state mid-execution?
// Curve get_virtual_price() example — check for similar patterns
// RE-7: Low-level calls — is return value checked? Is calldata sanitized?
// (bool success, bytes memory data) = target.call(data);
// require(success) — must be present
// RE-8: try/catch blocks — does a failed external call brick the protocol?
try oracle.getPrice() returns (uint256 price) {
// use price
} catch {
revert("Oracle unavailable"); // ✅ Fail safely, don't silently use 0
}
// RE-9: Callback-based patterns (flash loans, flash swaps) — can attacker control callback?
// The callback receiver is msg.sender — fully untrusted
// RE-10: Delegate calls — does delegatecall target change storage in unexpected slots?
Category 4: Token Handling (8 Items)
// TOK-1: SafeERC20 used for all token transfers?
// token.safeTransfer(), token.safeTransferFrom(), token.safeApprove()
// TOK-2: Balance delta measured for deposits (handles fee-on-transfer tokens)?
uint256 before = token.balanceOf(address(this));
token.transferFrom(user, address(this), amount);
uint256 received = token.balanceOf(address(this)) - before; // ✅
// TOK-3: Rebasing token shares tracked (not absolute amounts)?
// TOK-4: Token decimals read dynamically, not assumed to be 18?
uint8 decimals = IERC20Metadata(token).decimals(); // ✅
// TOK-5: ERC777 tokens — is tokensReceived() a reentrancy vector here?
// TOK-6: ERC20.approve() race condition handled?
// Reset to 0 before setting new value, or use increaseAllowance/decreaseAllowance
// TOK-7: Is the zero address accepted as a token? Does address(0) have special behavior?
require(token != address(0), "Invalid token"); // ✅ Should exist on whitelist/validation
// TOK-8: Can a malicious ERC20 token reenter the protocol via transfer callbacks?
// Only a risk if token whitelist is open or ERC777-compatible tokens are accepted
Category 5: Oracle and Price Feed (8 Items)
// ORA-1: Chainlink latestRoundData() has all 5 safety checks?
// answer > 0, answeredInRound >= roundId, updatedAt freshness,
// updatedAt != 0, price within min/max circuit breaker bounds
// ORA-2: Is spot price from a DEX pool used as an oracle? → Critical finding
// ORA-3: Is TWAP window long enough for the pool's liquidity?
// min 30 minutes for high-liquidity pools, longer for thin pools
// ORA-4: Is there a fallback oracle if primary fails?
// ORA-5: Can oracle updates be front-run? (Synthetix-style oracle front-running)
// ORA-6: Are prices denominated in the same currency across all uses?
// Mixing USD-denominated and ETH-denominated prices without conversion → error
// ORA-7: If oracle reverts, does it propagate and brick the entire protocol?
try oracle.getPrice() returns (uint256 p) { ... }
catch { revert("Price unavailable"); } // ✅ Controlled failure
// ORA-8: Protocol-owned oracle (admin-controlled price) — is this centralization risk documented?
Categories 6–9: Gas, Randomness, Upgradability, Governance
| Category | Item | What to Check |
|---|---|---|
| Gas/DoS | GAS-1 | Unbounded loops — can an attacker add enough entries to make loop OOG? |
| Gas/DoS | GAS-2 | Push-based payments — use pull pattern to isolate individual failures |
| Gas/DoS | GAS-3 | Block gas limit — can single function hit 30M gas limit? |
| Gas/DoS | GAS-4 | Mapping vs array — unbounded arrays are DoS vectors |
| Randomness | RNG-1 | block.timestamp or blockhash used for randomness? → miner-manipulable |
| Randomness | RNG-2 | Chainlink VRF used correctly? (commit-reveal, subscription funded) |
| Upgradability | UPG-1 | Storage layout preserved between upgrades? (use OpenZeppelin storage gap) |
| Upgradability | UPG-2 | Constructor logic moved to initialize()? No constructor in upgradeable contracts |
| Upgradability | UPG-3 | initializer modifier on initialize() prevents re-initialization? |
| Upgradability | UPG-4 | Is upgradeability protected by timelock and multisig? |
| Governance | GOV-1 | Voting power snapshot at proposal creation, not execution time? |
| Governance | GOV-2 | Timelock minimum delay enforced with hardcoded constant? |
| Governance | GOV-3 | Quorum cannot be set to 0 through governance proposal? |
General Solidity Pitfalls (10 Items)
// SOL-1: tx.origin used for authentication? → phishing attack vector
require(msg.sender == owner); // ✅
require(tx.origin == owner); // ❌ Dangerous
// SOL-2: block.timestamp used for time-sensitive logic?
// Validators can manipulate timestamp by ~15 seconds in either direction
// Fine for deadlines measured in hours, risky for subsecond precision
// SOL-3: Signature malleability — are ECDSA signatures checked with OpenZeppelin ECDSA?
// Raw ecrecover allows s-value manipulation; OZ ECDSA library prevents this
// SOL-4: Front-running vulnerability — does function outcome depend on ordering?
// Commit-reveal schemes or AMM slippage params as mitigation
// SOL-5: Emit events for all state-changing operations?
// Missing events make off-chain monitoring impossible → RE (Repudiation) threat
// SOL-6: assert() vs require() — assert() should only be used for invariants
// assert() consumes ALL gas (pre-0.8.0 behavior, now uses Panic)
// SOL-7: Self-destruct (deprecated but still in older code) — can it be called?
// selfdestruct sends ETH balance — could brick protocols relying on the contract
// SOL-8: delegatecall to user-controlled address?
// Allows arbitrary code to run in the protocol's storage context → critical
// SOL-9: Visibility — are all functions correctly marked?
// internal functions that should be private, public that should be external
// SOL-10: Immutables and constants — are magic numbers explained or named?
// uint256 constant MAX_LTV = 8000; // 80% in basis points ← ✅
// return amount * 8000 / 10000; // What is 8000? ← ❌
Checklist for Common Protocol Types
| Protocol Type | Extra Items Beyond Generic Checklist |
|---|---|
| ERC20 Token | allowance race condition, max supply enforcement, mint access control, pausability DoS |
| NFT (ERC721) | onERC721Received reentrancy, tokenId collision, royalty enforcement, metadata immutability |
| DEX / AMM | k-invariant check after all paths, slippage params, fee-on-transfer tokens, read-only reentrancy |
| Lending Protocol | accrueInterest() before health checks, oracle circuit breaker, fee token balance delta, pause blocks repayment |
| Bridge | message validation, replay protection with chainId, ecrecover(0) check, amount decimal normalization |
| Governance | snapshot at proposal creation, timelock minimum delay, quorum cannot be 0, parameter change minimums |
| ERC4626 Vault | share inflation attack, totalAssets includes donated tokens, fee-on-transfer collateral, virtual offset |
| Upgradeable Proxy | storage slot collision, initialize() protection, implementation self-destruct, selfselector clash |
Checklist Item: Upgradeable Contract Storage Layout
// ❌ DANGEROUS: Adding storage to base contract breaks derived storage
contract BaseV1 {
uint256 public value; // Slot 0
// storage gap for upgrades
uint256[49] private __gap; // Slots 1-49
}
contract DerivedV1 is BaseV1 {
address public owner; // Slot 50 (after gap)
}
// Upgrade: Add a variable to BaseV1 WITHOUT storage gap
contract BaseV2_BUGGY {
uint256 public value; // Slot 0
uint256 public newVar; // Slot 1 ← COLLISION! Was part of __gap
uint256[48] private __gap; // Slots 2-49
}
// DerivedV1.owner is still at Slot 50 — no collision here
// But BaseV1's slot 1 (formerly part of __gap) now has a new meaning
// If __gap was used as padding, old state at slot 1 is now interpreted as newVar
// ✅ CORRECT: Use storage gap pattern correctly
// Never insert variables in the MIDDLE of base contract storage
// Always append at end; consume from __gap
// Use OpenZeppelin Upgrades plugin to check storage layout compatibility
$ npx hardhat check --network hardhat # checks storage compatibility
Automated Tooling to Complement the Checklist
Slither: Covers reentrancy, unchecked calls, tx.origin, missing return values, integer overflow, and more (~40% of checklist items automated).
4naly3er: Generates an automated report from common detectors — good first-pass before manual review.
aderyn: Rust-based analyzer with different detector coverage than Slither — run both.
Semgrep: Custom rules for protocol-specific patterns — useful for checking a specific issue across a large codebase.
No tool covers logical bugs, protocol-specific invariant violations, or economic attacks. Those require the human auditor.
Building Your Personal Checklist
// After every contest: add to your personal checklist
// Format for each item:
// [CATEGORY] [ITEM] [SEVERITY] [SOURCE]
// Example entries you should add after studying this course:
// [ORACLE] Chainlink circuit breaker bounds not checked [HIGH] [Lesson 5-3]
// grep: latestRoundData, check for minAnswer/maxAnswer comparison
// [BRIDGE] ecrecover not checked for address(0) result [HIGH] [Lesson 5-4]
// grep: ecrecover, ECDSA.recover; check: require(signer != address(0))
// [TOKEN] Fee-on-transfer: balance delta not measured [HIGH] [Lesson 5-6]
// grep: transferFrom; check: balanceBefore/balanceAfter pattern
// [GOVERNANCE] Voting power read at execution not snapshot [CRITICAL] [Lesson 5-5]
// grep: balanceOf in vote counting; check: getPriorVotes with snapshotBlock
// [VAULT] No virtual offset in ERC4626 [MEDIUM] [Lesson 5-6]
// grep: totalSupply() == 0 branch; check: virtual offset or dead address burn
// Over 6 months, your checklist grows to 100+ personalized items
// Each item has: grep pattern, what to check, severity, which lesson/contest you learned it from
Cross-Protocol Interaction Checklist
Modern DeFi protocols do not exist in isolation — they call into each other, use shared liquidity, and rely on external governance decisions. This interaction surface is responsible for a growing percentage of high-value exploits. The following checklist items specifically address cross-protocol risk.
// CROSS-PROTOCOL INTERACTION CHECKLIST
// =====================================
// CP-1: External Contract Upgradability
// Are any external contracts called by this protocol upgradeable?
// If yes: is there protection if the upstream contract is upgraded maliciously?
// grep: IExternalProtocol(address("0x...")) ← hardcoded addresses are safer
// check: is the external address immutable or can an admin change it?
// CP-2: Flash Loan Integration Risk
// Does the protocol integrate with flash loan providers (Aave, dYdX, Uniswap)?
// Trace: can a user obtain flash loan then call any sensitive function in same tx?
// check: governance snapshot timing, liquidation timing, oracle update timing
// CP-3: Price Impact Assumptions
// Does the protocol assume it can swap any size without price impact?
// check: rebalance(), harvest(), liquidate() — are they MEV-sandwichable?
// grep: swap(, check: amountOutMin != 0
// CP-4: Composability Invariants
// Does the protocol maintain invariants that can be violated via external calls?
// Example: "totalDeposits == sum of individual deposits"
// Can a reentrant call violate this before both sides update?
// CP-5: Protocol Shutdown / Pause Propagation
// If Protocol A (dependency) pauses, what happens to Protocol B (this contract)?
// check: does a call to paused external contract propagate a revert?
// check: are there try/catch blocks around external calls that can fail?
// CP-6: Callback / Hook Reentrancy
// Does any external call give control to an unknown address?
// Pattern: safeTransferFrom (ERC721/1155), tokensReceived (ERC777)
// onERC1155Received, flash loan callback, Uniswap swap callback
// check: is all state written BEFORE any external call that passes control?
Time-Boxed Checklist Execution
A checklist is only useful if it gets executed. In a time-pressured audit, you must allocate checklist time explicitly — otherwise the deep-dive on one function eats all available time and you never run the checklist. Here is a time-boxed execution approach for a 7-day audit.
| Day | Checklist Activity | Time Budget | Output |
|---|---|---|---|
| Day 1 | Run automated tools (Slither, aderyn, 4naly3er) | 1-2 hours | Automated report, triage results |
| Day 2 | Access Control section (AC-1 through AC-10) | 2 hours | All AC items checked or flagged |
| Day 3 | Arithmetic + External Calls sections | 3 hours | Math checks done, reentrancy paths traced |
| Day 4 | Token + Oracle sections (if applicable) | 2 hours | Token interactions verified, oracle checks confirmed |
| Day 5 | Protocol-specific items (bridge/AMM/lending) | 2 hours | Domain-specific items complete |
| Day 6 | Full checklist rapid-fire review | 2 hours | Any remaining unchecked items investigated |
| Day 7 | Write PoC for top findings, submit | All day | Submitted findings with PoC tests |
Some auditors become so focused on completing every checklist item that they fail to follow promising leads deeply. Checklists are a floor, not a ceiling. If while checking AC-3 you notice unusual delegate call patterns, pivot and investigate — do not finish the checklist first. Mark the current item as "pending investigation" and follow the lead. Mechanical checklist completion at the cost of depth is a beginner's trap. The checklist catches the patterns you know; your intuition catches the ones you don't yet have patterns for.
Key Takeaways
- Checklists are safety nets, not replacements for understanding — use them on your final review pass, not as a substitute for threat modeling and deep reading.
- Organize your checklist by category so that under time pressure you can quickly identify which categories you have covered and which you have not.
- Access control issues and external call / reentrancy issues are the two highest-yield categories for Critical and High findings.
- Automated tools (Slither, 4naly3er, aderyn) cover roughly 40% of checklist items — they are a productivity multiplier, not a replacement for manual review.
- Build your own personalized checklist by adding items every time you miss a finding or learn about a new attack pattern.
- Protocol-specific checklists matter: a bridge audit needs bridge-specific items (message validation, replay protection) that a generic checklist will not include.