Auditing & Testing: From Unit Tests to Fuzzing & Invariants
Ship with confidence: rigorous tests, automated checks, and structured reviews before mainnet.
1) Tooling Stack
Auditing isn’t a single event, it’s a pipeline that starts on day one of development. The core stack below gives you fast iterations, property discovery, and automated safety nets.
- Foundry (forge/cast): Solidity-native tests, blazing fast, clean assertions (
forge-std
), mainnet forking, fuzzing & invariants built in. - Hardhat: JS/TS ecosystem, rich plugin universe (
ethers
/viem
,solidity-coverage
, task automation), excellent for app integration tests. - Slither: Static analysis that flags common bug patterns and code smells (reentrancy, shadowing, uninitialized storage, dangerous
delegatecall
). - The Graph: Index on-chain events to drive integration and end-to-end tests against historical data (great for protocol accounting checks).
2) Foundry Example
Foundry lets you write tests in Solidity, run them at native speed, and use powerful cheatcodes to sculpt chain state. You’ll typically create a src/
folder for contracts and a test/
folder for tests using forge-std
.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "../src/Vault.sol"; contract VaultTest is Test { Vault v; address alice = address(0xA11CE); function setUp() public { v = new Vault(); vm.deal(alice, 10 ether); // give alice ETH } function testDeposit() public { vm.prank(alice); // next call is from alice v.deposit{value: 1 ether}(); assertEq(v.balanceOf(alice), 1 ether); assertEq(address(v).balance, 1 ether); } // Fuzz: try many random deposit sizes within bounds function testFuzz_Deposit(uint128 amount) public { amount = uint128(bound(amount, 1e9, 10 ether)); // clamp vm.deal(alice, amount); vm.prank(alice); v.deposit{value: amount}(); assertEq(v.balanceOf(alice), amount); } }
Cheatcodes to know: deal
(set balances), prank/hoax
(impersonate), warp/roll
(time/blocks), expectRevert
(negative testing), startPrank/stopPrank
(sequence impersonation), mockCall
(mock external calls).
# Common commands forge test -vvv # verbose test logs forge test --fork-url $RPC # mainnet-fork tests forge snapshot # gas snapshots forge coverage # coverage report
3) Hardhat Example
Hardhat excels for app-level testing with TypeScript, fixtures, and plugin ergonomics. Use it to wire contracts, simulate front-end flows, and validate integrations (routers, oracles, proxies).
// test/Vault.t.ts (viem/ethers) import { expect } from "chai"; import { ethers } from "hardhat"; describe("Vault", () => { it("deposits ETH", async () => { const [u] = await ethers.getSigners(); const Vault = await ethers.getContractFactory("Vault"); const v = await Vault.deploy(); await v.waitForDeployment(); await u.sendTransaction({ to: await v.getAddress(), value: ethers.parseEther("1") }); expect(await v.balanceOf(u.address)).to.equal(ethers.parseEther("1")); }); it("reverts on withdraw more than balance", async () => { const [u] = await ethers.getSigners(); const v = await (await ethers.getContractFactory("Vault")).deploy(); await v.waitForDeployment(); await expect(v.connect(u).withdraw(ethers.parseEther("1"))).to.be.reverted; }); });
Fixtures & forking. Use Hardhat’s network forking to test oracle interactions, DEX routing, and token approvals against realistic mainnet state. Cache fixtures so each test starts from a clean snapshot.
// hardhat.config.ts (snippet) networks: { hardhat: { forking: { url: process.env.ALCHEMY_RPC! }, }, }
Coverage & gas. Pair with solidity-coverage
for line/branch metrics and log gas costs in CI to catch regressions.
4) Fuzz & Invariant Tests
Unit tests check specific inputs; fuzzing throws thousands of randomized inputs; invariant testing asks “what must always be true?” across arbitrary sequences. Together they uncover edge cases you wouldn’t hand-write.
Fuzzing
- Start from a valid state. Constrain inputs with
bound
(Foundry) to realistic ranges. - Assert conservation: balances never negative; totals match accounting; fees sum correctly.
- Include negative tests:
vm.expectRevert
before operations that should fail for bad inputs.
function testFuzz_WithdrawNeverExceedsBalance(uint96 dep, uint96 wd) public { dep = uint96(bound(dep, 1e12, 10 ether)); wd = uint96(bound(wd, 0, dep)); v.deposit{value: dep}(); uint balBefore = v.balanceOf(address(this)); v.withdraw(wd); assertEq(v.balanceOf(address(this)), balBefore - wd); }
Invariant testing (Foundry)
Encode the rules your system must obey no matter what order of calls or inputs.
contract Invariants is Test { Vault v; address a = address(0xA1); address b = address(0xB2); function setUp() public { v = new Vault(); targetContract(address(v)); // Foundry: fuzz calls into Vault vm.deal(a, 100 ether); vm.deal(b, 100 ether); } // Invariant: total liabilities == contract ETH balance function invariant_AccountingConserves() public { uint total = v.balanceOf(a) + v.balanceOf(b); assertEq(total, address(v).balance); } }
Add more invariants: “sum of shares ≤ cap”, “no user can withdraw more than deposited”, “fee recipient never exceeds configured split”, “post-upgrade storage layout unchanged”.
5) Static Analysis & Coverage
Static analyzers find issues without running the code. They won’t “prove” correctness, but they surface classes of bugs early and enforce hygiene.
- Slither: detects reentrancy patterns, write-after-write, dangerous
tx.origin
usage, shadowed variables, missing events, incorrectunchecked
blocks, uninitialized storage pointers. - Formatting & linting: consistent
forge fmt
, NatSpec docs on externals, explicitoverride
,immutable
for constants,payable
only when receiving ETH. - Coverage: target critical paths (auth modifiers, upgrade hooks, emergency pause, fee changes). Line coverage is not a quality badge, pair it with invariants and negative tests.
# Typical CI snippets slither . --checklist --exclude-informational forge test --fork-url $RPC --gas-report forge coverage --report lcov # hardhat alternative npx hardhat coverage
Storage layout checks. If you use proxies (UUPS/transparent), lock storage layout with a test that hashes layout or reads slot-by-slot. Upgrades must never clobber existing slots.
Oracles & external calls. Simulate oracle staleness/deviation. Mock external calls to revert, return extreme values, or consume more gas than expected. Ensure your code handles failure paths (e.g., try/catch
on call
).
6) Audit Checklist
Use this as a pre-audit gate. If each line is green, external audits become faster and more fruitful.
- Threat model: Enumerate assets at risk (user funds, governance, treasury), attackers (outsiders, admins), and trust assumptions (oracles, bridges, operators).
- Roles & permissions: Map owners, pausers, guardians, upgraders. Enforce principle of least privilege. Emit events on every privileged action.
- Upgrade safety: Proxies behind multisig + timelock; versioned initializer; storage gaps reserved; upgrade tests that prove invariants hold across versions.
- Economic checks: Fees can’t exceed limits; no fee-on-fee loops; rewards/interest rates behave across edge cases; TWAP and sanity checks around oracles.
- Input validation: Bounds on amounts, deadlines, recipient addresses; revert on zero address for critical roles;
nonReentrant
+ CEI pattern where applicable. - Pull over push: Prefer user-initiated withdrawals/claims; avoid sending ETH/tokens to arbitrary recipients during state changes.
- Emergency tooling: Pause only the minimum blast radius; document who can pause/unpause and under what criteria; add circuit breakers and caps.
- Testing depth: Unit + integration + fork + fuzz + invariants all passing; negative tests for failure paths; coverage reported for critical files.
- Docs & runbooks: NatSpec for externals; README with risk disclosures; deployment checklist; incident playbook (how to pause, communicate, and upgrade safely).
- Independent review: At least one internal reviewer not involved in the original implementation; separate audit firms for high-TVL protocols; public bug bounty live.
Pre-mainnet gate 1) All tests pass locally and in CI (fork + invariants) 2) Slither clean (or waivers documented) 3) Storage layout snapshot taken and guarded 4) Privileged ops require multisig + timelock 5) Docs updated; runbooks rehearsed (pause/upgrade)
Quick check
- What’s the advantage of Foundry vs Hardhat?
- When do you use invariant tests?
- Why pair multisig with a timelock?
Show answers
- Foundry is fast and Solidity-native; Hardhat shines with JS/TS integration and plugin ecosystem.
- To ensure core properties always hold across arbitrary sequences and state transitions, not just single inputs.
- Creates a public review window and reduces single-key risk for upgrades/parameter changes.
Go deeper
- Foundry Book, Hardhat Docs, Slither user guide, and property-based testing guides (search by tool name for official docs).
- “Smart Contract Best Practices” and real-world post-mortems to learn common failure modes.
- Public bug bounty platforms study scopes and past submissions to understand what auditors hunt for.