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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions