Section B · Technical Core

Evaluation & Quality

How protocol engineers prove correctness — the layered toolkit of unit tests, invariants, fuzzers, symbolic execution, and formal verification.

The verification pyramid

No single tool catches everything. Senior protocol teams stack them:

LayerToolCatchesCost
Unit testsFoundry forge testObvious bugs, regressions, happy pathsCheap
Fork testsFoundry with --fork-urlIntegration bugs with real deployed contractsCheap
Property fuzzingFoundry invariant, Echidna, MedusaState-machine bugs, invariant violationsModerate (hours of CI)
Symbolic executionHalmos, KontrolEdge cases in bounded scopes; "is this property true for ALL inputs?"Moderate
Formal verificationCertora, hevmMathematical guarantees on a formal specExpensive (engineer-weeks)
AuditSpearbit / Cantina / Code4rena / Sherlock / Trail of BitsAnything the above missed; design-level concernsVery expensive ($)

The pyramid: many cheap, fast tests at the base; fewer, slower, more rigorous tools above; eyes-on review at the top.

Unit and fork tests

Foundry's forge test is the workhorse. A few discipline notes:

contract MarketTest is Test {
    Morpho morpho;
    address USER = makeAddr("user");

    function setUp() public {
        morpho = new Morpho(OWNER);
    }

    function test_supply_mintsExpectedShares() public {
        deal(address(loanToken), USER, 100e18);
        vm.prank(USER);
        loanToken.approve(address(morpho), type(uint256).max);
        vm.prank(USER);
        uint256 shares = morpho.supply(marketParams, 100e18, 0, USER, "");
        assertEq(shares, 100e18 * morpho.SHARES_OFFSET());
    }

    function test_supply_revertsOnZero() public {
        vm.expectRevert(ErrorsLib.ZeroAssets.selector);
        morpho.supply(marketParams, 0, 0, USER, "");
    }
}
forge test --match-contract MarketTest -vvv
forge test --fork-url $MAINNET_RPC --fork-block-number 19000000 --match-test test_realIntegration
forge coverage --report lcov
  • Test names tell a story. test_supply_revertsOnZero reads as a sentence. testSupply2 does not.
  • Use vm.prank for the next call only; vm.startPrank for sequences.
  • Use vm.expectRevert(selector) rather than string matching. Custom errors give you the selector.
  • Fork tests pinned to a block. Otherwise tests are flaky as mainnet state moves.
  • Coverage is a floor, not a ceiling. 100% line coverage is meaningless if every test is happy-path.

Foundry invariant testing

Invariant tests assert properties that should hold across any sequence of calls. The fuzzer runs random sequences of "actions" against your contracts and re-checks invariants after each.

contract LendingHandler is Test {
    Morpho morpho;
    address[] internal actors;

    function supply(uint256 actorSeed, uint256 assets) external {
        assets = bound(assets, 1, 1e24);
        address actor = actors[actorSeed % actors.length];
        deal(address(loanToken), actor, assets);
        vm.startPrank(actor);
        loanToken.approve(address(morpho), assets);
        morpho.supply(marketParams, assets, 0, actor, "");
        vm.stopPrank();
        ghost_totalSupplied += assets;
    }

    uint256 public ghost_totalSupplied;
}

contract LendingInvariantTest is Test {
    LendingHandler handler;

    function setUp() public {
        handler = new LendingHandler();
        targetContract(address(handler));
        bytes4[] memory selectors = new bytes4[](3);
        selectors[0] = LendingHandler.supply.selector;
        selectors[1] = LendingHandler.borrow.selector;
        selectors[2] = LendingHandler.repay.selector;
        targetSelector(FuzzSelector(address(handler), selectors));
    }

    /// @dev Bookkeeping: total supplied equals sum of users' shares × price.
    function invariant_supplyAccounting() public view {
        Market memory m = morpho.market(id);
        assertGe(m.totalSupplyAssets, m.totalBorrowAssets);
    }

    /// @dev Solvency: no position is borrowing more than allowed.
    function invariant_allPositionsHealthy() public view {
        for (uint256 i; i < handler.actorCount(); ++i) {
            assertTrue(morpho.isHealthy(marketParams, handler.actor(i)));
        }
    }
}

The pieces:

  • Handler. The "stateful fuzzer" — bounds inputs, picks actors, mutates ghost variables.
  • Target contracts & selectors. Tell the fuzzer which functions to call.
  • Ghost variables. Off-chain bookkeeping the handler maintains so the invariant can cross-check.
  • Invariant. What must always be true.

Run with forge test --match-contract LendingInvariantTest --fuzz-runs 10000 --depth 200. Senior teams run for hours in CI.

Echidna and Medusa

Foundry's invariant tester is great but limited. For deeper campaigns, two alternatives have become standard:

  • Echidna (Trail of Bits) — Haskell-implemented property-based fuzzer, very fast at finding deep state-machine bugs. Properties defined as Solidity functions returning bool.
  • Medusa (Trail of Bits) — Go-based, multi-process fuzzer that can run Echidna-style and Foundry-style properties at higher throughput.
contract LendingFuzz {
    Morpho morpho;

    function echidna_supplyGeBorrow() public view returns (bool) {
        Market memory m = morpho.market(id);
        return m.totalSupplyAssets >= m.totalBorrowAssets;
    }

    function echidna_noUnhealthyAfterAccrual() public returns (bool) {
        morpho.accrueInterest(marketParams);
        for (uint256 i; i < actors.length; ++i) {
            if (!morpho.isHealthy(marketParams, actors[i])) {
                // Healthy now? Maybe it became unhealthy passively from accrual — that's fine.
                // But this assertion catches "no one became unhealthy due to a state change".
            }
        }
        return true;
    }
}
echidna . --contract LendingFuzz --config echidna.yaml
medusa fuzz --config medusa.json

Run them overnight in CI for any non-trivial protocol change.

Halmos & Kontrol

Where fuzzers test random concrete inputs, symbolic execution reasons about all inputs algebraically. Two open-source tools dominate:

  • Halmos (a16z crypto) — symbolic execution on Foundry-style tests. You write a normal test with symbolic inputs and it explores all paths.
  • Kontrol (Runtime Verification) — based on the K framework; integrates with Foundry tests.
// A symbolic test using Halmos. svm.createUint(...) is symbolic.
function check_supplyMintsConsistentShares(uint256 assets, uint256 totalAssets, uint256 totalShares) public {
    vm.assume(totalAssets > 0 && totalShares > 0);
    vm.assume(assets <= type(uint128).max);

    uint256 sharesOut = assets * totalShares / totalAssets;
    uint256 assetsBack = sharesOut * totalAssets / totalShares;
    assert(assetsBack <= assets);   // round-trip never gains assets
}
halmos --contract LendingSymbolicTest --function check_supplyMintsConsistentShares

Symbolic execution is best for local, mathematical properties: fixed-point conversions, share <-> asset round-trips, comparison functions, monotonicity of accumulators. It does not scale to whole-protocol reasoning.

Certora & CVL

Certora Prover is the commercial formal-verification tool of choice in DeFi. You write specs in CVL (Certora Verification Language); the prover discharges proofs against your bytecode using SMT solvers.

// Conceptual CVL — not Solidity
methods {
    function supply(address, uint256, uint256, address, bytes) external returns (uint256, uint256);
    function totalSupplyAssets() external returns (uint256) envfree;
    function totalSupplyShares() external returns (uint256) envfree;
}

invariant supplySharesNonZeroIffAssets()
    totalSupplyShares() == 0 <=> totalSupplyAssets() == 0;

rule supplyIncreasesAssets(env e, uint256 amount, address user) {
    uint256 before = totalSupplyAssets();
    supply(e, amount, 0, user, "");
    uint256 after = totalSupplyAssets();
    assert after >= before;
}

parametric rule noOpDoesNotChangeShares(method f) {
    uint256 before = totalSupplyShares();
    env e; calldataarg args;
    f(e, args);
    uint256 after = totalSupplyShares();
    // shares only change on supply/withdraw/etc — check the diff is expected
}

What you should know cold:

  • Invariants — properties that hold in every reachable state.
  • Rules — properties about a specific function call's effect.
  • Parametric rules — properties that quantify over any function (very powerful, very expensive).
  • Ghost state — auxiliary state defined only in the spec to make properties expressible.
  • Hooks — observe storage writes during execution.
  • Summary functions — replace a function's body with a simpler model when verification cannot handle the real implementation.
When Certora pays off

For protocols holding nine-figure TVL with stable invariants (lending core, vault math, stablecoin). The investment is heavy — engineer-weeks to write specs that pass — but you get mathematical guarantees against an entire class of bugs. Less useful for fast-moving periphery code.

Covering the math

Fuzzing whole protocols is great, but pure mathematical sub-routines deserve dedicated coverage. Examples:

  • IRM curves. Closed-form unit tests at every boundary: U=0, U just below kink, U at kink, U just above, U=100%.
  • Fixed-point ops. Round-trip identities: wadDiv(wadMul(x, y), y) ≤ x, etc.
  • Accumulator math. Compound-interest accumulator never decreases; matches a Python reference at high precision.
  • Share / asset conversion. Symmetry, monotonicity, invertibility within rounding tolerance.
function test_irm_atKink() public {
    // U = OPTIMAL_U should give exactly BASE + SLOPE_1
    uint256 r = irm.borrowRate(OPTIMAL_U);
    assertEq(r, BASE_RATE + SLOPE_1);
}

function test_irm_justAboveKink() public {
    uint256 r = irm.borrowRate(OPTIMAL_U + 1);
    // Slope2 is steep — small step above kink should produce
    // significant rate jump but not overflow.
    assertGt(r, BASE_RATE + SLOPE_1);
    assertLt(r, MAX_BORROW_RATE);
}

function testFuzz_wadMulInverse(uint256 a, uint256 b) public {
    a = bound(a, 1, 1e36);
    b = bound(b, 1, 1e36);
    uint256 product = a.wadMul(b);
    uint256 back = product.wadDiv(b);
    assertApproxEqAbs(back, a, 1);   // off by at most 1 wei
}

Managing state explosion

The single hardest thing about whole-protocol verification is the state space. A lending protocol with N markets and M users has roughly O(N · M) independent positions and an enormous reachable state. Tooling cannot brute-force this.

Standard reductions:

  • Symmetry reduction. If users are interchangeable, verify against 1-3 abstract users. Real users follow the same rules.
  • Market reduction. Verify invariants on a single market; argue they extend to N (this is often true in isolated-market designs).
  • Summary functions. Replace gnarly internal logic (e.g., loops, external calls) with an over-approximation that the prover can reason about.
  • Bounded sequences. "Any sequence of up to K calls" rather than unbounded. K=3-5 catches most bugs.
  • Decompose properties. Prove local properties about modules; compose the system from proven modules.
Senior reflex

If a property is too expensive to prove, that's a design signal — either the property is too strong, or the design is too coupled. Often the answer is to strengthen the locality of the design (make components more independent), which makes verification easier and the protocol cleaner.

Which tool when

ScenarioReach for
"Does this function do what I think on the happy path?"Foundry unit test
"Does this protocol stay solvent under random sequences?"Foundry invariant / Echidna
"Is my fixed-point math correct for all inputs?"Halmos symbolic test
"Can a parametric rule prove that no function ever causes bad debt?"Certora parametric rule
"Does integration with an external protocol work at a real block?"Foundry fork test
"Did anything change in gas usage with this PR?"forge snapshot
"Does my code shake out at scale?"Public audit contest + bug bounty