16 min read
Overview
Account Abstraction and ERC-4337 - Part 1 laid the foundation for understanding EIP-4337. This guide builds on it hands-on: you'll create an Openfort-managed smart account, send a user-paid ERC-4337 transaction, sponsor gas with a paymaster, and verify both on Base Sepolia through Quicknode.
- Create an EIP-7702 delegated smart account with Openfort on Base Sepolia
- Send a user-paid ERC-4337 transaction through Openfort's bundler and verify it independently through Quicknode
- Sponsor gas with an Openfort paymaster policy so the account needs 0 ETH to transact
- Confirm both transactions by checking the
EntryPointreceipt and readingeth_getCodeon the account address
What You Will Need
- A fundamental understanding of how EIP-4337 works (check out Part 1)
- Node.js 20 or later installed
- An Openfort account, with a test-mode secret key and a Wallet Secret from the Openfort dashboard
- A Quicknode account with a Base Sepolia endpoint
- A code editor (e.g., VSCode)
- A terminal window
What You Will Do
- Recap the fundamentals of Account Abstraction (ERC-4337)
- Create an EIP-7702 delegated smart account with Openfort
- Send a user-paid transaction using a smart account and ERC-4337
- Sponsor gas for a transaction with an Openfort paymaster
Account Abstraction and ERC-4337 Basics
What is Account Abstraction (ERC-4337)?
Part 1 of this guide series covered the fundamental concepts of Account Abstraction in Ethereum. This section recaps them before moving into ERC-4337 implementation:
- Ethereum's Account Challenges: Externally Owned Accounts (EOAs) have limitations in user experience, particularly when interacting with smart contracts, performing multi-step operations, or managing seed phrases. Smart Contract Accounts offer some solutions, but they also have their own challenges.
- Introduction to ERC-4337: ERC-4337, often referred to as Account Abstraction Using Alt Mempool, emerged as a promising solution to the mentioned challenges. This Ethereum Improvement Proposal (EIP) focuses on enhancing the wallet user experience.
Key Components of ERC-4337
- UserOperations: This is the action the user wants to take. This could be transferring funds from the smart contract account, interacting with another smart contract, or doing a social recovery call.
UserOperationobjects have similar fields to transaction objects we see in Ethereum today. However, fields like nonce and signature are account-specific (as implemented by ERC-4337). - Bundlers: Entities that gather and submit UserOperations to the Ethereum network via the
EntryPointcontract. Bundler participation is permissionless: anyone can run one, and each bundler chooses which EntryPoint versions and mempool rules to support. Since bundlers are incentivized to stay active, they receive fees and prioritize whichUserOperationthey bundle for maximum profitability. - EntryPoint: A single smart contract that validates and executes UserOperations. This is the contract most or all Bundlers on the network will be interacting with in order to send batched
UserOperationobjects. - Contract Accounts: These are contract accounts that entities other than the account owner control.
- Paymaster: Optional entities that can sponsor transaction fees (e.g., another entity can pay for your transaction fees).
- Aggregators: Help validate signatures from multiple UserOperations together.
With these concepts refreshed, the next section shows how to build and interact with an ERC-4337-compliant smart contract.
SimpleAccount.sol: An ERC-4337 Contract Example
The Ethereum Foundation has implemented a minimalistic example of an ERC-4337-compliant contract called SimpleAccount.sol.
Review the code below to understand its functionality; you won't create this file yourself, since Openfort's SDK is what you'll use later in this guide.
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.12;
/* solhint-disable avoid-low-level-calls */
/* solhint-disable no-inline-assembly */
/* solhint-disable reason-string */
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
import "../core/BaseAccount.sol";
import "./callback/TokenCallbackHandler.sol";
/**
* minimal account.
* this is sample minimal account.
* has execute, eth handling methods
* has a single signer that can send requests through the entryPoint.
*/
contract SimpleAccount is BaseAccount, TokenCallbackHandler, UUPSUpgradeable, Initializable {
using ECDSA for bytes32;
address public owner;
IEntryPoint private immutable _entryPoint;
event SimpleAccountInitialized(IEntryPoint indexed entryPoint, address indexed owner);
modifier onlyOwner() {
_onlyOwner();
_;
}
/// @inheritdoc BaseAccount
function entryPoint() public view virtual override returns (IEntryPoint) {
return _entryPoint;
}
// solhint-disable-next-line no-empty-blocks
receive() external payable {}
constructor(IEntryPoint anEntryPoint) {
_entryPoint = anEntryPoint;
_disableInitializers();
}
function _onlyOwner() internal view {
//directly from EOA owner, or through the account itself (which gets redirected through execute())
require(msg.sender == owner || msg.sender == address(this), "only owner");
}
/**
* execute a transaction (called directly from owner, or by entryPoint)
*/
function execute(address dest, uint256 value, bytes calldata func) external {
_requireFromEntryPointOrOwner();
_call(dest, value, func);
}
/**
* execute a sequence of transactions
* @dev to reduce gas consumption for trivial case (no value), use a zero-length array to mean zero value
*/
function executeBatch(address[] calldata dest, uint256[] calldata value, bytes[] calldata func) external {
_requireFromEntryPointOrOwner();
require(dest.length == func.length && (value.length == 0 || value.length == func.length), "wrong array lengths");
if (value.length == 0) {
for (uint256 i = 0; i < dest.length; i++) {
_call(dest[i], 0, func[i]);
}
} else {
for (uint256 i = 0; i < dest.length; i++) {
_call(dest[i], value[i], func[i]);
}
}
}
/**
* @dev The _entryPoint member is immutable, to reduce gas consumption. To upgrade EntryPoint,
* a new implementation of SimpleAccount must be deployed with the new EntryPoint address, then upgrading
* the implementation by calling `upgradeTo()`
*/
function initialize(address anOwner) public virtual initializer {
_initialize(anOwner);
}
function _initialize(address anOwner) internal virtual {
owner = anOwner;
emit SimpleAccountInitialized(_entryPoint, owner);
}
// Require the function call went through EntryPoint or owner
function _requireFromEntryPointOrOwner() internal view {
require(msg.sender == address(entryPoint()) || msg.sender == owner, "account: not Owner or EntryPoint");
}
/// implement template method of BaseAccount
function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash)
internal override virtual returns (uint256 validationData) {
bytes32 hash = userOpHash.toEthSignedMessageHash();
if (owner != hash.recover(userOp.signature))
return SIG_VALIDATION_FAILED;
return 0;
}
function _call(address target, uint256 value, bytes memory data) internal {
(bool success, bytes memory result) = target.call{value : value}(data);
if (!success) {
assembly {
revert(add(result, 32), mload(result))
}
}
}
/**
* check current account deposit in the entryPoint
*/
function getDeposit() public view returns (uint256) {
return entryPoint().balanceOf(address(this));
}
/**
* deposit more funds for this account in the entryPoint
*/
function addDeposit() public payable {
entryPoint().depositTo{value : msg.value}(address(this));
}
/**
* withdraw value from the account's deposit
* @param withdrawAddress target to send to
* @param amount to withdraw
*/
function withdrawDepositTo(address payable withdrawAddress, uint256 amount) public onlyOwner {
entryPoint().withdrawTo(withdrawAddress, amount);
}
function _authorizeUpgrade(address newImplementation) internal view override {
(newImplementation);
_onlyOwner();
}
}
Here's a recap of the code above:
The SimpleAccount contract above is controlled by an external owner address and is designed to interact with an EntryPoint contract (per the ERC-4337 standard), allowing the owner to execute transactions without paying gas themselves. The contract integrates with OpenZeppelin's library for other functionalities like cryptographic signature validation (ECDSA) and upgradeable contract patterns (UUPSUpgradeable and Initializable). It also imports the BaseAccount and callback handler. The BaseAccount is a core component that keeps track of the smart contract's nonce, assists in UserOperation payload validation, EntryPoint interaction, payment for execution (i.e., payPrefund()), and extensibility, allowing for custom implementations for functions like _validateSignature(), _validateNonce(), and _payPrefund().
The state variable owner stores the account's owner address, and _entryPoint is an immutable reference to an external contract that serves as the EntryPoint.
Two main functions, execute and executeBatch, allow the owner or the relay system's entry point to send transactions or a sequence of transactions, respectively. Both functions first check if the sender is either the EntryPoint or the owner before processing.
The _authorizeUpgrade hook lets the owner authorize an upgrade to a new implementation contract; it doesn't upgrade the owner itself. Any updates to the EntryPoint (e.g., _entryPoint) will require a new smart contract account deployment.
SimpleAccount is the canonical reference implementation for ERC-4337: a factory deploys a new contract account, and that account validates and executes UserOperations. The next section builds the same flow with Openfort, a wallet infrastructure provider that reaches a different account implementation by delegating an existing EOA with EIP-7702 instead of deploying through a factory. The EntryPoint contract and the UserOperation object are the same either way; only the route to code living at the account address changes.
Factory Deployment (SimpleAccount) | EIP-7702 Delegation (Openfort) | |
|---|---|---|
| Deployment step | A factory contract deploys new bytecode | An existing EOA signs an authorization delegating to an implementation contract |
| Resulting address | A new contract address | The same EOA address you already control |
| Code location | Deployed directly at the new address | A delegation designator (0xef0100...) pointing to shared implementation code |
| Gas cost | Pays deployment gas once, on first use | Pays authorization gas once, on first transaction |
Bottom line: both routes end with the same EntryPoint validating and executing the same UserOperation object; only how code ends up at the account address differs.
How to Build an ERC-4337 Smart Account with Openfort
The ERC-4337-compliant implementation in this section comes from Openfort, a wallet infrastructure provider whose Node SDK creates and manages the EIP-7702-delegated smart account, then routes every UserOperation through its own bundler and, optionally, its paymaster.
Install and Initialize the Openfort SDK
1. Open your terminal, create a new project, and install the Openfort SDK alongside viem, a TypeScript library for interacting with Ethereum used later to verify transactions independently through Quicknode. This guide runs TypeScript directly with tsx, so no build step or tsconfig.json is required:
mkdir openfort-erc4337-guide
cd openfort-erc4337-guide
npm init -y
npm pkg set type=module
npm install @openfort/openfort-node viem
npm install --save-dev tsx @types/node
2. Create a .env file with your Openfort test-mode secret key, your Wallet Secret (both from the Openfort dashboard), and your Quicknode Base Sepolia endpoint:
OPENFORT_SECRET_KEY=sk_test_...
OPENFORT_WALLET_SECRET=...
QUICKNODE_ENDPOINT=https://your-endpoint.base-sepolia.quiknode.pro/your-api-key/
3. Create src/openfort.ts to initialize the client once and share it across scripts:
import Openfort from '@openfort/openfort-node'
function requireEnv(name: string): string {
const value = process.env[name]
if (!value) throw new Error(`Missing required environment variable: ${name}`)
return value
}
export const rpcUrl = requireEnv('QUICKNODE_ENDPOINT')
export const openfort = new Openfort(requireEnv('OPENFORT_SECRET_KEY'), {
walletSecret: requireEnv('OPENFORT_WALLET_SECRET'),
})
The Wallet Secret encrypts the private key material Openfort manages for the account; the SDK needs both it and your secret key to sign anything.
Create an EIP-7702 Delegated Smart Account
4. Create src/create-account.ts:
import { openfort } from './openfort.js'
try {
const account = await openfort.accounts.evm.backend.create()
console.log('Account ID:', account.id)
console.log('Address: ', account.address)
} catch (error) {
console.error(
'Failed to create account:',
error instanceof Error ? error.message : error
)
process.exit(1)
}
Run it:
npx tsx --env-file=.env src/create-account.ts
Account ID: acc_0a3d39b7-8771-44a1-947d-e32c61792b31
Address: 0xae02855cc9d94369ccabb9c7a973c4e000d797f9
This address is a plain EOA; eth_getCode on it currently returns 0x. It only becomes a smart account, through an EIP-7702 delegation, the first time you send a transaction through it. That's a different route to the same destination as SimpleAccount: instead of a factory deploying new bytecode at a fresh address, Openfort delegates code to an address you already control.
Fund the Smart Account
Fund the address from the previous step using the Quicknode Multi-Chain Faucet. Send Base Sepolia test ETH to it.
The faucet requires a mainnet balance on the address requesting funds. If you already have test ETH elsewhere, transfer it directly to the address instead.

If you'd rather not fund the account, skip ahead to Sponsor Gas for the Transaction with an Openfort Paymaster and sponsor the gas instead.
Send a User-Paid UserOperation
5. With the account funded, create src/send-transaction.ts. Passing rpcUrl routes the chain reads Openfort performs (like checking whether the account is already delegated) through your Quicknode endpoint:
import { openfort, rpcUrl } from './openfort.js'
const [accountId, policyId] = process.argv.slice(2)
if (!accountId) {
throw new Error(
'Usage: npx tsx --env-file=.env src/send-transaction.ts <accountId> [sponsorshipId]'
)
}
try {
const account = await openfort.accounts.evm.backend.get({ id: accountId })
const response = await openfort.accounts.evm.backend.sendTransaction({
account,
chainId: 84532, // Base Sepolia
interactions: [{ to: account.address, value: '0', data: '0x' }],
rpcUrl,
...(policyId ? { policy: policyId } : {}),
})
const details = response.details as unknown as {
userOperation: Record<string, unknown>
userOperationHash: string
}
console.log('Abstraction type: ', response.abstractionType)
console.log('UserOperation hash: ', details.userOperationHash)
console.log('Transaction hash: ', response.response?.transactionHash)
console.log('UserOperation: ', details.userOperation)
} catch (error) {
console.error(
'Failed to send transaction:',
error instanceof Error ? error.message : error
)
process.exit(1)
}
interactions is Openfort's abstraction over a UserOperation's callData; each entry is a { to, value, data } call the account executes. The optional policy argument is covered in Sponsor Gas for the Transaction with an Openfort Paymaster. Run it with the account ID from the previous step:
npx tsx --env-file=.env src/send-transaction.ts <accountId>
Abstraction type: accountAbstractionV9
UserOperation hash: 0x90747bb2d7ca778b55cb0d1f4e4591d7cb0904e07833eff09bcfea6413ec5e42
Transaction hash: 0xd274b490c0dfbd152574a836371fc6f1bf5bc98f75dd2bb70de2663332171760
UserOperation: {
authorization: {
address: '0x0909bABe99b0A5f8C1fbfcD5E2510E6c15082c53',
chainId: '0x14a34',
nonce: '0x0',
// ...
// ...
},
// ...
}
Run the exact same command a second time. The transaction hash changes, but notice what's missing from the returned UserOperation the second time: the first call carried authorization and factory: '0x7702' fields, the EIP-7702 delegation happening inline. The second call omits both, because the account is already delegated and Openfort reuses it.
Analyze the Lifecycle of the Smart Account Transaction
6. Take the transaction hash from the first call and look it up on BaseScan. Independently of Openfort, verify the same transaction through your Quicknode endpoint. Create src/verify.ts:
import { createPublicClient, http } from "viem";
import { baseSepolia } from "viem/chains";
import { rpcUrl } from "./openfort.js";
const [transactionHash] = process.argv.slice(2) as [string];
// Ensure the hash is prefixed with 0x to satisfy viem's `0x${string}` type
const hash = (transactionHash.startsWith("0x") ? transactionHash : `0x${transactionHash}`) as `0x${string}`;
try {
const client = createPublicClient({
chain: baseSepolia,
transport: http(rpcUrl),
});
const receipt = await client.waitForTransactionReceipt({
hash,
});
console.log(receipt.status, receipt.from, receipt.to);
} catch (error) {
console.error(
"Failed to verify transaction:",
error instanceof Error ? error.message : error,
);
process.exit(1);
}
Run it:
npx tsx --env-file=.env src/verify.ts <transactionHash>
success 0x67ddcabd5be94e5f591f4e5f287bba59f922a30d 0x433709009b8330fda32311df1c2afa402ed8d009
- Status:
success, matching what BaseScan shows. - From:
0x67ddcabd5be94e5f591f4e5f287bba59f922a30d. This is Openfort's bundler, not your account; see Bundlers above for why bundler participation is permissionless and why different transactions can land through different bundlers. - To:
0x433709009B8330FDa32311DF1C2AFA402eD8D009. This is theEntryPointcontract, version 0.9. Bundlers don't call your account directly; they callhandleOpson the EntryPoint with your UserOperation, and the EntryPoint validates it before calling into your account. - Account code: reading
eth_getCodeon0xae02855cc9d94369ccabb9c7a973c4e000d797f9at the receipt's block now returns0xef01000909babe99b0a5f8c1fbfcd5e2510e6c15082c53, an EIP-7702 delegation designator: the fixed prefix0xef0100followed by an implementation address,0x0909bABe99b0A5f8C1fbfcD5E2510E6c15082c53, which is Calibur V9, Openfort's own account contract (not an ERC or EIP standard). The account's address never changed; only the code living at it did.
What happened: you built a UserOperation, Openfort's SDK signed it and handed it to the bundler, the bundler wrapped it in a transaction calling handleOps on the EntryPoint, and the EntryPoint validated the UserOperation, including the EIP-7702 authorization on the first call, before executing it against your account.
Sponsor Gas for the Transaction with an Openfort Paymaster
Instead of funding every account, you can have an Openfort paymaster cover gas. This needs a policy, the rule set that decides what to sponsor, and a fee sponsorship, the sponsorship instance you reference when sending.
7. Create src/sponsor.ts:
import { openfort } from './openfort.js'
try {
const policy = await openfort.policies.create({
scope: 'project',
description: 'Sponsor Base Sepolia gas',
rules: [
{
action: 'accept',
operation: 'sponsorEvmTransaction',
criteria: [{ type: 'evmNetwork', operator: 'in', chainIds: [84532] }],
},
],
})
const sponsorship = await openfort.feeSponsorship.create({
name: 'Quicknode ERC-4337 guide sponsorship',
strategy: { sponsorSchema: 'pay_for_user' },
policyId: policy.id,
})
console.log('Policy ID: ', policy.id)
console.log('Sponsorship ID:', sponsorship.id)
} catch (error) {
console.error(
'Failed to create sponsorship:',
error instanceof Error ? error.message : error
)
process.exit(1)
}
Run it:
npx tsx --env-file=.env src/sponsor.ts
Policy ID: ply_38d20343-22f2-4879-ad9d-70e8b9479baf
Sponsorship ID: pol_4ed5d0c8-12d7-44b0-92a0-971df1da5789
The policy argument on sendTransaction is typed as a policy ID, and its prefix is misleadingly close to another one: policies use ply_, fee sponsorships use pol_. Passing the ply_ policy ID fails with Invalid pol: '<id>'. Pass the pol_ sponsorship ID instead.
8. Create a second, unfunded account and send through it with the sponsorship ID as the policy argument. Substitute the account ID printed by the first command and the sponsorship ID from the previous step for the ones shown here:
npx tsx --env-file=.env src/create-account.ts
npx tsx --env-file=.env src/send-transaction.ts <account2_id> <sponsorship_id>
Abstraction type: accountAbstractionV9
UserOperation hash: 0x378be13bff62eb3104273583da1e3e7ef60f845c884acb25b8148383cb9f9d48
Transaction hash: 0x955ca4ebc6880cfe978543fccd601cc6671c7f8d845c8dad9599075877f38c7e
UserOperation: {
authorization: {
address: '0x0909bABe99b0A5f8C1fbfcD5E2510E6c15082c53',
chainId: '0x14a34',
nonce: '0x0',
// ...
// ...
},
// ...
}
Verifying this transaction through Quicknode the same way as before shows the same EntryPoint (v0.9) and a different bundler address, 0x9041f23861304F8E201cE7EA348fF45a54899765, confirming that bundlers are interchangeable. The returned UserOperation also carries fields the user-paid one never had: paymaster (0x9999fEe91eeF78fA05E03ea722Bb441151e7f63B), paymasterData, paymasterVerificationGasLimit, and validUntil. Checking the account's balance before and after the transaction confirms it stayed at 0 wei both times: the paymaster covered gas, so the account never needed ETH.
Test Your Knowledge
To challenge your understanding of ERC-4337, check out this short six-question quiz!
Wrap Up
Congrats! You have created an EIP-7702 delegated smart account with Openfort, sent both a user-paid and a gas-sponsored ERC-4337 transaction, and independently verified each one through Quicknode. This same pattern, an EOA delegated via EIP-7702 and routed through a bundler and optional paymaster, extends to batched transactions, session keys, and other Account Abstraction features Openfort exposes on top of the same EntryPoint contract.
If you have any questions, check out the Quicknode Forum for help. Stay up to date with the latest by following us on Twitter (@Quicknode) or Discord.
Next Steps
- Account Abstraction and ERC-4337 - Part 1 for the conceptual foundation this guide builds on
- EIP-7702 Implementation Guide: Build and Test Smart Accounts to build and test smart accounts with batched transactions and sponsored gas
- How to Send EIP-7702 Transactions with Ethers.js for a lower-level look at constructing EIP-7702 transactions yourself
- How to Build an ERC-20 Batch Swap dApp Using EIP-7702 for a full batched-transaction dApp example
Frequently Asked Questions
What is EIP-7702 delegation, and how is it different from a factory-deployed smart account?
EIP-7702 delegation lets an existing externally owned account (EOA) authorize an implementation contract to run at its own address, so the address never changes and only the code living at it does. A factory-deployed smart account, like SimpleAccount, instead creates a brand new contract address. Both routes end at the same EntryPoint validating and executing the same UserOperation object.
What's the difference between an Openfort policy ID and a fee sponsorship ID?
A policy (prefixed ply_) is the rule set that decides what Openfort will sponsor, such as which chains are eligible. A fee sponsorship (prefixed pol_) is the sponsorship instance created from that policy. The policy argument on sendTransaction expects the sponsorship's pol_ ID, not the policy's ply_ ID.
Do you need to fund an Openfort smart account before sending a transaction?
Only if a paymaster isn't sponsoring gas. A newly created account is a plain EOA with a 0 ETH balance, and the first user-paid transaction requires Base Sepolia test ETH to cover gas. If an Openfort paymaster policy covers the transaction instead, the account can stay at 0 ETH indefinitely.
Can this pattern be used on chains other than Base Sepolia?
Yes. Openfort's SDK supports EIP-7702 delegation and its bundler and paymaster on any chain it lists as supported. Change the chainId passed to sendTransaction and point rpcUrl at a Quicknode endpoint for that chain.
Why does a transaction's from address belong to a bundler instead of the account?
ERC-4337 bundlers submit the on-chain transaction that carries a UserOperation to the EntryPoint contract, so the transaction's from field is the bundler's address, not the account's. The EntryPoint validates the UserOperation and then executes it against the account.
What costs are involved in sponsoring gas with an Openfort paymaster?
Openfort's paymaster pays the gas for sponsored UserOperations on your behalf, billed according to your Openfort plan. Check the Openfort dashboard and Openfort's pricing page for current rates.
