Auditing and Testing (Foundry/Hardhat, fuzzing, static analysis)

Auditing & Testing: From Unit Tests to Fuzzing & Invariants

Ship with confidence: rigorous tests, automated checks, and structured reviews before mainnet.

TL;DR: Use Foundry or Hardhat for unit/integration tests, add fuzzing/invariant tests, run static analysis (Slither), measure coverage, and follow an audit checklist with clear threat models.

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).
Test taxonomy: Unit (single function), integration (contracts together), fork (against real mainnet state), fuzz (randomized inputs), invariant (properties always hold), and scenario (end-to-end user stories).

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
Pattern: keep tests white-box (call internal views via test hooks) for logic, and add black-box tests that simulate production usage. Assert events and storage layout where relevant.

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”.

Advanced: Differential testing (compare your implementation vs a reference), symbolic execution for path exploration, and metamorphic proxy tests to ensure upgrades preserve invariants.

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, incorrect unchecked blocks, uninitialized storage pointers.
  • Formatting & linting: consistent forge fmt, NatSpec docs on externals, explicit override, 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)
Tip: Rehearse a “chaos day”: simulate oracle outage, sequencer downtime, and emergency pause to ensure dashboards, alerts, and runbooks actually work.

Quick check

  1. What’s the advantage of Foundry vs Hardhat?
  2. When do you use invariant tests?
  3. 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.