Docs/Engineering/Invariant Test Suite
proposed

Invariant Test Suite

Foundry invariant test harness covering settlement correctness, accounting totals, and token conservation. Preventive — catches the class of bug in Findings

Invariant Test Suite

Target: test/invariant/ · Context: Audit History (Findings #1 and #2 — the class of bug this harness prevents)

Why

Findings #1 and #2 are both invariant violations — quantities that the contract should keep equal but doesn't. Unit tests check a specific path; invariant tests let Foundry search the state space, calling random sequences of functions and asserting invariants hold throughout. They would have flagged #1 immediately.

Foundry reference: Invariant Testing docs.

Structure

test/
  invariant/
    ManagerInvariants.t.sol       // asserts invariants
    handlers/
      ManagerHandler.sol          // bounded caller surface
    utils/
      APRegistry.sol              // tracks all APs ever registered

Handler — bounded action surface

The handler is the only contract Foundry calls during invariant runs. It wraps every public manager function and tracks ghost state (all APs ever added, all epochs ever touched). Bounding inputs prevents reverts from dominating the run.

solidity// test/invariant/handlers/ManagerHandler.sol
contract ManagerHandler is Test {
    ARUMintRedeemManager public manager;
    IERC20 public usdc;
    IARU public aru;

    address[] public aps;
    mapping(address => bool) public isTrackedAP;
    uint256[] public touchedEpochs;

    // Ghost accounting (recomputed independent of contract state)
    mapping(uint256 => uint256) public ghostMintTotal;
    mapping(uint256 => uint256) public ghostRedeemTotal;
    uint256 public ghostARUMinted;
    uint256 public ghostARUBurned;

    function addAP(uint256 seed) external {
        address ap = _pickOrCreate(seed);
        if (manager.isAP(ap)) return;
        vm.prank(admin);
        manager.addAP(ap);
        if (!isTrackedAP[ap]) {
            aps.push(ap);
            isTrackedAP[ap] = true;
        }
    }

    function requestMint(uint256 apSeed, uint256 amount) external {
        if (aps.length == 0) return;
        address ap = aps[apSeed % aps.length];
        if (!manager.isAP(ap)) return;
        amount = bound(amount, manager.minimumMintAmount(), 1_000_000e6);

        _dealUSDC(ap, amount);
        vm.prank(ap);
        usdc.approve(address(manager), amount);
        vm.prank(ap);
        try manager.requestMint(amount) {
            uint256 e = manager.currentEpoch();
            ghostMintTotal[e] += amount;
            _trackEpoch(e);
        } catch {}
    }

    function advanceTime(uint256 delta) external {
        vm.warp(block.timestamp + bound(delta, 1 hours, 3 days));
    }

    function completeMintsBatch(uint256 epochSeed, uint256 price, uint256 batchSize) external {
        if (touchedEpochs.length == 0) return;
        uint256 e = touchedEpochs[epochSeed % touchedEpochs.length];
        if (e >= manager.currentEpoch()) return;
        if (manager.epochMintSettled(e)) return;

        price = bound(price, 0.5e18, 2e18);
        batchSize = bound(batchSize, 1, aps.length);
        address[] memory batch = new address[](batchSize);
        for (uint256 i = 0; i < batchSize; i++) batch[i] = aps[i];

        vm.prank(admin);
        try manager.completeMints(e, price, batch) {
            // ghost accounting updates via event replay (simpler) or explicit calc
        } catch {}
    }

    // ... similar for requestRedeem, completeRedemptions, removeAP, closeEpoch*
}

Key guardrails:

  • Every call wrapped in try/catch — reverts don't count as "action failed," they just move on
  • bound on all numeric inputs (price, amount, batch size) to keep calls in feasible ranges
  • Ghost state recomputed externally so the invariant assertion doesn't accidentally depend on the thing being tested

Invariant assertions

solidity// test/invariant/ManagerInvariants.t.sol
contract ManagerInvariants is Test {
    ManagerHandler public handler;

    function setUp() public {
        // deploy tokens, manager, admin, handler
        handler = new ManagerHandler(manager, usdc, aru);
        targetContract(address(handler));
    }

    /// I-1: per-epoch mint total equals sum of individual requests
    function invariant_mintTotalEqualsSumOfRequests() public view {
        uint256 count = handler.touchedEpochsLength();
        for (uint256 i = 0; i < count; i++) {
            uint256 e = handler.touchedEpochs(i);
            uint256 sum;
            uint256 apCount = handler.apsLength();
            for (uint256 j = 0; j < apCount; j++) {
                sum += manager.mintRequests(e, handler.aps(j));
            }
            assertEq(sum, manager.epochTotalMintCollateral(e), "I-1");
        }
    }

    /// I-2: same for redeem
    function invariant_redeemTotalEqualsSumOfRequests() public view { /* ... */ }

    /// I-3: closed mint epoch has zero total
    function invariant_closedMintEpochIsZero() public view {
        uint256 count = handler.touchedEpochsLength();
        for (uint256 i = 0; i < count; i++) {
            uint256 e = handler.touchedEpochs(i);
            if (manager.epochMintSettled(e)) {
                assertEq(manager.epochTotalMintCollateral(e), 0, "I-3");
            }
        }
    }

    /// I-4: closed epoch has no leftover AP requests
    function invariant_closedMintEpochHasNoAPRequests() public view {
        uint256 count = handler.touchedEpochsLength();
        uint256 apCount = handler.apsLength();
        for (uint256 i = 0; i < count; i++) {
            uint256 e = handler.touchedEpochs(i);
            if (!manager.epochMintSettled(e)) continue;
            for (uint256 j = 0; j < apCount; j++) {
                assertEq(manager.mintRequests(e, handler.aps(j)), 0, "I-4");
            }
        }
    }

    /// I-5: ARU held by manager >= open redeem requests
    function invariant_aruHeldCoversOpenRedeems() public view {
        uint256 held = IERC20(address(aru)).balanceOf(address(manager));
        uint256 totalOpen;
        uint256 count = handler.touchedEpochsLength();
        for (uint256 i = 0; i < count; i++) {
            totalOpen += manager.epochTotalRedeemARU(handler.touchedEpochs(i));
        }
        assertGe(held, totalOpen, "I-5");
    }

    /// I-6: token conservation
    function invariant_tokenConservation() public view {
        assertEq(
            handler.ghostARUMinted() - handler.ghostARUBurned(),
            aru.totalSupply(),
            "I-6"
        );
    }
}

foundry.toml

toml[invariant]
runs = 256
depth = 100
fail_on_revert = false     # handler catches reverts
call_override = false
shrink_run_limit = 5000
  • 256 runs × 100 depth = 25,600 random calls per invariant — enough to catch the class of bug in Finding #1 in seconds.
  • fail_on_revert = false because the handler deliberately tolerates reverts.
  • shrink_run_limit set high so counter-examples get trimmed to minimal failing sequences.

Running

bashforge test --match-path test/invariant/*.t.sol -vvv
forge test --match-path test/invariant/*.t.sol --invariant-runs 1024  # deeper pass before release

CI gate

Added to the contract-repo CI in CI Hardening:

yaml- name: Invariant tests
  run: forge test --match-path 'test/invariant/*.t.sol' --invariant-runs 256
  timeout-minutes: 15

Deeper 1024-run pass is a required check before cutting a release tag.

Known limits

  • Invariant tests don't prove properties — they empirically fail to find violations. Pair with targeted unit tests for known edge cases (price lock mismatch, close-before-drain).
  • Handler design is the hard part: under-constrained handlers revert too often (low coverage); over-constrained handlers miss bug-shaped sequences. Iterate on handler complexity once the first pass is green.
  • Ghost state drift is its own failure mode. Keep ghost updates defensive and independent of the contract's internal state.