Week 03 — Solidity & Foundry Workflow

“An auditor without Foundry is a doctor without a stethoscope. The bug is not in the code you read — it’s in the trace, the storage slot, the fork that reproduces the exploit at block N. Reading Solidity teaches you what the code says; running it under Foundry teaches you what the code does. Both are required, and the second is where bugs are actually found.”

Tags: web3-security foundations solidity foundry testing cheatcodes invariant Learner: Past Tuan-02-Ethereum-EVM-Deep-Dive → ready for Phase 2 vulnerability work Time: 7 days (5–6h/day) Related: Tuan-04-Security-Foundations-CEI-AC · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-15-Audit-Methodology-Tooling


1. Context & Why

1.1 What this week is

You already know the EVM at the byte level (Week 02). This week converts that knowledge into the two languages an auditor actually works in:

  1. Solidity — read fluently, with awareness of every syntactic construct that becomes a security pitfall (modifiers, inheritance order, storage layout, custom errors, transient storage, unchecked).
  2. Foundry — drive contracts under test, fork mainnet, probe with cheatcodes, discover invariants by stateful fuzzing, read traces fluently.

By Friday, “writing a PoC” should be a 10-minute operation. Every subsequent week assumes you can spin up a Foundry project, fork mainnet, impersonate any address, capture traces, and assert exact storage slots without thinking. Lab fluency is non-negotiable.

1.2 Developer Solidity vs auditor Solidity

You’re reading the same code, asking different questions.

Developer asksAuditor asks
Does this compile and pass tests?What does it do on inputs not in the tests?
Is my modifier correctly scoped?What code path reaches state changes bypassing the modifier?
Is external cheaper than public?Does external accidentally expose a previously-gated function?
Does unchecked save gas?Can any input cause this block to wrap and corrupt accounting?
Will this proxy upgrade work?After upgrade, do storage layouts align? Did anyone change __gap?

1.3 Why Foundry, not Hardhat?

Foundry is the industry default for security work in 2026: Solidity-native tests, first-class mainnet fork, built-in cheatcodes, built-in fuzz and invariant testing, and a fast Rust core. Hardhat is fine for app dev with TS scripting; know it well enough to read older audit repos, but write your own work in Foundry.

1.4 Learning goals

By the end of the week you can:

  • Read any Solidity contract and identify in <30 seconds: visibility, modifier flow, inheritance linearization, storage layout, every external call boundary, every state-change-after-call.
  • Distinguish memory / storage / calldata semantics and predict gas/mutability on sight.
  • Write custom errors with parameters and explain why they beat require(cond, "string").
  • Use transient storage (tstore/tload) at assembly and high-level layers; know when it’s right and when it’s a footgun.
  • Bootstrap a Foundry project, write tests with all major cheatcodes, run fork tests, generate coverage, set up handler-pattern invariants.
  • Configure CI: forge test, forge coverage, forge snapshot, slither on every PR.
  • Read a forge test -vvvv trace and explain every CALL, SLOAD, SSTORE, LOG.

1.5 Primary references

SourceURLStatus
Solidity Documentation (0.8.29 / latest)https://docs.soliditylang.org/Current — pin compiler version in your foundry.toml
Solidity Security Considerationshttps://docs.soliditylang.org/en/latest/security-considerations.htmlCurrent
Foundry Bookhttps://book.getfoundry.sh/Current; check master for cheatcodes added since publication [verify]
forge-std (Test, Vm interface)https://github.com/foundry-rs/forge-stdCurrent; the authoritative cheatcode list lives here
OpenZeppelin Contractshttps://docs.openzeppelin.com/contractsCurrent
EIP-1153 (Transient Storage)https://eips.ethereum.org/EIPS/eip-1153Final; live since Cancun (Mar 2024)
EIP-2535 Diamondhttps://eips.ethereum.org/EIPS/eip-2535Final
Solidity 0.8.28 release notes (transient state vars)https://www.soliditylang.org/blog/2024/10/09/solidity-0.8.28-release-announcement/Current
Solidity 0.8.34 release notes (transient storage IR bugfix)https://soliditylang.org/blog/Current — [verify] bugfix release line for 0.8.28–0.8.33
Trail of Bits — Building Secure Contractshttps://github.com/crytic/building-secure-contractsCurrent
forge invariant docshttps://book.getfoundry.sh/forge/invariant-testingCurrent

Versioning rule for this lesson: examples are tested against Solidity 0.8.29 and Foundry stable as of 2026-05 [verify]. If your forge --version is newer, recheck cheatcode signatures and foundry.toml keys before copy-pasting.


2. Solidity for Auditors — the language at the depth you need

2.1 Value vs reference types, and the location triple

Value types (uint*, int*, bool, address, bytes1..32, enum) are passed by copy. Reference types (bytes, string, dynamic arrays, structs, mappings) require an explicit storage location.

LocationLifetimeMutableGasAuditor’s mental model
storagePersistentYesSLOAD 2.1k cold / 100 warm; SSTORE 5k–20kPointer into contract slots; writes persist; aliasing real
memoryFunction callYesCheap (linear-quadratic on expansion)Scratchpad; copies on assignment from storage
calldataFunction callNoCheapestRead-only view of tx input; saves a copy

The aliasing trap — storage pointer vs memory copy:

struct Position { uint256 amount; uint256 time; }
mapping(uint256 => Position) public positions;
 
function update(uint256 id) external {
    Position storage p = positions[id]; // pointer; writes go to state
    p.amount += 100;
}
 
function buggy(uint256 id) external {
    Position memory p = positions[id]; // COPY; mutation lost
    p.amount += 100; // positions[id] unchanged!
}

The #1 first-month Solidity bug. Every Type memory x = stateVar; deserves a “did the dev want this mutated?” challenge.

For function inputs you only read, calldata saves the copy vs memory. Modern Solidity allows calldata on public parameters. A protocol using memory everywhere is leaving small gas on the table — quality tell, not a finding.

2.2 Function visibility — and the “external is cheaper” myth

VisibilityOutside callableInside callableNotes
externalYesOnly via this.fn(...) (external call)Most restrictive
publicYesYes (directly)Default for state-var auto-getters
internalNoThis contract + derivedInheritance-accessible
privateNoThis contract onlyNot even derived

The “external is cheaper” myth:

  1. For reference-type parameters, external reads from calldata; public historically copied to memory. Real savings ~200 gas per long argument. Modern Solidity allows calldata on public parameters too, narrowing the gap. [verify on your compiler]
  2. For value-type parameters, public adds dispatcher code for both internal and external entry. ~20–50 gas per call.
  3. The actual reason to prefer external: principle of least exposure, not gas. external documents intent; start there and downgrade only if internal callers genuinely need access.

Auditor’s real concern:

  • A function meant internal but mistakenly public → unauthorized state mutation. Grep for _-prefix helpers accidentally public.
  • Excessive public helpers enlarge the attack surface and complicate access-control auditing.
  • Auto-generated public getters for sensitive mappings (e.g., lastClaim) can leak activity patterns; not a vuln per se, but worth flagging.

2.3 Modifiers — usage and abuse

Modifiers wrap function bodies; _; is where the body inlines.

Why auditors are suspicious of modifiers:

  1. Hidden control flow. A function with five modifiers executes ~5x the code you see. Every modifier must be read.
  2. State changes in modifiers run before the body. Trail of Bits recommends modifiers do checks only, not effects.
  3. Order matters. function f() external nonReentrant onlyOwner runs nonReentrant entry → onlyOwner check → body → nonReentrant exit. A revert anywhere reverts all state including the lock — so the lock is auto-released.
  4. Override + inheritance + modifierssuper-resolution headaches.

Heuristic: prefer if (cond) revert Err(); for checks. Reserve modifiers for cross-cutting concerns (nonReentrant, whenNotPaused) or repetitive access control where readability gain exceeds hidden-flow cost.

2.4 Custom errors vs require strings

Since 0.8.4: custom errors.

error Unauthorized(address caller);
error InsufficientBalance(uint256 requested, uint256 available);
 
function withdraw(uint256 amt) external {
    if (msg.sender != owner) revert Unauthorized(msg.sender);
    if (balances[msg.sender] < amt) revert InsufficientBalance(amt, balances[msg.sender]);
}

Three concrete wins:

  1. Gas. Strings store bytes-per-character in bytecode; custom errors are a 4-byte selector + ABI-encoded args. Strictly cheaper at deploy; roughly equal-or-better at runtime. [verify with forge snapshot]
  2. ABI surface. Custom errors are in the ABI. Clients and tools (Slither, vm.expectRevert(SomeError.selector)) match on type, not string.
  3. UX. Typed errors map cleanly to localized frontend messages.

Auditor flag: a fresh 2026 codebase still using require strings everywhere signals low code maturity (often combined with old OZ versions, no fuzz tests). Not a vulnerability — a quality tell.

Pre-0.8.26 gotcha: require(cond, CustomError(args)) works only from 0.8.26+ [verify]. On older pins, use if (!cond) revert CustomError(args);.

2.5 immutable vs constant vs storage

ModifierValue set atStored whereMutableTypes
constantCompile timeInlined into bytecode at every use siteNoValue types + string/bytes
immutableConstructor (compiler patches runtime bytecode at deploy)Inlined into runtime bytecodeNo after deployValue types only
StorageAnytimeStorage slotYesAll

Gas: constant and immutable reads are ~3 gas (PUSH32 of inlined value); storage SLOAD is 2100 cold / 100 warm.

Audit threat model — three different trust levels:

uint256 public constant FEE_BPS = 30;   // locked forever
uint256 public immutable owner;          // fixed at deploy; constructor must set
uint256 public dynamicFee;               // mutable; audit every setter caller

Initialization traps:

  • immutable declared but unassigned in constructor → zero forever. Classic bug: address immutable oracle; forgotten → all oracle reads fail.
  • Under upgradeable proxies, immutable values live in implementation bytecode. Different impls have different values; upgrading changes them silently.

2.6 Transient storage (EIP-1153, post-Cancun)

Cancun (Mar 2024) added TSTORE (0x5d) and TLOAD (0x5c): a key/value map that lives for the transaction and resets to zero at the end. Both cost 100 gas (warm-storage equivalent).

Solidity support:

  • 0.8.24: inline-assembly access (tstore, tload in Yul).
  • 0.8.28: first-class uint256 transient counter; for value types. [verify]
  • 0.8.34: fixes a high-severity IR-pipeline bug affecting clearing of mixed persistent and transient variables of the same type in 0.8.28–0.8.33. Pin >= 0.8.34 if using transient storage with via_ir. [verify exact range]

Canonical use cases:

  1. Reentrancy locks at ~95% gas reduction:
    uint256 transient private _locked;
    modifier nonReentrant() {
        require(_locked == 0, "REENTRANT");
        _locked = 1;
        _;
        _locked = 0;
    }
    ~300 gas total vs ~5k with persistent storage.
  2. Flash accounting (Uniswap V4): defer settlement; track deltas across calls in one tx.
  3. ERC-20 temporary approvals: approve-and-auto-revoke within a single tx; no “infinite approval” footgun.
  4. Inter-frame communication through untrusted intermediates: callers can pass state without exposing it to attacker-controlled hops.

Audit hazards:

  • Slot collision across contracts: untrusted callees may TSTORE on the same key your contract uses. Always namespace (e.g., keccak256("MyContract.reentrancyLock")).
  • Bundler/multicall bleed-through: ERC-4337 bundlers may pack many user ops in one tx; transient state may leak between ops. [verify per bundler]
  • Compiler bug 0.8.28–0.8.33 with via_ir — clearing same-type transient + persistent vars miscompiles to wrong opcode. Pin to 0.8.34+.
  • No EIP-3529 refunds for TSTORE. Storage-based gas-grief mechanics don’t carry over.

Auditor signal: a 2026 codebase still using uint256 private _status reentrancy guards everywhere is leaving ~5k gas per call. Not a finding — a maturity tell.

2.7 Inheritance — C3 linearization, super, virtual/override

Solidity uses C3 linearization for multiple inheritance. In contract D is A, B, C, bases are listed most-base → most-derived.

Diamond example:

contract A { function f() public virtual returns (string memory) { return "A"; } }
contract B is A { function f() public virtual override returns (string memory) { return string.concat("B->", super.f()); } }
contract C is A { function f() public virtual override returns (string memory) { return string.concat("C->", super.f()); } }
contract D is B, C { function f() public override(B, C) returns (string memory) { return string.concat("D->", super.f()); } }

D.f() returns "D->C->B->A". Linearization of D is D → C → B → A (rightmost in is B, C runs first via super). Reverse to is C, B → output "D->B->C->A".

Why auditors care:

  • super.foo() may invoke the wrong parent if inheritance order changes.
  • Storage layout follows linearization: parents’ state variables stack in MRO order. Reordering parents under a proxy upgrade = storage corruption.
  • override(A, B) list-order doesn’t change behavior; the is A, B order does.
  • Functions are not virtual by default since 0.5; must mark virtual and override explicitly.

Checklist:

  • Linearize every contract; confirm dev’s mental model matches C3.
  • Compare storage layouts (forge inspect storageLayout) across versions for upgrade safety.
  • Every super.foo() resolves to the intended parent?
  • Constructor parameter forwarding (B(arg)): every parent gets correct args?

2.8 Libraries — using for, internal vs external

Two flavors:

  • Internal libraries: internal functions only; bytecode inlined into each consumer.
  • External libraries: have at least one public/external function; deployed separately; consumers DELEGATECALL to them.

using LibA for TypeT; lets LibA.fn(x, ...) be called as x.fn(...) (first arg becomes self).

Audit-relevant facts:

  1. External libraries delegatecall — storage layout of the caller matters; library has no storage.
  2. Library selfdestruct is catastrophic: Parity multisig 2017 froze ~$280M because the library was killed via uninitialized init. Post-Cancun EIP-6780 limits this in normal scenarios, but the pattern still appears in legacy code.
  3. Internal libraries duplicate bytecode — watch the EIP-170 24576 byte deploy limit.
  4. File-level using for (post-0.8.13) can make method origin hard to grep — tooling caveat, not a vuln.

2.9 unchecked blocks — when ok, when dangerous

0.8+ has checked arithmetic by default. unchecked { ... } restores wrap-around, saving ~30–50 gas per op.

Safe: bounded loop counters (unchecked { ++i; }), subtraction after explicit >= check. Dangerous: (a * b) / c where a*b may overflow; arithmetic on untrusted inputs; signed casts inside unchecked.

Audit rule: every unchecked { ... } must have a comment proving why it cannot overflow. Missing or hand-wavy → Low/Medium finding. This is one of the most common Code4rena/Sherlock medium-severity findings.

2.10 receive vs fallback

Call has data?Match function?receive?fallback?Result
Noexistsreceive runs
Nomissingpayablefallback runs
Nomissingnon-payablerevert if value > 0
Yesyesfunction runs
Yesnoexistsfallback runs
Yesnomissingrevert

Audit-relevant:

  • fallback doing anything beyond logging is a red flag (esp. delegatecall proxy patterns).
  • receive doing state-mutating work runs on plain transfers — 1 wei ping triggers it.
  • Missing payable on fallback causes non-zero-value calls with calldata to revert; DoS vector.

2.11 Solidity version pragma — pin or float?

pragma solidity ^0.8.24;          // floats to any 0.8.x >= 24
pragma solidity 0.8.29;           // pinned exactly
pragma solidity >=0.8.20 <0.9.0;  // range

Auditor’s stance:

  • Production: pin exactly. The compiler is part of the TCB. Curve/Vyper 2023 showed compilers themselves have bugs.
  • Libraries: floating with ^ is acceptable if floor is tight (^0.8.20 not ^0.8.0).
  • Key version floors: 0.8.4 (custom errors), 0.8.20 (Shanghai), 0.8.24 (Cancun + transient opcodes), 0.8.28 (transient state vars), 0.8.34 (transient clearing bugfix).
  • Any ^0.8.<low> in production = Low/Info finding: “compiler pragma too permissive; pin to recent patch”.

3. Foundry — your daily driver

3.1 Install + verify

curl -L https://foundry.paradigm.xyz | bash
foundryup
forge --version
cast --version
anvil --version

foundryup updates to the latest released binary. Pin to a specific release (foundryup --version stable, or a tag) in CI for reproducibility.

3.2 Project structure

my-project/
├── foundry.toml          # config
├── remappings.txt        # optional; or use [profile.default.remappings] in toml
├── lib/                  # git-submodule dependencies (forge-std, OZ, etc.)
├── src/                  # production contracts
├── test/                 # unit tests
├── script/               # deployment scripts (Solidity, not bash)
├── out/                  # build artifacts (gitignored)
├── cache/                # compile cache (gitignored)
└── .github/workflows/    # CI

Initialize:

forge init my-project
cd my-project
forge install OpenZeppelin/openzeppelin-contracts

3.3 foundry.toml — the auditor’s reading list

A minimal, auditor-flavored foundry.toml:

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
test = "test"
script = "script"
 
solc_version = "0.8.29"        # pin
evm_version = "cancun"         # match production target (Cancun for transient storage)
optimizer = true
optimizer_runs = 200
via_ir = false                 # see §3.10 gotcha for coverage
 
# Test config
verbosity = 3                  # default trace level
fuzz = { runs = 1000, max_test_rejects = 65536 }
invariant = { runs = 256, depth = 50, fail_on_revert = false, call_override = false }
 
# Useful for fork tests
[profile.default.rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"
arbitrum = "${ARB_RPC_URL}"
 
[profile.ci]
verbosity = 4
fuzz = { runs = 10000 }
invariant = { runs = 1024, depth = 100 }

Audit reading order for foundry.toml (always check):

  1. solc_version and evm_version — do they match the deployment target?
  2. optimizer_runs — affects bytecode and gas. Production should match optimizer_runs used at audit time.
  3. via_ir — IR pipeline affects bytecode meaningfully. If the audit ran with via_ir = false and the team deploys with via_ir = true, the deployed code is different.
  4. [invariant] and [fuzz] — low runs / depth indicates token coverage of state-space; not a finding but a quality signal.

3.4 forge build and forge test

forge build
forge test --match-contract X --match-test test_drain
forge test -vvv         # traces on failures
forge test -vvvv        # traces on all tests
forge test -vvvvv       # + internal calls + storage R/W
forge test --gas-report

Verbosity: -v failures with reason; -vv + logs; -vvv + traces on failed tests; -vvvv traces on all tests; -vvvvv + internal calls and opcode-level setup. Each trace line is [gas] CONTRACT::function(args) (or CALL/STATICCALL/DELEGATECALL); indentation = call stack.

3.5 Foundry test anatomy

// test/Foo.t.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
 
import {Test, console} from "forge-std/Test.sol";
import {Foo} from "../src/Foo.sol";
 
contract FooTest is Test {
    Foo foo;
    address alice = makeAddr("alice");
    address bob   = makeAddr("bob");
 
    function setUp() public {
        foo = new Foo();
        vm.deal(alice, 100 ether);
    }
 
    function test_basic() public {
        vm.prank(alice);
        foo.doThing();
        assertEq(foo.value(), 42);
    }
}
  • Test from forge-std gives you the vm cheatcode interface, assertion helpers (assertEq, assertGt, etc.), and console logging.
  • Any function named test* is a test.
  • setUp() runs before each test (each test gets a fresh state).
  • testFuzz_* is a fuzz test; parameters become fuzzable inputs.
  • invariant_* is an invariant property (see §3.8).

3.6 Cheatcodes — the auditor’s PoC toolkit

Full list lives in forge-std/Vm.sol. Know these cold:

CheatcodePurpose
vm.prank(addr)Set msg.sender for next call
vm.startPrank(addr) / vm.stopPrank()Multi-call impersonation
vm.prank(sender, origin)Set both msg.sender and tx.origin
vm.deal(addr, amount)Set ETH balance
vm.warp(t) / vm.roll(b)Set block.timestamp / block.number
vm.expectRevert() / (bytes4) / (bytes)Assert next call reverts
vm.expectEmit(...)Assert specific event emitted
vm.recordLogs() / vm.getRecordedLogs()Capture and inspect events
vm.store(addr, slot, val) / vm.load(addr, slot)Raw storage R/W
vm.mockCall(addr, calldata, returndata)Override external call return
vm.mockCallRevert(addr, calldata, revertData)Force external call to revert
vm.label(addr, "name")Pretty trace names
vm.assume(cond)Discard fuzz input if false
vm.createFork(url) / vm.createSelectFork(url, block)Fork management
vm.makePersistent(addr)Account survives fork switch
vm.etch(addr, code)Replace bytecode at address
vm.chainId(id) / vm.fee(wei) / vm.coinbase(addr)Block context
vm.signMessage(privKey, hash) / vm.addr(privKey)Signing primitives
vm.envUint("NAME") etc.Read env vars
vm.snapshotState() / vm.revertToState(id)Branching state

Worked patterns:

// Custom-error revert assertion
vm.expectRevert(abi.encodeWithSelector(InsufficientBalance.selector, 100, 50));
vault.withdraw(100);
 
// Poke a mapping's storage slot directly
bytes32 slot = keccak256(abi.encode(alice, uint256(0))); // balances[alice], mapping at slot 0
vm.store(address(token), slot, bytes32(uint256(1000e18)));
 
// Mock an oracle return without deploying a fake
vm.mockCall(
    address(oracle),
    abi.encodeWithSelector(IOracle.latestPrice.selector),
    abi.encode(uint256(2000e8))
);
 
// Assert exact event sequence
vm.expectEmit(true, true, false, true, address(token));
emit Transfer(alice, bob, 100);
token.transfer(bob, 100);

3.7 forge coverage

forge coverage
forge coverage --report lcov --report-file lcov.info
forge coverage --match-contract Foo

Reports four metrics: line, statement, branch, function coverage.

Auditor’s interpretation:

  • Line < 80% → code never exercised. First place to look for bugs.
  • Branch gaps → edge-case paths never taken.
  • Function gaps → forgotten code, or deprecated/admin-only worth interrogating.

Gotchas (Foundry as of 2026 [verify]):

  • Assembly blocks show as uncovered even when fully exercised.
  • via_ir = true + coverage interact poorly: coverage disables the optimizer, and Yul-IR-no-optimizer hits “stack too deep” often. Use --ir-minimum or run coverage with via_ir = false. (See foundry-rs/foundry issues #6592, #8766, #13001.)
  • --optimizer-runs affects what gets inlined and reported. Match audit-time settings.

3.8 forge invariant — stateful fuzzing with handlers

Property-based fuzzing (testFuzz_*) supplies random inputs to one call. Invariant testing runs random sequences of calls and asserts an invariant after each.

Naive targetContract(address(vault)) fuzz reverts most of the time (e.g., withdrawing on zero balance). The handler pattern wraps the contract under test with bounded, precondition-aware functions and tracks ghost variables (sums, counters, last-seen values) used in invariants. See §6.6 for a worked example.

Configuration knobs (foundry.toml [invariant]):

KeyDefaultMeaning
runs256Independent call sequences
depth15Calls per sequence
fail_on_revertfalseIf true, any handler revert fails the test
call_overridefalseAllow reentrant calls in fuzz sequences
shrink_run_limit5000Attempts to minimize failing sequence
dictionary_weight40Weight of dictionary inputs vs random

Audit-quality invariants are protocol-specific:

  • ERC-4626: convertToShares(convertToAssets(s)) <= s (rounding direction).
  • Lending: collateral_value >= debt / collateral_factor.
  • CPMM: reserveA * reserveB >= k (monotone non-decreasing due to fees).
  • Vault: sum(user_balances) <= total_supply.

Audit signal: a repo with no invariant_* tests = tea-leaves quality. With them, the team has documented their mental model of invariants — scan for invariants they should have but didn’t list.

3.9 cast — the Swiss army knife

# Calls / sends
cast call <addr> "balanceOf(address)(uint256)" <user> --rpc-url $RPC
cast send <addr> "transfer(address,uint256)" <to> 1000 --private-key $PK --rpc-url $RPC
 
# Selector / sig
cast sig "transfer(address,uint256)"        # → 0xa9059cbb
cast 4byte 0xa9059cbb                       # reverse
cast 4byte-decode <calldata>                # decode whole input
 
# ABI
cast abi-encode "f(uint256,address)" 100 0x...
cast abi-decode "f()(uint256,address)" <returndata>
 
# Storage (e.g. EIP-1967 impl slot)
cast storage <proxy> 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
 
# Wallet
cast wallet new
cast wallet address $PK
cast wallet sign --private-key $PK <hash>
cast wallet verify --address $ADDR <hash> <sig>
 
# RPC
cast block-number --rpc-url $RPC
cast rpc eth_getBalance <addr> latest --rpc-url $RPC

Daily auditor uses: read EIP-1967 impl slot on a proxy to verify deployed code matches the audited impl; identify selectors in unverified contracts; query state on a fork without writing a test; impersonate via --unlocked --from <whale> on Anvil.

3.10 anvil — local Ethereum node

anvil                                                       # local chain
anvil --fork-url $MAINNET_RPC --fork-block-number 20000000  # pinned fork
anvil --hardfork cancun                                     # specify hardfork

Impersonation via RPC:

cast rpc anvil_impersonateAccount 0xWHALE --rpc-url http://localhost:8545
cast send 0xUSDC "transfer(address,uint256)" 0xMY 1000000000000 \
    --from 0xWHALE --unlocked --rpc-url http://localhost:8545

Equivalently from a test:

function test_whale_transfer() public {
    vm.createSelectFork("mainnet", 20_000_000);
    address USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
    vm.prank(WHALE);
    IERC20(USDC).transfer(address(this), 1_000_000e6);
}

Fork-testing patterns:

  1. Pin the block (vm.createSelectFork(url, block) or --fork-block-number) for reproducibility.
  2. Mock prices with vm.mockCall on the oracle’s view — faster than reproducing the manipulation flow.
  3. Impersonate the whale; don’t try to legitimately acquire collateral.
  4. vm.deal for ETH; vm.etch to swap bytecode with instrumented versions; vm.makePersistent(addr) to keep test contracts alive across fork switches.

3.11 forge fmt, forge snapshot, forge inspect

forge fmt                                # auto-format (configurable in foundry.toml [fmt])
forge fmt --check                        # CI mode: exit non-zero if changes needed
 
forge snapshot                           # write `.gas-snapshot` with gas per test
forge snapshot --diff                    # compare against committed snapshot
forge snapshot --check                   # CI mode
 
forge inspect <contract> abi             # ABI as JSON
forge inspect <contract> bytecode
forge inspect <contract> storageLayout   # storage layout JSON (auditor gold!)
forge inspect <contract> methodIdentifiers # function selector map

forge inspect storageLayout is the auditor’s secret weapon. It outputs the exact storage layout the compiler generated, including inherited variables, packing, and gaps. Compare across upgrades to catch storage collisions.

forge inspect MyContract storageLayout > layout-v1.json
# ... after upgrade ...
forge inspect MyContractV2 storageLayout > layout-v2.json
diff layout-v1.json layout-v2.json

4. Reading test suites as audit input

Test gaps are bug signals:

Test gapLikely bug class
No failure-path tests on access controlAuthorization gap
Only happy-path inputsEdge-case bug (rounding, off-by-one, zero)
No fork tests against real tokensIntegration bug (fee-on-transfer, USDT non-standard return)
No invariant testsAccounting bug (sum-of-balances ≠ total-supply)
No upgrade testsStorage collision on upgrade
All mocks, no Uniswap/Chainlink integrationOracle-handling, slippage bug

Auditor workflow on a new codebase:

  1. forge test — passes? If not, why?
  2. forge coverage — every function <100% branch coverage is a target.
  3. Read handler contracts — they encode the team’s view of state-space. Functions excluded from the handler deserve scrutiny.
  4. Read invariants — each is a “should always be true” claim. Try to falsify it.
  5. Read fork tests — what real-world conditions does the team test? More telling: what conditions don’t they test?

Audit-cost signal: comprehensive tests reduce audit cost (fewer PoCs to write, easier scoping). Some firms quote differently on poorly-tested code.


5. CI for contracts — GitHub Actions

Minimal CI workflow:

# .github/workflows/test.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
 
env:
  FOUNDRY_PROFILE: ci
 
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
 
      - name: Install Foundry
        uses: foundry-rs/foundry-toolchain@v1
        with:
          version: stable
 
      - name: Show forge version
        run: forge --version
 
      - name: Check formatting
        run: forge fmt --check
 
      - name: Build
        run: forge build --sizes
 
      - name: Run tests
        run: forge test -vvv
        env:
          MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }}
 
      - name: Run coverage
        run: forge coverage --report lcov
 
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./lcov.info
 
      - name: Check gas snapshots
        run: forge snapshot --check
 
  slither:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
      - uses: crytic/[email protected]
        with:
          fail-on: medium
          slither-config: slither.config.json

Auditor’s CI checklist (when reviewing a project’s CI):

  • forge test runs on every PR.
  • Coverage measured and tracked (codecov or similar).
  • forge snapshot --check to catch silent gas regressions.
  • forge fmt --check to enforce style consistency.
  • At minimum Slither in CI.
  • Echidna/Medusa/Halmos optional, but high-quality teams run them at least nightly.
  • Fuzz and invariant runs set higher in CI than locally (~10x).

6. Lab — Build a minimal Foundry project end-to-end

6.1 Lab goal

Implement a tiny lending contract, test it with every major cheatcode, fork mainnet to test against real USDC, run invariants with a handler, generate coverage, and ship CI.

The contract is intentionally bug-free (audit later weeks for actual exploits). The point is fluency, not the bug.

6.2 Setup

mkdir -p ~/web3-sec-lab/wk03 && cd ~/web3-sec-lab/wk03
forge init lending-lab
cd lending-lab
forge install OpenZeppelin/openzeppelin-contracts

Create remappings.txt:

forge-std/=lib/forge-std/src/
@openzeppelin/=lib/openzeppelin-contracts/

Edit foundry.toml:

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.29"
evm_version = "cancun"
optimizer = true
optimizer_runs = 200
fuzz = { runs = 1000 }
invariant = { runs = 256, depth = 50, fail_on_revert = false }
 
[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"
 
[profile.ci]
fuzz = { runs = 10000 }
invariant = { runs = 1024, depth = 100 }

6.3 Implement the lending contract

src/MicroLend.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
 
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
 
/// @title MicroLend
/// @notice Minimal single-asset lending: users deposit, can borrow up to LTV, must repay
contract MicroLend {
    using SafeERC20 for IERC20;
 
    error ZeroAmount();
    error InsufficientCollateral(uint256 requested, uint256 max);
    error OutstandingDebt(uint256 debt);
    error NotEnoughFunds();
 
    IERC20 public immutable asset;
    uint256 public immutable ltvBps; // e.g., 7500 = 75%
 
    mapping(address => uint256) public deposits;
    mapping(address => uint256) public debt;
 
    uint256 public totalDeposits;
    uint256 public totalDebt;
 
    event Deposit(address indexed user, uint256 amount);
    event Withdraw(address indexed user, uint256 amount);
    event Borrow(address indexed user, uint256 amount);
    event Repay(address indexed user, uint256 amount);
 
    constructor(IERC20 _asset, uint256 _ltvBps) {
        require(_ltvBps <= 9000, "ltv too high");
        asset = _asset;
        ltvBps = _ltvBps;
    }
 
    function deposit(uint256 amount) external {
        if (amount == 0) revert ZeroAmount();
        asset.safeTransferFrom(msg.sender, address(this), amount);
        deposits[msg.sender] += amount;
        totalDeposits += amount;
        emit Deposit(msg.sender, amount);
    }
 
    function withdraw(uint256 amount) external {
        if (amount == 0) revert ZeroAmount();
        if (debt[msg.sender] != 0) revert OutstandingDebt(debt[msg.sender]);
        uint256 d = deposits[msg.sender];
        if (amount > d) revert NotEnoughFunds();
        deposits[msg.sender] = d - amount;
        totalDeposits -= amount;
        asset.safeTransfer(msg.sender, amount);
        emit Withdraw(msg.sender, amount);
    }
 
    function borrow(uint256 amount) external {
        if (amount == 0) revert ZeroAmount();
        uint256 maxBorrow = (deposits[msg.sender] * ltvBps) / 10_000;
        uint256 newDebt = debt[msg.sender] + amount;
        if (newDebt > maxBorrow) revert InsufficientCollateral(newDebt, maxBorrow);
        if (asset.balanceOf(address(this)) < amount) revert NotEnoughFunds();
        debt[msg.sender] = newDebt;
        totalDebt += amount;
        asset.safeTransfer(msg.sender, amount);
        emit Borrow(msg.sender, amount);
    }
 
    function repay(uint256 amount) external {
        if (amount == 0) revert ZeroAmount();
        uint256 d = debt[msg.sender];
        uint256 actual = amount > d ? d : amount;
        asset.safeTransferFrom(msg.sender, address(this), actual);
        debt[msg.sender] = d - actual;
        totalDebt -= actual;
        emit Repay(msg.sender, actual);
    }
}

6.4 Unit tests with every major cheatcode

test/MicroLend.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
 
import {Test, Vm} from "forge-std/Test.sol";
import {MicroLend} from "../src/MicroLend.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
 
contract MockToken is ERC20 {
    constructor() ERC20("Mock", "MCK") { _mint(msg.sender, 1_000_000e18); }
    function mint(address to, uint256 amt) external { _mint(to, amt); }
}
 
contract MicroLendTest is Test {
    MicroLend lend;
    MockToken token;
    address alice = makeAddr("alice");
    address bob   = makeAddr("bob");
 
    event Deposit(address indexed user, uint256 amount);
 
    function setUp() public {
        token = new MockToken();
        lend = new MicroLend(IERC20(address(token)), 7500); // 75% LTV
        deal(address(token), alice, 10_000e18);
        deal(address(token), bob,   10_000e18);
        vm.label(address(lend),  "MicroLend");
        vm.label(address(token), "MockToken");
    }
 
    // 1. vm.prank — single-call impersonation
    function test_deposit_basic() public {
        vm.prank(alice); token.approve(address(lend), 1000e18);
        vm.prank(alice); lend.deposit(1000e18);
        assertEq(lend.deposits(alice), 1000e18);
    }
 
    // 2. vm.startPrank / stopPrank
    function test_deposit_borrow_flow() public {
        vm.startPrank(alice);
        token.approve(address(lend), type(uint256).max);
        lend.deposit(1000e18);
        lend.borrow(500e18);
        vm.stopPrank();
        assertEq(lend.debt(alice), 500e18);
    }
 
    // 3. vm.expectRevert with custom error selector
    function test_revert_zero_deposit() public {
        vm.expectRevert(MicroLend.ZeroAmount.selector);
        vm.prank(alice); lend.deposit(0);
    }
 
    function test_revert_over_borrow() public {
        vm.startPrank(alice);
        token.approve(address(lend), 1000e18);
        lend.deposit(1000e18);
        vm.expectRevert(
            abi.encodeWithSelector(MicroLend.InsufficientCollateral.selector, 800e18, 750e18)
        );
        lend.borrow(800e18);
        vm.stopPrank();
    }
 
    // 4. vm.expectEmit
    function test_deposit_emits_event() public {
        vm.prank(alice); token.approve(address(lend), 1000e18);
        vm.expectEmit(true, false, false, true, address(lend));
        emit Deposit(alice, 1000e18);
        vm.prank(alice); lend.deposit(1000e18);
    }
 
    // 5. vm.warp + vm.roll
    function test_warp_roll() public {
        vm.warp(block.timestamp + 7 days);
        vm.roll(block.number + 50_000);
    }
 
    // 6. vm.deal — set ETH balance
    function test_deal_eth() public {
        vm.deal(alice, 100 ether);
        assertEq(alice.balance, 100 ether);
    }
 
    // 7. vm.store + vm.load — confirm slot via `forge inspect MicroLend storageLayout`
    function test_store_load_deposits_slot() public {
        // `deposits` mapping slot — run forge inspect to confirm; here illustrative as slot 2
        bytes32 slot = keccak256(abi.encode(alice, uint256(2)));
        vm.store(address(lend), slot, bytes32(uint256(42 ether)));
        bytes32 read = vm.load(address(lend), slot);
        assertEq(uint256(read), 42 ether);
    }
 
    // 8. vm.mockCall — override an external call
    function test_mock_token_balance() public {
        vm.mockCall(
            address(token),
            abi.encodeWithSelector(IERC20.balanceOf.selector, address(lend)),
            abi.encode(uint256(1_000_000e18))
        );
        assertEq(token.balanceOf(address(lend)), 1_000_000e18);
    }
 
    // 9. vm.recordLogs — capture all events
    function test_record_logs() public {
        vm.recordLogs();
        vm.startPrank(alice);
        token.approve(address(lend), 1000e18);
        lend.deposit(1000e18);
        vm.stopPrank();
        Vm.Log[] memory entries = vm.getRecordedLogs();
        assertEq(entries.length, 3); // Approval + Transfer + Deposit
    }
 
    // 10. Fuzz — property-based
    function testFuzz_deposit_amount(uint256 amount) public {
        amount = bound(amount, 1, 10_000e18);
        vm.startPrank(alice);
        token.approve(address(lend), amount);
        lend.deposit(amount);
        vm.stopPrank();
        assertEq(lend.deposits(alice), amount);
    }
}

Run:

forge test -vv
forge test --match-test testFuzz -vv
forge inspect MicroLend storageLayout   # learn exact slots
forge snapshot

6.5 Mainnet fork test — real USDC

test/MicroLendFork.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
 
import {Test} from "forge-std/Test.sol";
import {MicroLend} from "../src/MicroLend.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 
contract MicroLendForkTest is Test {
    // Mainnet USDC
    address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
    // Pick a current whale from etherscan top USDC holders.
    // Example: Binance hot wallet (verify the balance at your pinned block)
    address constant USDC_WHALE = 0x37305B1cD40574E4C5Ce33f8e8306Be057fD7341; // [verify holder]
 
    MicroLend lend;
 
    function setUp() public {
        // Pin block for reproducibility. Use a recent stable block.
        // The value below is an example; replace with a known block where the whale held USDC.
        vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 19_000_000); // [verify]
        lend = new MicroLend(IERC20(USDC), 7500);
        vm.label(USDC, "USDC");
        vm.label(USDC_WHALE, "USDC_WHALE");
        vm.label(address(lend), "MicroLend");
    }
 
    function test_fork_deposit_real_usdc() public {
        uint256 amount = 1_000_000e6; // 1M USDC (6 decimals)
 
        // Impersonate the whale
        vm.startPrank(USDC_WHALE);
        IERC20(USDC).approve(address(lend), amount);
        lend.deposit(amount);
        vm.stopPrank();
 
        assertEq(IERC20(USDC).balanceOf(address(lend)), amount);
        assertEq(lend.deposits(USDC_WHALE), amount);
    }
}

Run:

export MAINNET_RPC_URL=https://eth-mainnet.public.blastapi.io   # or your provider
forge test --match-contract MicroLendForkTest -vvv

Troubleshooting:

  • If the whale doesn’t hold enough at the pinned block, switch whales (etherscan top holders) or use deal(USDC, address(this), amount) (Foundry’s deal cheatcode forges balances by writing storage slots, which works for most ERC-20s — but fails for USDC since 2023 due to internal balance accounting in the proxied impl; use whale impersonation instead).

6.6 Invariant test with handler pattern

test/handler/MicroLendHandler.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
 
import {Test} from "forge-std/Test.sol";
import {MicroLend} from "../../src/MicroLend.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 
contract MicroLendHandler is Test {
    MicroLend public lend;
    IERC20 public token;
    address[] public actors;
    uint256 public ghost_deposited;
    uint256 public ghost_withdrawn;
    uint256 public ghost_borrowed;
    uint256 public ghost_repaid;
 
    constructor(MicroLend _l, IERC20 _t, address[] memory _a) {
        lend = _l; token = _t; actors = _a;
    }
 
    function _pick(uint256 seed) internal view returns (address) {
        return actors[bound(seed, 0, actors.length - 1)];
    }
 
    function deposit(uint256 seed, uint256 amount) external {
        address a = _pick(seed);
        amount = bound(amount, 1, token.balanceOf(a));
        if (amount == 0) return;
        vm.startPrank(a);
        token.approve(address(lend), amount);
        lend.deposit(amount);
        vm.stopPrank();
        ghost_deposited += amount;
    }
 
    function withdraw(uint256 seed, uint256 amount) external {
        address a = _pick(seed);
        if (lend.debt(a) != 0) return;
        uint256 d = lend.deposits(a);
        if (d == 0) return;
        amount = bound(amount, 1, d);
        vm.prank(a); lend.withdraw(amount);
        ghost_withdrawn += amount;
    }
 
    function borrow(uint256 seed, uint256 amount) external {
        address a = _pick(seed);
        uint256 maxBorrow = (lend.deposits(a) * lend.ltvBps()) / 10_000;
        uint256 cur = lend.debt(a);
        if (cur >= maxBorrow) return;
        uint256 room = maxBorrow - cur;
        uint256 avail = token.balanceOf(address(lend));
        uint256 ceil = room < avail ? room : avail;
        if (ceil == 0) return;
        amount = bound(amount, 1, ceil);
        vm.prank(a); lend.borrow(amount);
        ghost_borrowed += amount;
    }
 
    function repay(uint256 seed, uint256 amount) external {
        address a = _pick(seed);
        uint256 d = lend.debt(a);
        if (d == 0 || token.balanceOf(a) == 0) return;
        amount = bound(amount, 1, d < token.balanceOf(a) ? d : token.balanceOf(a));
        vm.startPrank(a);
        token.approve(address(lend), amount);
        lend.repay(amount);
        vm.stopPrank();
        ghost_repaid += amount;
    }
}

test/MicroLend.invariant.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
 
import {Test} from "forge-std/Test.sol";
import {MicroLend} from "../src/MicroLend.sol";
import {MicroLendHandler} from "./handler/MicroLendHandler.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
 
contract MockToken is ERC20 {
    constructor() ERC20("Mock", "MCK") {}
    function mint(address to, uint256 amt) external { _mint(to, amt); }
}
 
contract MicroLendInvariantTest is Test {
    MicroLend lend;
    MockToken token;
    MicroLendHandler handler;
    address[] actors;
 
    function setUp() public {
        token = new MockToken();
        lend = new MicroLend(IERC20(address(token)), 7500);
        for (uint256 i = 0; i < 5; i++) {
            address a = makeAddr(string.concat("actor", vm.toString(i)));
            actors.push(a);
            token.mint(a, 100_000e18);
        }
        handler = new MicroLendHandler(lend, IERC20(address(token)), actors);
        targetContract(address(handler));
 
        bytes4[] memory sels = new bytes4[](4);
        sels[0] = MicroLendHandler.deposit.selector;
        sels[1] = MicroLendHandler.withdraw.selector;
        sels[2] = MicroLendHandler.borrow.selector;
        sels[3] = MicroLendHandler.repay.selector;
        targetSelector(FuzzSelector({addr: address(handler), selectors: sels}));
    }
 
    /// Token conservation: token balance == deposits - withdrawals + repays - borrows
    function invariant_token_conservation() public view {
        uint256 netIn = handler.ghost_deposited() - handler.ghost_withdrawn()
                      + handler.ghost_repaid() - handler.ghost_borrowed();
        assertEq(token.balanceOf(address(lend)), netIn);
    }
 
    /// Per-user debt never exceeds LTV-limit
    function invariant_no_user_over_LTV() public view {
        for (uint256 i = 0; i < actors.length; i++) {
            address a = actors[i];
            uint256 maxBorrow = (lend.deposits(a) * lend.ltvBps()) / 10_000;
            assertLe(lend.debt(a), maxBorrow);
        }
    }
 
    /// Sum of per-user balances equals tracked total
    function invariant_total_deposits_eq_sum() public view {
        uint256 sum;
        for (uint256 i = 0; i < actors.length; i++) sum += lend.deposits(actors[i]);
        assertEq(sum, lend.totalDeposits());
    }
}

Run:

forge test --match-contract MicroLendInvariantTest -vv

Tune runs and depth in foundry.toml for deeper exploration.

6.7 Coverage

forge coverage
forge coverage --report lcov --report-file lcov.info

Expected output (text): per-file table with line/branch/function/statement %.

If you see “stack too deep” with via_ir, either:

  • Add via_ir = true and --ir-minimum, OR
  • Build without via_ir for coverage (the default if via_ir = false).

6.8 Set up CI

Create .github/workflows/test.yml (from §5). Push to GitHub. Watch the run.

For the gas snapshot:

forge snapshot                       # writes `.gas-snapshot`
git add .gas-snapshot
git commit -m "baseline gas snapshot"

CI will then forge snapshot --check and fail on regressions.

6.9 Expected learning outcome

After this lab you should be able to:

  • Spin up a new Foundry project in <5 minutes.
  • Write a fork test that impersonates any account on mainnet.
  • Apply at least 8 different cheatcodes in a single test file without reaching for docs.
  • Build a handler contract for invariant testing.
  • Read forge coverage and forge inspect storageLayout outputs fluently.
  • Configure a CI pipeline that gates merges on build, tests, coverage, format, gas.

7. Anti-patterns (cataloged for the audit checklist)

Add to your audit checklist:

  • memory instead of storage for state-mutating struct/array references — silently no-op updates.
  • public on functions that should be internal — accidental external attack surface.
  • Modifiers that perform effects, not just checks — hidden state mutations.
  • require(condition, "string") in 2026 code — quality signal; cost: deploy gas, ABI bloat, worse UX.
  • unchecked block without justification comment — proof obligation missed.
  • Loose pragma (^0.8.0 or ^0.8.4) in production — exposes to old-compiler bugs.
  • immutable declared but not assigned in constructor — zero value forever.
  • immutable in upgradeable contracts where chain-specific value is expected — value lives in impl, not proxy.
  • Inheritance order changes without re-running storage-layout diff — silent storage collision.
  • receive / fallback with state-mutating logic — pinging the contract triggers it.
  • External library used without using for namespacing — global helper functions polluting type space.
  • Transient storage slot collision with other code in same tx — un-namespaced TSTORE keys.
  • Compiler version 0.8.28–0.8.33 with transient storage + via_ir — known compiler bug (clearing same-type vars). Pin >= 0.8.34. [verify exact range]
  • No invariant tests in repo — author hasn’t articulated their invariants.
  • Coverage < 80% line and < 70% branch — untested code paths likely contain bugs.
  • CI runs only forge test, not coverage / snapshot / fmt — low quality bar.
  • No forge snapshot baseline committed — silent gas regressions accepted.
  • Mock-only test suite (no fork tests) — integration bugs unsurfaced.
  • Production deploys with via_ir = true while audit ran via_ir = false — different bytecode audited vs deployed.

8. Trade-offs and Open Debates

DecisionOption AOption BAuditor’s view
via_irOff (default; legacy pipeline)On (Yul IR pipeline)If you turn it on for deploy, you must audit with it on. The bytecode is genuinely different.
Compiler pragmaPinned exact (e.g., 0.8.29)Floating (e.g., ^0.8.20)Production: pinned. Libraries: floating with high floor.
Custom errors vs require stringsStringsCustom errorsCustom errors, always. The only excuse is supporting a downstream that string-matches on revert — and if so, that downstream needs a finding.
external vs publicPublic everywhereExternal by defaultExternal by default. Principle of least exposure.
unchecked blocksForbid allAllow with justificationAllow with required comment explaining the safety argument. Spot-check the comment.
Transient storage for reentrancy guardsStorage-based (uint256 _status)Transient (uint256 transient _locked)Transient is strictly better in 2026 — ~5k gas saved per call. Confirm compiler is >= 0.8.34 if using high-level transient state vars.
Foundry fail_on_reverttrue (strict)false (allow filtering)Start with false for early development; tighten to true once handler is mature. Strict mode catches handler bugs where you forgot a precondition.
Mainnet fork in CIYes (paid RPC)No (only unit tests)Yes, with a fork run gated to PR labels or main only. Fork tests catch integration bugs no mock can.
forge snapshot in CIEnforce no regressionsTrack but don’t enforceEnforce. Silent gas regressions accumulate and are painful to bisect later.
Slither in CIRequired, fail on Medium+Advisory onlyRequired, fail on Medium+. False-positive rate is manageable; tune slither.config.json.

9. Quiz (≥80% to advance)

  1. Q: A function declares Position memory p = positions[id]; p.amount += 100;. The function returns. Has positions[id].amount changed?
    A: No. memory made a copy. The mutation is on the copy, discarded at function return. The state in positions[id] is unchanged. This is one of the most common Solidity dev errors and an audit grep target: Type memory x = stateVar.

  2. Q: You audit a contract with pragma solidity ^0.8.4; and find no unchecked blocks. The contract uses transient-storage-style patterns via Yul (tstore/tload). What’s the audit finding about the pragma?
    A: The pragma allows compilation with any 0.8.x ≥ 0.8.4, but tstore/tload require ≥ 0.8.24 (Cancun support). Furthermore, 0.8.28–0.8.33 have a known IR-pipeline bug affecting transient storage clearing (fixed in 0.8.34). Finding: pin pragma to ≥ 0.8.34 if using transient storage. Severity: Low (or Medium if via_ir is also enabled).

  3. Q: Custom errors are strictly cheaper to deploy than require strings. True or false, and why?
    A: True. Strings are stored in bytecode byte-by-byte. Custom errors compile to a 4-byte selector plus ABI-encoded args at the revert site. Deploy-cost savings scale with the number and length of revert messages. Runtime is also slightly cheaper. The only cost is slightly more code to declare the errors.

  4. Q: Explain why vm.deal(USDC_contract, ...) may not work to grant USDC balance to an address on a fork, and what to do instead.
    A: deal works for token balances by computing the storage slot of balanceOf[user] and writing it directly. For tokens with custom proxy/storage layouts (USDC is upgradeable; some balance state lives in deeper slots or in an implementation different from the proxy), the slot may not be where deal guesses. Use vm.prank(WHALE) plus IERC20.transfer(...) instead — impersonate a real holder and have them transfer.

  5. Q: In a forge invariant test with a handler, what does fail_on_revert = false (default) actually mean, and when do you want it true?
    A: false: when a handler function call reverts (e.g., trying to withdraw more than balance), the fuzzer records it and moves on; the invariant is still checked after. true: any revert in a handler call fails the test. You want true after the handler is mature with full guard clauses, to catch cases where the handler “lies” about state preconditions. Early development: false is more forgiving.

  6. Q: A contract has function f() external nonReentrant onlyOwner { ... }. The nonReentrant modifier sets a storage flag, runs _, clears the flag. The function reverts inside _. Is the reentrancy flag released?
    A: Yes. The revert reverts all state changes in the transaction, including the storage write that set the flag. The flag is back to its pre-call value. (Note: with transient storage, the same logic applies — TSTORE is also part of the tx-scoped state that reverts.)

  7. Q: You’re auditing a UUPS proxy. Implementation V1 has variables [A: uint256, B: address, C: mapping]. V2 adds D: uint256 and E: address before C. After upgrade, what happens to C’s data?
    A: Catastrophic. Inserting variables before C shifts its slot by 2 (one slot each for D and E, since address is < 32 bytes but typically not packed in this layout). What was the C mapping at slot 2 is now read as a single address (E), and all C data is unrecoverable (interpreted as D or E). Always append-only, or use storage gaps.

  8. Q: For a contract using using SafeMath for uint256; in 2026, what’s the audit-worthy comment?
    A: Solidity 0.8 added native overflow checks; SafeMath is redundant. The contract is either pre-0.8 (find out why it hasn’t been migrated) or post-0.8 with redundant SafeMath (wasted gas, code bloat). The redundant pattern itself isn’t a vuln but is a quality signal — the team is on an old playbook.

  9. Q: A forge test -vvv trace shows: CALL Vault::withdraw() [ ... ] -> CALL receiver::receive() [ ... ] -> CALL Vault::withdraw(). What bug class is this?
    A: Reentrancy. The trace shows the vault calling the receiver, which calls back into the vault’s withdraw before the first call’s state updates have completed. This is the canonical reentrancy trace and the single most important pattern to recognize in forge test output.

  10. Q: You see targetSelector(FuzzSelector({addr: ..., selectors: [a, b, c]})) in an invariant test setUp. What does this do, and why is it useful for auditors?
    A: It restricts the invariant fuzzer to calling only the selectors a, b, c on the given target (typically the handler). Useful because (1) auditors can see exactly which interface the team considers the “external surface” for fuzzing, (2) functions excluded are either trivial or were forgotten — both worth questioning, and (3) you can extend the list with admin-only or rare functions to stress paths the team’s tests don’t exercise.


10. Week 03 Deliverables

  • Foundry project lending-lab builds and passes forge test.
  • All 10 unit tests with cheatcodes in §6.4 pass.
  • Mainnet fork test passes against real USDC at a pinned block.
  • Invariant test with handler runs at default runs=256, depth=50 with zero failures and all three invariants holding.
  • forge coverage produces a report; line coverage ≥ 90% for src/MicroLend.sol.
  • .github/workflows/test.yml exists and a CI run is green.
  • .gas-snapshot committed.
  • Notes file: ~/web3-sec-lab/wk03/notes.md containing:
    • Your forge inspect MicroLend storageLayout output.
    • Written answers to all quiz questions in your own words.
    • One paragraph each on: “why pin compiler version”, “why use custom errors”, “when to use transient storage”, “what’s special about handler-pattern invariant tests”.

11. Where this leads

Next week: Tuan-04-Security-Foundations-CEI-AC. With Solidity fluent and Foundry as a daily tool, you start the security-focused phase. Week 04 builds the two universal principles every audit relies on:

  • Checks-Effects-Interactions as a structural defense against reentrancy and untrusted-external-call hazards.
  • Access Control patterns: Ownable, AccessControl, role granularity, timelocks, multi-sig handoff.

Plus storage-layout hazards in inheritance — the seed of every proxy collision bug — and input validation as a discipline.

The shift in mindset: this week you wrote code and tests. Next week you read other people’s code looking for the principles they violated. Every PoC you wrote here becomes a template for a PoC you’ll write against a buggy version of someone else’s contract.

By the end of Week 04 you’re ready for the long Vulnerability Classes weeks (05, 06), where the bug catalog explodes from “general principles” into named exploit shapes. The Foundry/Solidity fluency you built this week is the substrate that makes those weeks productive instead of overwhelming.


Last updated: 2026-05-16 See also: Roadmap · References · MOC-Web3-Security-Mastery · Tuan-01-Web3-Blockchain-Crypto-Fundamentals · Tuan-02-Ethereum-EVM-Deep-Dive · Tuan-04-Security-Foundations-CEI-AC