Lately, I’ve been exploring ERC-4337, which introduces account abstraction with an alternative mempool. It’s a really exciting topic because it lets developers hide much of the complexity with wallet management and dapp interaction, making the user experience more seamless.
I’m creating this series for developers who want to get hands-on with ERC-4337. This isn’t a series to learn about pros and cons of account abstraction. Instead, this series will focus on the ERC-4337 specification and walk through building account contracts using it.
Since we’ll be working with ERC-4337, I originally planned to explain concepts as we need them. But I realized it’s more helpful to start with an overview so you can understand the bigger picture upfront. So, let’s dive in and get familiar with ERC-4337!
Understanding ERC-4337: What It Is and How It Works
ERC-4337 defines how account abstraction should work on Ethereum or any EVM-compatible chain without changing the consensus layer. It introduces two key ideas: the UserOperation and the Alt Mempool, which are higher-layer components that rely on existing blockchain infrastructure.
A UserOperation is a high-level, pseudo-transaction object that holds both intent and verification data. Unlike regular transactions that are sent to validators, UserOperations are sent to bundlers through public or private alt mempools. These bundlers collect multiple UserOperations, bundle them together into a single transaction, and submit them to get included on the blockchain.
When bundlers send these UserOperations, they interact with a special contract called EntryPoint. The EntryPoint contract is responsible for validating and executing UserOperations. However, it doesn’t handle verification itself. Instead, the verification logic is stored in the Account Contract, which is the user’s smart contract wallet.
The Account Contract contains both the validation and execution logic for UserOperations. The ERC-4337 specification defines a standard interface for these contracts, ensuring they follow a consistent structure. Here's the IAccount
interface from the spec:
interface IAccount {
function validateUserOp
(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds)
external returns (uint256 validationData);
}
When a bundler submits a UserOperation to the EntryPoint contract, the EntryPoint first calls the validateUserOp
function on the user’s account contract. If the UserOperation is valid, the function returns SIG_VALIDATION_SUCCESS
. If validation is successful, the EntryPoint proceeds to execute the UserOperation's calldata on the account contract.
That’s a high-level overview of account abstraction with ERC-4337. Next, we’ll dive into the code and break down the key components of ERC-4337 starting with account contract in more detail. Let’s get started!
Setting Up the Project
We'll be using Foundry to write and test our smart contracts. If you’re not familiar with Foundry, I recommend checking out the Foundry documentation to get up to speed.
To create a new Foundry project, run the following commands in your terminal:
# Creates a new directory named 'simple-account'
mkdir simple-account
# Navigates into the 'simple-account' directory
cd simple-account
# Initializes a new Foundry project
forge init
Next, we need to install some dependencies that will help us develop our account contract. The eth-infinitism team has created an implementation of ERC-4337, which we’ll use as a base. To add it to our project, run the following command:
# Installs the ERC-4337 implementation from eth-infinitism
forge install eth-infinitism/account-abstraction@v0.7.0 --no-commit
Lastly, clean up the default files by removing everything inside the src
, script
, and test
folders. Then, create a new file named SimpleAccount.sol
inside the src
folder.
With this, your project setup is complete, and you’re ready to start developing the smart contract!
BaseAccount.sol
Much like with ERC-20 tokens, we don’t need to build everything from scratch. We can use BaseAccount.sol
from the eth-infinitism/account-abstraction
package as a foundation. Here's the BaseAccount.sol
implementation:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.23;
/* solhint-disable avoid-low-level-calls */
/* solhint-disable no-empty-blocks */
import "../interfaces/IAccount.sol";
import "../interfaces/IEntryPoint.sol";
import "./UserOperationLib.sol";
abstract contract BaseAccount is IAccount {
using UserOperationLib for PackedUserOperation;
function getNonce() public view virtual returns (uint256) {
return entryPoint().getNonce(address(this), 0);
}
function entryPoint() public view virtual returns (IEntryPoint);
function validateUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external virtual override returns (uint256 validationData) {
_requireFromEntryPoint();
validationData = _validateSignature(userOp, userOpHash);
_validateNonce(userOp.nonce);
_payPrefund(missingAccountFunds);
}
function _requireFromEntryPoint() internal view virtual {
require(
msg.sender == address(entryPoint()),
"account: not from EntryPoint"
);
}
function _validateSignature(
PackedUserOperation calldata userOp,
bytes32 userOpHash
) internal virtual returns (uint256 validationData);
function _validateNonce(uint256 nonce) internal view virtual {
}
function _payPrefund(uint256 missingAccountFunds) internal virtual {
if (missingAccountFunds != 0) {
(bool success, ) = payable(msg.sender).call{
value: missingAccountFunds,
gas: type(uint256).max
}("");
(success);
//ignore failure (its EntryPoint's job to verify, not account.)
}
}
}
Before diving into the code, let’s first understand some key specifications that ERC-4337 defines for account contracts. These are important to understand before moving forward:
- Trusted EntryPoint: The contract MUST verify that the caller is a trusted EntryPoint.
-
Signature Validation: If the account doesn't support signature aggregation, the contract MUST ensure that the signature is valid for the
userOpHash
. On a signature mismatch, it SHOULD returnSIG_VALIDATION_FAILED
(instead of reverting). Any other errors MUST cause a revert. -
Paying EntryPoint: The contract MUST ensure the EntryPoint (the caller) is paid at least the
missingAccountFunds
, which may be zero if the account’s deposit is already sufficient.
The provided BaseAccount.sol
implementation satisfies all these requirements in the validateUserOp
function.
function validateUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external virtual override returns (uint256 validationData) {
_requireFromEntryPoint();
validationData = _validateSignature(userOp, userOpHash);
_validateNonce(userOp.nonce);
_payPrefund(missingAccountFunds);
}
Let’s break down what happens in this function:
- _requireFromEntryPoint: This checks that the caller is the trusted EntryPoint contract.
- _validateSignature: This function handles the signature validation logic, which we will implement in our contract.
-
_payPrefund: This ensures that the EntryPoint is paid the
missingAccountFunds
, fulfilling the requirement of paying at least the minimum amount. - _validateNonce: This checks the transaction nonce, preventing replay attacks.
Why Do We Have a Nonce?
EOAs (Externally Owned Accounts) have a nonce to prevent replay attacks, ensuring that each transaction is unique. Now that we are building a smart contract wallet, we need a nonce for the same purpose. While we could define our own nonce validation logic, EntryPoint already handles this in a similar way, so we don’t need to focus too much on it for now.
In summary, the validateUserOp
function satisfies all the key requirements from the ERC-4337 specification.
SimpleAccount.sol
Now that we've met the core requirements, what’s left? From the BaseAccount
contract, we see that when inheriting it, we need to implement two key functions: _validateSignature
and entryPoint
. These two functions are essential to fulfill the basic requirements.
In addition to these, we also need a function to allow interaction with other accounts and contracts. ERC-4337 doesn’t specify what this function should be named, and it’s not important since the EntryPoint
contract will execute the calldata directly on the account contract.
Let’s begin by implementing the entryPoint
function, which is the simplest. Our goal is to define the trusted entryPoint
and return its address when the function is called.
EntryPoint Function
We’ll start by developing SimpleAccount.sol
. Begin by creating the SimpleAccount
contract and importing BaseAccount
from the BaseAccount.sol
file. This contract will inherit from BaseAccount
.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {BaseAccount} from "account-abstraction/core/BaseAccount.sol";
contract SimpleAccount is BaseAccount {
constructor() {}
}
Next, we need to define the trusted EntryPoint
in the constructor. The EntryPoint
address will be passed in as a parameter and stored in an immutable variable called i_entryPoint
, which is of type IEntryPoint
.
contract SimpleAccount is BaseAccount {
IEntryPoint private immutable i_entryPoint;
constructor(address entryPointAddress) {
i_entryPoint = IEntryPoint(entryPointAddress);
}
}
We'll also need to import the IEntryPoint
interface from the account-abstraction
module.
import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol";
Finally, we implement the entryPoint
function, which will return the trusted i_entryPoint
. This function will be a view
function and will override the inherited function from BaseAccount
, so we’ll add the override
keyword.
function entryPoint() public view override returns (IEntryPoint) {
return i_entryPoint;
}
At this point, your contract should look like this:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {BaseAccount} from "account-abstraction/core/BaseAccount.sol";
import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol";
contract SimpleAccount is BaseAccount {
IEntryPoint private immutable i_entryPoint;
constructor(address entryPointAddress) {
i_entryPoint = IEntryPoint(entryPointAddress);
}
function entryPoint() public view override returns (IEntryPoint) {
return i_entryPoint;
}
}
With this, you’ve successfully set up the basic structure of the SimpleAccount
contract, defining the trusted EntryPoint
. The next step would be to implement the _validateSignature
and the function that allows interaction with other contracts/accounts.
Account Contract Owner
Developers can implement any method they prefer to validate a UserOperation
, but for this series, we will use an ECDSA signature for validation. To perform this validation, we need to have the owner of the account contract. The owner's address will be provided in the constructor and stored in an immutable variable, i_owner
.
Here’s how we define the owner in the constructor:
// Variable to store the owner's address
address private immutable i_owner;
constructor(address entryPointAddress, address owner) {
i_entryPoint = IEntryPoint(entryPointAddress);
// Set the owner
i_owner = owner;
}
We also want to add a getter function, getOwner
, which returns the owner's address:
function getOwner() public view returns (address) {
return i_owner;
}
Next, we’ll work on the validateUserOp
function, but before diving into that, let's first understand the UserOperation
object and the data it contains.
UserOperation
We've mentioned the UserOperation
object several times, but what exactly does it contain?
The UserOperation
consists of common fields like the sender, nonce, gas details, calldata, and more—similar to what you’d find in a typical transaction. In addition to these standard fields, it includes extra ones, like accountFactory
, paymaster
, and signature
. Don’t worry about the extra fields just yet; we’ll explore them later in this series.
Once the UserOperation
is created by the user, it is sent to a bundler, which consolidates the fields and compresses the operation into a more compact format called PackedUserOperation
. This PackedUserOperation
will be sent to EntryPoint
. Here's what that looks like:
struct PackedUserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
bytes32 accountGasLimits;
uint256 preVerificationGas;
bytes32 gasFees;
bytes paymasterAndData;
bytes signature;
}
At this point, we only need to focus on two fields: calldata
, which is used for execution, and signature
, which is used to validate the UserOperation
.
validateUserOp
Function
The validateUserOp
function checks the validity of a UserOperation
. As we’ve seen in the BaseAccount
contract part of this article, this function is already implemented. However, the portion that handles signature validation—via the _validateSignature
function—needs to be implemented by the developer (in this case, us).
We’ll use an ECDSA signature to verify that the signature
in the UserOperation
is signed by the account contract’s owner. If the signer matches the owner, we return SIG_VALIDATION_SUCCESS
. If not, we return SIG_VALIDATION_FAILED
.
To verify the ECDSA signature, we can use the ecrecover
precompile, but it’s more efficient to use the OpenZeppelin ECDSA library, which simplifies the process and eliminates a lot of manual steps. Let’s install OpenZeppelin:
forge install OpenZeppelin/openzeppelin-contracts@v5.0.2 --no-commit
If installed correctly, you should see a message like this:
Installed openzeppelin-contracts v5.0.2
Now that OpenZeppelin is installed, we can begin writing the _validateSignature
function. The function signature is already defined in BaseAccount
, so we will override it:
function _validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash)
internal
view
override
returns (uint256) {}
As this function uses the PackedUserOperation
struct, we need to import it from the account-abstraction
package. Additionally, we'll import the constants used for signature validation (SIG_VALIDATION_SUCCESS
and SIG_VALIDATION_FAILED
) from the same package.
import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol";
import {SIG_VALIDATION_FAILED, SIG_VALIDATION_SUCCESS} from "account-abstraction/core/Helpers.sol";
Next, we need to convert the userOpHash
into a structured message. We will use OpenZeppelin’s MessageHashUtils.toEthSignedMessageHash
function to convert userOpHash
into the appropriate Ethereum Signed Message format. To recover the signer's address from this structured message and the provided signature, we’ll use OpenZeppelin’s ECDSA.recover
function. Let’s import these libraries before writing the function.
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
Now, let’s use these libraries to retrieve the address of the signer and store it in the messageSigner
variable.
function _validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash)
internal
view
override
returns (uint256)
{
bytes32 digest = MessageHashUtils.toEthSignedMessageHash(userOpHash);
address messageSigner = ECDSA.recover(digest, userOp.signature);
}
Now that we have the messageSigner
, we can compare it with the i_owner
. If they match, we return SIG_VALIDATION_SUCCESS
; otherwise, we return SIG_VALIDATION_FAILED
:
if (messageSigner == i_owner) {
return SIG_VALIDATION_SUCCESS;
} else {
return SIG_VALIDATION_FAILED;
}
Here’s the complete _validateSignature
function:
function _validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash)
internal
view
override
returns (uint256)
{
bytes32 digest = MessageHashUtils.toEthSignedMessageHash(userOpHash);
address messageSigner = ECDSA.recover(digest, userOp.signature);
if (messageSigner == i_owner) {
return SIG_VALIDATION_SUCCESS;
} else {
return SIG_VALIDATION_FAILED;
}
}
This completes the signature validation logic, ensuring the UserOperation
is signed by the account owner. The next step is to implement functionality for interacting with external accounts or contracts.
execute
Function
This function allows the account contract to interact with external contracts or accounts. While the function's name is arbitrary (since the EntryPoint
contract directly executes the calldata), in this contract, we'll call it execute
. The user or dapp constructing the UserOperation
will need to construct calldata for execution.
The execute
function will take three arguments:
-
dest
: The address of the contract or account to be called. -
value
: The amount of ETH to be sent with the call. -
funcCallData
: The calldata to be executed on the target address.
Here’s the basic function structure:
function execute(address dest, uint256 value, bytes calldata funcCallData) external {}
Since this function allows transferring assets and executing actions on behalf of the user (i.e., the owner of the account contract), it must be protected to prevent unauthorized access. To enforce access control, we'll use the _requireFromEntryPoint
function provided by the BaseAccount
contract, making sure that only entry points can call this function.
Next, the contract will perform the external call, and we’ll check if it was successful. If the call fails, we'll revert the transaction with a custom error, SimpleAccount__CallFailed
, which we define at the top of the contract.
Here’s how the error is defined, and it should be added at the top of the contract:
error SimpleAccount__CallFailed();
The complete execute
function is as follows:
function execute(address dest, uint256 value, bytes calldata funcCallData) external {
_requireFromEntryPoint(); // Restrict access to valid entry points
(bool success, bytes memory result) = dest.call{value: value}(funcCallData);
if (!success) {
revert SimpleAccount__CallFailed(result); // Revert if the call fails
}
}
With this function in place, we now have a simple account contract that can interact with other contracts and accounts, complying with ERC-4337 and compatible with the EntryPoint
contract and bundlers.
Here is the completed code for SimpleAccount:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {BaseAccount} from "account-abstraction/core/BaseAccount.sol";
import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol";
import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol";
import {SIG_VALIDATION_FAILED, SIG_VALIDATION_SUCCESS} from "account-abstraction/core/Helpers.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SimpleAccount is BaseAccount {
error SimpleAccount__CallFailed();
IEntryPoint private immutable i_entryPoint;
address private immutable i_owner;
constructor(address entryPointAddress, address owner) {
i_entryPoint = IEntryPoint(entryPointAddress);
i_owner = owner;
}
function _validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash)
internal
view
override
returns (uint256)
{
bytes32 digest = MessageHashUtils.toEthSignedMessageHash(userOpHash);
address messageSigner = ECDSA.recover(digest, userOp.signature);
if (messageSigner == i_owner) {
return SIG_VALIDATION_SUCCESS;
} else {
return SIG_VALIDATION_FAILED;
}
}
function execute(address dest, uint256 value, bytes calldata funcCallData) external {
_requireFromEntryPoint();
(bool success,) = dest.call{value: value}(funcCallData);
if (!success) {
revert SimpleAccount__CallFailed();
}
}
function entryPoint() public view override returns (IEntryPoint) {
return i_entryPoint;
}
function getOwner() public view returns (address) {
return i_owner;
}
}
Final Thoughts
In this article, we walked through the process of developing a simple account contract that meets the basic requirements of ERC-4337. We explored each function, along with the libraries we used, such as the account-abstraction
package from eth-infinitism and OpenZeppelin’s ECDSA library.
We also touched on important ERC-4337 concepts like bundlers and the entry point, though we haven't fully explored them yet. We’ll cover them in more detail as we progress through this series.
Next Steps
In the next article, we’ll shift focus to testing this contract and to gain better grasp of UserOperation
, including how to generate the UserOperation
hash via the entry point. If you’re excited to dive in, you can start writing tests now, which will help improve your understanding and ensure that the contract works as expected.
I hope you enjoyed this article, and I hope you’ll follow along with the rest of the series!
Top comments (0)