Your smart contracts passed every unit test. All edge cases covered. Code coverage: 98%.
Then someone calls three functions in a sequence nobody imagined, and $40 million disappears.
Unit tests verify what you expect. Invariant tests find what you didn't.
This guide shows you how to build invariant tests modeled on real 2026 DeFi exploits — oracle manipulation, flash loan attacks, and access control failures — so your protocol doesn't become the next post-mortem.
Why Unit Tests Aren't Enough
Unit tests answer: "Does function X produce output Y for input Z?"
Invariant tests answer: "Does property P hold after any sequence of function calls?"
The difference matters because DeFi exploits rarely break a single function. They break assumptions between functions — calling borrow() after manipulating the oracle that borrow() depends on, or using flashLoan() to enter a state that withdraw() never expected.
// A unit test catches this:
function test_withdraw_reverts_if_insufficient_balance() public {
vm.expectRevert("Insufficient balance");
vault.withdraw(1 ether);
}
// But NOT this (requires specific call sequence):
// 1. deposit() → 2. manipulate oracle → 3. borrow() → 4. withdraw()
// The invariant "totalDeposits >= totalBorrows" breaks
Let's build tests that catch real attack patterns.
Setup: The Exploit Detection Lab
forge init exploit-detection-lab
cd exploit-detection-lab
Configure foundry.toml for thorough invariant testing:
[invariant]
runs = 512 # Number of random call sequences
depth = 50 # Calls per sequence (higher = finds deeper bugs)
fail_on_revert = false # Don't fail on expected reverts
dictionary_weight = 80 # Use storage values as fuzz inputs
[fuzz]
runs = 10000
Pattern 1: Oracle Manipulation → Liquidation Cascade
Real-world reference: Aave's CAPO oracle desync (March 2026, $26M loss). The oracle's constrained rate diverged from reality, triggering false liquidations.
Vulnerable Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SimpleLending {
mapping(address => uint256) public deposits;
mapping(address => uint256) public borrows;
uint256 public collateralPrice; // Set by oracle
uint256 public constant LTV = 75; // 75% loan-to-value
address public oracle;
constructor(address _oracle) {
oracle = _oracle;
collateralPrice = 1000e18; // $1000 initial
}
function updatePrice(uint256 _price) external {
require(msg.sender == oracle, "Not oracle");
collateralPrice = _price; // No bounds checking!
}
function deposit() external payable {
deposits[msg.sender] += msg.value;
}
function borrow(uint256 amount) external {
uint256 collateralValue = deposits[msg.sender] * collateralPrice / 1e18;
uint256 maxBorrow = collateralValue * LTV / 100;
require(borrows[msg.sender] + amount <= maxBorrow, "Exceeds LTV");
borrows[msg.sender] += amount;
payable(msg.sender).transfer(amount);
}
function liquidate(address user) external {
uint256 collateralValue = deposits[user] * collateralPrice / 1e18;
uint256 maxBorrow = collateralValue * LTV / 100;
require(borrows[user] > maxBorrow, "Not liquidatable");
// Liquidator gets collateral at discount
uint256 seized = deposits[user];
deposits[user] = 0;
borrows[user] = 0;
payable(msg.sender).transfer(seized);
}
receive() external payable {}
}
The Invariant Test
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/SimpleLending.sol";
contract LendingHandler is Test {
SimpleLending public lending;
address[] public actors;
// Ghost variables — track expected state
uint256 public ghost_totalDeposits;
uint256 public ghost_totalBorrows;
uint256 public ghost_liquidationCount;
mapping(address => uint256) public ghost_userDeposits;
constructor(SimpleLending _lending) {
lending = _lending;
actors.push(address(0x1));
actors.push(address(0x2));
actors.push(address(0x3));
// Fund actors
for (uint i = 0; i < actors.length; i++) {
vm.deal(actors[i], 100 ether);
}
vm.deal(address(lending), 1000 ether);
}
function deposit(uint256 actorSeed, uint256 amount) external {
address actor = actors[actorSeed % actors.length];
amount = bound(amount, 0.01 ether, 10 ether);
vm.prank(actor);
lending.deposit{value: amount}();
ghost_totalDeposits += amount;
ghost_userDeposits[actor] += amount;
}
function borrow(uint256 actorSeed, uint256 amount) external {
address actor = actors[actorSeed % actors.length];
amount = bound(amount, 0.001 ether, 5 ether);
vm.prank(actor);
try lending.borrow(amount) {
ghost_totalBorrows += amount;
} catch {}
}
function updatePrice(uint256 newPrice) external {
// Simulate oracle updates — including malicious ones
newPrice = bound(newPrice, 1e16, 100_000e18); // $0.01 to $100K
vm.prank(lending.oracle());
lending.updatePrice(newPrice);
}
function liquidate(uint256 actorSeed, address target) external {
address actor = actors[actorSeed % actors.length];
vm.prank(actor);
try lending.liquidate(target) {
ghost_liquidationCount++;
} catch {}
}
}
contract LendingInvariantTest is Test {
SimpleLending public lending;
LendingHandler public handler;
address public oracle = address(0xCAFE);
function setUp() public {
lending = new SimpleLending(oracle);
handler = new LendingHandler(lending);
// Only target the handler
targetContract(address(handler));
}
/// @notice No user should lose deposits to liquidation
/// if the oracle price hasn't moved more than 50% in one update
function invariant_no_flash_crash_liquidation() public view {
// If price swings > 50% in a single update,
// liquidations are likely unfair
uint256 currentPrice = lending.collateralPrice();
// This invariant will FAIL — proving the oracle needs
// rate-of-change limits (exactly what broke Aave's CAPO)
if (handler.ghost_liquidationCount() > 0) {
// At least one liquidation happened —
// check if it was from a reasonable price move
assertTrue(
currentPrice >= 500e18, // Price shouldn't crash below $500
"Liquidations triggered by unreasonable oracle price"
);
}
}
/// @notice Protocol solvency: total deposits should cover obligations
function invariant_protocol_solvency() public view {
uint256 contractBalance = address(lending).balance;
// The contract should always have enough to cover deposits
// This catches extraction attacks
assertTrue(
contractBalance >= 0, // Simplified — real invariant would track all flows
"Protocol is insolvent"
);
}
/// @notice Ghost variable consistency check
function invariant_deposit_tracking() public view {
// Verify our ghost tracking matches contract state
// If this fails, our handler has a bug (meta-testing)
for (uint i = 0; i < 3; i++) {
address actor = handler.actors(i);
assertEq(
lending.deposits(actor),
handler.ghost_userDeposits(actor),
"Ghost deposit mismatch"
);
}
}
}
What This Catches
Run it: forge test --match-contract LendingInvariantTest -vvv
The fuzzer will discover that calling updatePrice(1e16) (crashing price to $0.01) followed by liquidate() allows seizing deposits at a massive discount. The fix: rate-of-change limits on oracle updates, exactly what Aave's CAPO was supposed to enforce.
// Fix: Bounded oracle updates
function updatePrice(uint256 _price) external {
require(msg.sender == oracle, "Not oracle");
uint256 maxChange = collateralPrice * 10 / 100; // Max 10% per update
require(
_price >= collateralPrice - maxChange &&
_price <= collateralPrice + maxChange,
"Price change too large"
);
collateralPrice = _price;
}
Pattern 2: Flash Loan + Reentrancy Extraction
Real-world pattern: Dozens of protocols in 2025-2026 lost funds to flash loans that manipulate balances mid-transaction.
The Invariant
/// @notice Conservation of value: ETH in + borrows out = ETH out + deposits
function invariant_conservation_of_value() public view {
uint256 contractETH = address(lending).balance;
uint256 totalDeposited = handler.ghost_totalDeposits();
uint256 totalBorrowed = handler.ghost_totalBorrows();
// Contract balance should equal deposits minus borrows
// If this breaks → funds are being extracted outside normal flows
assertGe(
contractETH + totalBorrowed,
totalDeposited,
"Value leak detected — possible extraction attack"
);
}
This invariant is deceptively powerful. Any flow that extracts value without going through the tracked deposit/borrow paths will trip it — including reentrancy, flash loan manipulation, or accounting errors.
Pattern 3: Access Control Escalation
Real-world reference: CrossCurve bridge hack (Feb 2026, $3M). Missing access control on expressExecute allowed anyone to call a privileged function.
Handler With Role-Based Actions
contract AccessControlHandler is Test {
ProtocolWithRoles public protocol;
address public admin = address(0xAD);
address public user1 = address(0x1);
address public attacker = address(0x666);
// Track which roles have performed which actions
mapping(bytes4 => bool) public adminOnlyFunctionsCalled;
mapping(bytes4 => address) public lastCaller;
function callAdminFunction(uint256 callerSeed) external {
address caller = _randomCaller(callerSeed);
vm.prank(caller);
try protocol.adminOnlyFunction() {
lastCaller[protocol.adminOnlyFunction.selector] = caller;
if (caller != admin) {
// This should NEVER succeed for non-admins
// If it does, we found an access control bug
emit log_named_address("ACCESS CONTROL BREACH by", caller);
}
} catch {}
}
function _randomCaller(uint256 seed) internal view returns (address) {
uint256 idx = seed % 3;
if (idx == 0) return admin;
if (idx == 1) return user1;
return attacker;
}
}
contract AccessControlInvariantTest is Test {
// ...
/// @notice Only admin should successfully call admin functions
function invariant_admin_only_functions() public view {
address caller = handler.lastCaller(
protocol.adminOnlyFunction.selector
);
assertTrue(
caller == address(0) || caller == handler.admin(),
"Non-admin called admin function!"
);
}
}
Pattern 4: ERC-4626 Vault Share Inflation
Real-world pattern: The "donation attack" where an attacker inflates the share price of a vault by directly sending tokens, causing rounding errors that steal from future depositors.
contract VaultInvariantTest is Test {
ERC4626Vault public vault;
VaultHandler public handler;
function setUp() public {
vault = new ERC4626Vault(address(token));
handler = new VaultHandler(vault, token);
targetContract(address(handler));
}
/// @notice Depositing X assets should always mint > 0 shares
/// (catches share inflation / first depositor attack)
function invariant_no_zero_share_deposits() public view {
if (vault.totalSupply() > 0 && vault.totalAssets() > 0) {
// After initial deposits exist, a deposit of 1e18
// should always yield shares
uint256 sharesFor1Token = vault.previewDeposit(1e18);
assertTrue(
sharesFor1Token > 0,
"Share inflation detected: deposit yields 0 shares"
);
}
}
/// @notice No single withdrawal should drain more than deposited
function invariant_no_profit_withdrawal() public view {
for (uint i = 0; i < handler.actorCount(); i++) {
address actor = handler.actors(i);
uint256 currentValue = vault.previewRedeem(
vault.balanceOf(actor)
);
uint256 totalDeposited = handler.ghost_deposited(actor);
// Allow 0.1% tolerance for rounding
assertLe(
currentValue,
totalDeposited * 1001 / 1000,
"User can extract more than deposited — vault accounting broken"
);
}
}
}
The Invariant Checklist: 10 Properties Every DeFi Protocol Should Test
Copy this into your test suite. Adapt the specifics to your protocol.
// ═══════════════════════════════════════════════════════
// THE DEFI INVARIANT CHECKLIST
// ═══════════════════════════════════════════════════════
// 1. SOLVENCY: Contract balance >= sum of all user claims
function invariant_solvency() public view;
// 2. CONSERVATION: Total value in = total value out + held
function invariant_conservation() public view;
// 3. NO FREE MONEY: No user can withdraw more than deposited + earned
function invariant_no_free_money() public view;
// 4. ORACLE BOUNDS: Price changes within expected range per update
function invariant_oracle_bounded() public view;
// 5. ACCESS CONTROL: Privileged functions only callable by authorized roles
function invariant_access_control() public view;
// 6. NO ZERO SHARES: Deposits always mint > 0 shares (ERC-4626)
function invariant_no_zero_shares() public view;
// 7. MONOTONIC COUNTERS: Nonces, IDs, epochs only increase
function invariant_monotonic() public view;
// 8. SUPPLY CONSERVATION: Token totalSupply = sum of all balances
function invariant_supply_conservation() public view;
// 9. LIQUIDATION FAIRNESS: Liquidations only when truly undercollateralized
function invariant_fair_liquidation() public view;
// 10. REENTRANCY GUARD: No nested calls to protected functions
function invariant_no_reentrancy() public view;
Pro Tips: Making Invariant Tests Actually Find Bugs
1. Use Ghost Variables Religiously
Ghost variables track expected state outside the contract. When they diverge from on-chain state, you've found a bug:
// In handler:
uint256 public ghost_totalDeposited;
function deposit(uint256 amount) external {
amount = bound(amount, 1e15, 100e18);
token.mint(actor, amount);
vm.prank(actor);
vault.deposit(amount, actor);
ghost_totalDeposited += amount; // Track independently
}
// In invariant test:
function invariant_ghost_matches_reality() public view {
assertEq(
vault.totalAssets(),
handler.ghost_totalDeposited() - handler.ghost_totalWithdrawn(),
"Accounting mismatch — ghost vs reality"
);
}
2. Bound Inputs, Don't Exclude Them
Don't skip edge cases — bound them to meaningful ranges:
// BAD: Skips small amounts entirely
function deposit(uint256 amount) external {
vm.assume(amount > 1e18); // Misses dust attacks!
...
}
// GOOD: Includes small amounts that might trigger rounding bugs
function deposit(uint256 amount) external {
amount = bound(amount, 1, type(uint128).max);
...
}
3. Include "Attacker" Actions in Your Handler
Model malicious behavior explicitly:
function attackerDirectTransfer(uint256 amount) external {
// Simulate donation attack (direct token transfer to vault)
amount = bound(amount, 1, 1000e18);
token.mint(address(this), amount);
token.transfer(address(vault), amount);
ghost_donated += amount;
}
4. Increase Depth for Sequence-Dependent Bugs
Some bugs only appear after 20+ function calls. Default depth of 15 misses them:
# foundry.toml — go deeper for complex protocols
[invariant]
runs = 256
depth = 100 # Find deep sequence bugs
5. Use afterInvariant() for End-of-Campaign Checks
function afterInvariant() public {
// Final check: can all users withdraw their full balance?
for (uint i = 0; i < handler.actorCount(); i++) {
address actor = handler.actors(i);
uint256 shares = vault.balanceOf(actor);
if (shares > 0) {
vm.prank(actor);
try vault.redeem(shares, actor, actor) {}
catch {
emit log_named_address(
"USER CANNOT WITHDRAW", actor
);
fail();
}
}
}
}
Running Your Exploit Detection Lab
# Standard run
forge test --match-contract InvariantTest -vvv
# Maximum thoroughness (CI/pre-audit)
forge test --match-contract InvariantTest \
-vvv \
--fuzz-runs 10000 \
--invariant-runs 1024 \
--invariant-depth 100
# Quick smoke test (pre-commit hook)
forge test --match-contract InvariantTest \
--invariant-runs 64 \
--invariant-depth 25
When Foundry finds a violation, it prints the exact call sequence:
[FAIL: Assertion violated]
[Sequence]
sender=0x666 addr=[handler] calldata=updatePrice(1000000000000000)
sender=0x1 addr=[handler] calldata=borrow(49999999999999999999)
sender=0x666 addr=[handler] calldata=liquidate(0, 0x1)
[Counterexample]
invariant_no_flash_crash_liquidation(): Liquidations triggered by unreasonable oracle price
That sequence is your exploit proof-of-concept. Turn it into a unit test, fix the code, and verify the invariant holds.
Conclusion: Test Properties, Not Paths
The protocols that lost $100M+ in 2026 all had unit tests. Many had audits. What they didn't have were invariant tests that modeled adversarial behavior.
The core insight: you can't enumerate all attack paths, but you can define the properties that must never break.
- "The protocol is always solvent" catches extraction attacks you never imagined
- "Oracle prices change within bounds" catches manipulation you didn't model
- "No user profits without providing value" catches economic exploits across any call sequence
Start with the 10-property checklist above. Add protocol-specific invariants. Run them in CI. The bugs Foundry finds at 3 AM are the bugs that would have cost you millions at mainnet.
This article is part of the DeFi Security Research series. Follow for weekly breakdowns of real incidents, audit techniques, and defense patterns.
DreamWork Security — dreamworksecurity.hashnode.dev
Top comments (0)