Target: test/invariant/ · Context: Audit History (Findings #1 and #2 — the class of bug this harness prevents)
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.
test/
invariant/
ManagerInvariants.t.sol // asserts invariants
handlers/
ManagerHandler.sol // bounded caller surface
utils/
APRegistry.sol // tracks all APs ever registered
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:
try/catch — reverts don't count as "action failed," they just move onbound on all numeric inputs (price, amount, batch size) to keep calls in feasible rangessolidity// 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"
);
}
}
toml[invariant]
runs = 256
depth = 100
fail_on_revert = false # handler catches reverts
call_override = false
shrink_run_limit = 5000
fail_on_revert = false because the handler deliberately tolerates reverts.shrink_run_limit set high so counter-examples get trimmed to minimal failing sequences.bashforge test --match-path test/invariant/*.t.sol -vvv
forge test --match-path test/invariant/*.t.sol --invariant-runs 1024 # deeper pass before release
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.