Section C · Coding

Coding Fundamentals

The toolchain a senior security engineer uses every day — Foundry, Echidna, Medusa, Slither, Halmos — and the muscle of reading code adversarially.

Foundry for security work

Foundry has become the default DeFi toolchain. For security engineers, the value isn't just "Solidity tests" — it's:

  • Mainnet forking, so you can replay state under attack scenarios.
  • Cheatcodes (vm.prank, vm.warp, vm.deal, vm.expectRevert) that let you simulate adversarial conditions.
  • Native invariant testing via --invariant.
  • Fast iteration — sub-second test runs.
  • Tooling integration: Slither runs on Foundry projects; Halmos consumes Foundry tests; Echidna can target Foundry-compiled artifacts.
forge install                          # install deps
forge build                            # compile
forge test -vvv                        # run tests, verbose
forge test --match-test testReentrancy # filter
forge test --gas-report                # gas usage per function
forge coverage                         # line/branch coverage
forge inspect MyContract storage-layout --pretty
forge inspect MyContract methods       # function selectors

forge test essentials

Test patterns a security engineer should write in their sleep:

contract MarketTest is Test {
    Market market;
    MockToken loanToken;
    address attacker = address(0x1337);

    function setUp() public {
        loanToken = new MockToken();
        market = new Market(address(loanToken));
        deal(address(loanToken), attacker, 1_000_000e18);
    }

    function test_DonationAttackDoesNotInflateShares() public {
        // Pre-mint dead shares (or whatever the mitigation is)
        market.initialize();

        // Attacker donates directly to market (transfers, doesn't deposit)
        vm.prank(attacker);
        loanToken.transfer(address(market), 1000e18);

        // A new depositor should still get fair shares
        address victim = address(0xBEEF);
        deal(address(loanToken), victim, 100e18);
        vm.startPrank(victim);
        loanToken.approve(address(market), 100e18);
        uint256 shares = market.deposit(100e18, victim);
        vm.stopPrank();

        // Assert: shares correspond reasonably to deposit (not zero)
        assertGt(shares, 0, "Victim got zero shares from donation attack");
    }

    function test_RevertWhen_NonOwnerPauses() public {
        vm.prank(attacker);
        vm.expectRevert("NOT_OWNER");
        market.pause();
    }
}

Invariant testing

Foundry's invariant fuzzer drives a sequence of random calls and checks invariants after each. Vastly more powerful than unit tests for protocol-level properties.

contract MarketInvariantTest is Test {
    Market market;
    Handler handler;

    function setUp() public {
        market = new Market();
        handler = new Handler(market);
        targetContract(address(handler));
        targetSelector(FuzzSelector({
            addr: address(handler),
            selectors: handler.getSelectors()
        }));
    }

    function invariant_NoBadDebt() public {
        assertLe(market.totalBorrowAssets(), market.totalSupplyAssets());
    }

    function invariant_SharesConserved() public {
        assertEq(market.totalSupplyShares(), handler.ghostSumOfShares());
    }
}

contract Handler is Test {
    Market market;
    uint256 public ghostSumOfShares;

    function deposit(uint256 amount, uint256 actorSeed) external {
        address actor = _seedActor(actorSeed);
        amount = bound(amount, 1, 1e30);
        deal(address(market.token()), actor, amount);
        vm.startPrank(actor);
        market.token().approve(address(market), amount);
        uint256 shares = market.deposit(amount, actor);
        vm.stopPrank();
        ghostSumOfShares += shares;
    }
    // ... more handlers
}

Configure fuzz depth and runs in foundry.toml:

[invariant]
runs = 256
depth = 500
fail_on_revert = false

Storage layout inspection

For upgradeable contracts, you should snapshot the storage layout and CI-fail on unexpected changes.

forge inspect Market storage-layout --pretty
# Outputs slot, offset, type, name for each state variable

forge inspect Market storage-layout > storage/Market.json
# Commit storage/Market.json; CI fails on git diff

cast for diagnostics

Foundry's cast is the swiss army knife for on-chain debugging:

# Decode a transaction
cast tx 0xabc... --rpc-url $RPC
cast 4byte 0xa9059cbb        # function selector → signature
cast sig "transfer(address,uint256)"
cast call $CONTRACT "totalSupply()(uint256)" --rpc-url $RPC
cast storage $CONTRACT 0 --rpc-url $RPC   # raw slot read
cast run 0xtxhash --quick   # trace a tx
cast --to-dec 0x...; cast --to-hex 123
cast wallet new
cast estimate $CONTRACT "foo(uint256)" 42

cast run with --debug gives you a TUI debugger over the transaction trace — invaluable for incident analysis.

Echidna and Medusa

Echidna (Trail of Bits) is the OG property-based fuzzer for Solidity. Specifications written as Solidity functions returning bool.

contract EchidnaMarket is Market {
    function echidna_no_bad_debt() public view returns (bool) {
        return totalBorrowAssets() <= totalSupplyAssets();
    }
}
echidna EchidnaMarket.sol --contract EchidnaMarket --test-mode property

Medusa (also Trail of Bits) is the newer Go-based fuzzer. Faster, integrates with Foundry, supports coverage-guided fuzzing. Reaching parity with Echidna and often preferred for new projects.

Slither analyzers and detectors

Slither (Trail of Bits) is the standard static-analysis tool for Solidity. Run it on every PR.

slither . --solc-remaps "@openzeppelin=lib/openzeppelin-contracts/contracts"
slither . --print human-summary
slither . --print contract-summary
slither . --detect reentrancy-eth,reentrancy-no-eth,uninitialized-state
slither . --triage-mode    # interactive triage of findings
slither-check-upgradeability ProxyContract InitContract  # diff layouts for upgrade

Most useful detectors to memorize:

  • reentrancy-eth, reentrancy-no-eth, reentrancy-benign
  • uninitialized-state, uninitialized-storage, uninitialized-local
  • arbitrary-send-eth
  • locked-ether
  • incorrect-equality (e.g., balance == 0 on a balance that may dust)
  • tx-origin
  • weak-prng
  • shadowing-state, shadowing-abstract

Custom detectors are also writable — useful if you have protocol-specific patterns to flag.

Reading PRs adversarially

The single highest-leverage daily habit. The motion:

  1. Diff first. What changed? Don't read the whole file; read what's new.
  2. Why does this change exist? Read the PR description, linked issue. If unclear, ask.
  3. What invariants might this break? For each invariant in your spec, ask if the change could violate it.
  4. What new attack surface? Any new external function, any new external call, any new role.
  5. What about the negative cases? Zero inputs, max inputs, empty state, paused state, post-incident state.
  6. Compose with existing. Does this play nicely with multicall, with batch operations, with re-entrancy guards?

Leave your review as a structured comment. Even if you don't block the PR, leaving an artifact ("I considered X, Y, Z; here's why I think they hold") builds a paper trail for future audits.

DSA patterns to know

You're a user of these, not a researcher in them. The level of fluency you need:

  • Call graphs. What functions can call function X? Slither builds this; you should be able to read one.
  • Taint propagation. If x comes from user input, where does it flow? Useful for tracking trust boundaries.
  • Control-flow graphs. Per-function CFG; useful for spotting unreachable code or surprising branches.
  • Data-flow analysis. What state can be modified between A and B?
  • Symbolic execution. What Halmos/Manticore/Mythril do internally. You should know the concept; you don't have to implement.

If you're asked a coding-interview-style problem (rare in security loops but possible): the most likely shapes are parse a call trace and find the reentrant call, walk a storage layout, compute a 4-byte selector from a function signature. Drill those, not LeetCode.

# Compute a function selector from a signature
cast sig "transfer(address,uint256)"
# 0xa9059cbb

# Or in Python:
python3 -c "from eth_utils import keccak; print('0x' + keccak(text='transfer(address,uint256)')[:4].hex())"