← Back to the library
Web3How it works · Deep dive · 15 min

Account abstraction: ERC-4337, EIP-7702 and smart accounts

How do smart accounts let users batch calls, skip gas tokens and sign with passkeys, and what changes when an EOA delegates its code with EIP-7702?

Account abstraction moves validation (who may act, which signature counts, who pays gas) out of the protocol and into the account's own code. ERC-4337 does it with UserOperations relayed by bundlers through a shared EntryPoint contract; EIP-7702 lets an existing EOA point its code at such a contract without changing address.

THE MENTAL MODEL

A plain EOA has one rule hard-coded into Ethereum: a valid secp256k1 signature from this key, paid in ETH by this key. A smart account replaces that rule with a function, validateUserOp, so the rule can be a passkey, a multisig, a session key limited to one contract until Friday, or a paymaster that pays gas in exchange for USDC or nothing at all. ERC-4337 makes this work without a protocol change: users sign a UserOperation (an intent-shaped struct, not a transaction), bundlers collect them from an alternative mempool and submit one real transaction calling EntryPoint.handleOps, and the EntryPoint runs validation for every op before executing any of them. EIP-7702 attacks the address problem: since Pectra, an EOA can sign an authorization that sets its code to a delegation pointer, so the same address users already have gains smart-account behaviour. The key remains the root of trust, and the delegate contract becomes as security-critical as the key.

HOW IT FITS TOGETHER

A paymaster-sponsored UserOperationOff-chain RPCs on top, the bundler's single handleOps transaction below. Validation of every op runs before any execution.
  1. App / wallet → Paymasterpm_getPaymasterStubDataERC-7677 paymaster web service. Stub data lets the bundler estimate gas including paymaster validation.
  2. App / wallet → Bundlereth_estimateUserOperationGas
  3. App / wallet → Paymasterpm_getPaymasterDataThe service applies its policy (allowlisted contracts, spend caps) and signs the sponsorship.
  4. Paymaster → App / walletpaymaster + paymasterData
  5. App / wallet → Bundlereth_sendUserOperation (owner-signed)The bundler simulates validation off-chain under ERC-7562 rules before accepting it into its mempool.
  6. Bundler → EntryPointtx: handleOps([ops], beneficiary)One ordinary transaction, paid by the bundler's EOA. It may carry many users' ops.
  7. EntryPoint → Smart accountvalidateUserOp(op, hash, 0)
  8. Smart account → EntryPointvalidationData (sig ok, time range)A bad signature returns SIG_VALIDATION_FAILED rather than reverting.
  9. EntryPoint → PaymastervalidatePaymasterUserOp → contextThe paymaster's EntryPoint deposit must cover the op's max cost.
  10. EntryPoint → Smart accountExecute callData (batched calls)
  11. EntryPoint → PaymasterpostOp(mode, context, actualGasCost)Optional settlement hook, e.g. charge the user ERC-20s for the gas just spent.
  12. EntryPoint → BundlerRefund from deposit + UserOperationEvent
  13. Bundler → App / walleteth_getUserOperationReceiptCheck success: the outer transaction can succeed while this op's execution reverted.
EOA vs ERC-4337 smart account vs 7702-delegated EOA7702 and 4337 compose: a delegated EOA can run a 4337 account implementation and use bundlers and paymasters.
Plain EOAERC-4337 smart accountEIP-7702 delegated EOA
AddressDerived from the keyNew contract address (CREATE2, counterfactual)Existing EOA address, history and assets kept
Who validatesProtocol: secp256k1 signatureAccount code: any schemeDelegate code, but the EOA key can always act directly
Key loss or rotationFunds gone; no rotationRotate owners, social or guardian recoveryKey cannot be revoked; it can re-delegate at will
BatchingOne call per transactionMany calls per UserOperationMany calls via the delegate's execute()
Gas sponsorshipSender pays in ETHPaymasters: sponsored or paid in ERC-20Via relayer tx or 4337 paymaster
Signature checks by dappsecrecoverERC-1271; ERC-6492 before deploymentecrecover works; ERC-1271 if delegate supports it
Passkeys (P-256)NoYes, via P256VERIFY or a verifier contractYes, if the delegate validates P-256
Setup costNoneDeployment on first op (initCode)One authorization, in a type-4 tx or a UserOp

KEY TERMS

UserOperation and EntryPoint
A UserOperation is a struct (sender, nonce, initCode or factory, callData, gas limits and fees, paymaster fields, signature) signed by the account's owner. The EntryPoint singleton validates and executes bundles of them and handles gas accounting, deposits and refunds. Current releases are v0.7 (0x0000000071727De22E5E9d8BAf0edAc6f37da032), v0.8 (native 7702 support, EIP-712 op hashing) and v0.9 (paymaster signatures after the user, block-number validity ranges). Accounts are bound to one version; check which one your SDK and bundler target.
Bundlers and the alt mempool
Bundlers are relayers that simulate each op's validation, keep a separate UserOperation mempool, and submit handleOps transactions, earning the difference between what ops pay and what the transaction costs. ERC-7562 restricts what validation code may touch (no environment opcodes like TIMESTAMP, limited storage access) so one op cannot invalidate a whole bundle after simulation.
Paymasters
A paymaster is a contract with an EntryPoint deposit that agrees to pay for ops in validatePaymasterUserOp and can settle afterwards in postOp. Verifying paymasters check an off-chain sponsor signature (your policy service); ERC-20 paymasters charge the user tokens. Unbounded sponsorship is a faucet: rate-limit and allowlist by account, method and contract.
EIP-7702 delegation
A type-4 transaction carries an authorization list of signed (chainId, address, nonce) tuples. Each sets the signer's code to 0xef0100 || address, so calls to the EOA execute the delegate's code with the EOA's storage and balance. It persists until re-delegated (delegating to the zero address clears it). chainId 0 makes the authorization valid on every chain, which is rarely what you want.
ERC-1271 and ERC-6492 signatures
Contracts cannot produce ECDSA signatures, so verifiers call isValidSignature(hash, signature) on the account and expect the magic value 0x1626ba7e. A counterfactual account has no code yet; ERC-6492 wraps the signature with factory calldata and the 0x6492... suffix so a verifier can simulate deployment first. Verify with a library that handles EOA, 1271 and 6492 in that precedence.
Session keys
A secondary key the account accepts only under constraints: specific contracts and selectors, spend limits, expiry. There is no single standard yet; implementations are account modules (often ERC-7579 validators and policies), and ERC-7715 (draft) proposes wallet_requestExecutionPermissions so dapps can request them uniformly. Keep the scope narrow; a session key is a bearer credential.
Passkey signers (P-256)
WebAuthn passkeys sign with secp256r1, not Ethereum's secp256k1. RIP-7212 introduced a P256VERIFY precompile at 0x100 on rollups; EIP-7951 brought it to L1 in Fusaka (Dec 2025) with the same interface, corrected edge cases and a 6900 gas cost. Where it is absent, accounts fall back to a Solidity verifier costing hundreds of thousands of gas.

IN YOUR STACK

TypeScript · viem 2 account abstraction Client

import { createPublicClient, encodeFunctionData, erc20Abi, http, parseUnits } from "viem";
import { createBundlerClient, toCoinbaseSmartAccount } from "viem/account-abstraction";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";

const client = createPublicClient({ chain: base, transport: http() });
const bundlerClient = createBundlerClient({ client, transport: http(process.env.BUNDLER_URL) });

const account = await toCoinbaseSmartAccount({
  client,
  owners: [privateKeyToAccount(process.env.OWNER_KEY as `0x${string}`)],
  version: "1.1",
});

// Approve + deposit atomically: both calls run in one UserOperation.
const hash = await bundlerClient.sendUserOperation({
  account,
  calls: [
    { to: USDC, data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [VAULT, parseUnits("50", 6)] }) },
    { to: VAULT, abi: vaultAbi, functionName: "deposit", args: [parseUnits("50", 6)] },
  ],
});
const receipt = await bundlerClient.waitForUserOperationReceipt({ hash });
if (!receipt.success) throw new Error(`UserOperation reverted: ${receipt.reason}`);
  • The account address is known before deployment; the first UserOperation deploys it through initCode. Funds can be sent there in advance.
  • Coinbase Smart Wallet targets EntryPoint v0.6 in viem. Pick an account implementation whose EntryPoint version your bundler supports.
  • The returned hash is a userOpHash, not a transaction hash; the receipt contains the bundle transaction.
viem 2 account abstraction documentation (opens in new tab)

TypeScript · viem 2 (EIP-7702) Client

import { createWalletClient, encodeFunctionData, http, type Address, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia } from "viem/chains";

const eoa = privateKeyToAccount(process.env.EOA_KEY as Hex);
const walletClient = createWalletClient({ account: eoa, chain: sepolia, transport: http() });

// Audited batch-executor implementation the EOA will delegate to.
const DELEGATE: Address = "0x...";

const authorization = await walletClient.signAuthorization({
  contractAddress: DELEGATE,
  executor: "self", // the EOA sends the tx itself, so the auth uses nonce + 1
});

const hash = await walletClient.sendTransaction({
  authorizationList: [authorization], // type-4 (set code) transaction
  to: eoa.address, // call the EOA, which now runs DELEGATE's code
  data: encodeFunctionData({ abi: delegateAbi, functionName: "execute", args: [calls] }),
});
  • Without executor: 'self' the authorization uses the current nonce, which is right when a relayer submits the transaction and pays gas.
  • The delegation persists across transactions. Delegate only to audited code designed for 7702: it must protect initialization with the EOA's signature and avoid storage collisions with any previous delegate.
  • Wallets generally refuse to let dapps request arbitrary 7702 authorizations; in practice the wallet chooses the delegate.
viem 2 (EIP-7702) documentation (opens in new tab)

TypeScript · viem 2 Server

import { createPublicClient, http, type Address, type Hex } from "viem";
import { base } from "viem/chains";

const client = createPublicClient({ chain: base, transport: http(process.env.RPC_URL) });

// Login: the client signed a server-issued, single-use challenge.
export async function verifyLogin(address: Address, message: string, signature: Hex) {
  // Tries, in order: ERC-6492 wrapper (counterfactual account), ERC-1271
  // isValidSignature on deployed code, then plain ecrecover for EOAs.
  const valid = await client.verifyMessage({ address, message, signature });
  if (!valid) throw new Error("invalid signature");
  return address;
}

// On-chain equivalent (OpenZeppelin):
//   SignatureChecker.isValidSignatureNow(signer, hash, signature)
// handles EOAs and ERC-1271, but not ERC-6492 counterfactual wrappers.
  • Never use ecrecover alone for addresses that might be smart accounts; it returns some address for any signature and will not match the account.
  • 1271 validity can change over time (owner rotated, session key expired), so verify at use time and bind the message to chain, domain and nonce, ideally with EIP-712 or SIWE (ERC-4361).
viem 2 documentation (opens in new tab)

TypeScript · viem 2 account abstraction Client

import { createPublicClient, http, type Hex } from "viem";
import { createBundlerClient, createPaymasterClient, toSimple7702SmartAccount } from "viem/account-abstraction";
import { signAuthorization } from "viem/actions";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia } from "viem/chains";

const client = createPublicClient({ chain: sepolia, transport: http() });
const owner = privateKeyToAccount(process.env.EOA_KEY as Hex);
// The EOA's own address, running eth-infinitism's Simple7702Account (EntryPoint v0.8).
const account = await toSimple7702SmartAccount({ client, owner });

const bundlerClient = createBundlerClient({
  client,
  transport: http(process.env.BUNDLER_URL),
  paymaster: createPaymasterClient({ transport: http(process.env.PAYMASTER_URL) }),
  paymasterContext: { sponsorshipPolicyId: process.env.POLICY_ID }, // provider-specific
});
const needsDelegation = !(await account.isDeployed());
const hash = await bundlerClient.sendUserOperation({
  account,
  calls: [{ to: GAME, abi: gameAbi, functionName: "claimDaily" }],
  // First op only: carry the 7702 authorization; the bundler adds it to a type-4 tx.
  authorization: needsDelegation ? await signAuthorization(client, account.authorization) : undefined,
});
  • The user holds no ETH: the paymaster's EntryPoint deposit pays, and the bundler is refunded from it.
  • paymasterContext is passed through to the ERC-7677 service. Its shape (policy IDs, ERC-20 token choice) depends on the provider.
  • Enforce sponsorship policy server-side per user and per method; anyone who can reach the paymaster endpoint can try to spend your deposit.
viem 2 account abstraction documentation (opens in new tab)

TypeScript · viem 2 WebAuthn Client

import {
  createWebAuthnCredential, toCoinbaseSmartAccount, toWebAuthnAccount, type P256Credential,
} from "viem/account-abstraction";
import { bundlerClient, client } from "./clients";

// Registration: the authenticator creates a P-256 key; only the public key leaves it.
export async function register(username: string) {
  const credential = await createWebAuthnCredential({ name: username });
  await api.saveCredential({ id: credential.id, publicKey: credential.publicKey });
  return credential;
}

// Later sessions: rebuild the owner from the stored id and public key.
export async function sendWithPasskey(credential: Pick<P256Credential, "id" | "publicKey">) {
  const owner = toWebAuthnAccount({ credential });
  const account = await toCoinbaseSmartAccount({ client, owners: [owner], version: "1.1" });
  const hash = await bundlerClient.sendUserOperation({
    account,
    calls: [{ to: RECIPIENT, value: 1_000_000_000_000_000n }],
  }); // triggers the OS passkey prompt to sign the userOpHash
  return bundlerClient.waitForUserOperationReceipt({ hash });
}
  • The account address is derived from its owners, so losing the stored public key loses the address. Persist id and publicKey server-side and add a second owner or recovery path.
  • Passkeys are bound to the relying-party ID (your domain); a passkey created on one domain cannot sign for another.
  • On-chain verification uses the P256VERIFY precompile where the chain has it (RIP-7212 on many L2s, EIP-7951 on L1) and is much more expensive elsewhere.
viem 2 WebAuthn documentation (opens in new tab)

WHEN TO REACH FOR WHAT

Plain EOA

Use when Power users and backend hot wallets where one key and ETH for gas are acceptable, and simplicity of tooling matters most.

Avoid when Mainstream users who will lose seed phrases, need gasless onboarding, or would benefit from batching approve + action.

ERC-4337 smart account

Use when New users onboarding with passkeys or email, sponsored gas, recovery, multisig or session keys; you control the wallet experience (embedded wallet, game, app).

Avoid when Users already have funded EOAs they will not migrate from, or you need every dapp and CEX to treat the address exactly like an EOA.

EIP-7702 delegated EOA

Use when Existing EOA users should gain batching, sponsorship or session keys without moving assets or changing address; ideally wallet-driven and combined with a 4337 implementation.

Avoid when You need real key rotation or recovery: the original key can always override the delegate. Or the chain has not activated 7702.

Relayer / meta-transactions

Use when A single, narrow sponsored action (claim, mint) on contracts you own that support ERC-2771 or permit-style signatures.

Avoid when You need general-purpose sponsorship across arbitrary contracts; trusted-forwarder setups have had msg.sender spoofing bugs.

WHERE IT BITES

  • Treating a mined handleOps transaction as success: the bundle succeeds even when your op's execution reverted. Read UserOperationEvent or the op receipt's success flag.
  • Verifying signatures with ecrecover only. Smart accounts need ERC-1271, and freshly created ones need ERC-6492 until deployed.
  • Mixing EntryPoint versions: an account built for v0.6 or v0.7 cannot be submitted to a bundler endpoint for v0.8, and userOpHashes differ per version and chain.
  • Signing 7702 authorizations with chainId 0 or to an unaudited delegate. The delegate controls everything the EOA owns, on every chain the authorization covers.
  • Running an open paymaster without per-user quotas; bots will drain the deposit within hours.
  • Assuming the P-256 precompile exists on every chain, or that its gas cost matches RIP-7212's on L2s.

CLOSE THE AI. EXPLAIN THIS.

Walk a sponsored UserOperation from wallet to EntryPoint and back: which RPCs are called, what validateUserOp and validatePaymasterUserOp each check, who pays the bundler, and why 'transaction succeeded' is not enough to tell the user their action worked.

SOURCES

Explainer reviewed