2026-03-31·13 min read

How to fuzz ERC-4337 account abstraction wallets

How to fuzz ERC-4337 account abstraction wallets

ERC-4337 brings account abstraction to Ethereum without protocol changes. Smart contract wallets (also called smart accounts) validate their own transactions, manage their own nonce logic, and can delegate gas payment to paymasters. This flexibility is powerful, but it introduces an entirely different class of invariants compared to DeFi token math. The bugs here are about authorization boundaries, gas accounting, and nonce ordering.

Fuzzing ERC-4337 wallets means verifying that only authorized signers can execute operations, that gas costs never exceed budgets, that nonces prevent replay attacks, and that paymasters stay solvent. This guide walks through identifying those invariants, writing properties with Chimera, and running campaigns that stress the validation and execution paths.

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

What ERC-4337 guarantees

The EIP-4337 specification defines a system where user operations go through an EntryPoint contract. A bundler collects these operations from an off-chain mempool and submits them to the EntryPoint, which calls validateUserOp() on the target account to decide whether to accept the operation. If validation passes, the EntryPoint executes the operation's calldata against the account. Gas accounting happens at the EntryPoint level.

The guarantees that matter most for security:

  1. Validation correctness. Only operations signed by authorized signers should pass validateUserOp(). All others must be rejected.
  2. Gas accounting. The actual gas cost charged to the account (or paymaster) must never exceed maxGasCost specified in the user operation.
  3. Nonce monotonicity. Each nonce key's sequence value must increase monotonically. No gaps, no replays.
  4. Paymaster solvency. If a paymaster sponsors gas, its deposit in the EntryPoint must cover the cost. The paymaster must not go negative.
  5. Execution atomicity. If execution reverts, the account's state must not change (beyond gas consumption and nonce increment).
  6. No unauthorized execution. An invalid signature must never result in state-changing execution on the account.

These are fundamentally different from DeFi invariants. There's no share accounting or exchange rates here. Instead, we're testing authorization logic, cryptographic verification, and gas math. The fuzzer's job is to find inputs that bypass validation or break the gas model.

Key invariants

Validation correctness

The most important property. validateUserOp() must return a success code only when the operation carries a valid signature from an authorized signer. For any other signature, it must return a failure code or revert.

validateUserOp(op_with_invalid_sig) != SIG_VALIDATION_SUCCESS

Gas accounting

The EntryPoint tracks gas usage and charges the account (or paymaster). The actual gas cost must never exceed the maximum the user operation authorized.

actualGasCost <= preVerificationGas + verificationGasLimit + callGasLimit

Nonce monotonicity

ERC-4337 uses a 2D nonce scheme: a 192-bit key and a 64-bit sequence. For each key, the sequence must be strictly incrementing with no gaps.

nonce(key)_after == nonce(key)_before + 1  // after successful op

Paymaster solvency

If a paymaster validates a user operation, it commits to paying the gas. Its EntryPoint deposit must cover the commitment.

entryPoint.balanceOf(paymaster) >= 0  // Never negative after postOp

Execution atomicity

If the execution phase of a user operation reverts, only the execution effects should roll back. The nonce increment and gas charge from validation must persist.

Setting up Chimera

We use the Chimera framework with a setup that includes a mock EntryPoint, a target account implementation, and a paymaster. The beginner's guide covers basic installation.

The file structure:

test/
  invariants/
    Setup.sol          // Deploy EntryPoint, account, paymaster
    TargetFunctions.sol // UserOp construction and submission
    Properties.sol      // Validation and gas invariants
    CryticTester.sol    // Entrypoint for Echidna/Medusa

Setup

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

import {EntryPoint} from "account-abstraction/core/EntryPoint.sol";
import {SimpleAccount} from "src/SimpleAccount.sol";
import {SimplePaymaster} from "src/SimplePaymaster.sol";
import {BaseSetup} from "@chimera/BaseSetup.sol";

abstract contract Setup is BaseSetup {
    EntryPoint internal entryPoint;
    SimpleAccount internal account;
    SimplePaymaster internal paymaster;

    address internal owner;
    uint256 internal ownerKey;
    address internal unauthorized;
    uint256 internal unauthorizedKey;

    // Ghost tracking
    uint256 internal lastNonce;
    uint256 internal paymasterDepositBefore;

    function setup() internal virtual override {
        entryPoint = new EntryPoint();

        // Create owner keypair
        ownerKey = 0xA11CE;
        owner = vm.addr(ownerKey);

        // Create unauthorized keypair
        unauthorizedKey = 0xBAD;
        unauthorized = vm.addr(unauthorizedKey);

        // Deploy account owned by owner
        account = new SimpleAccount(entryPoint, owner);

        // Deploy and fund paymaster
        paymaster = new SimplePaymaster(entryPoint);
        entryPoint.depositTo{value: 10 ether}(address(paymaster));

        // Fund account
        entryPoint.depositTo{value: 10 ether}(address(account));
        vm.deal(address(account), 100 ether);

        lastNonce = account.getNonce();
        paymasterDepositBefore = entryPoint.balanceOf(address(paymaster));
    }
}

We create two keypairs: the authorized owner and an unauthorized signer. The fuzzer will try both, and our properties verify that only the owner's signatures pass validation. The paymaster gets a deposit in the EntryPoint so we can test gas sponsorship flows.

Writing properties

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

import {Setup} from "./Setup.sol";
import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol";

abstract contract Properties is Setup {
    // P-1: Nonce monotonicity
    function invariant_nonceMonotonic() public view returns (bool) {
        uint256 currentNonce = account.getNonce();
        return currentNonce >= lastNonce;
    }

    // P-2: Paymaster solvency
    function invariant_paymasterSolvent() public view returns (bool) {
        return entryPoint.balanceOf(address(paymaster)) >= 0;
    }

    // P-3: Account has valid EntryPoint reference
    function invariant_entryPointConsistency() public view returns (bool) {
        return address(account.entryPoint()) == address(entryPoint);
    }

    // P-4: Owner hasn't changed unexpectedly
    function invariant_ownerUnchanged() public view returns (bool) {
        return account.owner() == owner;
    }
}

P-1 tracks nonce progression. P-2 verifies the paymaster never goes insolvent. P-4 guards against unauthorized ownership transfers. The validation correctness check (the most critical property) is tested inline in the handlers, since it requires constructing specific user operations.

Writing target functions

Building user operations for the fuzzer is more involved than wrapping a simple token transfer. Each handler constructs a PackedUserOperation, signs it, and submits it through the EntryPoint.

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

import {Properties} from "./Properties.sol";
import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

abstract contract TargetFunctions is Properties {

    // --- Valid operations ---

    function handler_executeWithValidSig(
        address dest,
        uint256 value,
        bytes calldata funcData
    ) public {
        value = bound(value, 0, 1 ether);

        PackedUserOperation memory op = _buildUserOp(
            address(account),
            account.getNonce(),
            abi.encodeCall(account.execute, (dest, value, funcData))
        );

        bytes32 opHash = entryPoint.getUserOpHash(op);
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(
            ownerKey,
            MessageHashUtils.toEthSignedMessageHash(opHash)
        );
        op.signature = abi.encodePacked(r, s, v);

        PackedUserOperation[] memory ops = new PackedUserOperation[](1);
        ops[0] = op;

        uint256 nonceBefore = account.getNonce();

        try entryPoint.handleOps(ops, payable(address(this))) {
            // Nonce must have incremented
            assert(account.getNonce() == nonceBefore + 1);
            lastNonce = account.getNonce();
        } catch {
            // Execution revert is fine, but nonce should still increment
            // if validation passed
        }
    }

    // --- Invalid signature (must always fail) ---

    function handler_executeWithInvalidSig(
        address dest,
        uint256 value
    ) public {
        value = bound(value, 0, 1 ether);

        PackedUserOperation memory op = _buildUserOp(
            address(account),
            account.getNonce(),
            abi.encodeCall(account.execute, (dest, value, ""))
        );

        bytes32 opHash = entryPoint.getUserOpHash(op);
        // Sign with unauthorized key
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(
            unauthorizedKey,
            MessageHashUtils.toEthSignedMessageHash(opHash)
        );
        op.signature = abi.encodePacked(r, s, v);

        PackedUserOperation[] memory ops = new PackedUserOperation[](1);
        ops[0] = op;

        uint256 nonceBefore = account.getNonce();

        try entryPoint.handleOps(ops, payable(address(this))) {
            // If this succeeds, unauthorized execution happened
            assert(false); // P-CRITICAL: unauthorized sig must not execute
        } catch {
            // Expected: invalid sig should revert
            assert(account.getNonce() == nonceBefore);
        }
    }

    // --- Nonce replay attempt ---

    function handler_replayNonce(uint256 staleNonce) public {
        staleNonce = bound(staleNonce, 0, account.getNonce());
        if (staleNonce >= account.getNonce()) return; // Not actually stale

        PackedUserOperation memory op = _buildUserOp(
            address(account),
            staleNonce,
            abi.encodeCall(account.execute, (address(0x1), 0, ""))
        );

        bytes32 opHash = entryPoint.getUserOpHash(op);
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(
            ownerKey,
            MessageHashUtils.toEthSignedMessageHash(opHash)
        );
        op.signature = abi.encodePacked(r, s, v);

        PackedUserOperation[] memory ops = new PackedUserOperation[](1);
        ops[0] = op;

        // Stale nonce must revert
        try entryPoint.handleOps(ops, payable(address(this))) {
            assert(false); // Replay must not succeed
        } catch {
            // Expected
        }
    }

    // --- Paymaster-sponsored operation ---

    function handler_paymasterSponsored(address dest) public {
        uint256 depositBefore = entryPoint.balanceOf(address(paymaster));

        PackedUserOperation memory op = _buildUserOp(
            address(account),
            account.getNonce(),
            abi.encodeCall(account.execute, (dest, 0, ""))
        );
        op.paymasterAndData = abi.encodePacked(address(paymaster));

        bytes32 opHash = entryPoint.getUserOpHash(op);
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(
            ownerKey,
            MessageHashUtils.toEthSignedMessageHash(opHash)
        );
        op.signature = abi.encodePacked(r, s, v);

        PackedUserOperation[] memory ops = new PackedUserOperation[](1);
        ops[0] = op;

        try entryPoint.handleOps(ops, payable(address(this))) {
            lastNonce = account.getNonce();
            // Paymaster deposit should have decreased
            uint256 depositAfter = entryPoint.balanceOf(address(paymaster));
            assert(depositAfter <= depositBefore);
            paymasterDepositBefore = depositAfter;
        } catch {}
    }

    // --- Time manipulation ---

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

    // --- Helper ---

    function _buildUserOp(
        address sender,
        uint256 nonce,
        bytes memory callData
    ) internal pure returns (PackedUserOperation memory) {
        return PackedUserOperation({
            sender: sender,
            nonce: nonce,
            initCode: "",
            callData: callData,
            accountGasLimits: bytes32(abi.encodePacked(uint128(200000), uint128(200000))),
            preVerificationGas: 50000,
            gasFees: bytes32(abi.encodePacked(uint128(1 gwei), uint128(1 gwei))),
            paymasterAndData: "",
            signature: ""
        });
    }
}

Key design decisions in these handlers:

  • handler_executeWithInvalidSig is the most important handler. It constructs a properly formatted user operation but signs it with the wrong key. If this ever succeeds, the wallet has a critical authorization bypass.
  • handler_replayNonce tries to re-submit an operation with a nonce that was already consumed. This tests the replay protection.
  • handler_paymasterSponsored verifies that paymaster-sponsored operations correctly deduct from the paymaster's deposit.

What makes ERC-4337 different to fuzz

There are several properties of account abstraction that make fuzzing it different from DeFi token contracts:

Cryptographic verification in the loop. Every user operation needs a valid ECDSA signature. The fuzzer can't just throw random bytes at validateUserOp() and expect meaningful coverage. We use vm.sign() to produce valid signatures for the authorized key and invalid-but-well-formed signatures for the unauthorized key.

Two-phase execution. ERC-4337 separates validation from execution. A bug might live in validation (accepting bad signatures), in execution (side effects on revert), or in the boundary between them (gas accounting across phases). Our handlers test all three.

EntryPoint as intermediary. Unlike direct contract calls, everything goes through the EntryPoint. The fuzzer must construct complete user operations and submit them via handleOps(), not call the account directly. This adds complexity but also tests the real execution path.

Gas is a first-class concern. In DeFi fuzzing, gas is usually irrelevant to correctness. In ERC-4337, gas accounting is a security property. A paymaster that undercharges goes insolvent. An account that overestimates gas wastes user funds.

Running the campaign

Foundry (quick smoke test)

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

UserOp construction is expensive per-call, so 5000 runs gives reasonable coverage without long waits.

Medusa (broad coverage)

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

We use a shorter callSequenceLength than DeFi campaigns because each call is more expensive to construct and execute.

Echidna (deep exploration)

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

Echidna's shrinking is especially useful here. When a validation bypass is found, the shrunk sequence shows the exact operation that bypassed the check. For a comparison of fuzzers, see our practical guide.

Interpreting results

Validation bypass

If handler_executeWithInvalidSig triggers its assertion failure, an unauthorized signer executed an operation on the account. This is a critical vulnerability. The most common causes:

  • Signature recovery returning the wrong address. Using ecrecover directly without checking for zero-address returns or malleable signatures.
  • Missing validation logic. The validateUserOp() function returns success unconditionally for certain operation types.
  • Signature type confusion. If the account supports multiple signature schemes (multisig, ERC-1271), the selector logic might fall through to a permissive default.

Nonce replay

If handler_replayNonce succeeds, the nonce validation is broken. This allows transaction replay, where a previously executed operation gets re-executed. Check the nonce validation in both the account and the EntryPoint integration.

Paymaster drain

If the paymaster deposit goes to zero or the invariant_paymasterSolvent check fails, the gas accounting allows operations that cost more than the paymaster committed to. Look at the postOp() implementation and verify it correctly accounts for the actual gas consumed.

Beyond the basics

Once core properties pass, consider:

  • Add multi-sig validation. If your account supports multiple owners or threshold signatures, add handlers that test authorization with different signer combinations.
  • Test account upgrades. If the account is upgradeable (UUPS or similar), add a handler that upgrades the implementation and verify all properties still hold.
  • Test batched operations. Submit multiple user operations in a single handleOps() call and verify that nonce ordering and gas accounting work correctly across the batch.
  • Run in CI. Set up continuous fuzzing for every PR that touches wallet logic.

ERC-4337 fuzzing is different from DeFi fuzzing, but the methodology is the same. Define what must always be true, write handlers that exercise the full surface, and let the fuzzer find the states where your assumptions break.

If you're building an ERC-4337 wallet and want an expert invariant suite, request an audit with Recon. Authorization and gas accounting bugs in smart wallets have direct financial impact, and fuzzing is the best way to find them before an attacker does.

Related Posts

Related Glossary Terms

Secure your smart wallet before deployment