BchainPay logoBchainPay
EngineeringEthereumAccount AbstractionERC-4337Stablecoins

ERC-4337 smart account checkout: UserOps, bundlers, USDC paymasters

How ERC-4337 routes UserOperations through a bundler to the EntryPoint, sponsors gas with a USDC paymaster, and what breaks in production.

By Cipher · Founding engineer, BchainPay8 min read
Illustration for ERC-4337 smart account checkout: UserOps, bundlers, USDC paymasters

ERC-4337 is the standard that powers smart-contract wallets without touching Ethereum's consensus layer. If your checkout handles EOA signers today, you will soon encounter users who sign with Safe, Biconomy Nexus, ZeroDev Kernel, or any wallet built on the UserOperation infrastructure. Safe alone guards over $100B in assets, and smart-account wallets now process tens of thousands of transactions per day on mainnet and L2s.

This post covers the full stack: what a UserOperation contains, how a bundler validates and submits it, how a USDC paymaster sponsors ETH gas, and the failure modes we hit integrating smart-account support into BchainPay.

The four ERC-4337 actors#

Every smart-account transaction routes through four components:

  • Smart Account (SA) — a contract at the user's address. It validates signatures and executes arbitrary calls. Common implementations: Safe modules, Biconomy Nexus, ZeroDev Kernel, Alchemy Light Account.
  • Bundler — an off-chain node that accepts UserOperations over JSON-RPC, simulates them for validity, batches several into one handleOps() transaction, and fronts the ETH base gas cost.
  • EntryPoint0x0000000071727De22E5E9d8BAf0edAc6f37da032 (v0.7) — a single audited contract at the same address on every EVM chain that supports ERC-4337. It mediates validation and execution for every op.
  • Paymaster — an optional contract the EntryPoint calls to sponsor (or re-denominate) the gas cost. A USDC paymaster covers ETH gas and pulls stablecoins from the user's SA in a post-execution hook.

The execution path:

SA signs UserOp
  → Bundler simulates + batches
    → EntryPoint.handleOps()
      → SA.validateUserOp()          (signature check)
      → Paymaster.validatePaymasterUserOp()  (optional sponsorship)
      → SA.execute()                 (the actual USDC transfer)
      → Paymaster.postOp()           (USDC pulled from SA)

UserOperation v0.7 struct#

ERC-4337 v0.7 split the original initCode and paymasterAndData blobs into named fields. The struct you'll build in TypeScript:

type UserOperation = {
  sender:                        Address;  // the smart account
  nonce:                         bigint;   // per-SA nonce from EntryPoint
  factory:                       Address;  // SA factory (zero if already deployed)
  factoryData:                   Hex;      // calldata for factory.createAccount()
  callData:                      Hex;      // encoded SA.execute() payload
  callGasLimit:                  bigint;   // gas for the SA call frame
  verificationGasLimit:          bigint;   // gas for validateUserOp + paymaster validation
  preVerificationGas:            bigint;   // bundler overhead, charged unconditionally
  maxFeePerGas:                  bigint;   // EIP-1559 semantics
  maxPriorityFeePerGas:          bigint;
  paymaster:                     Address;  // zero if user pays ETH directly
  paymasterVerificationGasLimit: bigint;
  paymasterPostOpGasLimit:       bigint;
  paymasterData:                 Hex;      // passed to validatePaymasterUserOp()
  signature:                     Hex;      // verified by SA.validateUserOp()
};

Two fields trip up developers coming from EOA integrations:

  1. nonce is per-account, stored by the EntryPoint. Call EntryPoint.getNonce(sender, key) to fetch the current value. Using a stale nonce produces AA25 invalid account nonce at bundler validation.
  2. preVerificationGas is a bundler-defined overhead charge — not a number you simulate yourself. Always fetch it from eth_estimateUserOperationGas; hand-computing it causes silent rejections.

Building checkout callData#

To charge a user for an order in USDC, encode the call through the SA's executor:

import { encodeFunctionData, parseUnits } from 'viem';
 
const transferData = encodeFunctionData({
  abi: erc20Abi,
  functionName: 'transfer',
  args: [MERCHANT_ADDRESS, parseUnits('49.99', 6)],
});
 
const callData = encodeFunctionData({
  abi: saAbi,               // the specific SA's ABI
  functionName: 'execute',
  args: [USDC_ADDRESS, 0n, transferData],
});

For an atomic approve-then-transfer in one round-trip, use executeBatch:

const callData = encodeFunctionData({
  abi: saAbi,
  functionName: 'executeBatch',
  args: [[
    { target: USDC_ADDRESS, value: 0n, data: approveData },
    { target: USDC_ADDRESS, value: 0n, data: transferData },
  ]],
});

The execute / executeBatch ABI is not uniform across SA implementations. Safe uses execTransaction; Biconomy Nexus and ZeroDev Kernel use execute with different selectors. Detect the SA type from its bytecode hash or query supportsInterface if the SA implements ERC-165; BchainPay does this automatically when it builds the call_data field of the payment intent.

USDC paymaster: how gas sponsorship works#

The EntryPoint calls two hooks on the paymaster contract:

  1. validatePaymasterUserOp — called during the validation phase, before execution. The paymaster checks the op, decides whether to sponsor it, and returns a context blob that is forwarded to postOp.
  2. postOp — called after execution. For a USDC paymaster, this is where the token pull happens. If postOp reverts, the entire execution is rolled back but the bundler still charges gas.

The paymasterData layout is paymaster-specific. BchainPay's USDC paymaster expects a short-lived ECDSA approval:

// BchainPay USDC paymaster data layout
const paymasterData = encodeAbiParameters(
  [{ type: 'uint48' }, { type: 'uint48' }, { type: 'bytes' }],
  [validAfter, validUntil, paymasterSignature],
);

validAfter and validUntil are UNIX timestamps. We use a 90-second window. If the bundler can't include the op before expiry, validatePaymasterUserOp reverts with AA32 paymaster expired or not due.

The paymaster must maintain an ETH deposit in the EntryPoint. BchainPay monitors EntryPoint.balanceOf(paymaster) and auto-refills when it falls below the high-watermark. An empty deposit blocks every sponsored op until it is replenished.

Bundler RPC reference#

Bundlers expose a JSON-RPC endpoint. The three calls for payment flows:

eth_estimateUserOperationGas#

Always call this before signing. Pass the partially assembled op with a dummy signature (most SA implementations accept it in simulation context):

curl https://api.bchainpay.com/v1/bundler \
  -H 'Content-Type: application/json' \
  -d '{
    "jsonrpc": "2.0", "id": 1,
    "method": "eth_estimateUserOperationGas",
    "params": [
      {
        "sender":         "0xSA…",
        "nonce":          "0x3",
        "callData":       "0x…",
        "paymaster":      "0xPM…",
        "paymasterData":  "0x…",
        "signature":      "0xDEAD"
      },
      "0x0000000071727De22E5E9d8BAf0edAc6f37da032"
    ]
  }'

Response:

{
  "callGasLimit":                  "0x12a05",
  "verificationGasLimit":          "0x186a0",
  "preVerificationGas":            "0xc350",
  "paymasterVerificationGasLimit": "0x9c40",
  "paymasterPostOpGasLimit":       "0x4e20"
}

Add 15–20% headroom to each limit before signing. Gas prices and storage slots can change between simulation and block inclusion.

eth_sendUserOperation#

curl https://api.bchainpay.com/v1/bundler \
  -H 'Content-Type: application/json' \
  -d '{
    "jsonrpc": "2.0", "id": 2,
    "method": "eth_sendUserOperation",
    "params": [
      { /* fully signed UserOperation */ },
      "0x0000000071727De22E5E9d8BAf0edAc6f37da032"
    ]
  }'
# Returns: { "result": "0xUSEROP_HASH" }

The return value is the UserOperation hash, not a transaction hash. Store it to track the op.

eth_getUserOperationByHash#

curl https://api.bchainpay.com/v1/bundler \
  -H 'Content-Type: application/json' \
  -d '{
    "jsonrpc": "2.0", "id": 3,
    "method": "eth_getUserOperationByHash",
    "params": ["0xUSEROP_HASH"]
  }'
# Returns: { transactionHash, blockNumber, entryPoint, … } once mined
# Returns: null while still pending

Poll until transactionHash is non-null, then confirm via the EntryPoint event log.

Confirming settlement via EntryPoint events#

Do not rely on the bundler poll alone. Filter the UserOperationEvent emitted by the EntryPoint for every included op:

import { parseAbiItem } from 'viem';
 
const logs = await client.getLogs({
  address: '0x0000000071727De22E5E9d8BAf0edAc6f37da032',
  event: parseAbiItem(
    'event UserOperationEvent(' +
    '  bytes32 indexed userOpHash,' +
    '  address indexed sender,' +
    '  address indexed paymaster,' +
    '  uint256 nonce,' +
    '  bool success,' +
    '  uint256 actualGasCost,' +
    '  uint256 actualGasUsed' +
    ')'
  ),
  args: { userOpHash: pendingHash },
  fromBlock: receiptBlock,
});
 
if (!logs[0]?.args.success) {
  // execution reverted; USDC transfer did NOT happen
  // check UserOperationRevertReason event for inner revert data
}

success: false means the EntryPoint included the op and charged gas, but SA.execute() reverted. The companion UserOperationRevertReason event carries the inner revert data. BchainPay surfaces this as payment_intent.execution_failed with the decoded reason attached.

BchainPay smart account detection#

When a payment intent is created for an on-chain address, BchainPay calls eth_getCode on the address. If the result is non-empty, the intent includes UserOp routing metadata:

{
  "id": "pi_01HWX…",
  "checkout_mode": "userop",
  "network": "base",
  "entrypoint": "0x0000000071727De22E5E9d8BAf0edAc6f37da032",
  "bundler_url": "https://api.bchainpay.com/v1/bundler",
  "paymaster_url": "https://api.bchainpay.com/v1/paymaster",
  "paymaster_address": "0xBcPm…",
  "call_data": "0x…",
  "gas_estimates": {
    "call_gas_limit":                  "0x12a05",
    "verification_gas_limit":          "0x186a0",
    "pre_verification_gas":            "0xc350",
    "paymaster_verification_gas_limit":"0x9c40",
    "paymaster_post_op_gas_limit":     "0x4e20"
  }
}

The checkout SDK reads checkout_mode and routes accordingly. EOA wallets receive checkout_mode: "eoa" and use the standard eth_sendRawTransaction path. Merchant integration code does not change.

Failure modes before going live#

Simulation-execution state divergence. Bundlers simulate against the current block. If a USDC balance or paymaster allowance changes in the same block before handleOps() executes, the op reverts on-chain despite passing simulation. Avoid racing approvals across multiple bundlers for the same SA simultaneously.

Counterfactual address mismatch. When deploying a new SA within the same UserOp (factory is non-zero), sender must equal the CREATE2 address the factory will deploy to. A single constructor argument difference shifts the address and produces AA10 sender already constructed — or silently deploys to the wrong address if the factory doesn't revert.

Bundler MEV reordering. Bundlers may reorder ops inside a batch by tip size. For price-locked checkout windows, set a tight validUntil in paymasterData and accept that ops submitted close to expiry may be dropped if the bundler can't include them in time.

AA31 paymaster deposit too low. The paymaster's prefunded ETH balance in the EntryPoint fell below the max possible gas cost for the op. Monitor EntryPoint.balanceOf(paymaster) with an alert at 2x the average gas cost; depletion during a traffic spike blocks every sponsored op until replenished.

Key takeaways#

  • ERC-4337 adds a parallel execution path — UserOperation, bundler, EntryPoint, Smart Account — with no consensus-layer change. The EntryPoint v0.7 is a shared singleton at 0x0000000071727De22E5E9d8BAf0edAc6f37da032 across all EVM chains.
  • The nonce is per-SA and per-key-slot, stored inside the EntryPoint; do not manage it alongside your EOA nonce pool.
  • Always call eth_estimateUserOperationGas before signing; add 15–20% headroom to each of the five gas fields.
  • A USDC paymaster covers ETH gas and recoups stablecoins in postOp; sign a short-lived approval into paymasterData to prevent stale sponsorships.
  • Confirm settlement by filtering UserOperationEvent on the EntryPoint, not just the bundler poll; success: false means gas was consumed but the USDC transfer did not happen.
  • Detect smart accounts with eth_getCode server-side and switch checkout modes transparently so merchant integration code stays unchanged.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading