invariant testing
Invariant testing asserts properties that must hold no matter what sequence of actions a contract goes through, then fires long, random sequences of calls at the system and checks after each that the property still holds. An invariant is a rule the protocol should never break, for example a lending pool's assets always cover what it owes depositors, or the sum of all user balances always equals the token's total supply. Where a unit test checks one path and a parameter fuzz test varies one call's inputs, invariant testing varies the whole order and combination of calls.
Mechanically, you define invariant functions and let a framework (Foundry's invariant testing or Echidna) generate random sequences of calls against the contracts, often routed through a handler that bounds inputs to realistic ranges and tracks ghost variables (extra bookkeeping the real contract does not keep). After every call in every sequence the framework re-checks all invariants; the first violation is reported as a concrete, minimized sequence of transactions. Because it is stateful and explores multi-step interactions, it catches the cross-function and accumulated-state bugs that single-call tests miss.
The art is choosing invariants that are both true and tight. A weak invariant like the contract does not revert is nearly useless; a strong one like no sequence of deposits, withdrawals, and transfers ever lets a user remove more value than they added, or total assets always greater than or equal to total liabilities, directly encodes solvency and conservation, the exact things real exploits violate. Good invariants typically capture solvency, value conservation, monotonic accounting, and access constraints, and writing them forces a precise understanding of what the protocol promises.
function invariant_solvency() public view {
assertGe(vault.totalAssets(), vault.totalLiabilities());
}A property re-checked after every call in every random sequence.
The art is choosing invariants that are both true and tight. The contract does not revert is useless; the sum of user balances equals totalSupply, or no call sequence lets a user withdraw more than they deposited, catches real exploits.