How to fuzz ERC-4626 vaults: a step-by-step guide
How to fuzz ERC-4626 vaults: a step-by-step guide
ERC-4626 is the tokenized vault standard that most DeFi yield protocols build on. It defines a clean interface for deposits, withdrawals, and share accounting, along with a set of guarantees that are surprisingly easy to break. Rounding errors, share inflation attacks, and deposit/withdraw asymmetries have all been exploited in production vaults.
Fuzzing is the fastest way to verify that your vault's implementation actually holds the guarantees the standard promises. This guide walks through the full process: identifying the right invariants, writing properties with Chimera, and running campaigns with multiple fuzzers.
<a href="/request-audit" class="cta-button">Get expert vault fuzzing</a>
What ERC-4626 guarantees
The EIP-4626 specification defines a standard API for yield-bearing vaults. At its core, a vault accepts deposits of an underlying asset, mints shares to depositors, and allows redemption of shares for the underlying asset (plus yield). The standard specifies several functions (deposit(), mint(), withdraw(), redeem()) and a set of preview/max functions that must be consistent with actual behavior.
The guarantees that matter most for security:
- Share/asset conversion consistency.
convertToShares(convertToAssets(shares))should round-trip without unbounded loss. - Preview accuracy.
previewDeposit(assets)must return the exact number of sharesdeposit(assets)would mint (or fewer, never more). - Deposit/withdraw symmetry. Depositing then immediately withdrawing shouldn't create or destroy value (beyond rounding).
- Rounding direction. Conversions should favor the vault (round down on the way out, round up on the way in) to prevent share inflation attacks.
- Solvency. The vault's asset balance must cover what it owes to all shareholders.
- Max function accuracy.
maxDeposit(),maxWithdraw(), etc. should return values that don't revert when used.
These look simple on paper. In practice, custom yield strategies, fee mechanisms, and rebasing tokens make them hard to maintain. That's exactly where fuzzing shines.
Key invariants
Before writing code, let's define the invariants we'll test. These are the properties that should hold after every transaction, no matter what sequence of operations the fuzzer generates.
Solvency
The most fundamental invariant. The vault must always hold enough of the underlying asset to cover all outstanding shares.
asset.balanceOf(vault) >= vault.totalAssets()
If this fails, your vault is insolvent — shareholders can't fully redeem.
Share/asset ratio monotonicity
For a yield-bearing vault, the exchange rate (assets per share) should never decrease except through legitimate losses. In many implementations, it should be monotonically non-decreasing.
vault.convertToAssets(1e18) // Should never decrease over time
// (assuming no loss events)
Deposit/withdraw round-trip
A user who deposits X assets and immediately redeems all their shares should get back at most X assets (rounding favors the vault).
assetsOut <= assetsIn // After deposit then full redeem, no profit
Preview/actual consistency
Preview functions must not overstate returns. The actual shares minted by deposit() must be less than or equal to what previewDeposit() promised.
actualShares <= previewDeposit(assets) // For depositor
actualAssets <= previewRedeem(shares) // For redeemer
No zero-amount inflation
Depositing should not mint shares if zero assets are transferred. Withdrawing should not transfer assets if zero shares are burned. This prevents share inflation attacks where an attacker manipulates the ratio with zero-cost operations.
Setting up Chimera
We'll use the Chimera framework so our properties run on Foundry, Echidna, and Medusa without changes. If you haven't set up Chimera before, our beginner's guide covers the full installation.
The file structure:
test/
invariants/
Setup.sol // Deploy vault + tokens, fund actors
TargetFunctions.sol // Handler functions the fuzzer calls
Properties.sol // Invariant checks
CryticTester.sol // Entrypoint for Echidna/Medusa
Setup
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {MockERC20} from "./mocks/MockERC20.sol";
import {ERC4626Vault} from "src/ERC4626Vault.sol";
import {BaseSetup} from "@chimera/BaseSetup.sol";
abstract contract Setup is BaseSetup {
MockERC20 internal asset;
ERC4626Vault internal vault;
address[] internal actors;
function setup() internal virtual override {
asset = new MockERC20("Test Token", "TT", 18);
vault = new ERC4626Vault(asset);
// Create 3 actors
actors.push(address(0x10001));
actors.push(address(0x10002));
actors.push(address(0x10003));
// Fund actors and approve vault
for (uint256 i = 0; i < actors.length; i++) {
asset.mint(actors[i], 1_000_000e18);
vm.prank(actors[i]);
asset.approve(address(vault), type(uint256).max);
}
// Seed initial state to avoid empty-vault edge cases
asset.mint(address(this), 10_000e18);
asset.approve(address(vault), type(uint256).max);
vault.deposit(10_000e18, address(this));
}
}
We seed the vault with an initial deposit to avoid the "first depositor" edge case, where an attacker can manipulate an empty vault's share price. Your real contract should have its own protection against this, and the fuzzer will test that too.
Writing properties
Each property is a view function that returns bool. The fuzzer calls these after every transaction and reports a violation if any returns false.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Setup} from "./Setup.sol";
abstract contract Properties is Setup {
// P-1: Solvency — vault holds enough assets
function invariant_solvency() public view returns (bool) {
return asset.balanceOf(address(vault)) >= vault.totalAssets();
}
// P-2: No free shares — totalSupply == 0 iff totalAssets == 0
function invariant_noFreeShares() public view returns (bool) {
if (vault.totalSupply() == 0) {
return vault.totalAssets() == 0;
}
return vault.totalAssets() > 0;
}
// P-3: Preview deposit doesn't overstate
function invariant_previewDepositSafe() public view returns (bool) {
// previewDeposit should round down (fewer shares = safer for vault)
uint256 assets = 1e18;
uint256 previewShares = vault.previewDeposit(assets);
uint256 convertShares = vault.convertToShares(assets);
// previewDeposit must return <= convertToShares for safety
return previewShares <= convertShares + 1; // +1 for rounding tolerance
}
// P-4: Preview redeem doesn't overstate
function invariant_previewRedeemSafe() public view returns (bool) {
if (vault.totalSupply() == 0) return true;
uint256 shares = 1e18;
uint256 previewAssets = vault.previewRedeem(shares);
uint256 convertAssets = vault.convertToAssets(shares);
return previewAssets <= convertAssets + 1;
}
// P-5: Exchange rate monotonicity (tracked across calls)
uint256 internal lastExchangeRate;
function invariant_exchangeRateMonotonic() public returns (bool) {
if (vault.totalSupply() == 0) return true;
uint256 currentRate = vault.convertToAssets(1e18);
if (lastExchangeRate != 0 && currentRate < lastExchangeRate) {
return false;
}
lastExchangeRate = currentRate;
return true;
}
}
Note that P-5 is stateful: it tracks the exchange rate across calls and flags any decrease. This is the kind of property that stateful fuzzing handles well and stateless fuzzing can't check at all.
Writing target functions
Target functions (handlers) are what the fuzzer actually calls. Each handler wraps a vault operation with bounded inputs.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Properties} from "./Properties.sol";
abstract contract TargetFunctions is Properties {
// --- Core vault operations ---
function handler_deposit(uint256 actorSeed, uint256 assets) public {
address actor = actors[actorSeed % actors.length];
uint256 maxDep = vault.maxDeposit(actor);
if (maxDep == 0) return;
assets = bound(assets, 1, min(maxDep, asset.balanceOf(actor)));
if (assets == 0) return;
vm.prank(actor);
vault.deposit(assets, actor);
}
function handler_mint(uint256 actorSeed, uint256 shares) public {
address actor = actors[actorSeed % actors.length];
uint256 maxMint = vault.maxMint(actor);
if (maxMint == 0) return;
shares = bound(shares, 1, maxMint);
uint256 assetsNeeded = vault.previewMint(shares);
if (assetsNeeded > asset.balanceOf(actor)) return;
vm.prank(actor);
vault.mint(shares, actor);
}
function handler_withdraw(uint256 actorSeed, uint256 assets) public {
address actor = actors[actorSeed % actors.length];
uint256 maxWith = vault.maxWithdraw(actor);
if (maxWith == 0) return;
assets = bound(assets, 1, maxWith);
vm.prank(actor);
vault.withdraw(assets, actor, actor);
}
function handler_redeem(uint256 actorSeed, uint256 shares) public {
address actor = actors[actorSeed % actors.length];
uint256 maxRedeem = vault.maxRedeem(actor);
if (maxRedeem == 0) return;
shares = bound(shares, 1, maxRedeem);
vm.prank(actor);
vault.redeem(shares, actor, actor);
}
// --- Yield simulation ---
function handler_simulateYield(uint256 yieldAmount) public {
yieldAmount = bound(yieldAmount, 1, 100_000e18);
asset.mint(address(vault), yieldAmount);
}
// --- Time manipulation ---
function handler_warpTime(uint256 delta) public {
delta = bound(delta, 1, 365 days);
vm.warp(block.timestamp + delta);
}
// --- Round-trip check (per-call property) ---
function handler_depositWithdrawRoundTrip(
uint256 actorSeed,
uint256 assets
) public {
address actor = actors[actorSeed % actors.length];
assets = bound(assets, 1e6, asset.balanceOf(actor) / 2);
if (assets == 0) return;
uint256 balanceBefore = asset.balanceOf(actor);
vm.startPrank(actor);
uint256 shares = vault.deposit(assets, actor);
uint256 assetsBack = vault.redeem(shares, actor, actor);
vm.stopPrank();
// Should not profit from round-trip
assert(asset.balanceOf(actor) <= balanceBefore);
}
function min(uint256 a, uint256 b) internal pure returns (uint256) {
return a < b ? a : b;
}
}
A few things to notice:
handler_simulateYieldmints tokens directly to the vault, simulating yield accrual. This lets the fuzzer explore states where the exchange rate has changed.handler_warpTimeadvances the block timestamp, which matters for vaults with time-dependent mechanics.handler_depositWithdrawRoundTripis an inline assertion property: it checks the round-trip invariant on every call, not just at the end of the sequence.
Running the campaign
Foundry (quick smoke test)
forge test --match-contract CryticTester --fuzz-runs 10000
This takes under a minute and catches basic violations. Good for development iteration.
Medusa (broad coverage)
{
"fuzzing": {
"targetContracts": ["CryticTester"],
"testLimit": 500000,
"callSequenceLength": 100,
"workers": 8,
"corpusDirectory": "corpus-medusa"
}
}
medusa fuzz
Medusa's parallel workers will explore the state space fast. Check the corpus directory afterward for coverage reports.
Echidna (deep exploration)
# echidna.yaml
testMode: assertion
testLimit: 2000000
seqLen: 150
corpusDir: "corpus-echidna"
echidna . --contract CryticTester --config echidna.yaml
Echidna's directed exploration and sequence shrinking give you the cleanest reproducers when a property fails. For a deep dive on when to use each, see our Echidna vs Medusa comparison.
Interpreting results
When a property fails, you'll get a call sequence that triggered the violation. Here's what to look for.
Solvency violations
If invariant_solvency fails, the vault owes more to shareholders than it holds. Common causes:
- Rounding in the wrong direction. Withdrawal calculations that round up instead of down, giving users more assets than their shares are worth.
- Fee accounting bugs. Fees that are subtracted from the vault's balance but not from
totalAssets(). - Direct transfers. Someone sends assets directly to the vault without going through
deposit(), inflating the exchange rate, then another user withdraws at the inflated rate.
Exchange rate manipulation
If invariant_exchangeRateMonotonic fails, the exchange rate decreased without a legitimate loss event. This is how share inflation attacks work: the attacker drives the rate up, then crashes it to steal from other depositors. See our rounding errors deep dive for the mechanics.
Round-trip profit
If handler_depositWithdrawRoundTrip fails, a user can deposit and immediately withdraw for a net profit. This is directly exploitable. An attacker loops the operation to drain the vault.
Look at the fuzzer's call sequence to understand what state the vault was in when the violation occurred. The sequence of handler_simulateYield and handler_warpTime calls leading up to the failure tells you the preconditions. Echidna's shrinking will reduce this to the minimal reproducer.
Beyond the basics
Once your core ERC-4626 properties are passing, consider these next steps:
- Add protocol-specific properties. Fee mechanisms, caps, whitelists, timelocks, anything your vault adds on top of the standard.
- Test upgrade paths. If your vault is upgradeable, include a handler that simulates the upgrade and check that all properties still hold.
- Run in CI. Set up continuous fuzzing so every PR gets fuzzed against your invariant suite.
- Async vaults. If your vault uses ERC-7540 async deposits and redemptions, see our ERC-7540 fuzzing guide for the additional lifecycle invariants.
- Graduate to Recon Pro. For production vaults handling significant TVL, Recon Pro runs multi-day cloud campaigns that go far deeper than local runs.
The ERC-4626 standard gives you a clean property specification to test against. That's rare — most protocols require custom property design from scratch. Take advantage of it.
If you're building an ERC-4626 vault and want experts to build a complete invariant suite for your specific implementation, request an audit with Recon. We've fuzzed dozens of vault implementations and know exactly where they break.
Related Posts
How to fuzz ERC-1155 multi-token contracts
ERC-1155 combines batch operations with mandatory receiver callbacks, creating a reentrancy surface ...
How to fuzz ERC 2535 diamond proxies: storage, selectors, and upgrades
Diamond proxies delegate calls to multiple facets, and the bugs live in upgrade sequences and storag...