In the previous article, we explored how EIP-7702 lets you turn a regular wallet into a smart account without losing its address or transaction history — a key step toward evolving Ethereum accounts. Read it below.
https://hackernoon.com/turn-a-regular-wallet-into-a-smart-account-with-eip-7702?embedable=true
Now, it’s time to look at the standard that made Account Abstraction truly real: ERC-4337.
Account Abstraction has long promised to combine security, usability, and programmability into a single account model. With ERC-4337, this vision became possible without modifying the Ethereum protocol itself. Instead of changing consensus rules, it introduces a parallel transaction layer where smart contracts act as wallets with fully programmable validation logic. That unlocks a new design space — wallets that use passkeys instead of seed phrases, pay gas in USDC, or recover access via social recovery, all without relying on traditional EOAs.
Most articles about ERC-4337 are theoretical, which is fine for a high-level overview. But to truly understand it, we need to go deeper — into the EntryPoint architecture, how UserOperations are coordinated, and how a wallet can even deploy itself.
In this article, we’ll build ERC-4337 infrastructure from first principles: deploy an EntryPoint on a local chain, create a minimal ECDSA-based smart wallet, implement a factory for deterministic deployment, and build a simple pseudo-bundler to execute a complete UserOperation that deploys the wallet and sends ETH.
By the end, you’ll have working code and a clear understanding of how the pieces of Account Abstraction fit together in practice.
Why ERC-4337 Is a Game Changer
Traditionally, sending a transaction on Ethereum requires an Externally Owned Account (EOA) with a private key. Losing that key means losing access. Every transaction must be signed with ECDSA and paid for in ETH. Fingerprint login isn’t possible. Third-party gas payments aren’t either.
Account Abstraction fundamentally changes this model. The wallet becomes a smart contract that defines its own signature verification logic. It can validate a standard ECDSA signature, a passkey, a multisig approval, or any other rule implemented in Solidity.
However, smart contracts can’t initiate transactions directly — they still need an EOA. ERC-4337 solves this by introducing a new interaction flow built around the UserOperation format — a specialized type of transaction for smart accounts.
A UserOperation is sent to specialized nodes called bundlers. Bundlers collect operations, combine them into a single transaction, and forward it to the EntryPoint contract. The EntryPoint validates each operation, coordinates execution with smart accounts, and settles gas payments.
For the user, the process feels seamless — a simple action, a signature, and execution (or with session keys, sometimes even without signing).
Everything else — bundlers, EntryPoint logic, validation, and gas accounting — happens behind the scenes.
At the infrastructure level, however, this architecture forms the foundation of the next generation of smart wallets on Ethereum.
Setting Up the Environment
Foundry is used for working with ERC-4337 — a framework that simplifies Solidity development and automates testing and deployment.
If Foundry is not installed:
curl -L https://foundry.paradigm.xyz | bash
foundryup
Project initialization and dependency installation:
mkdir erc4337-wallet && cd erc4337-wallet
forge init --no-git
forge install eth-infinitism/account-abstraction
forge install OpenZeppelin/openzeppelin-contracts
The eth-infinitism repository contains the official ERC-4337 implementation, including the EntryPoint contract used across the standard’s ecosystem.
Current version — 0.8.
Configuration for foundry.toml:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.28"
evm_version = "cancun"
remappings = [
"account-abstraction/=lib/account-abstraction/contracts/",
"@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/"
]
These remappings specify where Forge should locate imported contracts. Without them, imports such as account-abstraction/core/BaseAccount.sol will not resolve correctly.
Starting Anvil — the local Ethereum node included with Foundry:
anvil
The terminal displays test accounts with balances of 10,000 ETH and their private keys. The first account serves as the bundler, and the second as the wallet owner.
Inside the EntryPoint
At the center of ERC-4337 lies the EntryPoint — a singleton contract that coordinates the entire Account Abstraction flow. It acts as the execution hub between three key actors: bundlers, who collect and submit operations; smart accounts, which validate and execute them; and paymasters, who optionally sponsor gas.
The EntryPoint operates through a structured, multi-phase process designed for safety, efficiency, and deterministic behavior.
Validation Phase
When a bundler submits a batch of UserOperations, the EntryPoint begins by validating each one.
During this phase, it calls the smart account’s validateUserOp() function. The validation must complete within strict gas limits and can only access predefined storage slots. These constraints protect bundlers from malicious operations that might consume unbounded gas or rely on state that could change before execution.
Inside validation, the account verifies the user’s signature — whether it’s ECDSA, a passkey, or a multi-sig approval — checks the nonce to prevent replay attacks, and confirms gas funding. The gas can be preloaded from the account’s EntryPoint deposit or covered by a paymaster.
If validation fails for any operation, the EntryPoint halts that UserOperation before it reaches the execution phase.
Execution Phase
After successful validation, the EntryPoint executes each approved operation in sequence.
It calls the account’s execute() (or executeBatch() for multiple actions) with the user’s intended payload — sending ETH, calling another contract, or performing a complex DeFi transaction.
Each operation is isolated: if one fails, the rest of the batch continues unaffected. This isolation ensures predictable execution even in mixed batches submitted by bundlers.
Gas Management and Compensation
Throughout the process, the EntryPoint precisely tracks gas consumption. Every smart account and paymaster must maintain a deposit with the EntryPoint to cover execution costs.
When operations run, the actual gas used is deducted from the respective deposit. The bundler is reimbursed — including a priority fee — guaranteeing that bundlers never lose ETH even if an operation reverts.
Stake requirements for paymasters and factories are also enforced at this level to protect the network from denial-of-service attacks.
All accounting — gas tracking, compensation, and refunds — happens atomically within the same transaction, ensuring complete consistency.
Deploying to a Local Network
EntryPoint v0.8 is a non-upgradable contract responsible for managing all UserOperations.
It uses a deterministic address across every network:
The EntryPoint version 0.8 is a non-upgradable contract that handles all UserOperations. It has a deterministic address across all networks:
0x4337084d9e255ff0702461cf8895ce9e3b5ff108
This canonical address is deployed via the CREATE2 opcode through a deterministic deployment factory.
The factory itself lives at:
0x4e59b44847b379578588920ca78fbf26c0b4956c
and is available on most Ethereum networks, including Anvil by default.
Deployment
The most reliable way to deploy EntryPoint is by using the official eth-infinitism repository, which provides the reference implementation for ERC-4337.
Clone the
Then deploy the EntryPoint contract using the built-in scripts:
yarn hardhat deploy --network dev
After a successful deployment, the output should look similar to:
deploying "EntryPoint" (tx: 0x456b31559abf2560e9968663e4a73f0db03d1a0ff73019f71b61dcc6846f5f0c)...: deployed at 0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108 with 5034766 gas
==entrypoint addr= 0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108
Verification
Ensure that the deployed EntryPoint matches the canonical address:
0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108
This deterministic address guarantees compatibility with existing Account Abstraction infrastructure — including bundlers, paymasters, and simulation tools.
Any deviation can cause UserOperations to fail validation or be rejected by bundler nodes.
Understanding the EntryPoint Interface
The EntryPoint contract exposes a well-defined interface that different participants in the ERC-4337 flow interact with — bundlers, smart accounts, and paymasters. Each category of function has a distinct role in coordinating validation, execution, and gas management.
Core UserOperation Handling
The main function used by bundlers is handleOps().
It processes a batch of UserOperations within a single transaction, acting as the operational core of ERC-4337.
function handleOps(
PackedUserOperation[] calldata ops,
address payable beneficiary
) external;
Here’s what happens behind the scenes:
Bundlers collect UserOperations from the ERC-4337 mempool, group them into an array, and submit them to the EntryPoint along with a beneficiary address — usually their own — where the gas reimbursement will be sent after successful execution.
The EntryPoint then:
- Validates each operation by calling the account’s validateUserOp() method.
- Executes them sequentially using the account’s execute() function.
- Tracks and settles gas usage for every operation in the batch.
In practice, this means multiple smart accounts can have their transactions executed atomically in a single Ethereum transaction, improving efficiency and reducing overhead for bundlers.
Aggregated Operations
For scenarios that involve many signatures — such as multi-user applications or rollup-style batching — ERC-4337 introduces handleAggregatedOps():
function handleAggregatedOps(
UserOpsPerAggregator[] calldata opsPerAggregator,
address payable beneficiary
) external;
Signature aggregation is an advanced optimization that combines multiple cryptographic signatures into a single aggregated proof, reducing gas consumption.
For example, BLS signatures can represent 100 distinct signatures as one aggregated value, allowing the EntryPoint to verify them collectively rather than individually.
This approach is especially useful for large-scale systems such as wallet relayers or account networks where many users perform operations simultaneously.
Although aggregation won’t be implemented in our minimal setup, it’s an important part of ERC-4337’s scalability roadmap — a feature designed to make smart accounts as efficient as EOAs, even when executing at scale.
Deposit Management
Smart accounts and paymasters interact with the EntryPoint through a built-in deposit system that ensures smooth gas accounting and prevents bundlers from taking on risk.
Each account or paymaster maintains a balance within the EntryPoint contract.
This balance is used to pay for gas whenever a UserOperation is executed.
function depositTo(address account) external payable;
function balanceOf(address account) external view returns (uint256);
function withdrawTo(address payable withdrawAddress, uint256 withdrawAmount) external;
When a smart account is deployed, a small amount of ETH is deposited to cover its operations.
The EntryPoint tracks these balances and automatically deducts the actual gas cost after each UserOperation.
Any unused funds can be withdrawn at any time.
This system keeps the payment process atomic and trustless: bundlers are reimbursed instantly, and accounts retain full control over their deposits.
Nonce Management
To prevent replay attacks, each account has a nonce system:
function getNonce(address sender, uint192 key) external view returns (uint256);
The getUserOpHash function computes the hash that accounts must sign. It uses EIP-712 typed data hashing, which incorporates the EntryPoint address and chain ID into the hash. This prevents replay attacks—a UserOperation signed for one chain or EntryPoint instance cannot be replayed on another. The getNonce function retrieves the current nonce for an account with a specific key—ERC-4337 supports parallel nonce tracking so accounts can submit multiple independent UserOperation sequences.
Building a Minimal Smart Account
With the EntryPoint interface in place, it’s time to see how it actually works in practice. Let’s build a minimal smart account — just enough logic to validate ECDSA signatures from a single owner.
This version isn’t production-ready. It’s intentionally simple — stripped down to the essentials — so we can focus on the mechanics of how smart accounts communicate with the EntryPoint. The contract extends BaseAccount from the official account-abstraction library and uses a hardcoded EntryPoint address for clarity. It’s the bare minimum setup, but it forms the foundation for everything that comes next.
In future iterations, we’ll evolve this base into a full smart wallet — adding WebAuthn for passkey authentication, paymasters for gas abstraction, and recovery logic for better UX. For now, simplicity wins. This minimal implementation is exactly what’s needed to understand how the pieces fit together.
Inside the Smart Account Architecture
A smart account is a contract that implements the IAccount interface, which requires a single method:
function validateUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData);
When the EntryPoint processes a UserOperation, it calls this method on the account contract. The account checks the signature, verifies the operation is authorized, and returns validation data. If validation succeeds, the EntryPoint then calls the account's execute method with the actual operation to perform. Note that execute() is not part of the IAccount interface itself—it's provided by the BaseAccount implementation, which also includes executeBatch() for multiple operations.
Our customSimpleAccount contract extends BaseAccount from the account-abstraction library, which provides the core implementation including the validateUserOp method and execution logic. Here's the complete implementation:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "account-abstraction/core/BaseAccount.sol";
import "account-abstraction/core/Helpers.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SimpleAccount is BaseAccount {
address public constant ENTRY_POINT_ADDRESS =
0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108;
address public owner;
constructor(address _owner) {
owner = _owner;
}
receive() external payable {}
function entryPoint() public view override returns (IEntryPoint) {
return IEntryPoint(ENTRY_POINT_ADDRESS);
}
function _validateSignature(
PackedUserOperation calldata userOp,
bytes32 userOpHash
) internal override returns (uint256) {
if (owner != ECDSA.recover(userOpHash, userOp.signature))
return SIG_VALIDATION_FAILED;
return SIG_VALIDATION_SUCCESS;
}
}
This implementation stores a single owner address and validates that UserOperations are signed by this owner using standard ECDSA signature verification. The EntryPoint address is hardcoded as a constant for simplicity. The account can execute arbitrary calls, transfer ETH, and manage its deposit with the EntryPoint through methods inherited from BaseAccount.
Now that we have our SimpleAccount contract, let's deploy it. Create a deployment script at
script/DeploySimpleAccount.s.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "forge-std/Script.sol";
import "../src/SimpleAccount.sol";
contract DeploySimpleAccount is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
address owner = vm.addr(deployerPrivateKey);
vm.startBroadcast(deployerPrivateKey);
SimpleAccount account = new SimpleAccount(owner);
console.log("SimpleAccount deployed:", address(account));
console.log("Owner:", owner);
vm.stopBroadcast();
}
}
Run the deployment:
forge script script/DeploySimpleAccount.s.sol --rpc-url http://localhost:8545 --broadcast
Note the deployed address—you'll need it for the next steps. In a real-world scenario, wallets are typically deployed through a UserOperation that includes factory initCode, allowing the wallet to be created and used in a single operation. We'll explore this pattern in the following articles
Depositing Funds to the EntryPoint
Before sending UserOperations, the account needs an EntryPoint deposit to pay for gas. Since our custom SimpleAccount is minimal and doesn't include helper methods like addDeposit(), we'll directly call the EntryPoint's depositTo() function:
First, export the necessary variables from your deployment:
# Export the account address from your deployment output
export ACCOUNT_ADDRESS=<your_deployed_account_address>
export OWNER_PRIVATE_KEY=<your_owner_private_key>
export ENTRYPOINT=0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108
Now fund the account:
# Send ETH to your smart account (for the account to hold directly)
cast send $ACCOUNT_ADDRESS --value 1ether --private-key $OWNER_PRIVATE_KEY
# Fund the EntryPoint balance by calling depositTo on the EntryPoint
cast send $ENTRYPOINT \
"depositTo(address)" $ACCOUNT_ADDRESS \
--value 0.5ether \
--private-key $OWNER_PRIVATE_KEY
# Check the account's deposit balance
cast call $ENTRYPOINT \
"balanceOf(address)(uint256)" $ACCOUNT_ADDRESS
The account can now pay for UserOperation execution from its EntryPoint deposit.
Creating and Sending a UserOperation
Let's create a simple UserOperation that transfers ETH. In a production environment, you'd use a bundler service, but for learning, we'll act as the bundler ourselves. This means we'll construct the UserOperation, simulate it to check validity, then submit it by calling handleOps directly from an EOA.
Understanding UserOperations
A UserOperation is the ERC-4337 equivalent of a transaction. Instead of being processed directly by the blockchain, it's submitted to bundlers who package it into an actual transaction. Here's the structure:
struct PackedUserOperation {
address sender; // The smart account
uint256 nonce; // Anti-replay protection
bytes initCode; // Factory address + creation data (empty for existing accounts)
bytes callData; // Data to pass to account's execute() function
bytes32 accountGasLimits; // Packed: verificationGasLimit || callGasLimit
uint256 preVerificationGas; // Gas overhead for bundler
bytes32 gasFees; // Packed: maxPriorityFee || maxFeePerGas
bytes paymasterAndData; // Paymaster address + verification data (empty for self-pay)
bytes signature; // Signature for validation
}
The gas fields deserve attention. verificationGasLimit covers signature validation, callGasLimit covers execution, and preVerificationGas compensates the bundler for the overhead of including your operation. The accountGasLimits and gasFees fields pack two values into one bytes32 for gas efficiency.
Building the UserOperation Script
Create a TypeScript script
Step 1: Setup and Configuration
First, configure the connection and wallets:
import { ethers } from "ethers";
import IEntryPoint from "./out/IEntryPoint.sol/IEntryPoint.json";
const ENTRYPOINT = "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108";
const ACCOUNT = "0x..."; // Your deployed SimpleAccount
const OWNER_KEY = "0x...";
const BUNDLER_KEY =
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; // Anvil #0
const provider = new ethers.JsonRpcProvider("http://localhost:8545");
const owner = new ethers.Wallet(OWNER_KEY, provider);
const bundler = new ethers.Wallet(BUNDLER_KEY, provider);
Step 2: Construct the UserOperation
Now build the UserOperation structure. We'll send 0.1 ETH to another address:
async function sendUserOp() {
// Setup EntryPoint contract
const entryPoint = new ethers.Contract(ENTRYPOINT, IEntryPoint.abi, bundler);
// Get nonce for replay protection
const nonce = await entryPoint.getNonce(ACCOUNT, 0);
// Encode the action: transfer 0.1 ETH
const recipient = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const value = ethers.parseEther("0.1");
const callData = new ethers.Interface([
"function execute(address,uint256,bytes)",
]).encodeFunctionData("execute", [recipient, value, "0x"]);
// Build UserOperation
const userOp = [
ACCOUNT, // sender
nonce, // nonce
"0x", // initCode
callData, // callData
ethers.solidityPacked(["uint128", "uint128"], [100000, 100000]), // accountGasLimits
50000n, // preVerificationGas
ethers.solidityPacked(["uint128", "uint128"], [1000000000, 1000000000]), // gasFees
"0x", // paymasterAndData
"0x", // signature (will be filled after signing)
];
// ... continue to next step
}
Step 3: Simulate the UserOperation
Before signing, bundlers simulate to verify the operation will succeed. The IEntryPointSimulations.simulateValidation() function executes validation logic but always reverts, even on success. This prevents state changes while returning diagnostic information in the revert data as a ValidationResult struct containing gas estimates and stake info.
The simulation calls your account's validateUserOp method (which we'll explore in depth in future articles) to check if the signature and other validation logic would pass. For now, understand that simulation is the bundler's safety net—it protects them from including invalid operations that would waste gas.
try {
const sim = new ethers.Contract(
ENTRYPOINT,
[
"function simulateValidation((address,uint256,bytes,bytes,bytes32,uint256,bytes32,bytes,bytes))",
],
bundler
);
await sim.simulateValidation.staticCall(userOp);
} catch (error: any) {
// Always reverts! Check if revert contains ValidationResult (success) or actual error
}
Step 4: Sign and Submit
After simulation confirms validity, sign the UserOperation and submit it to the EntryPoint:
async function sendUserOp() {
// ... previous code …
// Sign the UserOperation hash
// getUserOpHash returns an EIP-712 formatted hash - sign it directly without Ethereum message prefix
const userOpHash = await entryPoint.getUserOpHash(userOp);
const signature = owner.signingKey.sign(userOpHash).serialized;
// Update signature in the UserOperation array
userOp[8] = signature;
// Submit to EntryPoint as bundler
console.log("Submitting UserOperation...");
const tx = await entryPoint.handleOps([userOp], bundler.address);
const receipt = await tx.wait();
console.log("UserOperation executed:", receipt.hash);
}
sendUserOp().catch(console.error);
This step-by-step approach mirrors the real bundler workflow. Simulation is the key safety mechanism—it ensures bundlers never risk gas on invalid operations.
Observing Gas Payment
Now that we've sent a UserOperation and it executed successfully, we can verify that the gas payment mechanism worked correctly. The bundler's ETH balance should have increased (they received gas compensation from the EntryPoint), while the fees were withdrawn from the account's deposit balance on the EntryPoint. Let's check the balances to confirm this:
# Check account's EntryPoint deposit (decreased)
cast call $ENTRYPOINT \
"balanceOf(address)(uint256)" $ACCOUNT_ADDRESS \
--rpc-url http://localhost:8545
# Check bundler balance (increased with gas compensation)
cast balance 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --rpc-url http://localhost:8545
The EntryPoint deducted gas costs from the account's deposit and paid the bundler. This atomic payment mechanism ensures bundlers never lose money, even if a UserOperation's execution reverts.
What We’ve Learned
We explored the EntryPoint— the core contract behind ERC-4337 that makes account abstraction work.
It validates operations, manages gas, compensates bundlers, and keeps every step atomic.
We looked at its main interfaces — handleOps, deposit management, and nonce utilities — and saw how they connect smart accounts to the network.
To put theory into action, we built a minimal SimpleAccount, deployed it with Foundry, funded it through the EntryPoint, and executed our first UserOperation end-to-end.
This foundation unlocks the next step: factories, bundlers, paymasters, and advanced wallet features like passkeys and social recovery.
ERC-4337 isn’t just a protocol — it’s the blueprint for the next generation of smart wallets.
