🌳 Advanced ⏱️ 45 min

How to Read a New Codebase

The difference between a professional auditor and a beginner is not that the professional knows more vulnerabilities — it's that they can extract maximum signal from a codebase in minimum time. Walking into a 5,000-line codebase with no strategy wastes the most valuable asset in a competitive audit: hours. This lesson gives you a repeatable, battle-tested process for reading any new Solidity codebase efficiently.

📖 The First 30 Minutes

Your first 30 minutes on a new codebase set the tone for the entire audit. Resist the urge to immediately read code. Instead, gather context: understand what the protocol claims to do, who has audited it before, what tests exist, and where the money flows. Only then does reading individual contracts become productive.

Step 1: Read the Documentation (10 minutes)

Start with the README. Then the protocol docs (Gitbook, Notion, or website). Then any specifications in the repo. You are looking to answer: What does this protocol do? What problem does it solve? What are the key invariants the developers claim to maintain? What edge cases do they acknowledge?

🔍 Documentation Review Checklist
## What to extract from documentation: ## 1. Protocol purpose # "This is a lending protocol where users deposit ETH and borrow stablecoins" # → Sets expectations: look for health factor math, liquidation logic, oracle usage ## 2. Key invariants (often stated explicitly) # "Total debt can never exceed total deposited collateral" # "All LP shares are backed 1:1 by underlying tokens" # → These are your primary targets — find where they could be violated ## 3. Trust model (who is trusted?) # "Owner can change interest rates but not withdraw user funds" # → Verify this claim in code — does the owner ACTUALLY have that constraint? ## 4. Known limitations # "Does not support fee-on-transfer tokens" # → Check that the code actually enforces this (allowlist? token validation?) ## 5. External integrations # "Uses Chainlink for prices, Uniswap v3 for swaps" # → These become your external call review targets

Step 2: Read Previous Audits

⚠️ Prior Audit Findings Are Often Repeated

Studies of competitive audit results consistently show that 20-40% of High/Medium findings in any given contest were also present (though perhaps in different form) in a previous audit. Either the fix was incomplete, the fix introduced a new issue, or a similar pattern exists elsewhere in the codebase. Reading prior audit reports is one of the highest-ROI activities in the first hour of any audit.

🔍 Extracting Value from Prior Audit Reports
## What to note from prior audit reports: ## 1. All High/Critical findings — even if marked "fixed" # Question: Was the fix correct? Did it introduce new bugs? # Example: A reentrancy fix might use nonReentrant on some functions # but miss a different entry point that was added later ## 2. Disputed or "won't fix" findings # These are often still present — and the auditor's argument may be valid ## 3. The auditors' threat model # What did they focus on? What did they explicitly NOT cover? # Areas not covered = your opportunity ## 4. The overall code quality assessment # "This codebase has excellent test coverage" vs # "Tests are minimal and do not cover edge cases" # → The latter is a signal to look harder at edge cases ## Where to find prior audits: # - /audits/ directory in the repo # - Protocol's website security page # - Solodit.xyz search by protocol name

Step 3: Survey the Test Suite

🔍 Reading Tests as a Security Signal
// What good test coverage tells you: // - Developers understand the happy path thoroughly // - Edge cases they tested are probably safe // - Edge cases NOT tested are your hunting ground // Run: forge coverage --report summary // Example output: // | File | % Lines | % Statements | % Branches | // | Vault.sol | 94.2% | 91.8% | 78.3% | ← high coverage // | LiquidationManager.sol | 43.1% | 38.9% | 22.0% | ← danger zone // | OracleAdapter.sol | 61.2% | 55.0% | 41.0% | ← focus here // Grep for what's NOT tested: // What revert strings appear in code but never in tests? // grep -r "require(" src/ | grep -v "test" // grep -r "revert " src/ | grep -v "test" // Compare to: grep -r "expectRevert" test/ // ❌ Red flags in test suites: // - No fuzz tests // - No fork tests (uses mocks for everything) // - Only happy-path tests (no "should fail when..." tests) // - Tests that skip the full liquidation flow // - No integration tests between contracts

Step 4: Map All Entry Points

🔍 Entry Point Enumeration
// Command to list all public/external functions: // forge inspect src/Vault.sol:Vault abi | jq '.[] | select(.type=="function") | .name' // Or with Slither: // slither . --print function-summary // What to record for each entry point: struct EntryPointAnalysis { string functionName; string visibility; // public / external string accessControl; // onlyOwner / onlyRole / anyone bool hasModifier; // nonReentrant? whenNotPaused? string[] externalCalls; // What does it call externally? string[] stateChanges; // What state variables does it modify? uint256 valueAtRisk; // How much can be moved by this function? } // Prioritize entry points by value at risk: // 1. Functions that transfer ETH or ERC20 tokens → highest priority // 2. Functions that change critical parameters (oracle, LTV, fees) // 3. Functions that affect access control (grantRole, transferOwnership) // 4. Pure view functions → lowest priority

Step 5: Trace Asset Flow

🔍 Asset Flow Mapping
// Every token that can enter the protocol must have a corresponding exit path // Any mismatch between entry and exit accounting is a vulnerability // Example: Vault protocol asset flow // // Entry paths: // 1. deposit(token, amount) → tokens in, shares out // 2. mint(shares, maxDeposit) → tokens in, shares out // 3. harvest() → yield tokens in from strategy // // Exit paths: // 1. withdraw(token, amount) → shares in, tokens out // 2. redeem(shares, minOut) → shares in, tokens out // 3. emergencyWithdraw() → all tokens out (admin) // // Questions to answer: // - Are there any paths where tokens can enter but never exit? // - Can shares be created without depositing tokens? (free shares attack) // - Can tokens leave without burning shares? (share value inflation) // - What happens to yield tokens if harvest() is never called?

Step 6: Identify Admin Powers

🚨 Admin Powers Are Critical Surface

In every audited codebase, map every function with an access control modifier. Document: What can the owner do? Can they rug users? Can they frontrun transactions? Can they disable withdrawals? Is there a timelock? Are keys in a hardware wallet? A protocol where the owner can drain funds at will has critical centralization risk — even if it's technically "working as intended." This is a finding, even if it's "acknowledged" in the report.

🔍 Admin Power Audit Template
// Audit all onlyOwner / onlyAdmin / onlyRole functions // For each, ask: what is the WORST thing this can do? // Example findings from admin power review: // LOW RISK: Parameter changes within safe bounds function setFee(uint256 newFee) external onlyOwner { require(newFee <= MAX_FEE); // Max 5% — bounded by constant ✅ fee = newFee; } // MEDIUM RISK: Can pause but not steal function pause() external onlyOwner { _pause(); // ← Blocks deposits/withdrawals — DoS risk } // CRITICAL RISK: Can drain the protocol function recoverERC20(address token, uint256 amount) external onlyOwner { IERC20(token).transfer(owner(), amount); // ← Can steal collateral tokens! // If token == collateralToken: critical rug vector }

Code Quality Signals

SignalGood SignsRed Flags
NamingDescriptive names (amountIn, collateralFactor)Single letters (a, b, x), misleading names
NatSpec@param, @return, @dev on all public functionsNo comments, or comments that contradict code
Test Coverage>90% branch coverage, fuzz tests, fork tests<60% line coverage, no edge case tests
Audit HistoryMultiple audits by reputable firms, findings fixedNo prior audits, or findings marked "won't fix"
Recent ChangesChanges are small, well-documented, testedLarge refactor days before audit, no new tests
ComplexityFunctions do one thing clearly300-line functions with nested logic
InheritanceFlat, easy-to-trace hierarchy10-level deep diamond inheritance

Note-Taking System for Audits

✏️ Try It: Audit Note Template
## Audit Notes Template ## File: Vault.sol ## Date: 2025-01-15 ## Auditor: [your handle] ### Suspicious Lines (needs deeper review) ## Line 234: accrueInterest() not called before health check — [LEAD] ## Line 456: unchecked arithmetic in reward calculation — [LEAD] ## Line 891: safeTransfer to user before state update — possible CEI violation ### Confirmed Findings ## [HIGH] Missing slippage on internal swap (line 623) — tracked in finding 001 ### Questions for Protocol Team ## Q: Is token in the collateralToken whitelist only? Or can any ERC20 be used? ## Q: What is the governance delay on oracle changes? ### Reviewed Clean (no issues) ## deposit() — CEI correct, SafeERC20 used, nonReentrant present ## withdraw() — CEI correct, health check with fresh oracle price ## _accrueInterest() — math checks out, rounding favors protocol

What to Skim vs. What to Read Word-by-Word

💡 Reading Speed Strategy

Skim quickly: Library files (OpenZeppelin imports you know well), getter/view functions, events, errors, constants, structs. These rarely contain bugs.

Read carefully: Functions that transfer tokens or ETH, functions that update critical state (debt, collateral, shares), functions with complex math (especially with division), functions that call external contracts, upgrade and initialization logic, any function the protocol's whitepaper says is "the core invariant."

Read word-by-word: Any function you've flagged as suspicious, the initialization path of upgradeable contracts, signature verification logic, and oracle price computation paths.

Using Slither for the First Pass

🔍 Essential Slither Commands
# Install Slither $ pip3 install slither-analyzer # Basic run — output all findings $ slither . # Generate a call graph (who calls what) $ slither . --print call-graph # Open: call-graph.dot with Graphviz or xdot # Generate inheritance diagram $ slither . --print inheritance-graph # Human-readable function summary $ slither . --print human-summary # List all modifiers applied to each function $ slither . --print modifiers # Run only specific detectors $ slither . --detect reentrancy-eth,unchecked-transfer,divide-before-multiply # Filter low-severity findings to reduce noise $ slither . --exclude-low --exclude-informational # Typical first-pass workflow: # 1. Run with --print human-summary to understand architecture # 2. Run full detector pass, save to file # 3. Read each High/Medium finding carefully # 4. Mark false positives, investigate true positives

Reading Speed: Identifying High-ROI Areas Quickly

In a 7-day contest, time is your scarcest resource. The difference between top auditors and average ones is not just skill — it is knowing where to spend time. The following heuristics help you identify the highest-return areas of any codebase within the first two hours.

🔍 High-ROI Patterns to Look For First
// Grep patterns that frequently lead to findings: // 1. External calls before state updates (CEI violations) $ grep -n "\.call{" src/ -r # ETH transfers $ grep -n "\.transfer(" src/ -r # ERC20 transfers // After each: is state updated BEFORE or AFTER this line? // 2. Unchecked blocks around non-loop code $ grep -n "unchecked {" src/ -r // After each: verify ALL operations inside are actually safe to not check // 3. Division operations (precision loss) $ grep -n " / " src/ -r | grep -v "test\|//\|*\*" // After each: is multiplication done first? Is rounding correct? // 4. Chainlink latestRoundData calls $ grep -rn "latestRoundData" src/ // After each: are all 5 safety checks present? // 5. Block.timestamp usage $ grep -rn "block.timestamp" src/ // After each: is this manipulable by validators? Is precision needed? // 6. tx.origin authentication $ grep -rn "tx.origin" src/ // Almost always a finding — phishing attack vector // 7. Signature verification without zero-address check $ grep -rn "ecrecover\|ECDSA.recover" src/ // After each: is signer != address(0) checked? // 8. Initialize functions on implementation contracts $ grep -rn "function initialize" src/ // Is the initializer modifier present? Is it protected?

Reading Inheritance Hierarchies

🔍 Navigating Multi-Inheritance in Solidity
// Deep inheritance is a common source of subtle bugs // Example: Protocol with complex inheritance contract Vault is ERC4626, AccessControl, Pausable, ReentrancyGuard, Ownable { // This contract inherits behavior from 5 different contracts // Key questions: // - Who is "owner" vs who has "DEFAULT_ADMIN_ROLE"? // - Does Ownable.transferOwnership() transfer AccessControl admin too? // - Which functions from ERC4626 are overridden here? // - Is there a storage collision between ERC4626 and AccessControl? // C3 linearization order for Solidity: // When the same function is in multiple parents, which one runs? // Answer: the most-derived contract's version, following C3 algorithm // Solidity shows you the order: it's the order in the "is" list, right-to-left } // How to trace inheritance quickly with Slither: $ slither . --print inheritance-graph // Then: grep for function overrides in the target contract $ grep -n "override" src/Vault.sol // Each override: check what the parent does, what is changed in child // Common inheritance bugs: // 1. Missing super.functionName() call in overridden function // → Parent's logic (e.g., pause check) skipped entirely // 2. Incorrect modifier order: modifier runs on wrong version of function // 3. Storage variable shadows parent's variable with same name

Building a Mental Model: The State Machine Approach

💡 State Machine Thinking

Every smart contract is a state machine. The state is defined by its storage variables, and the transitions are its external functions. Before reading individual functions, draw the state machine: What are the possible states? What transitions exist between them? What transitions should be impossible? Impossible transitions that are actually possible in code are findings. For example: a position that goes from "open" to "closed" and then can be re-opened is a double-exit bug. A function that can be called during "paused" state when it should not is an access control finding.

Common Time-Wasters to Avoid

Time-WasterWhy to AvoidWhat to Do Instead
Reading OpenZeppelin codeIt is battle-tested and well-audited; bugs are extremely rareKnow OZ's behavior; only read overridden functions
Understanding every math formulaMost math is correct; symbolic analysis takes hoursFocus on rounding direction, overflow, and division-before-multiply
Reading the test suite line-by-lineTests show the happy path; you need the edgesLook at what is NOT tested (coverage gaps)
Chasing gas optimizationGas findings are low priority; they do not win contestsSubmit gas findings only after all security findings are done
Re-reading the same function 5 timesIf it is not clear after 2 reads, ask a clarifying questionMark as "needs clarification" and move to the next function

Reading Protocol Upgrades and Migration Code

Upgrade mechanisms and migration scripts are among the most dangerous and least-reviewed code paths in a protocol. They run once, often with elevated privileges, and are rarely covered by the existing test suite. When a codebase uses upgradeable proxies or has migration logic, read these sections with extra care.

🔍 Upgrade and Migration Code Review
// When you encounter upgradeable contracts, check: // 1. Implementation contract access // grep: initialize( — does the implementation have an initialize() function? // If yes: is it protected? Unprotected initialize() on implementation = Critical // Attack: attacker calls initialize() on the implementation directly // → Takes ownership of implementation → calls delegatecall to selfdestruct proxy // Fix: OpenZeppelin's _disableInitializers() in implementation constructor // 2. Upgrade authorization // grep: upgradeTo( or upgradeToAndCall( — who can call this? // Acceptable: governance + timelock // Dangerous: single admin address // UUPS pattern: _authorizeUpgrade() — what modifier protects it? // 3. Migration script security // Look in: scripts/, deploy/, migrations/ directories // Common bugs in migration scripts: // - Wrong address passed to initialize() // - Ownership transferred to wrong address // - State not properly migrated (old users cannot claim in new system) // 4. Storage layout compatibility // When reviewing V2 of a proxy contract: // New variables must be APPENDED — never inserted between existing ones // Use: cast storage [proxyAddress] --rpc-url $RPC to inspect live storage // Compare V1 storage layout vs V2 — any shift in slot positions = bug // 5. Initialization state // Can initialize() be called twice? // grep: initializer modifier from OpenZeppelin — correct usage // grep: bool private initialized — custom pattern, check for reentrancy in init

Building an Annotation Layer as You Read

Professional auditors do not read code passively — they annotate it as they go. Annotations create a queryable record of your understanding, flag questions for later, and prevent re-reading the same code multiple times. Here is how to build an effective annotation layer using inline comments and a separate notes file.

🔍 Annotation System for Code Review
// Copy the repository to a personal annotation branch: $ git checkout -b audit/my-annotations // Now add inline comments freely — never send these back to the protocol // Annotation tag system: // @audit-issue - Confirmed finding, needs PoC // @audit-lead - Suspicious, needs more investigation // @audit-ok - Reviewed and confirmed safe, note why // @audit-q - Question for the protocol team / needs clarification // @audit-note - Important design decision to remember // Example annotated code: function withdraw(uint256 amount) external { // @audit-issue reentrancy: ETH transfer before state update (bool success,) = msg.sender.call{value: amount}(""); require(success); balances[msg.sender] -= amount; // @audit-issue CEI violation: effect after interaction } function setAdmin(address newAdmin) external onlyOwner { admin = newAdmin; // @audit-ok onlyOwner protected; no timelock needed per scope } function compound() external { uint256 rewards = pendingRewards(); // @audit-lead does pendingRewards() revert if oracle is stale? check behavior deposit(rewards); } // At end of each session, grep your annotations for a summary: $ grep -rn "@audit-issue" src/ // Shows every confirmed finding across all files in one view $ grep -rn "@audit-lead" src/ | wc -l # How many leads remain?

Key Takeaways

  • Spend your first 30 minutes on documentation, prior audits, and test coverage before reading any contract code.
  • Prior audit findings recur — always check whether fixes were complete and whether similar patterns exist elsewhere in the codebase.
  • Test coverage gaps are a map of where developers are least confident — focus your manual review on uncovered branches and functions.
  • Map all entry points, trace all asset flows, and document all admin powers before diving into individual functions.
  • Grep-based pattern searches (ecrecover, latestRoundData, tx.origin, unchecked) surface high-priority areas in minutes without reading all the code.
  • Code quality signals (naming, NatSpec, recent changes, test coverage) are strong predictors of where bugs will be found.
  • Slither's printers (call-graph, human-summary, modifiers) are faster and more reliable for architecture understanding than reading inheritance chains manually.
  • Think in state machines — impossible state transitions that are actually possible in code are almost always findings.