Skip to content

bug: ERC-4337 Paymaster on Arc Testnet — validatePaymasterUserOp must not use ReentrancyGuard; violates ERC-7562, Pimlico silently rejects #141

@osr21

Description

@osr21

Summary

When building an ERC-4337 Paymaster for gasless bridging on Arc Testnet, we found that adding OpenZeppelin's ReentrancyGuard (nonReentrant) to validatePaymasterUserOp causes Pimlico to silently reject all UserOps at simulation time. The error message gives no indication that ReentrancyGuard is the cause.


Root cause

ERC-7562 ("Account Abstraction Validation Scope Rules") forbids unstaked paymasters from writing to global (non-associated) storage during the validation phase. OpenZeppelin's nonReentrant modifier writes a _locked / _status boolean to contract storage — this is a global storage write that falls outside the allowed storage slots for an unstaked paymaster.

Pimlico's bundler enforces ERC-7562 by tracing validatePaymasterUserOp via debug_traceCall before including a UserOp in a bundle. When it detects the forbidden SSTORE to _status, it rejects the UserOp. The rejection propagates as a generic validation failure with no pointer to the actual cause.


Reproduction

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@account-abstraction/contracts/core/BasePaymaster.sol";

contract MyPaymaster is BasePaymaster, ReentrancyGuard {

    function validatePaymasterUserOp(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash,
        uint256 maxCost
    )
        external
        override
        nonReentrant          // ← VIOLATION: writes _status to global storage
        returns (bytes memory context, uint256 validationData)
    {
        // ...
    }
}

Symptom from Pimlico / eth_estimateUserOperationGas:

UserOperation reverted during simulation with reason: paymaster validation failed

No stack trace, no mention of nonReentrant, no ERC-7562 citation — the error is essentially opaque.


Why nonReentrant seems necessary but isn't

Developers add nonReentrant out of caution. In practice, the EntryPoint (v0.7) never re-enters validatePaymasterUserOp — it calls validation once per UserOp in a single top-level call. The onlyEntryPoint modifier is sufficient protection:

  1. Only the EntryPoint can call validatePaymasterUserOp
  2. The EntryPoint does not re-enter it
  3. Therefore reentrancy via validatePaymasterUserOp is not possible

Fix

Remove nonReentrant from validatePaymasterUserOp. Use onlyEntryPoint for access control — ERC-7562 compliant, sufficient protection.

function validatePaymasterUserOp(
    PackedUserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 maxCost
)
    external
    override
    onlyEntryPoint          // ✓ sufficient — EntryPoint never re-enters validation
    returns (bytes memory context, uint256 validationData)
{
    // ERC-7562 compliant: no global storage writes in validation phase
}

nonReentrant can still be applied to postOp and other non-validation functions. ERC-7562 only restricts the validation phase.


Related: entryPoint as constant vs immutable

A separate Arc-specific issue that typically surfaces alongside this one: if your Paymaster has two immutable variables (e.g. entryPoint + usdc), the Solidity compiler emits a 0x60c0-prefixed constructor. This init pattern silently reverts on Arc Testnet and Avalanche Fuji — the deployment transaction succeeds but the contract is empty.

Fix: keep entryPoint as a Solidity constant (hardcoded in bytecode), leaving only one immutable:

// ✗ Two immutables — 0x60c0 constructor — silently reverts on Arc / Fuji
IEntryPoint public immutable entryPoint;
IERC20       public immutable usdc;

// ✓ One immutable + one constant — 0x60a0 constructor — works everywhere
IEntryPoint public constant entryPoint =
    IEntryPoint(0x0000000071727De22E5E9d8BAf0edAc6f37da032);
IERC20 public immutable usdc;

See #109 for the full PUSH0 / evmVersion / constructor pattern issues.


Environment

  • Arc Testnet (Chain ID 5042002), evmVersion: "paris", solc 0.8.20
  • Pimlico bundler v2, ERC-4337 EntryPoint v0.7 (0x0000000071727De22E5E9d8BAf0edAc6f37da032)
  • Paymaster deployed via pnpm --filter @workspace/scripts run deploy-paymaster

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions