Advanced invariant testing techniques for DeFi protocols
Advanced Invariant Testing Techniques for DeFi Protocols
By nican0r
Basic invariant testing checks that "X is always true." Advanced invariant testing checks that "X is always true, even when users do weird things in weird orders under weird market conditions." If you're working on a DeFi protocol — lending, DEX, vault, staking — the basic version won't cut it. You need techniques that handle real protocol complexity.
I've spent the last couple years writing invariant suites for DeFi protocols, and these are the patterns that actually find bugs.
Ghost variables and expected-Value tracking
You've probably seen ghost variables mentioned before, and here's why they're the single most important technique in your arsenal.
A ghost variable is a variable in your test harness that tracks what the protocol's state should be. Every time you call a protocol function, you update the ghost alongside it. Then your invariant checks that the protocol's actual state matches the ghost.
Here's a lending protocol example:
// Ghost tracking for a lending pool
uint256 ghost_totalDeposits;
uint256 ghost_totalBorrows;
uint256 ghost_totalRepaid;
uint256 ghost_totalLiquidated;
mapping(address => uint256) ghost_userDeposits;
mapping(address => uint256) ghost_userBorrows;
function handler_deposit(uint256 actorSeed, uint256 amount) public {
address actor = _selectActor(actorSeed);
amount = clampBetween(amount, 1, token.balanceOf(actor));
vm.prank(actor);
pool.deposit(amount);
ghost_totalDeposits += amount;
ghost_userDeposits[actor] += amount;
}
function handler_borrow(uint256 actorSeed, uint256 amount) public {
address actor = _selectActor(actorSeed);
uint256 maxBorrow = pool.maxBorrowable(actor);
if (maxBorrow == 0) return;
amount = clampBetween(amount, 1, maxBorrow);
vm.prank(actor);
pool.borrow(amount);
ghost_totalBorrows += amount;
ghost_userBorrows[actor] += amount;
}
function handler_repay(uint256 actorSeed, uint256 amount) public {
address actor = _selectActor(actorSeed);
uint256 debt = pool.debtOf(actor);
if (debt == 0) return;
amount = clampBetween(amount, 1, debt);
vm.prank(actor);
pool.repay(amount);
ghost_totalRepaid += amount;
ghost_userBorrows[actor] -= amount;
}
Now the invariant:
function invariant_poolSolvency() public view returns (bool) {
uint256 expectedBalance = ghost_totalDeposits - ghost_totalBorrows + ghost_totalRepaid;
uint256 actualBalance = token.balanceOf(address(pool));
return actualBalance >= expectedBalance;
}
If the protocol has a rounding bug in repay() that gives users credit for 1 more wei than they actually paid, this invariant will catch it after enough repay calls. The ghost says "the pool should have X tokens" and the actual balance is lower. Bug found.
Function-Level vs system-Level invariants
There are two categories of invariants, and you need both.
System-level invariants check global properties that should always hold regardless of what happened:
// Sum of all user balances == total supply (always true)
function invariant_balanceSumEqualsTotalSupply() public view returns (bool) {
uint256 sum;
for (uint256 i = 0; i < actors.length; i++) {
sum += vault.balanceOf(actors[i]);
}
return sum == vault.totalSupply();
}
// Pool can't have negative equity
function invariant_poolNonNegativeEquity() public view returns (bool) {
return pool.totalAssets() >= pool.totalLiabilities();
}
Function-level invariants check properties of specific operations. These are tighter and catch more specific bugs:
// After a deposit, the user's balance must increase by at least 1 share
// (no zero-share deposits)
function handler_deposit_checkShares(uint256 actorSeed, uint256 amount) public {
address actor = _selectActor(actorSeed);
amount = clampBetween(amount, 1, token.balanceOf(actor));
uint256 sharesBefore = vault.balanceOf(actor);
vm.prank(actor);
vault.deposit(amount, actor);
uint256 sharesAfter = vault.balanceOf(actor);
assert(sharesAfter > sharesBefore); // Must get at least 1 share
}
Function-level invariants baked directly into handlers are one of the most effective patterns for DeFi. They catch bugs at the exact point they happen, making debugging much easier.
Temporal properties: before/After checks
Some properties aren't about absolute state, they're about how state changes relative to what it was before. These are temporal properties.
// Liquidity should never decrease after a deposit
// (it can increase by less than the deposited amount due to fees, but never decrease)
function handler_deposit_liquidityIncreases(uint256 actorSeed, uint256 amount) public {
address actor = _selectActor(actorSeed);
amount = clampBetween(amount, pool.minDeposit(), token.balanceOf(actor));
if (amount == 0) return;
uint256 liquidityBefore = pool.totalLiquidity();
vm.prank(actor);
pool.deposit(amount);
uint256 liquidityAfter = pool.totalLiquidity();
assert(liquidityAfter >= liquidityBefore);
}
// Share price should never decrease after a repayment
function handler_repay_sharePriceStable(uint256 actorSeed, uint256 amount) public {
address actor = _selectActor(actorSeed);
uint256 debt = pool.debtOf(actor);
if (debt == 0) return;
amount = clampBetween(amount, 1, debt);
uint256 priceBefore = pool.sharePrice();
vm.prank(actor);
pool.repay(amount);
uint256 priceAfter = pool.sharePrice();
assert(priceAfter >= priceBefore);
}
These temporal invariants are how you catch donation attacks, share inflation attacks, and other exploits where the attacker profits by making the share price move in an unexpected direction.
Conditional invariants
Not every property holds in every state. Some properties only hold under specific conditions. Conditional invariants handle this:
// If a position is healthy, liquidation should revert
function handler_liquidateHealthyReverts(uint256 liquidatorSeed, uint256 targetSeed) public {
address liquidator = _selectActor(liquidatorSeed);
address target = _selectActor(targetSeed);
// Only test when the target is healthy
if (pool.healthFactor(target) >= 1e18) {
vm.prank(liquidator);
try pool.liquidate(target, 1) {
// If this succeeds, that's a bug -- healthy positions
// shouldn't be liquidatable
assert(false);
} catch {
// Expected -- liquidation should revert
}
}
}
// If utilization is below the kink, borrow rate should be in the low range
function invariant_borrowRateBelowKink() public view returns (bool) {
if (pool.utilization() <= pool.kink()) {
uint256 rate = pool.borrowRate();
return rate <= pool.maxRateBelowKink();
}
return true; // Don't check if we're above the kink
}
The key: always return true (or skip the assertion) when the condition isn't met. You don't want false positives from states where the property doesn't apply.
Bounding input spaces
Bad input bounding is the #1 reason invariant suites produce useless results. If your fuzzer spends 80% of its time hitting reverts, it's not testing anything.
Here's how to bound effectively for DeFi:
function handler_swap(uint256 actorSeed, uint256 amountIn, bool zeroForOne) public {
address actor = _selectActor(actorSeed);
// Don't swap more than the actor has
address tokenIn = zeroForOne ? address(token0) : address(token1);
uint256 maxAmount = IERC20(tokenIn).balanceOf(actor);
if (maxAmount == 0) return;
// Don't swap dust -- it just creates noise
uint256 minAmount = 1e6; // 1 USDC equivalent
if (maxAmount < minAmount) return;
amountIn = clampBetween(amountIn, minAmount, maxAmount);
vm.prank(actor);
dex.swap(zeroForOne, int256(amountIn), actor);
}
A few principles:
- Minimum amounts that matter. Swapping 1 wei doesn't test anything real. Set minimums at economically meaningful levels.
- Respect protocol limits. If the protocol has a max deposit of 1M tokens, bound to that.
- Early returns instead of reverts. If a handler can't execute (zero balance, no debt to repay), return early. Don't let it revert and waste a fuzzer cycle.
Dealing with external dependencies
DeFi protocols depend on oracles, other protocols, and tokens with quirky behavior. Here's how to handle them:
Oracle mocking
function handler_updateOraclePrice(uint256 newPrice) public {
uint256 currentPrice = oracle.latestAnswer();
// Bound price changes to realistic ranges
// Max 50% drop or 100% increase per update
uint256 minPrice = currentPrice / 2;
uint256 maxPrice = currentPrice * 2;
newPrice = clampBetween(newPrice, minPrice, maxPrice);
oracle.setPrice(int256(newPrice));
}
function handler_makeOracleStale() public {
// Simulate oracle going stale
vm.warp(block.timestamp + 1 hours + 1);
}
The price bounds matter. A 99.99% crash in a single oracle update isn't realistic, and it'll drown out real bugs with noise. But a 50% drop? That happens. Test for it.
Fee-on-Transfer and rebasing tokens
If your protocol claims to support arbitrary ERC20 tokens, test with weird ones:
function setup() internal override {
// Standard token
normalToken = new MockERC20("Normal", "NORM", 18);
// Fee-on-transfer token (2% fee)
feeToken = new MockFeeToken("Fee", "FEE", 18, 200);
// Rebasing token
rebaseToken = new MockRebaseToken("Rebase", "REB", 18);
// 6-decimal token (like USDC)
sixDecToken = new MockERC20("Six", "SIX", 6);
}
Then run the same invariants against each token variant. Bugs that only appear with fee-on-transfer tokens are real bugs, several major exploits have come from protocols that assumed transferFrom(amount) actually transfers amount.
Property composition
Complex invariants are built from simpler ones. Here's the composition pattern:
// Base property: total supply matches sum of balances
function prop_supplyMatchesBalances() internal view returns (bool) {
uint256 sum;
for (uint256 i = 0; i < actors.length; i++) {
sum += token.balanceOf(actors[i]);
}
// Include non-actor holders
sum += token.balanceOf(address(pool));
sum += token.balanceOf(address(treasury));
return sum == token.totalSupply();
}
// Base property: pool is solvent
function prop_poolSolvent() internal view returns (bool) {
return pool.totalAssets() >= pool.totalLiabilities();
}
// Composed property: the system is consistent
function invariant_systemConsistency() public view returns (bool) {
return prop_supplyMatchesBalances() && prop_poolSolvent();
}
Break complex invariants into composable pieces. When something fails, you can check each piece individually to locate the bug.
Debugging failing invariants
An invariant fails. Now what? Here's my process:
1. Read the call sequence. What functions were called, in what order, with what parameters? Most fuzzers print this.
2. Check the ghost variables. Do the ghosts match expected values? If a ghost is wrong, the bug might be in your harness, not the protocol.
-
Minimize the sequence. Try removing calls from the middle. Can you reproduce with fewer steps? Most real bugs need 2-5 calls.
-
Add event logging. Put
emitstatements in your handlers. Re-run and trace the values.
event DebugDeposit(address actor, uint256 amount, uint256 sharesMinted, uint256 ghostTotal);
function handler_deposit(uint256 actorSeed, uint256 amount) public {
// ... setup ...
uint256 sharesBefore = vault.balanceOf(actor);
vm.prank(actor);
vault.deposit(amount, actor);
uint256 sharesMinted = vault.balanceOf(actor) - sharesBefore;
ghost_totalDeposits += amount;
emit DebugDeposit(actor, amount, sharesMinted, ghost_totalDeposits);
}
- Write a regression test. Once you've minimized the sequence, write it as a regular test:
function test_regression_rounding_bug() public {
// Reproduces the invariant failure
handler_deposit(0, 1000e18); // actor 0 deposits
handler_deposit(1, 1); // actor 1 deposits 1 wei
handler_withdraw(1, 1); // actor 1 withdraws -- gets 0 back!
// The 1 wei is now stuck, and the ghost drifts
}
This regression test ensures the bug stays fixed forever.
Putting it into practice
If you're starting with invariant testing for the first time, check out How to Write Your First Invariant Test. Get comfortable with the basics before adding ghosts and temporal properties.
For DeFi-specific property patterns, what to test in lending protocols, DEXs, and vaults, see Property Design Patterns for DeFi Lending. It covers the actual properties that catch real bugs.
And remember: the goal isn't to write the most properties. It's to write the right properties. Five well-designed invariants with solid ghost tracking will catch more bugs than fifty surface-level checks.
Focus on accounting. Focus on solvency. Focus on the properties that, if they fail, mean real money is at risk. Everything else is secondary.
Try Recon Pro
nican0r specializes in DeFi security and property-based testing at Recon. He's written invariant suites for lending protocols, DEXs, bridges, and everything in between.
Related Posts
AMM and DEX invariant testing: properties every swap protocol needs
Every AMM needs these properties: constant product conservation, fee accounting, LP share math, and ...
Postmortem: The Lending Protocol Reentrancy That Fuzzing Missed — And Invariants Didn't
The dev team ran Echidna for 24 hours: zero findings. The same vulnerability was found by invariant ...