Chimera advanced patterns: multi-contract fuzzing with actors and ghosts
Chimera Advanced Patterns: Multi-Contract Fuzzing with Actors and Ghosts
By kn0t
If you've written your first invariant test and got it passing, congrats, you're ahead of most teams. But real protocols aren't single contracts. They're webs of interacting pieces: a lending pool talks to an oracle, which prices a token, which has its own transfer logic. Testing one contract in isolation won't catch the bugs that live in the seams between them.
That's where Chimera really shines. It gives you a framework to set up multi-contract environments, simulate multiple users doing different things, and track expected state with ghost variables — all while keeping your test suite runnable on both Echidna and Medusa.
Let's dig into the patterns that make this work.
Why multi-contract testing matters
Single-contract fuzzing is great for catching arithmetic bugs, reentrancy, and basic access control issues. But the nastiest DeFi exploits? They almost always involve interactions between contracts.
Think about it:
- A lending protocol depends on an oracle. What if the oracle returns stale data mid-liquidation?
- A vault deposits into a strategy that interacts with an AMM. What happens when the AMM's reserves get manipulated between the vault's deposit and withdrawal?
- A governance token has delegation. Does the voting power invariant hold when users transfer tokens while votes are active?
You can't test these with a single target contract. You need the whole system.
Setting up a multi-contract target
Here's the basic structure. We'll use a simplified lending protocol as the running example (a pool, a price oracle, and an ERC20 collateral token).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {TargetFunctions} from "./TargetFunctions.sol";
import {CryticAsserts} from "@chimera/CryticAsserts.sol";
abstract contract Setup is CryticAsserts {
LendingPool pool;
MockOracle oracle;
MockERC20 collateral;
MockERC20 debtToken;
address[] actors;
function setup() internal virtual override {
// Deploy the ecosystem
collateral = new MockERC20("Collateral", "COL", 18);
debtToken = new MockERC20("Debt", "DEBT", 18);
oracle = new MockOracle();
pool = new LendingPool(
address(collateral),
address(debtToken),
address(oracle)
);
// Set initial oracle price
oracle.setPrice(address(collateral), 1000e18);
// Create actors with different roles
actors = new address[](3);
actors[0] = address(0x10000); // depositor
actors[1] = address(0x20000); // borrower
actors[2] = address(0x30000); // liquidator
// Fund actors
for (uint256 i = 0; i < actors.length; i++) {
collateral.mint(actors[i], 1_000_000e18);
debtToken.mint(actors[i], 1_000_000e18);
}
// Seed the pool with liquidity
debtToken.mint(address(pool), 10_000_000e18);
}
}
Notice what's happening here. We don't just deploy the pool — we deploy everything it depends on, fund multiple actors, and seed initial state. This is the foundation every multi-contract test suite needs.
The actor pattern
Real protocols have users with different permissions and behaviors. An admin can pause the protocol. A depositor supplies collateral. A borrower takes loans. A liquidator closes unhealthy positions. Your fuzzer should simulate all of these.
The actor pattern gives each call a "caller" selected from a set of addresses:
abstract contract TargetFunctions is Setup {
// Ghost variables for tracking
mapping(address => uint256) ghost_deposited;
mapping(address => uint256) ghost_borrowed;
uint256 ghost_totalDeposits;
uint256 ghost_totalBorrows;
// Actor selection -- the fuzzer picks the index
function _getActor(uint8 actorIndex) internal view returns (address) {
return actors[actorIndex % actors.length];
}
function deposit(uint8 actorIndex, uint256 amount) public {
address actor = _getActor(actorIndex);
amount = clampBetween(amount, 1, collateral.balanceOf(actor));
// Prank as the actor
vm.prank(actor);
collateral.approve(address(pool), amount);
vm.prank(actor);
pool.deposit(amount);
// Update ghosts
ghost_deposited[actor] += amount;
ghost_totalDeposits += amount;
}
function borrow(uint8 actorIndex, uint256 amount) public {
address actor = _getActor(actorIndex);
uint256 maxBorrow = pool.maxBorrowable(actor);
if (maxBorrow == 0) return;
amount = clampBetween(amount, 1, maxBorrow);
vm.prank(actor);
pool.borrow(amount);
// Update ghosts
ghost_borrowed[actor] += amount;
ghost_totalBorrows += amount;
}
function liquidate(uint8 actorIndex, uint8 targetIndex, uint256 amount) public {
address liquidator = _getActor(actorIndex);
address target = _getActor(targetIndex);
if (!pool.isLiquidatable(target)) return;
uint256 debt = pool.debtOf(target);
amount = clampBetween(amount, 1, debt);
vm.prank(liquidator);
debtToken.approve(address(pool), amount);
vm.prank(liquidator);
pool.liquidate(target, amount);
// Update ghosts
ghost_borrowed[target] -= amount;
ghost_totalBorrows -= amount;
}
}
There's a subtle but important detail: each handler function takes an actorIndex that the fuzzer controls. The fuzzer doesn't know about "depositors" vs "borrowers", it just picks numbers. The modulo operation maps those numbers to actual addresses. This means the fuzzer can naturally discover interesting sequences like "actor 0 deposits, then actor 1 borrows against actor 0's collateral, then the oracle price drops, then actor 2 liquidates actor 1."
Ghost variables: tracking expected state
Ghost variables are the secret weapon of serious invariant testing. They're variables that exist only in your test harness, not in the protocol itself, and they track what the state should be.
The idea is simple. Every time you call a protocol function, you also update a parallel "expected" value. Then your invariants check that the protocol's actual state matches the expected state.
abstract contract Properties is TargetFunctions {
// INVARIANT: Total deposits tracked by ghost must match
// the actual collateral balance held by the pool
function invariant_deposits_match_balance() public view returns (bool) {
return collateral.balanceOf(address(pool)) >= ghost_totalDeposits - ghost_totalBorrows;
}
// INVARIANT: No individual user can have borrowed more
// than they deposited (at the correct collateral ratio)
function invariant_no_undercollateralized_borrows() public view returns (bool) {
for (uint256 i = 0; i < actors.length; i++) {
address actor = actors[i];
uint256 collateralValue = ghost_deposited[actor] * oracle.getPrice(address(collateral)) / 1e18;
uint256 maxAllowed = collateralValue * pool.collateralFactor() / 1e18;
if (ghost_borrowed[actor] > maxAllowed) {
return false;
}
}
return true;
}
// INVARIANT: Ghost total must equal sum of individual ghosts
function invariant_ghost_consistency() public view returns (bool) {
uint256 sumDeposits;
uint256 sumBorrows;
for (uint256 i = 0; i < actors.length; i++) {
sumDeposits += ghost_deposited[actors[i]];
sumBorrows += ghost_borrowed[actors[i]];
}
return sumDeposits == ghost_totalDeposits && sumBorrows == ghost_totalBorrows;
}
}
That last invariant (ghost_consistency) is what I call a "meta-invariant." It doesn't test the protocol; it tests your test harness. If this fails, your ghost tracking has a bug. Always include at least one of these. It'll save you hours of debugging false positives.
Cross-contract invariants
This is where multi-contract setups really pay off. You can write invariants that span the entire system:
// The oracle should never report a zero price for active collateral
function invariant_oracle_nonzero_price() public view returns (bool) {
return oracle.getPrice(address(collateral)) > 0;
}
// The pool's accounting should be consistent with token balances
function invariant_pool_solvency() public view returns (bool) {
uint256 poolCollateral = collateral.balanceOf(address(pool));
uint256 poolDebt = debtToken.balanceOf(address(pool));
uint256 totalOwed = pool.totalBorrows();
// Pool should always have enough debt tokens to cover
// what hasn't been borrowed out
return poolDebt >= pool.totalDeposits() - totalOwed;
}
These invariants catch bugs that live in the interactions between contracts. The solvency check, for instance, verifies that the pool's internal accounting (totalBorrows, totalDeposits) stays consistent with the actual token balances. If there's a rounding error in the borrow function that slowly leaks tokens, this invariant catches it.
Adding oracle manipulation
Here's where things get fun. Real attackers manipulate oracles. Your fuzzer should too:
function manipulateOraclePrice(uint256 newPrice) public {
// Bound to realistic range -- 0.01x to 100x current price
uint256 currentPrice = oracle.getPrice(address(collateral));
newPrice = clampBetween(newPrice, currentPrice / 100, currentPrice * 100);
oracle.setPrice(address(collateral), newPrice);
}
Now the fuzzer can interleave price changes with deposits, borrows, and liquidations. This is how you find bugs like "if the oracle price drops 50% between a borrow and the next block, the position becomes liquidatable but the liquidation bonus makes the pool insolvent."
setUp patterns for complex DeFi deployments
For real protocols, your setup gets complicated fast. Here's how to keep it manageable:
Layer your setup functions:
abstract contract Setup is CryticAsserts {
function setup() internal virtual override {
_deployCore();
_deployPeriphery();
_configureRoles();
_seedLiquidity();
_createActors();
}
function _deployCore() internal {
// Token deploys, pool factory, core contracts
}
function _deployPeriphery() internal {
// Oracles, routers, helpers
}
function _configureRoles() internal {
// Admin roles, operator permissions, guardian addresses
}
function _seedLiquidity() internal {
// Initial deposits so the protocol isn't empty
}
function _createActors() internal {
// Fund test actors with tokens and approvals
}
}
This pattern keeps your setup readable even when you're deploying 15+ contracts. Each function has one job.
Pre-approve everything:
Don't let your fuzzer waste cycles hitting "insufficient allowance" reverts. In your actor setup, approve all contracts for max amounts:
function _createActors() internal {
for (uint256 i = 0; i < actors.length; i++) {
vm.startPrank(actors[i]);
collateral.approve(address(pool), type(uint256).max);
debtToken.approve(address(pool), type(uint256).max);
vm.stopPrank();
}
}
Putting it all together
Here's the full hierarchy for a Chimera-based multi-contract test:
Setup (deploys everything, creates actors)
└── TargetFunctions (handler functions with ghost updates)
└── Properties (invariants checking ghosts + actual state)
└── CryticTester (Echidna/Medusa entry point)
└── FoundryTester (Foundry entry point)
The beauty of this structure is that your properties and target functions work identically whether you run them with Echidna, Medusa, or Foundry. Write once, test everywhere.
Common pitfalls
Ghost drift. If you forget to update a ghost in one handler, all your ghost-dependent invariants become unreliable. Be disciplined, every state change in the protocol needs a matching ghost update.
Over-constraining actors. Don't give actors fixed roles. Let the fuzzer decide. If you hard-code "actor 0 always deposits," you'll miss bugs that require a single user to both deposit and borrow.
Ignoring reverts. If your handler function silently returns on a revert, you might miss important bugs. Track which calls revert and why. Sometimes the most interesting finding is "this function reverts when it shouldn't."
Stale ghost state after liquidation. Liquidations change multiple users' positions. Make sure your ghost updates handle the full cascading effect, not just the liquidator's side.
What's next
If you haven't built your first invariant test yet, start with How to Write Your First Invariant Test. Once you're comfortable with the basics, come back here and add actors and ghosts.
For more on stateful fuzzing and why call sequences matter, check out Stateful Fuzzing Explained: Sequence Matters.
And if you want to run these patterns with minimal setup, the Chimera framework gives you the scaffolding to write properties once and test them on every major fuzzer.
Get a Security Review
kn0t specializes in invariant testing and fuzzing infrastructure at Recon. When he's not writing ghost variables, he's probably debugging why they drifted.
Related Posts
Why we built Chimera: write once, fuzz everywhere
Every fuzzer needs different test code. Chimera lets you write properties once and run them with Fou...
From Zero to Fuzzing: A Beginner's Guide to the Chimera Framework
A hands-on beginner tutorial for the Chimera framework. Go from an empty project to running invarian...