# 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:

```solidity
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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1728022825170/b5bcaab7-627b-49d2-a1b0-525b78e13215.png align="center")

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](https://book.getfoundry.sh/) to get up to speed.

To create a new Foundry project, run the following commands in your terminal:

```bash
# 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:

```bash
# 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:

```solidity
// 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 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.

```solidity
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:

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`.

```solidity
// 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`.

```solidity
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.

```solidity
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.

```solidity
function entryPoint() public view override returns (IEntryPoint) {
    return i_entryPoint;
}
```

At this point, your contract should look like this:

```solidity
// 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:

```solidity
// 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:

```solidity
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:

```solidity
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:

```bash
forge install OpenZeppelin/openzeppelin-contracts@v5.0.2 --no-commit
```

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

```bash
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:

```solidity
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.

```solidity
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.

```solidity
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.

```solidity
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`:

```solidity
if (messageSigner == i_owner) {
    return SIG_VALIDATION_SUCCESS;
} else {
    return SIG_VALIDATION_FAILED;
}
```

Here’s the complete `_validateSignature` function:

```solidity
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:

```solidity
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:

```solidity
error SimpleAccount__CallFailed();
```

The complete `execute` function is as follows:

```solidity
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:

```solidity
// 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!
