DEV Community

Nikhil
Nikhil

Posted on • Originally published at decryptedbytes.com

Developer’s Guide to ERC-4337 #1 | Developing Simple Account

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

ERC-4337 UserOp Flow

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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.)
        }
    }
} 
Enter fullscreen mode Exit fullscreen mode

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 return SIG_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);
}

Enter fullscreen mode Exit fullscreen mode

Let’s break down what happens in this function:

  1. _requireFromEntryPoint: This checks that the caller is the trusted EntryPoint contract.
  2. _validateSignature: This function handles the signature validation logic, which we will implement in our contract.
  3. _payPrefund: This ensures that the EntryPoint is paid the missingAccountFunds, fulfilling the requirement of paying at least the minimum amount.
  4. _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() {}
}

Enter fullscreen mode Exit fullscreen mode

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);
    }
}

Enter fullscreen mode Exit fullscreen mode

We'll also need to import the IEntryPoint interface from the account-abstraction module.

import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol";

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

We also want to add a getter function, getOwner, which returns the owner's address:

function getOwner() public view returns (address) {
    return i_owner;
}

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

If installed correctly, you should see a message like this:

Installed openzeppelin-contracts v5.0.2

Enter fullscreen mode Exit fullscreen mode

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) {}

Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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 {}

Enter fullscreen mode Exit fullscreen mode

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();

Enter fullscreen mode Exit fullscreen mode

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
    }
}

Enter fullscreen mode Exit fullscreen mode

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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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)