EIP-4337 Account Abstraction



πŸ”΅ The Problem

Before understanding why we need this EIP or what problems it is trying to solve, we first need to understand that Ethereum has two account types: contract accounts and Externally Owned Accounts (EOAs).

Contract accounts represent a smart contract β€” they contain compiled Solidity code as bytecode. We initiate calls to these accounts using transactions. Transactions contain data such as methods to call, parameters to pass, and ETH amounts to send. These accounts cannot initiate transactions themselves.

EOA accounts are your normal wallet accounts. If you have used MetaMask/Uniswap/Coinbase Wallet/Trust Wallet, the type of account you had was an EOA. These accounts contain user balances and are primarily the way users interact with the ETH blockchain. We need to hold the private keys to these accounts to sign valid transactions before sending them to the network. These then talk to smart contract accounts to perform some actions e.g. transfer, approve. Contract accounts do not have private keys. They are created by sending a special deployment transaction to address 0x0 and an address is calculated like this:

address = keccak256(0xFF + deployer_address + salt + keccak256(initcode))[last 20 bytes]

EIP-4337 was created to address a few problems that arise from the setup above.

  1. πŸ”‘ Seed phrase/private key risk β€” if a user loses the private key to their EOA, they lose access to their wallet and funds. This is not good. It prevents adoption. You never have to worry about this with regular banks after all. Seed phrase management is a problem.
  2. β›½ Gas has to be paid in ETH β€” EOAs have to initiate transactions, which means gas has to be paid in ETH. You ever been stuck with an amount of money in say USDT but no ETH to get it out?
  3. πŸ“¦ Lack of transaction batching β€” In this older model, if you wanted to send money to a dapp you would have to both approve and transfer β€” two common ERC20 functions. You could imagine this is very cumbersome for more complex transactions e.g. those you might find at a trading terminal: buy X, swap for Y, arbitrage with Z, convert back to Y, swap for X.
  4. 🧠 Wallets could be smarter β€” There’s no reason for example why we couldn’t have logic that allows a person to transfer coins to their next of kin when they die, or set spending limits on a wallet, or multisig capabilities. You cannot do this with regular EOAs β€” they do not hold any logic or code.
  5. βš›οΈ Quantum vulnerability β€” EOAs are implemented on layer 1, so public and private keys are generated using ECDSA. ECDSA is theoretically vulnerable to quantum computers. Can we choose a different signature scheme? EIP-4337 allows us to choose different signature schemes including Apple’s Face ID or even WebAuthn keys to sign transactions. We will see how shortly.

EIP-4337 was created to help create a better wallet experience for users. Rather than make protocol-level changes to how transactions look and what EOAs contain β€” which would require a hard fork β€” EIP-4337, or Account Abstraction, implemented a new layer.

AA essentially follows in the footsteps of projects like Gnosis Safe. The idea with Gnosis Safe is that you have a smart contract where your funds are held that has logic (including multisig), so it can enforce spending limits, validate pending transactions by getting a certain number of signatures, and even have social recovery. The idea of EIP-4337 is we can have a smart contract that represents logic/code that we want to execute and link it to our EOA. AA (Account Abstraction) is implemented as follows:

UserOperations β†’ Bundler β†’ EntryPoint β†’ AA Contract

πŸ—οΈ Implementation Overview

UserOperations This is essentially a transaction. They are not called transactions to avoid confusion with regular transactions. Normal ETH transactions are initiated by EOAs and validated against those EOA accounts. Since the whole point of AA is to have a special type of transaction β€” e.g. where someone can submit a transaction for us or we pay in ETH β€” we need a special type of transaction. These have their own mempool (where transactions are validated) as their logic is different from regular transactions. The UserOperation struct looks like this:

sender                        address   - The account making the UserOperation
nonce                         uint256   - Anti-replay parameter
factory                       address   - Account factory for new accounts, 0x7702 flag for EIP-7702 accounts, otherwise address(0)
factoryData                   bytes     - Data for the account factory if factory is provided, or EIP-7702 initialization data, or empty array
callData                      bytes     - The data to pass to the sender during the main execution call
callGasLimit                  uint256   - The amount of gas to allocate for the main execution call
verificationGasLimit          uint256   - The amount of gas to allocate for the verification step
preVerificationGas            uint256   - Extra gas to pay the bundler
maxFeePerGas                  uint256   - Maximum fee per gas (similar to EIP-1559 max_fee_per_gas)
maxPriorityFeePerGas          uint256   - Maximum priority fee per gas (similar to EIP-1559 max_priority_fee_per_gas)
paymaster                     address   - Address of paymaster contract, or empty if the sender pays for gas itself
paymasterVerificationGasLimit uint256   - Gas allocated for paymaster validation code (only if paymaster exists)
paymasterPostOpGasLimit       uint256   - Gas allocated for paymaster post-operation code (only if paymaster exists)
paymasterData                 bytes     - Data for paymaster (only if paymaster exists)
signature                     bytes     - Data passed into the sender to verify authorization

  • sender β€” represents whoever is sending the UserOperation
  • factory β€” if the AA contract does not exist, this is the factory that will create it (uses CREATE2)
  • factoryData β€” data to call the factory function with
  • paymaster β€” contract paying fees on the user’s behalf; if this exists, the paymaster is contacted to authorize fees
  • paymasterData β€” data to pass to the paymaster, used for verification
  • signature β€” used for verification to confirm the EOA actually authorized the transaction and is valid
  • callData β€” the actual operation you are trying to call on another contract (more on this shortly)

Bundler These validate and bundle user operations (transactions) from the mempool and submit them on-chain as real transactions. They call a function on an EntryPoint contract (deployed on mainnet) called handleOperations: The bundler bundles different user operations to save gas. EntryPoint.handleOps([op1, op2, op3...])


EntryPoint This is a smart contract that handles verification and execution logic of transactions. No one owns it, no one can upgrade it. Its job is to take operations and handle them. Some of the actions it performs are, 1.Deploy the AA contract using the factory if set and it doesn’t exist yet 2. Call execute on our AA contract (for validating user operations) 3. Validate the wallet 4. Validate the paymaster


AA Contract Our AA contract β€” the contract we own that has the functionality we want to utilize. e.g. limit transactions per week, only send a transaction if validated by another address we own. The bundler’s job is to pass transactions to mainnet. The EntryPoint contract’s job is to figure out what contracts the user operations point to. Let’s solidify this with some example code.


πŸ’» The Code

I’ll show some code for an AA wallet that sets a limit of 1000 tokens a day on EOA wallets.

AA Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@account-abstraction/contracts/interfaces/IAccount.sol";
import "@account-abstraction/contracts/interfaces/IEntryPoint.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract SpendLimitWallet is IAccount {
    using ECDSA for bytes32;

    // The EOA that owns this wallet β€” the only one who can sign UserOps
    address public owner;

    // The canonical EntryPoint contract β€” deployed by the EIP authors
    // same address on every EVM chain
    IEntryPoint public immutable entryPoint;

    // How much of each token can be spent per day
    // token address => max amount (in wei, so 1000 tokens = 1000 * 10^18)
    mapping(address => uint256) public dailyLimit;

    // How much of each token has been spent today
    // token address => amount spent since last reset
    mapping(address => uint256) public spentToday;

    // When the daily counter was last reset for each token
    // token address => timestamp of last reset
    mapping(address => uint256) public lastResetTime;

    // 86400 seconds = 1 day β€” used to check if we need to reset the counter
    uint256 public constant DAY = 1 days;

    event TokenTransferred(address token, address to, uint256 amount);

    constructor(
        address _owner,          // the EOA that will sign UserOps for this wallet
        IEntryPoint _entryPoint  // the EntryPoint contract address
    ) {
        owner = _owner;
        entryPoint = _entryPoint;
    }

    // Check if 24 hours have passed since last reset
    // If so, wipe the spentToday counter for this token
    function _resetIfNewDay(address token) internal {
        if (block.timestamp >= lastResetTime[token] + DAY) {
            spentToday[token] = 0;
            lastResetTime[token] = block.timestamp;
        }
    }

    // Helper to see how many tokens can still be sent today
    function remainingToday(address token) public view returns (uint256) {
        return dailyLimit[token] - spentToday[token];
    }

    // Owner calls this to set a limit on any token they want
    function setDailyLimit(address token, uint256 limit) external {
        require(msg.sender == address(entryPoint), "only entrypoint");
        dailyLimit[token] = limit;
        lastResetTime[token] = block.timestamp;
    }

    // The actual token transfer function β€” called by EntryPoint after validation
    // This is what the callData in the UserOperation points to
    function transferToken(
        address token,   // which ERC20 to transfer (TOKEN_ADDRESS)
        address to,      // recipient (RECIPIENT)
        uint256 amount   // how many tokens in raw units (AMOUNT)
    ) external {
        // Only the EntryPoint can call this β€” prevents anyone bypassing AA
        require(msg.sender == address(entryPoint), "only entrypoint");

        // Reset daily counter if it's been more than 24 hours
        _resetIfNewDay(token);

        // Check the requested amount doesn't exceed what's left for today
        uint256 remaining = remainingToday(token);
        require(amount <= remaining, "daily limit exceeded");

        // Track how much has been spent today
        spentToday[token] += amount;

        // Do the actual ERC20 transfer from this wallet to the recipient
        // The wallet must hold enough tokens for this to succeed
        IERC20(token).transfer(to, amount);
        emit TokenTransferred(token, to, amount);
    }

    // Called by EntryPoint before execution to verify the UserOp is legitimate
    // This is the core of AA β€” custom validation logic in code, not protocol
    function validateUserOp(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash,         // hash of the entire UserOperation
        uint256 missingAccountFunds // how much ETH the wallet needs to put in to cover gas
    ) external returns (uint256) {
        require(msg.sender == address(entryPoint), "only entrypoint");

        // Recover the address that signed this UserOperation
        // and check it matches our owner
        bytes32 hash = userOpHash.toEthSignedMessageHash();
        address recovered = hash.recover(userOp.signature);
        if (recovered != owner) {
            return 1; // non-zero = validation failed, EntryPoint will revert
        }

        // If this wallet doesn't have enough ETH deposited in the EntryPoint
        // to cover gas, top it up now
        if (missingAccountFunds > 0) {
            (bool ok,) = address(entryPoint).call{value: missingAccountFunds}("");
            require(ok, "prefund failed");
        }

        return 0; // zero = validation passed, proceed to execution
    }

    // Generic execute for any other contract calls beyond token transfers
    function execute(address target, uint256 value, bytes calldata data) external {
        require(msg.sender == address(entryPoint), "only entrypoint");
        (bool ok, bytes memory result) = target.call{value: value}(data);
        if (!ok) {
            assembly { revert(add(result, 32), mload(result)) }
        }
    }

    // Accept ETH β€” needed to prefund gas costs
    receive() external payable {}
}

Factory

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "./SpendLimitWallet.sol";

// Deployed once β€” used to create new SpendLimitWallet instances
// Anyone can call createWallet to deploy their own smart wallet
contract SpendLimitWalletFactory {
    // Same EntryPoint used by all wallets this factory creates
    IEntryPoint public immutable entryPoint;

    event WalletCreated(address wallet, address owner);

    constructor(IEntryPoint _entryPoint) {
        entryPoint = _entryPoint;
    }

    // Deploy a new SpendLimitWallet using CREATE2
    // CREATE2 means the address is deterministic β€” you can know it before deploying
    // salt is a number you choose β€” changing it gives a different wallet address
    function createWallet(
        address owner, // the EOA that will own this wallet
        uint256 salt   // arbitrary number to make address unique (e.g. 1234)
    ) external returns (SpendLimitWallet wallet) {
        wallet = new SpendLimitWallet{salt: bytes32(salt)}(
            owner,
            entryPoint
        );
        emit WalletCreated(address(wallet), owner);
    }

    // Compute what address a wallet WILL have before deploying it
    // This is how we know walletAddress before the wallet exists on-chain
    // Used in step 1 of the off-chain code
    function getAddress(
        address owner,
        uint256 salt
    ) external view returns (address) {
        // Hash of the contract bytecode + constructor arguments
        bytes32 bytecodeHash = keccak256(abi.encodePacked(
            type(SpendLimitWallet).creationCode,
            abi.encode(owner, entryPoint)
        ));
        // CREATE2 address derivation formula
        return address(uint160(uint256(keccak256(abi.encodePacked(
            bytes1(0xff),   // CREATE2 prefix
            address(this),  // factory address
            bytes32(salt),  // your chosen salt
            bytecodeHash    // hash of what will be deployed
        )))));
    }
}

User Operation β€” Deploy our AA contract

Now we will both deploy our AA contract and send user operations:

import {
  createWalletClient,
  createPublicClient,
  http,
  encodeFunctionData,
  parseUnits,
  zeroAddress,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet } from "viem/chains";

// ─── Setup ────────────────────────────────────────────────────────────────────

const account = privateKeyToAccount(PRIVATE_KEY);

const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(RPC_URL),
});

const walletClient = createWalletClient({
  account,
  chain: mainnet,
  transport: http(RPC_URL),
});

// Bundler RPC β€” Pimlico, Alchemy, Stackup etc
const bundlerClient = createPublicClient({
  chain: mainnet,
  transport: http(BUNDLER_RPC_URL),
});

// ─── Addresses ───────────────────────────────────────────────────────────────

const USDC_ADDRESS = "0x...";
const RECIPIENT = "0x...";
const SALT = 1234n;

// ─── ABIs ────────────────────────────────────────────────────────────────────

const factoryAbi = [
  {
    name: "createWallet",
    type: "function",
    inputs: [
      { name: "owner", type: "address" },
      { name: "salt", type: "uint256" },
    ],
    outputs: [{ type: "address" }],
  },
  {
    name: "getAddress",
    type: "function",
    inputs: [
      { name: "owner", type: "address" },
      { name: "salt", type: "uint256" },
    ],
    outputs: [{ type: "address" }],
  },
] as const;

const walletAbi = [
  {
    name: "setDailyLimit",
    type: "function",
    inputs: [
      { name: "token", type: "address" },
      { name: "limit", type: "uint256" },
    ],
    outputs: [],
  },
  {
    name: "transferToken",
    type: "function",
    inputs: [
      { name: "token", type: "address" },
      { name: "to", type: "address" },
      { name: "amount", type: "uint256" },
    ],
    outputs: [],
  },
] as const;

const entryPointAbi = [
  {
    name: "getUserOpHash",
    type: "function",
    inputs: [
      {
        name: "userOp",
        type: "tuple",
        components: [
          { name: "sender", type: "address" },
          { name: "nonce", type: "uint256" },
          { name: "factory", type: "address" },
          { name: "factoryData", type: "bytes" },
          { name: "callData", type: "bytes" },
          { name: "callGasLimit", type: "uint256" },
          { name: "verificationGasLimit", type: "uint256" },
          { name: "preVerificationGas", type: "uint256" },
          { name: "maxFeePerGas", type: "uint256" },
          { name: "maxPriorityFeePerGas", type: "uint256" },
          { name: "paymaster", type: "address" },
          { name: "paymasterData", type: "bytes" },
          { name: "paymasterVerificationGasLimit", type: "uint256" },
          { name: "paymasterPostOpGasLimit", type: "uint256" },
          { name: "signature", type: "bytes" },
        ],
      },
    ],
    outputs: [{ type: "bytes32" }],
  },
] as const;

// ─── Helper: sign a UserOp ────────────────────────────────────────────────────

// Reusable function β€” gets the hash from EntryPoint and signs it
async function signUserOp(userOp: typeof baseUserOp) {
  const userOpHash = await publicClient.readContract({
    address: ENTRYPOINT_ADDRESS,
    abi: entryPointAbi,
    functionName: "getUserOpHash",
    args: [userOp],
  });

  const signature = await walletClient.signMessage({
    message: { raw: userOpHash },
  });

  return { ...userOp, signature };
}

// ─── Compute wallet address ───────────────────────────────────────────────────

// Same for both UserOps β€” the wallet lives here before and after deployment
const walletAddress = await publicClient.readContract({
  address: FACTORY_ADDRESS,
  abi: factoryAbi,
  functionName: "getAddress",
  args: [account.address, SALT],
});

// ─── Fund the wallet before anything ─────────────────────────────────────────

// Send ETH to walletAddress to cover gas for both UserOps
// Send USDC to walletAddress so it has tokens to transfer in UserOp 2
// The wallet doesn't need to be deployed yet β€” funds wait at the address

// ═════════════════════════════════════════════════════════════════════════════
// USER OP 1 β€” Deploy wallet + set USDC daily limit
// ═════════════════════════════════════════════════════════════════════════════

// Deploy the wallet and immediately set a 1000 USDC/day limit
// We call setDailyLimit as the callData so both happen in one UserOp
const deployCallData = encodeFunctionData({
  abi: walletAbi,
  functionName: "setDailyLimit",
  args: [
    USDC_ADDRESS,
    parseUnits("1000", 6), // 1000 USDC per day β€” USDC has 6 decimals
  ],
});

const deployUserOp = await signUserOp({
  sender: walletAddress,

  // nonce 0 β€” this is the very first UserOp from this wallet
  nonce: 0n,

  // Factory set β€” EntryPoint will deploy the wallet before doing anything else
  factory: FACTORY_ADDRESS,

  // Tells the factory how to deploy our wallet
  factoryData: encodeFunctionData({
    abi: factoryAbi,
    functionName: "createWallet",
    args: [account.address, SALT],
  }),

  // After deploying, call setDailyLimit on the newly deployed wallet
  callData: deployCallData,

  callGasLimit: 150_000n,
  verificationGasLimit: 200_000n, // slightly higher β€” includes factory deploy gas
  preVerificationGas: 50_000n,

  maxFeePerGas: parseUnits("10", "gwei"),
  maxPriorityFeePerGas: parseUnits("1", "gwei"),

  // No paymaster β€” wallet pays its own gas
  paymaster: zeroAddress,
  paymasterData: "0x" as "0x${string}",
  paymasterVerificationGasLimit: 0n,
  paymasterPostOpGasLimit: 0n,

  signature: "0x" as "0x${string}",
});

// Send UserOp 1 to the bundler
const deployOpHash = await bundlerClient.request({
  method: "eth_sendUserOperation",
  params: [deployUserOp, ENTRYPOINT_ADDRESS],
});

console.log("UserOp 1 sent β€” deploying wallet + setting limits:", deployOpHash);

// Wait for UserOp 1 to land on-chain before sending UserOp 2
// The bundler exposes eth_getUserOperationReceipt to poll for inclusion
let receipt = null;
while (!receipt) {
  await new Promise((r) => setTimeout(r, 2000)); // poll every 2 seconds
  receipt = await bundlerClient.request({
    method: "eth_getUserOperationReceipt",
    params: [deployOpHash],
  });
}

console.log("Wallet deployed at:", walletAddress);
console.log("USDC daily limit set to 1000");

// ═════════════════════════════════════════════════════════════════════════════
// USER OP 2 β€” Transfer 500 USDC to recipient
// ═════════════════════════════════════════════════════════════════════════════

const transferUserOp = await signUserOp({
  sender: walletAddress,

  // nonce 1 β€” wallet exists now, increment nonce for every subsequent UserOp
  nonce: 1n,

  // No factory β€” wallet is already deployed, leave these empty
  factory: zeroAddress,
  factoryData: "0x" as "0x${string}",

  // Call transferToken β€” wallet checks daily limit then does the ERC20 transfer
  callData: encodeFunctionData({
    abi: walletAbi,
    functionName: "transferToken",
    args: [
      USDC_ADDRESS, // which token
      RECIPIENT, // who to send to
      parseUnits("500", 6), // 500 USDC β€” leaves 500 remaining for today
    ],
  }),

  callGasLimit: 150_000n,
  verificationGasLimit: 150_000n, // lower now β€” no factory deploy this time
  preVerificationGas: 50_000n,

  maxFeePerGas: parseUnits("10", "gwei"),
  maxPriorityFeePerGas: parseUnits("1", "gwei"),

  paymaster: zeroAddress,
  paymasterData: "0x" as `0x${string}`,
  paymasterVerificationGasLimit: 0n,
  paymasterPostOpGasLimit: 0n,

  signature: "0x" as `0x${string}`,
});

// Send UserOp 2 to the bundler
// notice we are sending this so we have to pay gas
// but as long as we signed it and have a valid signature
// anyone else can submit it
const transferOpHash = await bundlerClient.request({
  method: "eth_sendUserOperation",
  params: [transferUserOp, ENTRYPOINT_ADDRESS],
});

console.log("UserOp 2 sent β€” transferring 500 USDC:", transferOpHash);

We deployed our AA contract. Now we can:

  1. ✍️ Sign a transaction describing intent and have anyone submit it
  2. 🧩 Have any logic in the AA contract e.g. verify signature using Apple’s Face ID, limit transfers per day, multisig, sign in with custom logic
  3. πŸ’Έ Pay gas in other coins β€” we can pay someone USDC to submit this transaction for us on mainnet. We signed a valid transaction that can be validated. We decoupled the submission part.

❓ Questions

Who pays bundlers / what’s the incentive? Paid by the EntryPoint contract. They also take the spread between actual gas cost and maxFeePerGas.


References

EIPs
EIPs Explained: EIP-4337
Account Abstraction on Dune