Skip to content

Invariant testing a lending protocol with Foundry

A practical guide to Foundry invariant tests for DeFi lending protocols: handlers, bounded inputs, ghost variables, time travel, and the invariants worth checking.

· 4 min read

  • Foundry
  • Invariant testing
  • Lending

Unit tests check that a function does what you expect for the inputs you thought of. Lending protocols rarely fail on inputs someone thought of. They fail after an unusual sequence: a deposit, a borrow, a long pause, a price drop, a partial repayment and a liquidation, in that order.

Invariant tests are how you find those sequences before an attacker does. Foundry calls your contracts in long random sequences and, after every call, checks properties that must always hold. This guide shows how to set that up for a lending protocol, and which invariants are worth writing.

What an invariant is

An invariant is a statement about your system that should be true in every reachable state. For a lending pool, for example:

  • the pool’s assets always cover what lenders can withdraw plus what borrowers owe;
  • the sum of all user balances equals the total supply;
  • a user’s debt never decreases unless they repay or are liquidated;
  • interest indexes never go down.

If you can’t write down your protocol’s invariants, that is itself a finding. Auditors will ask for them.

The handler pattern

Pointing Foundry’s fuzzer straight at your protocol mostly produces reverts: random addresses with no tokens calling functions with absurd amounts. A handler sits in between. It exposes the actions a real user can take, bounds inputs to sensible ranges, sets up the right caller and approvals, and records what happened.

contract PoolHandler is Test {
    Pool internal pool;
    ERC20Mock internal asset;
    address[] internal actors;

    // Ghost variables: our own bookkeeping, used by the invariants
    uint256 public ghostDeposited;
    uint256 public ghostWithdrawn;
    uint256 public ghostLastIndex;
    bool public ghostIndexDecreased;

    // Runs after every action: record whether the borrow index ever went down
    modifier trackIndex() {
        _;
        uint256 index = pool.borrowIndex();
        if (index < ghostLastIndex) ghostIndexDecreased = true;
        ghostLastIndex = index;
    }

    constructor(Pool _pool, ERC20Mock _asset, address[] memory _actors) {
        pool = _pool;
        asset = _asset;
        actors = _actors;
    }

    function deposit(uint256 actorSeed, uint256 amount) external trackIndex {
        address actor = actors[bound(actorSeed, 0, actors.length - 1)];
        amount = bound(amount, 1, 1_000_000e18);

        asset.mint(actor, amount);
        vm.startPrank(actor);
        asset.approve(address(pool), amount);
        pool.deposit(amount, actor);
        vm.stopPrank();

        ghostDeposited += amount;
    }

    function borrow(uint256 actorSeed, uint256 amount) external trackIndex {
        address actor = actors[bound(actorSeed, 0, actors.length - 1)];
        uint256 max = pool.maxBorrow(actor);
        if (max == 0) return;
        amount = bound(amount, 1, max);

        vm.prank(actor);
        pool.borrow(amount);
    }

    function warp(uint256 secondsForward) external trackIndex {
        vm.warp(block.timestamp + bound(secondsForward, 1, 30 days));
    }

    // repay, withdraw, liquidate and setPrice follow the same shape
}

A few details matter:

  • bound instead of vm.assume. Bounding maps every random input onto a valid one. Assuming throws inputs away, and too many discards make the run useless.
  • A small, fixed set of actors. Five or ten addresses means the same users deposit, borrow and get liquidated repeatedly, which is where interesting states come from.
  • Skip impossible actions early. If an actor can’t borrow, return. Don’t let the call revert.
  • Time travel is an action. Interest, vesting and staleness bugs only appear when time passes between calls.
  • Track history in the handler. Invariant functions see only the current state, so properties about change over time, like an index that must never decrease, are recorded by the handler after each action.

Wiring it up

The invariant test contract deploys the protocol, creates the handler, and tells Foundry to call only the handler:

contract PoolInvariants is Test {
    Pool internal pool;
    ERC20Mock internal asset;
    PoolHandler internal handler;
    address[] internal actors;

    function setUp() public {
        // deploy asset, oracle and pool ...
        handler = new PoolHandler(pool, asset, actors);
        targetContract(address(handler));
    }

    function invariant_solvent() public view {
        assertGe(
            asset.balanceOf(address(pool)) + pool.totalDebt(),
            pool.totalLiabilities(),
            "pool insolvent"
        );
    }

    function invariant_supplyMatchesBalances() public view {
        uint256 sum;
        for (uint256 i; i < actors.length; i++) sum += pool.balanceOf(actors[i]);
        assertEq(sum, pool.totalSupply());
    }

    function invariant_indexNeverDecreases() public view {
        assertFalse(handler.ghostIndexDecreased(), "borrow index decreased");
    }
}

Configure the run in foundry.toml:

[invariant]
runs = 256
depth = 200
fail_on_revert = true

depth is how many calls make up one sequence. Lending bugs often need dozens of steps, so don’t leave it low. fail_on_revert = true treats any revert in the handler as a failure. That forces you to write handlers that only take valid actions, and it surfaces functions that revert when they shouldn’t, such as a repayment that fails because of rounding.

Invariants worth writing for a lending protocol

  • Solvency. Assets held plus debt owed cover everything lenders can claim.
  • Conservation. Tokens in equal tokens out plus tokens held, tracked with ghost variables.
  • Monotonic indexes. Interest and exchange-rate indexes never decrease.
  • Health after borrowing. No position is unhealthy immediately after a successful borrow or withdrawal.
  • Liquidation improves health. A liquidation never leaves the position, or the pool, worse off.
  • No free money. No actor can end a sequence with more value than they put in, unless they earned interest as a lender.
  • Rounding direction. Shares minted and assets returned always round in the pool’s favour.

When an invariant fails

Foundry prints the failing call sequence and shrinks it to a shorter one that still fails. Read it like a story: who did what, in which order, at what time. Then turn it into a plain unit test. That test becomes a permanent regression check, and it’s also the clearest way to explain the bug to your team or an auditor.

Make it part of the build

Invariant tests are most useful when they run on every pull request, with a longer campaign on a schedule. They take a few days to set up well. In exchange, they find the kind of bug that otherwise gets found by an auditor, or by an attacker.

If you’d like help building an invariant suite before an audit, see our smart contract development and audit preparation service.

Have something you'd like built?

Tell us what you're building. We'll reply within two working days with questions, an approach and next steps.

Start a project
Esc

↑ ↓ to move · Enter to open