From 15980c2aed553a4b1b75a0051b48a04cb81895cf Mon Sep 17 00:00:00 2001 From: Oliver Anyanwu Date: Sun, 17 May 2026 12:42:47 +0100 Subject: [PATCH] test: add stateful invariant suite for Vault Mirrors the Staking invariant pattern: handler exposes deposit/withdraw, foundry picks random sequences across three actors, and three invariants are checked after every call: - contract ETH balance == sum of user balances - contract balance == ghostDeposits - ghostWithdrawals (independent accounting catches state corruption that affects both sides equally) - totalAssets() never disagrees with balance --- test/VaultInvariant.t.sol | 80 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 test/VaultInvariant.t.sol diff --git a/test/VaultInvariant.t.sol b/test/VaultInvariant.t.sol new file mode 100644 index 0000000..9dd36dd --- /dev/null +++ b/test/VaultInvariant.t.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {Vault} from "../src/Vault.sol"; + +/// Stateful invariant handler - foundry picks random sequences of these calls +/// and runs the invariant after each. +contract VaultInvariantHandler is Test { + Vault public vault; + address[] public actors; + + // Tracked alongside the contract so the invariant can compare against + // an independently-maintained sum. + uint256 public ghostTotalDeposits; + uint256 public ghostTotalWithdrawals; + + constructor(Vault _vault) { + vault = _vault; + actors.push(makeAddr("v_actor1")); + actors.push(makeAddr("v_actor2")); + actors.push(makeAddr("v_actor3")); + for (uint256 i; i < actors.length; i++) { + vm.deal(actors[i], 100 ether); + } + } + + function deposit(uint256 actorSeed, uint256 amount) external { + address actor = actors[actorSeed % actors.length]; + amount = bound(amount, 1, 5 ether); + vm.prank(actor); + vault.deposit{value: amount}(); + ghostTotalDeposits += amount; + } + + function withdraw(uint256 actorSeed, uint256 amount) external { + address actor = actors[actorSeed % actors.length]; + uint256 current = vault.balances(actor); + if (current == 0) return; + amount = bound(amount, 1, current); + vm.prank(actor); + vault.withdraw(amount); + ghostTotalWithdrawals += amount; + } + + function sumActorBalances() external view returns (uint256 sum) { + for (uint256 i; i < actors.length; i++) { + sum += vault.balances(actors[i]); + } + } +} + +contract VaultInvariantTest is Test { + Vault public vault; + VaultInvariantHandler public handler; + + function setUp() public { + vault = new Vault(); + handler = new VaultInvariantHandler(vault); + targetContract(address(handler)); + } + + /// Contract ETH balance must equal the sum of recorded user balances at all times + function invariant_contractBalanceEqualsUserSum() public view { + assertEq(address(vault).balance, handler.sumActorBalances()); + } + + /// Ghost accounting: net flow (deposits - withdrawals) must equal contract balance + function invariant_netFlowEqualsBalance() public view { + assertEq( + address(vault).balance, + handler.ghostTotalDeposits() - handler.ghostTotalWithdrawals() + ); + } + + /// totalAssets() is a thin view but should never lie + function invariant_totalAssetsMatchesBalance() public view { + assertEq(vault.totalAssets(), address(vault).balance); + } +}