Account Abstraction (ERC-4337)

Build seamless Web3 experiences with smart accounts, gas sponsorship, and bundled transactions. No browser extension required.

What is ERC-4337?

ERC-4337 introduces account abstraction to Ethereum without consensus-layer changes. Instead of requiring every user to have an EOA (Externally Owned Account) that pays gas with ETH, ERC-4337 enables smart contract wallets with programmable logic:

  • Smart Accounts -- Wallets are smart contracts. They can enforce multi-sig, spending limits, session keys, social recovery, and any custom logic.
  • UserOperations -- Instead of sending transactions directly, users submit UserOperations (UserOps) to a mempool. A bundler batches multiple UserOps into a single on-chain transaction.
  • Paymasters -- Third-party contracts that sponsor gas on behalf of users. Your users never need to hold ETH for gas fees.
  • Bundlers -- Specialized nodes that collect UserOps, validate them, and submit them on-chain via the EntryPoint contract.

Architecture

User -> UserOperation -> Bundler -> EntryPoint -> Smart Account
                                  |
                              Paymaster (gas sponsorship)
1

Create a Smart Account

Smart accounts are counterfactually deployed -- the address is deterministic and can receive funds before on-chain deployment. Deployment happens automatically when the first UserOperation is sent.

const BASE_URL = "https://api.bootnode.dev/v1";
const API_KEY = process.env.BOOTNODE_API_KEY!;

// Create a smart account
async function createSmartAccount(ownerAddress: string, chain: string) {
  const res = await fetch(`${BASE_URL}/wallets/create`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": API_KEY,
    },
    body: JSON.stringify({
      owner: ownerAddress,
      chain,
    }),
  });

  const wallet = await res.json();
  console.log("Smart account address:", wallet.address);
  console.log("Status:", wallet.status); // "counterfactual"
  return wallet;
}

// The smart account address is deterministic:
// Same owner + same chain + same salt = same address every time.
const account = await createSmartAccount(
  "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
  "base"
);
// => { address: "0x7A0b...ABCD", status: "counterfactual", ... }
2

Send a UserOperation

A UserOperation is the ERC-4337 equivalent of a transaction. Build the UserOp, estimate gas, sign it, and submit it to the bundler.

import { encodeFunctionData, parseAbi } from "viem";

const ENTRY_POINT = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789";

// 1. Build the UserOperation
const callData = encodeFunctionData({
  abi: parseAbi(["function transfer(address to, uint256 amount) returns (bool)"]),
  functionName: "transfer",
  args: [
    "0x9876543210abcdef9876543210abcdef98765432" as `0x${string}`,
    1000000n, // 1 USDC
  ],
});

const userOp = {
  sender: account.address,
  nonce: "0x0",
  initCode: "0x", // Empty if already deployed
  callData,
  callGasLimit: "0x0",       // Will be estimated
  verificationGasLimit: "0x0", // Will be estimated
  preVerificationGas: "0x0",   // Will be estimated
  maxFeePerGas: "0x0",        // Will be estimated
  maxPriorityFeePerGas: "0x0", // Will be estimated
  paymasterAndData: "0x",
  signature: "0x",
};

// 2. Estimate gas via the bundler
const estimateRes = await fetch(`${BASE_URL}/bundler/base/mainnet`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-API-Key": API_KEY,
  },
  body: JSON.stringify({
    jsonrpc: "2.0",
    id: 1,
    method: "eth_estimateUserOperationGas",
    params: [userOp, ENTRY_POINT],
  }),
});

const gasEstimate = await estimateRes.json();
console.log("Gas estimate:", gasEstimate.result);
// => {
//   callGasLimit: "0x30d40",
//   verificationGasLimit: "0x186a0",
//   preVerificationGas: "0xc350"
// }

// 3. Fill in gas values
userOp.callGasLimit = gasEstimate.result.callGasLimit;
userOp.verificationGasLimit = gasEstimate.result.verificationGasLimit;
userOp.preVerificationGas = gasEstimate.result.preVerificationGas;
userOp.maxFeePerGas = "0x59682f00";       // 1.5 gwei
userOp.maxPriorityFeePerGas = "0x3b9aca00"; // 1 gwei

// 4. Sign the UserOperation (using owner's private key)
// In production, use a proper signing library like viem or ethers
// userOp.signature = await signUserOp(userOp, ownerPrivateKey);

// 5. Submit to the bundler
const sendRes = await fetch(`${BASE_URL}/bundler/base/mainnet`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-API-Key": API_KEY,
  },
  body: JSON.stringify({
    jsonrpc: "2.0",
    id: 2,
    method: "eth_sendUserOperation",
    params: [userOp, ENTRY_POINT],
  }),
});

const sendResult = await sendRes.json();
const userOpHash = sendResult.result;
console.log("UserOp hash:", userOpHash);
// => "0x1234567890abcdef..."

// 6. Wait for receipt
const receiptRes = await fetch(`${BASE_URL}/bundler/base/mainnet`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-API-Key": API_KEY,
  },
  body: JSON.stringify({
    jsonrpc: "2.0",
    id: 3,
    method: "eth_getUserOperationReceipt",
    params: [userOpHash],
  }),
});

const receipt = await receiptRes.json();
console.log("Receipt:", receipt.result);
// => { success: true, actualGasUsed: "0x5208", ... }
3

Use Paymaster for Gas Sponsorship

Sponsor gas for your users so they never need to hold ETH. Create a gas policy, then request paymaster data before sending the UserOp.

// 1. Create a gas policy (one-time setup)
const policyRes = await fetch(`${BASE_URL}/gas/policies`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-API-Key": API_KEY,
  },
  body: JSON.stringify({
    name: "My App Free Gas",
    chain: "base",
    rules: {
      max_gas_usd: "0.50",        // Max $0.50 per operation
      max_per_user_per_day: 20,    // 20 free ops per user per day
      allowed_contracts: [         // Only these contracts
        "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
      ],
    },
  }),
});

const policy = await policyRes.json();
console.log("Policy ID:", policy.id);
// => "gp_x1y2z3w4v5u6"

// 2. Request sponsorship for a UserOp
const sponsorRes = await fetch(`${BASE_URL}/gas/sponsor`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-API-Key": API_KEY,
  },
  body: JSON.stringify({
    chain: "base",
    policy_id: policy.id,
    user_operation: userOp,
  }),
});

const sponsorData = await sponsorRes.json();
console.log("Sponsored gas cost:", sponsorData.sponsored_gas_usd);
// => "$0.03"

// 3. Attach paymaster data to the UserOp
userOp.paymasterAndData = sponsorData.paymasterData;

// 4. Sign and send as before
// The user's transaction is now completely gas-free.
4

Batch Transactions

Smart accounts can execute multiple calls in a single UserOperation. This is useful for approve + swap, multi-send, or any multi-step workflow.

import { encodeFunctionData, parseAbi, encodeAbiParameters } from "viem";

const erc20Abi = parseAbi([
  "function approve(address spender, uint256 amount) returns (bool)",
  "function transfer(address to, uint256 amount) returns (bool)",
]);

// Smart account executeBatch ABI
const executeBatchAbi = parseAbi([
  "function executeBatch(address[] targets, uint256[] values, bytes[] calldata)",
]);

// Build batch: approve + transfer in one UserOp
const targets = [
  "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
  "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
];

const values = [0n, 0n]; // No ETH value for ERC-20 calls

const calls = [
  // First: approve spender for 1000 USDC
  encodeFunctionData({
    abi: erc20Abi,
    functionName: "approve",
    args: [
      "0xDEF1234567890abcdef1234567890abcDEF12345" as `0x${string}`,
      1000000000n, // 1000 USDC
    ],
  }),
  // Second: transfer 500 USDC to recipient
  encodeFunctionData({
    abi: erc20Abi,
    functionName: "transfer",
    args: [
      "0x9876543210abcdef9876543210abcdef98765432" as `0x${string}`,
      500000000n, // 500 USDC
    ],
  }),
];

// Encode the batch call
const batchCallData = encodeFunctionData({
  abi: executeBatchAbi,
  functionName: "executeBatch",
  args: [
    targets as readonly `0x${string}`[],
    values,
    calls as readonly `0x${string}`[],
  ],
});

// Use batchCallData as the callData in your UserOperation
const batchUserOp = {
  sender: account.address,
  nonce: "0x1",
  initCode: "0x",
  callData: batchCallData,
  // ... gas fields, signature, etc.
};

// Estimate, sponsor, sign, and send as before.
// Both approve + transfer execute atomically in one transaction.

Bundler JSON-RPC Methods

MethodDescription
eth_sendUserOperationSubmit a UserOp to the bundler mempool
eth_estimateUserOperationGasEstimate gas for a UserOp
eth_getUserOperationByHashGet UserOp details by hash
eth_getUserOperationReceiptGet execution receipt for a UserOp
eth_supportedEntryPointsList supported EntryPoint contract addresses

Supported Chains for ERC-4337

Ethereum
Base
Arbitrum
Optimism
Polygon
Avalanche C-Chain
BNB Smart Chain
Lux Network
Linea
Scroll
zkSync Era
Polygon zkEVM

Testnet support available on Sepolia, Base Sepolia, Arbitrum Sepolia, and all major testnet networks.

Next Steps