Skip to content

[ERC-4337 / ERC-7562] nonReentrant on validatePaymasterUserOp violates storage rules — Pimlico silently rejects UserOps #5

Description

@osr21

Symptom

A USDC-funded Paymaster contract that uses the nonReentrant modifier on validatePaymasterUserOp causes all UserOperations to be silently rejected by Pimlico (and other ERC-7562-compliant bundlers) during simulation. The error message is generic:

AA33 reverted (or) validatePaymasterUserOp simulation failed

No on-chain revert occurs — the rejection happens at the bundler's simulation step, before the UserOp is ever broadcast.

Root cause

ERC-7562 restricts what storage an unstaked paymaster may write during validation:

An unstaked paymaster may only write to storage slots associated with the userOp.sender (sender-keyed storage).

A standard nonReentrant modifier writes to a global boolean (_locked), which is NOT keyed on userOp.sender. This write is detected during simulation and the UserOp is rejected.

Affected code pattern

// ❌ WRONG — violates ERC-7562 for unstaked paymasters
function validatePaymasterUserOp(...) external nonReentrant onlyEntryPoint ... {
    // _locked (global bool) write is forbidden during validation
}

Fix

Remove nonReentrant from validatePaymasterUserOp. The onlyEntryPoint modifier already provides sufficient protection — the EntryPoint never re-enters its own validation phase:

// ✅ CORRECT
function validatePaymasterUserOp(...) external onlyEntryPoint whenNotPaused ... {
    // Only writes locked[user] — sender-keyed, allowed by ERC-7562
    locked[user] += maxUsdcCost;
}

nonReentrant is safe (and good practice) on user-callable functions like deposit(), withdraw(), deductGas(), and postOp() — just not on validatePaymasterUserOp.

Staking as alternative

Staking the paymaster in the EntryPoint via addStake() allows global storage writes. However, staking requires a dedicated function in the contract and a separate ETH deposit. Removing nonReentrant from validatePaymasterUserOp is simpler and fully correct.

Related: deductGas must check unlocked balance

If your paymaster supports both a legacy relayer path (deductGas) and the ERC-4337 path, make sure deductGas checks the available (unlocked) balance, not the total balance:

// ❌ WRONG — can double-charge if a UserOp is pending
require(balances[user] >= usdcCost, "underfunded");

// ✅ CORRECT — respects in-flight reservations
uint256 available = balances[user] > locked[user] ? balances[user] - locked[user] : 0;
require(available >= usdcCost, "underfunded");

Without this check, a concurrent pending UserOp and a relayer deduction can violate the locked ≤ balance invariant, leading to double-charging.

References

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions