Account Abstraction (ERC-4337)

Difficulty: Advanced

Account abstraction is a paradigm shift in how Ethereum accounts work, aiming to make smart contract wallets the primary interface rather than externally owned accounts (EOAs). Ethereum currently has two account types: EOAs controlled by a single private key and contract accounts that execute code. EOAs are limited because they can only authorize transactions through ECDSA signatures, cannot batch operations atomically, and always require ETH for gas. Account abstraction through ERC-4337 enables smart contract wallets with programmable authentication, gas sponsorship, batched transactions, and social recovery, all without modifying the Ethereum consensus layer.

ERC-4337, authored by Vitalik Buterin, Yoav Weiss, and others, introduces a higher-level mempool system that runs in parallel with the standard transaction mempool. Instead of submitting standard Ethereum transactions, users create UserOperation objects that describe their intent. These UserOps are broadcast to a specialized mempool where Bundlers (nodes that act like specialized block builders) aggregate multiple UserOps into a single on-chain transaction and submit it to a singleton EntryPoint contract. The EntryPoint validates each UserOp by calling the sender wallet's validateUserOp function, executes the operation if validation passes, and handles gas accounting including paymaster sponsorship.

The UserOperation struct is the central data structure of ERC-4337. It contains the sender address (the smart wallet), a nonce for replay protection, optional initCode for deploying the wallet on the first operation, callData encoding the actual action to perform, gas parameters, optional paymasterAndData for sponsored transactions, and a signature field that is interpreted by the wallet contract itself. This last point is crucial: because the wallet defines its own validation logic, the signature field can contain an ECDSA signature, a multi-signature threshold proof, a WebAuthn/passkey assertion, a session key authorization, or any other authentication scheme the wallet developer implements.

Paymasters solve the cold-start problem where new users need ETH before they can do anything. A Paymaster is a smart contract that agrees to pay gas fees for certain UserOperations. It implements validatePaymasterUserOp to decide whether to sponsor a UserOp and postOp for post-execution accounting. Common patterns include verifying paymaster approval (to sponsor specific dApp interactions), accepting ERC-20 tokens as payment (so users pay gas in USDC or DAI), and off-chain signature authorization (where a backend signs approvals for eligible users). This enables onboarding flows where users never need to acquire ETH.

The ecosystem has matured significantly since ERC-4337 was deployed. The canonical EntryPoint v0.7 contract lives at the same address across all EVM chains. Bundler implementations exist from Pimlico, Alchemy, Stackup, and others. Wallet SDKs like ZeroDev, Biconomy, Safe, and Alchemy Account Kit provide high-level APIs that abstract away the UserOp construction entirely. Advanced features like session keys (time-limited permissions for dApps), modular account architecture (ERC-6900 and ERC-7579 for pluggable wallet modules), and cross-chain account deployment are rapidly evolving.

Code examples

Simple Smart Account with Custom Validation

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

import "@account-abstraction/contracts/core/BaseAccount.sol";
import "@account-abstraction/contracts/interfaces/IEntryPoint.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import "@openzeppelin/contracts/proxy/utils/Initializable.sol";

/
 * @title SimpleSmartAccount
 * @notice ERC-4337 compatible smart wallet with single-owner ECDSA validation
 */
contract SimpleSmartAccount is BaseAccount, Initializable {
    using ECDSA for bytes32;
    using MessageHashUtils for bytes32;

    address public owner;
    IEntryPoint private immutable _entryPoint;

    event SimpleAccountInitialized(IEntryPoint indexed entryPoint, address indexed owner);

    modifier onlyOwnerOrEntryPoint() {
        require(
            msg.sender == address(entryPoint()) || msg.sender == owner,
            "account: not Owner or EntryPoint"
        );
        _;
    }

    constructor(IEntryPoint entryPoint_) {
        _entryPoint = entryPoint_;
        _disableInitializers(); // Prevent implementation init
    }

    function initialize(address owner_) public initializer {
        owner = owner_;
        emit SimpleAccountInitialized(_entryPoint, owner_);
    }

    function entryPoint() public view override returns (IEntryPoint) {
        return _entryPoint;
    }

    /// @dev Core ERC-4337: validate the UserOp signature
    function _validateSignature(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash
    ) internal view override returns (uint256 validationData) {
        // Convert to Ethereum signed message hash
        bytes32 hash = userOpHash.toEthSignedMessageHash();
        address recovered = hash.recover(userOp.signature);

        if (recovered != owner) {
            return SIG_VALIDATION_FAILED; // Returns 1
        }
        return SIG_VALIDATION_SUCCESS; // Returns 0
    }

    /// @notice Execute a single call (only via EntryPoint or owner)
    function execute(
        address target,
        uint256 value,
        bytes calldata data
    ) external onlyOwnerOrEntryPoint {
        (bool success, bytes memory result) = target.call{value: value}(data);
        if (!success) {
            assembly {
                revert(add(result, 32), mload(result))
            }
        }
    }

    /// @notice Batch multiple calls in one UserOp
    function executeBatch(
        address[] calldata targets,
        uint256[] calldata values,
        bytes[] calldata datas
    ) external onlyOwnerOrEntryPoint {
        require(targets.length == values.length && values.length == datas.length, "length mismatch");
        for (uint256 i = 0; i < targets.length; i++) {
            (bool success, bytes memory result) = targets[i].call{value: values[i]}(datas[i]);
            if (!success) {
                assembly {
                    revert(add(result, 32), mload(result))
                }
            }
        }
    }

    /// @notice Deposit ETH to EntryPoint for gas pre-funding
    function addDeposit() public payable {
        entryPoint().depositTo{value: msg.value}(address(this));
    }

    receive() external payable {}
}

This smart account implements ERC-4337 by extending BaseAccount. The _validateSignature function is called by the EntryPoint during UserOp validation. It recovers the signer from the signature and checks ownership. The execute and executeBatch functions allow single or batched calls but restrict access to the EntryPoint or the owner. The Initializable pattern supports deployment via a factory with CREATE2 for deterministic addresses.

Account Factory for Deterministic Deployment

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

import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "@openzeppelin/contracts/utils/Create2.sol";
import "./SimpleSmartAccount.sol";

/
 * @title SimpleAccountFactory
 * @notice Deploys SimpleSmartAccount proxies using CREATE2
 * @dev The factory address + owner + salt = deterministic wallet address
 *      Users can compute their wallet address before deployment
 */
contract SimpleAccountFactory {
    SimpleSmartAccount public immutable accountImplementation;

    constructor(IEntryPoint entryPoint_) {
        accountImplementation = new SimpleSmartAccount(entryPoint_);
    }

    /
     * @notice Deploy a new smart account (or return existing)
     * @param owner The EOA that will control this smart account
     * @param salt Unique salt for deterministic address
     */
    function createAccount(
        address owner,
        uint256 salt
    ) external returns (SimpleSmartAccount) {
        // Compute the deterministic address
        address addr = getAddress(owner, salt);

        // If already deployed, return existing account
        uint256 codeSize;
        assembly {
            codeSize := extcodesize(addr)
        }
        if (codeSize > 0) {
            return SimpleSmartAccount(payable(addr));
        }

        // Deploy ERC1967 proxy pointing to the implementation
        ERC1967Proxy proxy = new ERC1967Proxy{salt: bytes32(salt)}(
            address(accountImplementation),
            abi.encodeCall(SimpleSmartAccount.initialize, (owner))
        );

        return SimpleSmartAccount(payable(address(proxy)));
    }

    /
     * @notice Compute the counterfactual address of an account
     * @dev Used by the SDK to know the wallet address before deployment
     */
    function getAddress(
        address owner,
        uint256 salt
    ) public view returns (address) {
        bytes memory bytecode = abi.encodePacked(
            type(ERC1967Proxy).creationCode,
            abi.encode(
                address(accountImplementation),
                abi.encodeCall(SimpleSmartAccount.initialize, (owner))
            )
        );

        return Create2.computeAddress(bytes32(salt), keccak256(bytecode));
    }
}

// === Usage in UserOperation ===
// When a user's smart wallet isn't deployed yet, the UserOp includes:
//   initCode = abi.encodePacked(
//     factoryAddress,
//     abi.encodeCall(factory.createAccount, (ownerAddress, 0))
//   )
// The EntryPoint calls the factory, deploys the wallet,
// then proceeds with validation and execution.
// The user's address is known BEFORE deployment via getAddress().

The factory uses CREATE2 for deterministic addresses, meaning a user's smart wallet address is known before it is deployed on-chain. The first UserOperation includes initCode that tells the EntryPoint to call this factory. Subsequent operations skip deployment since the wallet already exists. The ERC1967 proxy pattern keeps deployment costs low while allowing future upgrades.

Paymaster for Gas Sponsorship

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

import "@account-abstraction/contracts/core/BasePaymaster.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

/
 * @title VerifyingPaymaster
 * @notice Sponsors gas for UserOps approved by an off-chain signer
 * @dev Backend signs approval for eligible UserOps (e.g., new users,
 *      specific dApp interactions, promotional campaigns)
 */
contract VerifyingPaymaster is BasePaymaster {
    using ECDSA for bytes32;
    using MessageHashUtils for bytes32;

    address public verifyingSigner;

    // Tracks sponsored gas per account (for rate limiting)
    mapping(address => uint256) public sponsoredGas;
    uint256 public maxSponsoredGasPerAccount = 0.05 ether;

    event GasSponsored(address indexed account, uint256 actualGasCost);

    constructor(
        IEntryPoint entryPoint_,
        address verifyingSigner_
    ) BasePaymaster(entryPoint_) {
        verifyingSigner = verifyingSigner_;
    }

    /
     * @dev Called by EntryPoint during validation phase
     * @param userOp The UserOperation to potentially sponsor
     * @param userOpHash Hash of the UserOp (used for signature)
     * @param maxCost Maximum gas cost this UserOp might consume
     */
    function _validatePaymasterUserOp(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash,
        uint256 maxCost
    ) internal view override returns (bytes memory context, uint256 validationData) {
        // Extract signature and validity window from paymasterAndData
        // paymasterAndData = [paymaster address (20)] [validUntil (6)] [validAfter (6)] [signature (65)]
        (uint48 validUntil, uint48 validAfter, bytes memory signature) =
            _parsePaymasterData(userOp.paymasterAndData);

        // Recreate the hash that the backend signer should have signed
        bytes32 hash = keccak256(
            abi.encode(
                userOp.sender,
                userOp.nonce,
                keccak256(userOp.callData),
                validUntil,
                validAfter,
                block.chainid,
                address(this)
            )
        ).toEthSignedMessageHash();

        // Verify the backend approved this UserOp
        address recovered = hash.recover(signature);
        if (recovered != verifyingSigner) {
            return ("", _packValidationData(true, validUntil, validAfter));
        }

        // Check rate limit
        require(
            sponsoredGas[userOp.sender] + maxCost <= maxSponsoredGasPerAccount,
            "Sponsorship limit exceeded"
        );

        // Return context for postOp accounting
        return (
            abi.encode(userOp.sender, maxCost),
            _packValidationData(false, validUntil, validAfter)
        );
    }

    /
     * @dev Called after UserOp execution for gas accounting
     */
    function _postOp(
        PostOpMode mode,
        bytes calldata context,
        uint256 actualGasCost,
        uint256 actualUserOpFeePerGas
    ) internal override {
        (address sender, ) = abi.decode(context, (address, uint256));
        sponsoredGas[sender] += actualGasCost;
        emit GasSponsored(sender, actualGasCost);
    }

    function _parsePaymasterData(bytes calldata paymasterAndData)
        internal
        pure
        returns (uint48 validUntil, uint48 validAfter, bytes memory signature)
    {
        // Skip first 20 bytes (paymaster address, added by EntryPoint)
        validUntil = uint48(bytes6(paymasterAndData[20:26]));
        validAfter = uint48(bytes6(paymasterAndData[26:32]));
        signature = paymasterAndData[32:];
    }

    /// @notice Owner can update the signer
    function setVerifyingSigner(address newSigner) external onlyOwner {
        verifyingSigner = newSigner;
    }

    /// @notice Owner can update the per-account limit
    function setMaxSponsoredGas(uint256 newMax) external onlyOwner {
        maxSponsoredGasPerAccount = newMax;
    }
}

This VerifyingPaymaster sponsors gas for UserOperations that carry a valid off-chain signature from a backend server. The backend decides which users or operations to sponsor (e.g., first-time users, specific dApp calls) and signs an approval. The paymaster verifies this signature on-chain during validation. The postOp hook tracks cumulative sponsored gas per account for rate limiting. This pattern is used by most production paymasters including Pimlico, Alchemy, and Biconomy.

Sending UserOperations with permissionless.js SDK

// Using permissionless.js (Pimlico's SDK) for ERC-4337 v0.7
import { createSmartAccountClient } from 'permissionless';
import { toSimpleSmartAccount } from 'permissionless/accounts';
import { createPimlicoClient } from 'permissionless/clients/pimlico';
import { createPublicClient, http, parseEther, encodeFunctionData } from 'viem';
import { sepolia } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';

// ERC-20 ABI fragment for approve
const erc20Abi = [{
  name: 'approve',
  type: 'function',
  inputs: [
    { name: 'spender', type: 'address' },
    { name: 'amount', type: 'uint256' },
  ],
  outputs: [{ name: '', type: 'bool' }],
}] as const;

async function main() {
  // 1. Create public client for reading chain state
  const publicClient = createPublicClient({
    chain: sepolia,
    transport: http('https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY'),
  });

  // 2. Create Pimlico bundler + paymaster client
  const pimlicoClient = createPimlicoClient({
    transport: http('https://api.pimlico.io/v2/sepolia/rpc?apikey=PIMLICO_KEY'),
    entryPoint: {
      address: '0x0000000071727De22E5E9d8BAf0edAc6f37da032',
      version: '0.7',
    },
  });

  // 3. Create smart account from an EOA signer
  const signer = privateKeyToAccount('0xYOUR_PRIVATE_KEY');
  const smartAccount = await toSimpleSmartAccount({
    client: publicClient,
    owner: signer,
    entryPoint: {
      address: '0x0000000071727De22E5E9d8BAf0edAc6f37da032',
      version: '0.7',
    },
  });

  console.log('Smart wallet address:', smartAccount.address);
  // Address is deterministic - same signer always gets the same address

  // 4. Create the smart account client (combines account + bundler + paymaster)
  const smartAccountClient = createSmartAccountClient({
    account: smartAccount,
    chain: sepolia,
    bundlerTransport: http('https://api.pimlico.io/v2/sepolia/rpc?apikey=PIMLICO_KEY'),
    paymaster: pimlicoClient,  // Gas sponsorship via Pimlico
    userOperation: {
      estimateFeesPerGas: async () =>
        (await pimlicoClient.getUserOperationGasPrice()).fast,
    },
  });

  // 5. Send a simple transfer (SDK handles UserOp construction)
  const txHash = await smartAccountClient.sendTransaction({
    to: '0xRecipientAddress',
    value: parseEther('0.01'),
  });
  console.log('Transfer tx:', txHash);

  // 6. Batch operations: approve + swap in a single UserOp
  const batchHash = await smartAccountClient.sendTransaction({
    calls: [
      {
        to: '0xUSDC_ADDRESS',
        data: encodeFunctionData({
          abi: erc20Abi,
          functionName: 'approve',
          args: ['0xDEX_ROUTER', parseEther('100')],
        }),
      },
      {
        to: '0xDEX_ROUTER',
        value: 0n,
        data: encodeFunctionData({
          abi: erc20Abi, // placeholder for router ABI
          functionName: 'approve',
          args: ['0xDEX_ROUTER', parseEther('100')],
        }),
      },
    ],
  });
  console.log('Batch tx:', batchHash);
}

main().catch(console.error);

This example shows how SDK abstractions make ERC-4337 feel like regular transactions. The developer creates a smart account from an EOA signer, connects it to a bundler and paymaster, and sends transactions using familiar sendTransaction calls. The SDK handles UserOp construction, gas estimation, bundler submission, and paymaster negotiation behind the scenes. Batch operations are sent as a single UserOp that atomically executes multiple calls.

Key points

Concepts covered

ERC-4337, UserOperation, Bundler, Paymaster, Smart Account