From Zero to Fuzzing: A Beginner's Guide to the Chimera Framework
By Kn0t · Lead Invariants EngineerFrom Zero to Fuzzing: A Beginner's Guide to the Chimera Framework
You've heard that fuzzing finds bugs that unit tests miss. You want to try it, but the tooling landscape is confusing. Echidna? Medusa? Foundry? Do you need to learn all three?
No. With the Chimera framework, you write your tests once and run them with any fuzzer. This tutorial takes you from zero to a working invariant test suite. No prior fuzzing experience required.
What Is Chimera?
Chimera is a Solidity testing framework developed by Recon that provides a unified interface for writing invariant tests. You write your properties and target functions once, and Chimera handles the compatibility layer so they work with:
- Foundry (
forge test) — for fast local iteration - Echidna — for thorough stateful fuzzing
- Medusa — for parallel, high-throughput fuzzing
This means you never have to rewrite tests when switching tools or choose a fuzzer before you start writing properties.
Step 1: Install Dependencies
First, make sure you have Foundry installed:
// Run in your terminal:
// curl -L https://foundry.paradigm.xyz | bash
// foundryup
Create a new project (or use an existing one):
// forge init my-protocol
// cd my-protocol
Install Chimera as a dependency:
// forge install Recon-Fuzz/chimera
Add the remapping to your foundry.toml:
// In foundry.toml, add to your remappings:
// @chimera/=lib/chimera/src/
Step 2: Understand the Project Structure
Chimera projects follow a specific structure inside test/recon/:
test/recon/
Setup.sol — Deploy contracts and initialize state
TargetFunctions.sol — Functions the fuzzer can call
BeforeAfter.sol — State snapshots for transition properties
Properties.sol — Invariant properties to check
CryticTester.sol — Entry point for Echidna/Medusa
Each file has a specific role, and they inherit in a chain:
Setup → BeforeAfter → Properties → TargetFunctions → CryticTester
Let's build each one for a simple ERC-4626 vault.
Step 3: Write Setup.sol
Setup.sol deploys all contracts and initializes the testing environment:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {BaseSetup} from "@chimera/BaseSetup.sol";
import {SimpleVault} from "src/SimpleVault.sol";
import {MockERC20} from "@chimera/MockERC20.sol";
contract Setup is BaseSetup {
SimpleVault vault;
MockERC20 token;
function setup() internal override {
// Deploy the underlying token
token = new MockERC20("Test Token", "TT", 18);
// Deploy the vault
vault = new SimpleVault(address(token));
// Mint tokens to test actors
token.mint(address(this), 1_000_000e18);
token.approve(address(vault), type(uint256).max);
}
}
The key points: inherit from BaseSetup, override the setup() function, deploy everything your protocol needs.
Step 4: Write TargetFunctions.sol
Target functions define what the fuzzer is allowed to do. Each function represents one action a user could take:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Properties} from "./Properties.sol";
contract TargetFunctions is Properties {
function handler_deposit(uint256 amount) external {
amount = bound(amount, 1, token.balanceOf(address(this)));
vault.deposit(amount, address(this));
}
function handler_withdraw(uint256 shares) external {
uint256 maxShares = vault.balanceOf(address(this));
if (maxShares == 0) return;
shares = bound(shares, 1, maxShares);
vault.redeem(shares, address(this), address(this));
}
function handler_donate(uint256 amount) external {
amount = bound(amount, 1, token.balanceOf(address(this)));
// Direct transfer to vault (simulates yield or donation attack)
token.transfer(address(vault), amount);
}
}
Notice the handler_ prefix — this is a convention that makes it easy to identify fuzzer-callable functions. The bound() call constrains random inputs to valid ranges.
Step 5: Write Properties.sol
Properties are the invariants that must always hold. This is where you define what "correct" means:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {BeforeAfter} from "./BeforeAfter.sol";
contract Properties is BeforeAfter {
// The vault must always be solvent:
// actual token balance >= what all shareholders can claim
function invariant_solvency() public view returns (bool) {
uint256 totalClaim = vault.convertToAssets(vault.totalSupply());
uint256 actualBalance = token.balanceOf(address(vault));
return actualBalance >= totalClaim;
}
// No deposit should mint zero shares (would mean lost funds)
function invariant_noFreeDeposits() public view returns (bool) {
if (vault.totalSupply() == 0) return true;
// A minimum deposit should always yield at least 1 share
uint256 sharesForMinDeposit = vault.convertToShares(1);
// This can legitimately be 0 for very small amounts;
// adjust the minimum deposit based on your protocol
return true; // Simplified for tutorial
}
}
The solvency invariant is your most important property. If it ever fails, the vault has a critical accounting bug.
Step 6: Write CryticTester.sol
This is the entry point for Echidna and Medusa. It is minimal:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {TargetFunctions} from "./TargetFunctions.sol";
import {CryticAsserts} from "@chimera/CryticAsserts.sol";
contract CryticTester is TargetFunctions, CryticAsserts {
constructor() {
setup();
}
}
The CryticAsserts mixin provides helper functions and ensures compatibility with Echidna and Medusa's testing interfaces.
Step 7: Run with Foundry
The fastest way to iterate is with Foundry. Create a small test wrapper:
// test/recon/FoundryTest.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Test} from "forge-std/Test.sol";
import {TargetFunctions} from "./TargetFunctions.sol";
contract FoundryTest is Test, TargetFunctions {
function setUp() public {
setup();
}
function invariant_solvency() public view {
bool result = invariant_solvency();
assertTrue(result, "Solvency violated");
}
}
Run it:
// forge test --match-contract FoundryTest -vvv
Foundry's invariant testing will call your handler_ functions randomly and check the invariant after each call.
Step 8: Run with Echidna
Install Echidna, then create a config file:
# echidna.yaml
testMode: assertion
testLimit: 100000
seqLen: 50
corpusDir: "corpus-echidna"
cryticArgs: ["--compile-force-framework", "foundry"]
Run:
// echidna test/recon/CryticTester.sol --contract CryticTester --config echidna.yaml
Step 9: Run with Medusa
Install Medusa, then initialize the config:
// medusa init
Update medusa.json to point to your CryticTester contract, then run:
// medusa fuzz
What Happens When a Property Fails
When any fuzzer finds a violation, it reports:
- The failing property — which invariant was broken
- The transaction sequence — the exact calls that triggered the failure
- The call data — the specific arguments used
For example, Echidna might report:
invariant_solvency(): failed!
Call sequence:
handler_deposit(1000000000000000000)
handler_donate(500000000000000000)
handler_deposit(1)
handler_withdraw(1000000000000000001)
This tells you exactly how to reproduce the bug. From here, you can write a unit test with this specific sequence and debug it step by step.
Next Steps
Once you are comfortable with the basics:
- Add more properties: Accounting consistency, access control, rate monotonicity
- Add more actors: Use multiple addresses to test multi-user interactions
- Increase sequence length: Test longer, more complex transaction sequences
- Run in the cloud: Use Recon Pro for multi-hour campaigns with high parallelism
For a deeper dive into property design, read our invariant testing guide. For advanced Chimera patterns, check the Chimera framework documentation.
Fuzzing is a skill that compounds. The more properties you write and the more campaigns you run, the more intuition you build for where bugs hide. Start with solvency, iterate from there, and you will be surprised what your fuzzer finds.
Need expert help building your invariant test suite? Request an audit with Recon — we will set up a comprehensive Chimera-based testing framework tailored to your protocol and run it at scale.