Deep dive · Ethereum

Account Abstraction

Smart-contract wallets, programmable authentication and gasless transactions. A walk through ERC-4337 — UserOperations, bundlers, the EntryPoint, paymasters and account factories — with working Solidity for each piece.

ERC-4337UserOperationEntryPointPaymasterCREATE2

What is Account Abstraction?

Account Abstraction is a transformative approach in blockchain technology that enhances security, user experience, flexibility, scalability and interoperability. By enabling custom logic for account management and transaction handling, it opens up new possibilities for innovation in the decentralized ecosystem — making blockchain more accessible, secure and versatile for both developers and users.

In this post we compare account abstraction to traditional account setups, introduce some of the exciting new use cases this technology enables, and explain how you, as a web3 developer, can take advantage of this new paradigm.

Account abstraction overview diagram
Clone the repo
$ git clone https://github.com/ramachandrareddy352/account-abstraction/
$ cd account-abstraction
$ npm install
EOA vs Smart Contract Account

An Externally Owned Account (EOA) is controlled by a single private key — whoever holds the key controls every asset, and the authentication scheme (ECDSA) can never change. A Smart Contract Account (SCA) moves that logic into code: validation, authentication and execution become programmable. ERC-4337 standardises this without any consensus-layer change to Ethereum.


01 Key concepts of ERC-4337

The building blocks of Account Abstraction are the UserOperation, Bundler, Sender (SCA), EntryPoint, Paymaster and Aggregator. Together they let developers build smart-contract wallets and make dApps compatible with them.

1 · UserOperation

A UserOperation is a "pseudo-transaction object" representing a user's transaction intent. It can contain multiple instructions and additional data to execute smart-contract calls initiated by the SCA. UserOperations begin the 4337 transaction flow.

UserOperation vs traditional transaction
  • Additional fields — new fields in the structure (EntryPoint, Bundler, Aggregator references).
  • Alternate mempool — UserOps are sent to a separate mempool where bundlers package them into transactions.
  • Authentication — for a regular transaction, auth is always a signature from a single fixed private key. In a UserOp, authentication is programmable.
UserOperation struct (EntryPoint v0.6)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

struct UserOperation {
    address sender;                 // the Smart Contract Account
    uint256 nonce;                  // anti-replay value, managed by EntryPoint
    bytes   initCode;               // factory + calldata to deploy the SCA (first tx only)
    bytes   callData;               // the call the SCA will execute
    uint256 callGasLimit;           // gas for the execution phase
    uint256 verificationGasLimit;   // gas for the validation phase
    uint256 preVerificationGas;     // gas to compensate the bundler for calldata/overhead
    uint256 maxFeePerGas;           // EIP-1559 style fee cap
    uint256 maxPriorityFeePerGas;   // EIP-1559 style priority fee
    bytes   paymasterAndData;       // paymaster address + data (empty = self-pay)
    bytes   signature;              // SCA-verified signature over the whole UserOp
}
2 · Bundler

A bundler watches the alternative UserOp mempool, bundles many UserOperations into a single transaction and submits it to the EntryPoint. Bundlers are compensated with a portion of the gas fees. Because every Ethereum transaction must still be initiated by an EOA, bundlers have EOAs — and in an account-abstracted world they are the only participants that need one.

3 · EntryPoint

The EntryPoint is a singleton contract that receives bundled transactions, then verifies and executes each UserOperation. During verification it checks the SCA can pay the maximum gas it might use; if not, it rejects the op. During execution it calls the account with the supplied calldata and reimburses the bundler.

EntryPoint.handleOps — simplified flow
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

interface IAccount {
    function validateUserOp(
        UserOperation calldata userOp,
        bytes32 userOpHash,
        uint256 missingAccountFunds
    ) external returns (uint256 validationData);
}

contract EntryPointLike {
    // Bundler calls this with a batch of UserOperations
    function handleOps(UserOperation[] calldata ops, address payable beneficiary) external {
        uint256 opsLen = ops.length;

        // ---- Phase 1: validation loop ----
        for (uint256 i = 0; i < opsLen; i++) {
            UserOperation calldata op = ops[i];
            bytes32 opHash = getUserOpHash(op);
            uint256 missingFunds = _requiredPrefund(op); // top-up the SCA must cover

            // The SCA decides if the signature/nonce/limits are valid
            uint256 validationData = IAccount(op.sender).validateUserOp(op, opHash, missingFunds);
            require(validationData == 0, "AA: signature/validation error");
        }

        // ---- Phase 2: execution loop ----
        for (uint256 i = 0; i < opsLen; i++) {
            UserOperation calldata op = ops[i];
            (bool ok, ) = op.sender.call{gas: op.callGasLimit}(op.callData);
            // failures are recorded but do not revert the whole bundle
            emit UserOperationEvent(getUserOpHash(op), op.sender, ok);
            _compensateBundler(op, beneficiary);
        }
    }

    function getUserOpHash(UserOperation calldata) public view returns (bytes32) { /* ... */ }
    function _requiredPrefund(UserOperation calldata) internal pure returns (uint256) { /* ... */ }
    function _compensateBundler(UserOperation calldata, address payable) internal { /* ... */ }

    event UserOperationEvent(bytes32 indexed userOpHash, address indexed sender, bool success);
}

Splitting validation and execution into two separate loops is deliberate: the EntryPoint validates the entire batch before executing any op, so a single invalid UserOp can be dropped without wasting the gas of the others.

4 · Paymaster

The Paymaster is an ERC-4337 contract that implements gas-payment policies. It creates flexibility in how gas is paid and by whom, removing the requirement to hold the chain's native token. Users can pay gas in any ERC-20 (USDC, USDT) or have a dApp sponsor the fees entirely.

Paymasters let application developers sponsor gas fees for users, accept stablecoins for gas, and accept other ERC-20 tokens for gas.

A minimal verifying Paymaster
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

interface IPaymaster {
    function validatePaymasterUserOp(
        UserOperation calldata userOp,
        bytes32 userOpHash,
        uint256 maxCost
    ) external returns (bytes memory context, uint256 validationData);

    function postOp(uint8 mode, bytes calldata context, uint256 actualGasCost) external;
}

contract SponsorPaymaster is IPaymaster {
    address public immutable entryPoint;
    address public owner;
    mapping(address => bool) public sponsored; // dApp whitelists SCAs

    constructor(address _entryPoint) {
        entryPoint = _entryPoint;
        owner = msg.sender;
    }

    // Called by the EntryPoint during validation
    function validatePaymasterUserOp(
        UserOperation calldata userOp,
        bytes32,
        uint256
    ) external view returns (bytes memory context, uint256 validationData) {
        require(msg.sender == entryPoint, "only EntryPoint");
        require(sponsored[userOp.sender], "sender not sponsored");
        // returning 0 means "valid, no time bounds"
        return (abi.encode(userOp.sender), 0);
    }

    // Called after execution so the paymaster can do accounting / charge in ERC-20
    function postOp(uint8, bytes calldata context, uint256 actualGasCost) external {
        require(msg.sender == entryPoint, "only EntryPoint");
        address account = abi.decode(context, (address));
        emit GasSponsored(account, actualGasCost);
    }

    function setSponsored(address account, bool ok) external {
        require(msg.sender == owner, "not owner");
        sponsored[account] = ok;
    }

    event GasSponsored(address indexed account, uint256 cost);
}
5 · Aggregator

An Aggregator is a contract implementing a signature scheme that supports aggregation — it can verify a single combined signature standing in for many individual signatures. If multiple messages are signed with different keys, a single combined signature proves all constituent signatures are valid. By combining signatures (e.g. BLS), aggregators save on calldata costs, validating many bundled UserOperations in one step.


02 New use cases enabled

1 · Authorization over assets inside a wallet

With a conventional EOA, anyone who knows the private key controls every asset. Because an SCA is programmable, you can enforce rules about what a given key may do — multi-signature requirements, time-locks, transfer-amount and frequency limits, even restricting which contracts the account may interact with.

Spending-limit guard inside an SCA
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

contract LimitedAccount {
    address public owner;
    uint256 public dailyLimit;       // max wei spendable per day
    uint256 public spentToday;
    uint256 public windowStart;

    constructor(address _owner, uint256 _dailyLimit) {
        owner = _owner;
        dailyLimit = _dailyLimit;
        windowStart = block.timestamp;
    }

    modifier withinLimit(uint256 amount) {
        // reset the rolling 24h window
        if (block.timestamp >= windowStart + 1 days) {
            windowStart = block.timestamp;
            spentToday = 0;
        }
        require(spentToday + amount <= dailyLimit, "daily limit exceeded");
        spentToday += amount;
        _;
    }

    // The SCA exposes execute(); EntryPoint reaches it via callData
    function execute(address to, uint256 value, bytes calldata data)
        external
        withinLimit(value)
        returns (bytes memory)
    {
        require(msg.sender == owner || msg.sender == address(this), "not authorized");
        (bool ok, bytes memory ret) = to.call{value: value}(data);
        require(ok, "call failed");
        return ret;
    }
}
2 · Fee sponsorship

Normally a user needs the chain's native token in their EOA before they can transact — a real friction point that may involve a CEX, KYC and AML. ERC-4337's paymaster can cover transaction fees on behalf of the user, so SCA creation and future transactions cost the user nothing.

3 · Enhanced fee payments

Although protocol-level fees are still paid in ETH, the programmability of SCAs plus paymasters lets the user pay in any ERC-20 they like — a stablecoin or a dApp's native token (AAVE, UNI, COMP, …).

4 · Account automation and pull transactions

In web2, many payments are "pull" based — a subscription pulls funds automatically. EVM chains today only have "push" transactions, where the owner must actively send funds. Account abstraction lets you grant a third party permission to pull funds from your SCA for a specified amount and periodicity, enabling web2-like automatic payments.

A session-key / pull-payment module
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

contract PullPaymentModule {
    struct Subscription {
        address payee;      // who may pull
        uint256 amount;     // max per period
        uint256 period;     // seconds between pulls
        uint256 lastPull;   // timestamp of last successful pull
        bool    active;
    }

    // account => payee => subscription
    mapping(address => mapping(address => Subscription)) public subs;

    function approveSubscription(address payee, uint256 amount, uint256 period) external {
        subs[msg.sender][payee] =
            Subscription(payee, amount, period, 0, true);
        emit Approved(msg.sender, payee, amount, period);
    }

    // Called by the payee to pull the recurring amount
    function pull(address account) external {
        Subscription storage s = subs[account][msg.sender];
        require(s.active, "no subscription");
        require(block.timestamp >= s.lastPull + s.period, "too early");
        s.lastPull = block.timestamp;

        // the account contract must implement payOut()
        ILimited(account).payOut(s.payee, s.amount);
        emit Pulled(account, s.payee, s.amount);
    }

    function cancel(address payee) external {
        subs[msg.sender][payee].active = false;
    }

    event Approved(address indexed account, address indexed payee, uint256 amount, uint256 period);
    event Pulled(address indexed account, address indexed payee, uint256 amount);
}

interface ILimited { function payOut(address to, uint256 amount) external; }
5 · Batch transactions

Current dApps often require several independent approvals for a single task — e.g. on Uniswap you sign an approve then sign the swap. An SCA can batch both into one approval flow, greatly improving UX.

executeBatch on the account
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

contract BatchAccount {
    address public owner;

    constructor(address _owner) { owner = _owner; }

    struct Call { address to; uint256 value; bytes data; }

    // One UserOp, many calls — approve + swap in a single atomic execution
    function executeBatch(Call[] calldata calls) external {
        require(msg.sender == owner || msg.sender == address(this), "not authorized");
        for (uint256 i = 0; i < calls.length; i++) {
            (bool ok, ) = calls[i].to.call{value: calls[i].value}(calls[i].data);
            require(ok, "batch call failed");
        }
    }
}
6 · Improved recovery

Wallet security is one of web3's biggest problems — self-custody is too complex for the average user, and pure centralized custody has its own risks. Account abstraction enables flexible recovery, such as social recovery (introduced by Vitalik Buterin), where a user's social network helps recover the wallet.

Social-recovery guardians
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

contract SocialRecovery {
    address public owner;
    address[] public guardians;
    uint256 public threshold;          // how many guardians must approve

    address public proposedOwner;
    uint256 public approvals;
    mapping(address => bool) public hasApproved;

    constructor(address[] memory _guardians, uint256 _threshold) {
        guardians = _guardians;
        threshold = _threshold;
        owner = msg.sender;
    }

    modifier onlyGuardian() {
        bool ok;
        for (uint256 i; i < guardians.length; i++) if (guardians[i] == msg.sender) ok = true;
        require(ok, "not a guardian");
        _;
    }

    function proposeRecovery(address newOwner) external onlyGuardian {
        proposedOwner = newOwner;
        approvals = 0;
        // reset prior approvals
        for (uint256 i; i < guardians.length; i++) hasApproved[guardians[i]] = false;
        emit RecoveryProposed(newOwner);
    }

    function approveRecovery() external onlyGuardian {
        require(!hasApproved[msg.sender], "already approved");
        hasApproved[msg.sender] = true;
        approvals++;
        if (approvals >= threshold) {
            owner = proposedOwner;          // rotate the key
            emit RecoveryExecuted(owner);
        }
    }

    event RecoveryProposed(address indexed newOwner);
    event RecoveryExecuted(address indexed newOwner);
}

03 Traditional wallets vs AA wallets

In a traditional wallet, a user owns a private key that controls an EOA, and every dApp interaction needs the user's approval — a signed transaction sent to a node and into the mempool. Under ERC-4337, transactions are initiated by an SCA acting on behalf of the user. The user first signals intent with a UserOperation, signs it (ECDSA, BLS, or any scheme the SCA supports), and sends it off-chain to a Bundler. The bundler — an EOA — aggregates UserOps and forwards them to the SCA through the EntryPoint.

Side-by-side
Traditional EOA                         │  Account Abstraction (ERC-4337)
────────────────────────────────────────┼──────────────────────────────────────────────
basic unit = transaction                 │  basic unit = user operation (userOp)
signed with the end user's private key   │  signed by the user with any SCA-supported method
sent to an Ethereum node via RPC         │  sent to a bundler via RPC
packaged into a block, added to chain    │  enters userOp mempool, picked up by a bundler,
                                         │    sent to the EntryPoint for processing
calldata processed by target contract    │  calldata processed by the SCA, which usually
                                         │    then calls a function on a target contract

Note that under AA, "account creation" doesn't refer to deploying the SCA — it refers to calculating the SCA's address counterfactually. The actual deployment happens when the user sends their first UserOp (carrying the initCode). And rather than sending a transaction from an EOA, the wallet constructs a UserOp and sends it to a bundler — done manually with low-level code, or via tooling like Alchemy's AA SDK.


04 Anatomy of a UserOp

To understand what a UserOp does, look at its core components. The first three fields mirror a normal transaction; the rest make abstraction possible.

UserOperation struct fields
  • sender — the SCA's address.
  • nonce — a unique value preventing replay attacks.
  • initCode — if supplied, the code responsible for creating the SCA (first UserOp only).
  • callData — the method call to execute on the SCA.
  • signature — a sender-verified signature over the entire UserOp.

The sender, nonce and signature fields align closely with their counterparts in a traditional transaction.

initCode & the account factory

A cornerstone of AA is retaining the EOA-like ability to derive a wallet locally and immediately accept funds before it's even deployed. The standard proposes a factory contract with a method to create an account, triggered the first time a user sends a UserOp. The initCode field contains the factory address, the function that creates the SCA (usually createAccount), and its parameters — the owner address and a salt — encoded as calldata.

AccountFactory.sol — counterfactual deployment via CREATE2
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import {SimpleAccount} from "./SimpleAccount.sol";

contract AccountFactory {
    address public immutable entryPoint;

    constructor(address _entryPoint) { entryPoint = _entryPoint; }

    // EntryPoint calls this through initCode on the very first UserOp
    function createAccount(address owner, uint256 salt) external returns (address) {
        address predicted = getAddress(owner, salt);

        // if already deployed, just return it (idempotent)
        if (predicted.code.length > 0) {
            return predicted;
        }

        // deterministic deploy: same owner+salt => same address on any chain
        SimpleAccount account =
            new SimpleAccount{salt: bytes32(salt)}(entryPoint, owner);
        return address(account);
    }

    // Counterfactual address — known before deployment, so funds can be sent first
    function getAddress(address owner, uint256 salt) public view returns (address) {
        bytes32 hash = keccak256(
            abi.encodePacked(
                bytes1(0xff),
                address(this),
                bytes32(salt),
                keccak256(abi.encodePacked(
                    type(SimpleAccount).creationCode,
                    abi.encode(entryPoint, owner)
                ))
            )
        );
        return address(uint160(uint256(hash)));
    }
}
callData

While callData also exists in traditional transactions, here it refers to the code the SCA will execute. The specific function it calls inside the SCA depends on the wallet's intended use, and can vary.

SimpleAccount.sol — validation + execution
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract SimpleAccount {
    using ECDSA for bytes32;

    address public immutable entryPoint;
    address public owner;

    uint256 internal constant SIG_VALIDATION_FAILED = 1;

    constructor(address _entryPoint, address _owner) {
        entryPoint = _entryPoint;
        owner = _owner;
    }

    modifier onlyEntryPoint() {
        require(msg.sender == entryPoint, "account: not from EntryPoint");
        _;
    }

    // Phase 1: the EntryPoint asks the account to validate the signature
    function validateUserOp(
        UserOperation calldata userOp,
        bytes32 userOpHash,
        uint256 missingAccountFunds
    ) external onlyEntryPoint returns (uint256 validationData) {
        // recover the signer from the userOp hash
        address recovered = userOpHash.toEthSignedMessageHash().recover(userOp.signature);
        if (recovered != owner) {
            return SIG_VALIDATION_FAILED;   // non-zero => EntryPoint rejects
        }

        // pre-fund the EntryPoint for the gas it will spend on us
        if (missingAccountFunds > 0) {
            (bool ok, ) = payable(entryPoint).call{value: missingAccountFunds}("");
            (ok);   // ignore failure here; EntryPoint re-checks
        }
        return 0;   // 0 => valid, no time bounds
    }

    // Phase 2: the EntryPoint executes the intended call
    function execute(address to, uint256 value, bytes calldata data)
        external
        onlyEntryPoint
        returns (bytes memory)
    {
        (bool ok, bytes memory ret) = to.call{value: value}(data);
        require(ok, "account: call reverted");
        return ret;
    }

    receive() external payable {}
}

05 Lifecycle of a UserOperation

Walking through a transfer end-to-end:

  1. User initiates the operation through a dApp or wallet interface — e.g. transferring tokens.
  2. Create the UserOperation object with the operation details: sender, recipient, value, callData, gas limit, gas price, nonce, and any custom conditions.
  3. Validate the UserOperation — the SCA validates against its custom rules: balance, signatures, nonce correctness and any other logic.
  4. Meta-transaction (optional) — if the user lacks native token for gas, a paymaster/relayer submits the signed op and is reimbursed by alternative means.
  5. Submit to the blockchain — the validated op is submitted as a transaction, directly or via a bundler.
  6. SCA executes the operation — the wallet processes the call based on its details and custom rules, e.g. transferring tokens once all conditions are met.
  7. Custom logic and hooks — pre/post hooks can run additional checks or supplementary tasks during execution.
  8. Handle gas fees — fees are paid per the predefined method (relayer pays, or deducted from the user/SCA).
  9. Confirmation — once executed, the op is confirmed and the interface updates to reflect success.
Building & sending a UserOp (ethers v6 + viem-style)
import { ethers } from "ethers";

// 1) derive the counterfactual SCA address from the factory
const sender = await factory.getAddress(ownerAddress, salt);

// 2) encode the call the SCA should perform (transfer 1 token)
const callData = accountIface.encodeFunctionData("execute", [
  tokenAddress,
  0,
  tokenIface.encodeFunctionData("transfer", [recipient, amount]),
]);

// 3) assemble the UserOperation
const userOp = {
  sender,
  nonce: await entryPoint.getNonce(sender, 0),
  initCode: isDeployed ? "0x" : factoryInitCode, // factory + createAccount(owner, salt)
  callData,
  callGasLimit: 200_000n,
  verificationGasLimit: 150_000n,
  preVerificationGas: 50_000n,
  maxFeePerGas: ethers.parseUnits("30", "gwei"),
  maxPriorityFeePerGas: ethers.parseUnits("2", "gwei"),
  paymasterAndData: "0x",                          // self-pay; or paymaster addr + data
  signature: "0x",
};

// 4) hash + sign with the owner key
const userOpHash = await entryPoint.getUserOpHash(userOp);
userOp.signature = await ownerWallet.signMessage(ethers.getBytes(userOpHash));

// 5) ship it to the bundler RPC, not a normal node
await bundlerProvider.send("eth_sendUserOperation", [userOp, entryPointAddress]);

06 Potential drawbacks

Account Abstraction offers many benefits, but it also brings challenges worth weighing:

  • Learning curve — a steep ramp to understand the new concepts and implement them correctly, which can slow developer adoption in the short term.
  • Security risks — new participants (bundlers, paymasters) can be exploited if not properly secured. Replay attacks are a concern since signature/nonce handling is left to the developer.
  • Potential for centralization — although anyone can run a bundler or paymaster, a few dominant entities in these roles could lead to centralization.
  • Maintenance challenges — new ecosystem elements make the network and applications harder to maintain and monitor.
  • More expensive — performing an action via a UserOp is typically costlier than an EOA transaction, due to the overhead that makes AA possible (paying the bundler, EntryPoint validation, etc.).

References