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)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", ... }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", ... }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.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
| Method | Description |
|---|---|
| eth_sendUserOperation | Submit a UserOp to the bundler mempool |
| eth_estimateUserOperationGas | Estimate gas for a UserOp |
| eth_getUserOperationByHash | Get UserOp details by hash |
| eth_getUserOperationReceipt | Get execution receipt for a UserOp |
| eth_supportedEntryPoints | List supported EntryPoint contract addresses |
Supported Chains for ERC-4337
Testnet support available on Sepolia, Base Sepolia, Arbitrum Sepolia, and all major testnet networks.