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 selectorsforge 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 = falseStorage 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 diffcast 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)" 42cast 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 propertyMedusa (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 upgradeMost useful detectors to memorize:
reentrancy-eth,reentrancy-no-eth,reentrancy-benignuninitialized-state,uninitialized-storage,uninitialized-localarbitrary-send-ethlocked-etherincorrect-equality(e.g.,balance == 0on a balance that may dust)tx-originweak-prngshadowing-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:
- Diff first. What changed? Don't read the whole file; read what's new.
- Why does this change exist? Read the PR description, linked issue. If unclear, ask.
- What invariants might this break? For each invariant in your spec, ask if the change could violate it.
- What new attack surface? Any new external function, any new external call, any new role.
- What about the negative cases? Zero inputs, max inputs, empty state, paused state, post-incident state.
- 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
xcomes 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())"