2026-03-30·14 min read

How to fuzz ERC-7540 async vaults

How to fuzz ERC-7540 async vaults

ERC-7540 extends ERC-4626 with asynchronous deposit and redemption flows. Instead of instant in-and-out, users submit requests that get fulfilled later by an off-chain or on-chain operator. This async lifecycle is what protocols like Centrifuge use for real-world asset vaults where redemptions take days, not blocks. The problem is that async adds an entire state machine on top of the already tricky ERC-4626 accounting. Most of the new bugs hide in the transitions between request states.

Fuzzing async vaults is harder than fuzzing standard vaults. The fuzzer needs to drive requests through their full lifecycle, simulate operator fulfillments at arbitrary times, and verify that the ERC-4626 guarantees still hold while assets are in transit. This guide covers the invariants, the Chimera setup, and the handler patterns that make async vault fuzzing effective.

<a href="/request-audit" class="cta-button">Get expert async vault fuzzing</a>

What ERC-7540 guarantees

The EIP-7540 specification builds on ERC-4626 by adding request-based flows. A user calls requestDeposit() or requestRedeem() to submit a request. The vault operator later fulfills requests, moving them from "pending" to "claimable." The user then claims via the standard deposit() / mint() / withdraw() / redeem() functions.

The guarantees that matter most for security:

  1. Lifecycle ordering. A request must go through pending → claimable → claimed. Skipping states breaks accounting.
  2. Pending >= claimable. The pending amount for any controller must always be greater than or equal to the claimable amount. Claimable assets are a subset of pending.
  3. Solvency with in-transit assets. The vault must remain solvent when accounting for both deposited assets and assets locked in pending requests.
  4. No value creation in round-trips. Requesting a deposit and then cancelling (or requesting then claiming) must not produce a net profit.
  5. Cancellation returns exact amounts. If a pending request is cancelled, the escrowed assets must return to the original owner with no loss or gain.
  6. Fulfillment correctness. The operator's fulfillment must convert pending amounts to claimable amounts using a consistent exchange rate.

We validated these invariants on the Centrifuge deployment, and Recon's public ERC-7540 properties repo implements them as reusable Chimera properties. This guide explains the reasoning behind those properties.

Key invariants

Before writing properties, we define what must hold true across every possible state of the async lifecycle.

Request-claim lifecycle ordering

Every request has a state: none → pending → claimable → claimed. Transitions must follow this order. A request that was never created must not be claimable. A request that's already claimed must not reappear as pending.

if pendingRequest(controller) == 0:
    claimableRequest(controller) == 0

Pending >= claimable

At any point, the pending amount for a controller must be at least as large as the claimable amount. Claimable is the portion of pending that the operator has fulfilled.

pendingDepositRequest(controller) >= claimableDepositRequest(controller)
pendingRedeemRequest(controller) >= claimableRedeemRequest(controller)

Solvency with in-transit assets

Standard ERC-4626 solvency checks don't account for assets locked in pending requests. The vault must remain solvent when we include escrowed assets in the accounting.

asset.balanceOf(vault) >= totalAssets() + totalPendingDeposits

No value creation in async round-trip

A user who requests a deposit, waits for fulfillment, claims shares, then immediately redeems shouldn't profit. The async flow must not create value.

Cancellation returns exact escrowed assets

If the vault supports cancellation, cancelling a pending request must return the exact escrowed amount to the requester. No fees on cancellation (unless explicitly documented), no partial returns.

What makes async vaults harder to fuzz

Standard ERC-4626 fuzzing uses a simple pattern: the fuzzer calls deposit/withdraw/redeem directly. With ERC-7540, the fuzzer must drive a multi-step lifecycle:

  1. Submit requests (requestDeposit / requestRedeem)
  2. Simulate operator fulfillment (an external call that moves requests from pending to claimable)
  3. Claim (standard ERC-4626 functions that consume claimable amounts)

This means we need a handler for operator fulfillment. Without it, the fuzzer submits requests that never get fulfilled, and the interesting invariants around the claim step never get tested.

We also need to track request IDs across the lifecycle. The fuzzer might submit multiple requests for the same controller, and we need to verify that partial fulfillments and interleaved claims don't break accounting.

Setting up Chimera

We use the Chimera framework with an extended setup that includes the async lifecycle tracking. The beginner's guide covers basic installation.

The file structure:

test/
  invariants/
    Setup.sol          // Deploy vault + tokens, fund actors, init tracking
    TargetFunctions.sol // Request, fulfill, claim, cancel handlers
    Properties.sol      // Lifecycle and solvency checks
    CryticTester.sol    // Entrypoint for Echidna/Medusa

Setup

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {MockERC20} from "./mocks/MockERC20.sol";
import {ERC7540Vault} from "src/ERC7540Vault.sol";
import {BaseSetup} from "@chimera/BaseSetup.sol";

abstract contract Setup is BaseSetup {
    MockERC20 internal asset;
    ERC7540Vault internal vault;
    address[] internal actors;
    address internal operator;

    // Ghost tracking for pending/claimed amounts
    mapping(address => uint256) internal ghostPendingDeposits;
    mapping(address => uint256) internal ghostPendingRedeems;

    function setup() internal virtual override {
        asset = new MockERC20("Test Token", "TT", 18);
        vault = new ERC7540Vault(asset);
        operator = address(0xAAAA);

        actors.push(address(0x10001));
        actors.push(address(0x10002));
        actors.push(address(0x10003));

        for (uint256 i = 0; i < actors.length; i++) {
            asset.mint(actors[i], 10_000_000e18);
            vm.prank(actors[i]);
            asset.approve(address(vault), type(uint256).max);
        }

        // Seed vault with initial deposit to avoid empty-vault edge cases
        asset.mint(address(this), 100_000e18);
        asset.approve(address(vault), type(uint256).max);
        vault.deposit(100_000e18, address(this));
    }
}

The operator address simulates the off-chain actor that fulfills requests. In production vaults like Centrifuge, this is a permissioned role that processes redemptions after real-world asset settlements complete.

Writing properties

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Setup} from "./Setup.sol";

abstract contract Properties is Setup {
    // P-1: Pending >= claimable for deposits
    function invariant_pendingGeClaimableDeposit() public view returns (bool) {
        for (uint256 i = 0; i < actors.length; i++) {
            uint256 pending = vault.pendingDepositRequest(0, actors[i]);
            uint256 claimable = vault.claimableDepositRequest(0, actors[i]);
            if (pending < claimable) return false;
        }
        return true;
    }

    // P-2: Pending >= claimable for redeems
    function invariant_pendingGeClaimableRedeem() public view returns (bool) {
        for (uint256 i = 0; i < actors.length; i++) {
            uint256 pending = vault.pendingRedeemRequest(0, actors[i]);
            uint256 claimable = vault.claimableRedeemRequest(0, actors[i]);
            if (pending < claimable) return false;
        }
        return true;
    }

    // P-3: Solvency including escrowed assets
    function invariant_solvencyWithEscrow() public view returns (bool) {
        uint256 totalEscrowed = 0;
        for (uint256 i = 0; i < actors.length; i++) {
            totalEscrowed += vault.pendingDepositRequest(0, actors[i]);
        }
        return asset.balanceOf(address(vault)) >= vault.totalAssets();
    }

    // P-4: No zero-pending claimable (lifecycle ordering)
    function invariant_lifecycleOrdering() public view returns (bool) {
        for (uint256 i = 0; i < actors.length; i++) {
            uint256 pendingDep = vault.pendingDepositRequest(0, actors[i]);
            uint256 claimableDep = vault.claimableDepositRequest(0, actors[i]);
            // If nothing is pending, nothing should be claimable
            if (pendingDep == 0 && claimableDep > 0) return false;

            uint256 pendingRed = vault.pendingRedeemRequest(0, actors[i]);
            uint256 claimableRed = vault.claimableRedeemRequest(0, actors[i]);
            if (pendingRed == 0 && claimableRed > 0) return false;
        }
        return true;
    }

    // P-5: Ghost pending matches contract pending
    function invariant_ghostPendingSync() public view returns (bool) {
        for (uint256 i = 0; i < actors.length; i++) {
            if (vault.pendingDepositRequest(0, actors[i]) != ghostPendingDeposits[actors[i]]) {
                return false;
            }
        }
        return true;
    }
}

P-1 and P-2 are the core lifecycle invariants. P-3 verifies solvency accounts for all asset locations. P-4 enforces the state machine ordering. P-5 cross-checks the contract state against our independent ghost tracker.

Writing target functions

The handlers must cover the full async lifecycle: request, fulfill, claim, and cancel.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Properties} from "./Properties.sol";

abstract contract TargetFunctions is Properties {

    // --- Request phase ---

    function handler_requestDeposit(uint256 actorSeed, uint256 assets) public {
        address actor = actors[actorSeed % actors.length];
        uint256 bal = asset.balanceOf(actor);
        if (bal == 0) return;
        assets = bound(assets, 1, bal / 2);

        vm.prank(actor);
        vault.requestDeposit(assets, actor, actor);

        ghostPendingDeposits[actor] += assets;
    }

    function handler_requestRedeem(uint256 actorSeed, uint256 shares) public {
        address actor = actors[actorSeed % actors.length];
        uint256 bal = vault.balanceOf(actor);
        if (bal == 0) return;
        shares = bound(shares, 1, bal);

        vm.prank(actor);
        vault.requestRedeem(shares, actor, actor);

        ghostPendingRedeems[actor] += shares;
    }

    // --- Operator fulfillment ---

    function handler_fulfillDeposit(uint256 actorSeed) public {
        address controller = actors[actorSeed % actors.length];
        uint256 pending = vault.pendingDepositRequest(0, controller);
        if (pending == 0) return;

        vm.prank(operator);
        vault.fulfillDeposit(controller, pending);
    }

    function handler_fulfillRedeem(uint256 actorSeed) public {
        address controller = actors[actorSeed % actors.length];
        uint256 pending = vault.pendingRedeemRequest(0, controller);
        if (pending == 0) return;

        vm.prank(operator);
        vault.fulfillRedeem(controller, pending);
    }

    // --- Claim phase (standard ERC-4626 functions) ---

    function handler_claimDeposit(uint256 actorSeed) public {
        address actor = actors[actorSeed % actors.length];
        uint256 claimable = vault.claimableDepositRequest(0, actor);
        if (claimable == 0) return;

        vm.prank(actor);
        vault.deposit(claimable, actor);

        ghostPendingDeposits[actor] -= claimable;
    }

    function handler_claimRedeem(uint256 actorSeed) public {
        address actor = actors[actorSeed % actors.length];
        uint256 claimable = vault.claimableRedeemRequest(0, actor);
        if (claimable == 0) return;

        vm.prank(actor);
        vault.redeem(claimable, actor, actor);

        ghostPendingRedeems[actor] -= claimable;
    }

    // --- Time manipulation ---

    function handler_warpTime(uint256 delta) public {
        delta = bound(delta, 1, 30 days);
        vm.warp(block.timestamp + delta);
    }

    // --- Yield simulation ---

    function handler_simulateYield(uint256 amount) public {
        amount = bound(amount, 1, 100_000e18);
        asset.mint(address(vault), amount);
    }

    // --- Round-trip check ---

    function handler_requestClaimRoundTrip(
        uint256 actorSeed,
        uint256 assets
    ) public {
        address actor = actors[actorSeed % actors.length];
        assets = bound(assets, 1e6, asset.balanceOf(actor) / 4);
        if (assets == 0) return;

        uint256 balanceBefore = asset.balanceOf(actor);

        // Request deposit
        vm.prank(actor);
        vault.requestDeposit(assets, actor, actor);

        // Simulate immediate fulfillment
        vm.prank(operator);
        vault.fulfillDeposit(actor, assets);

        // Claim shares
        vm.prank(actor);
        uint256 shares = vault.deposit(assets, actor);

        // Immediately redeem
        vm.prank(actor);
        vault.redeem(shares, actor, actor);

        // Should not profit from the round-trip
        assert(asset.balanceOf(actor) <= balanceBefore);
    }
}

The critical handler here is handler_fulfillDeposit / handler_fulfillRedeem. Without these, the fuzzer would submit requests that sit in pending forever, and the claim-phase invariants would never fire. By giving the fuzzer control over when fulfillment happens, we test interleaved scenarios: partial fulfillments, fulfillments after yield accrual, fulfillments with stale exchange rates.

The round-trip handler (handler_requestClaimRoundTrip) compresses the full lifecycle into a single call, verifying that the async flow doesn't create value even under ideal timing conditions.

Running the campaign

Foundry (quick smoke test)

forge test --match-contract CryticTester --fuzz-runs 10000

For async vaults, Foundry's default sequence length might not be enough to drive requests through the full lifecycle. Consider increasing runs if you see low property coverage.

Medusa (broad coverage)

{
  "fuzzing": {
    "targetContracts": ["CryticTester"],
    "testLimit": 1000000,
    "callSequenceLength": 150,
    "workers": 8,
    "corpusDirectory": "corpus-medusa"
  }
}
medusa fuzz

We increase callSequenceLength to 150 because the async lifecycle requires at least three calls (request → fulfill → claim) to complete one cycle. Longer sequences let the fuzzer explore multiple interleaved cycles.

Echidna (deep exploration)

# echidna.yaml
testMode: assertion
testLimit: 3000000
seqLen: 200
corpusDir: "corpus-echidna"
echidna . --contract CryticTester --config echidna.yaml

Echidna's sequence shrinking is especially useful for async vaults. When a property fails, the shrunk sequence shows the minimal request/fulfill/claim pattern that triggers the bug. For more on choosing between fuzzers, see our comparison guide.

Interpreting results

Lifecycle ordering violations

If invariant_lifecycleOrdering fails, the vault allows claiming without prior fulfillment. This usually means the claim function doesn't properly check whether the operator has fulfilled the request. Look at the deposit() / redeem() overrides and verify they check claimableDepositRequest() before proceeding.

Pending/claimable inconsistency

If invariant_pendingGeClaimableDeposit fails, the claimable amount exceeds the pending amount. This can happen when:

  • Fulfillment logic double-counts. The operator fulfills a request, and the fulfillment function adds to claimable without properly tracking the pending reduction.
  • Cancellation doesn't reduce claimable. If a request is partially fulfilled and then cancelled, the claimable portion might not get cleared.

Solvency with escrow

If invariant_solvencyWithEscrow fails while standard solvency passes, the vault's accounting doesn't include escrowed assets. This is the most common class of bug in async vault implementations. The totalAssets() function must account for assets that entered the vault via requestDeposit() but haven't turned into shares yet.

The Centrifuge case study documents a real instance of this class of bug found through exactly these properties.

Beyond the basics

Once your core ERC-7540 properties pass, consider:

  • Test partial fulfillments. Modify the fulfill handler to fulfill a random fraction of the pending amount, not just the full amount.
  • Test multiple concurrent requests. Have the fuzzer submit several requests from the same controller before the operator fulfills any, then fulfill them in different orders.
  • Add ERC-4626 properties. ERC-7540 vaults must still satisfy all ERC-4626 invariants. Layer the ERC-4626 property suite on top of the async properties.
  • Use the public properties repo. Recon's ERC-7540 reusable properties are battle-tested on production vaults and ready to drop into your project.

Async vaults add real complexity to the fuzzing setup, but the payoff is proportional. The bugs that live in the request/fulfill/claim lifecycle are exactly the kind that slip through unit tests and manual review.

If you're building an ERC-7540 vault and want expert help designing the invariant suite, request an audit with Recon. We built the reference properties for this standard and have applied them to production deployments.

Related Posts

Related Glossary Terms

Related Open Source

Secure your async vault before launch