Account Abstraction Part 2: From Theory to Execution

Extropy.IO
26 min readFeb 27, 2024

Exploring the Power of User Operations with Solidity

Introduction

In Part 1, we talked about the transformative potential of Account Abstraction and ERC-4337 within the Ethereum ecosystem, highlighting its pivotal role in enhancing user experience and security by merging the functionalities of externally owned accounts (EOAs) and contract accounts. As we transition from theory to hands-on practice in Part 2, our journey takes a practical turn, guiding you through setting up your development environment, deploying an EntryPoint contract, and preparing for executing user operations. This tutorial is designed to equip developers with the skills needed to implement Account Abstraction in their Ethereum projects. While we aim to make this guide accessible, a basic familiarity with using the terminal, npm commands, and the Hardhat environment will be beneficial. Such foundational knowledge will help you navigate the setup, deployment, and execution phases more smoothly, ensuring you can fully engage with the intricacies of Account Abstraction. Great, now that we’re all caught up, let’s roll up our sleeves and dive into the exciting world of smart contract deployment!

Setting Up The Project

Create a New Repo

First, we’ll create a new repository named smart-accounts and navigate into it:

mkdir smart-accounts && cd smart-accounts

Next, initialize a new Node.js project and install Hardhat, a preferred Ethereum development environment for compiling, deploying, testing, and debugging smart contracts:

npm init -y  # Initializes a new Node.js project
npm i hardhat # Installs Hardhat

To set up a new Hardhat project:

npx hardhat init

Choose “Create a JavaScript project” and accept all default settings. This process scaffolds a basic Hardhat project structure:

.
├── README.md # Project description
├── contracts # Directory for Solidity contracts
├── hardhat.config.js # Hardhat configuration file
├── node_modules # Installed node packages
├── package-lock.json # Locked node package versions
├── package.json # Node project manifest
├── scripts # Deployment and interaction scripts
└── test # Test files for your contracts

Although this tutorial won’t cover writing tests, it’s a good practice to familiarize yourself with testing concepts for smart contract development. We’ll focus on contract development and deployment.

With our project environment now ready, we’ll dive into one of the most crucial components of Account Abstraction: the EntryPoint contract. This next step is vital for enabling the advanced functionalities that Account Abstraction promises.

Entrypoint Set-Up and Explanation

The EntryPoint contract stands at the heart of Account Abstraction, acting as the unified gateway for all transactions to interact with the Ethereum blockchain. By standardizing the interface for user operations, the EntryPoint contract streamlines transaction handling and enables advanced features such as sponsored transactions and custom authentication methods. Essentially, it bridges our smart contracts with the Ethereum network, fostering a more flexible and robust interaction model.

To implement this, we’ll utilize a reference EntryPoint contract from the eth-infinitism/account-abstraction repository. You can incorporate it into your project by installing the @account-abstraction/contracts package:

npm i @account-abstraction/contracts

For the purposes of this tutorial, it’s recommended to pin the version to @0.6.0 to ensure consistency. However, feel free to experiment with newer versions as they become available.

Now that we understand the pivotal role of the EntryPoint contract, let’s proceed to integrate it into our project.

Deploying the Entrypoint Contract

This process begins with the modification of an existing contract in our project. We’ll use the Lock.sol contract located in the contracts folder as a starting point.

First, we’ll rename Lock.sol to Account.sol to better reflect its new purpose. Then, we'll replace its contents with an import statement for the EntryPoint.sol contract, as shown below:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;
import "@account-abstraction/contracts/core/EntryPoint.sol";

The modification of Account.sol to import EntryPoint.sol is a small but pivotal moment in our project setup. This action links our contract with the EntryPoint contract, a key component in the Account Abstraction framework. By doing so, we're preparing our Account contract to act as an abstracted user account, capable of leveraging the EntryPoint's functionalities. Just like that, with a simple line of code, we've set the stage for something big.

Once imported, the EntryPoint.sol contract serves multiple purposes:

  • Transaction Gateway: It acts as the primary interface for all transactions, ensuring that user operations adhere to a standardized process.
  • Advanced Features: It enables the use of advanced blockchain functionalities such as sponsored transactions and custom authentication mechanisms within our Account contract.
  • Flexibility and Power: Importing EntryPoint.sol into Account.sol extends the capabilities of our contract, making it more flexible and powerful in interacting with the Ethereum network.

Deploy Script

With our EntryPoint contract ready, it's time to deploy it to the blockchain. For this, we'll utilize a deployment script found in the scripts folder. Initially, this script is set up to deploy a different contract, like Lock.sol, but we'll modify it for our EntryPoint.sol contract.

Navigate to the scripts folder and rename the deploy.js file to deploy-entry.js. Open this file, and you'll find the line designed to deploy the Lock contract, resembling:

const lock = await hre.ethers.deployContract("Lock", [unlockTime], { value: lockedAmount });

Since we’re deploying the EntryPoint contract, which doesn't require constructor arguments like unlockTime or value, modify this line to:

const ep = await hre.ethers.deployContract("EntryPoint");

This change aligns our script with our current goal: deploying the EntryPoint contract. We also simplify the variable name to ep for clarity. After updating, our deploy script will look like this:

deploy-entry.js

const hre = require("hardhat");
async function main() {
const ep = await hre.ethers.deployContract("EntryPoint");
await ep.waitForDeployment();
console.log(`EntryPoint deployed to: ${ep.address}`);
}
main().then(() => process.exit(0)).catch(error => {
console.error(error);
process.exit(1);
});

Note the use of ep.address for logging the deployed contract's address, ensuring you have the correct output for verification. This script deploys the EntryPoint contract to our local Hardhat network, simplifying our initial exploration. When moving to a testnet or mainnet, adjustments for network configuration and gas considerations will be necessary.

So, now our script is ready — but are you ready to deploy this EntryPoint? Hold that thought – we're on the brink of this moment, but first, we need to launch our local Ethereum node.

Launching Hardhat for Local Development

To simulate an Ethereum blockchain environment on our machines, we’ll launch a Hardhat node. This local node allows us to deploy and test our contracts in an isolated environment without incurring real-world transaction costs. Open a new terminal tab and execute:

npx hardhat node

Upon starting, Hardhat provides you with a list of accounts, including their private keys and preloaded with fake ETH, like this:

Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:854
Accounts
========
WARNING: These accounts, and their private keys, are publicly known.
Any funds sent to them on Mainnet or any other live network WILL BE LOST.
Account #0: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000 ETH)
Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

Important: Remember, these accounts are for development purposes only. Never use them on the Mainnet or any public network to avoid loss of funds. Yes that’s 10000 ETH, and no you’re not rich.

Next, ensure your project is configured to use this local network by setting the defaultNetwork in the hardhat.config.js file:

require("@nomicfoundation/hardhat-toolbox");
module.exports = {
defaultNetwork: "localhost",
solidity: "0.8.19",
};

This configuration directs Hardhat to use our local node by default for deploying and interacting with contracts. Now, you’re all set to deploy your contract to the local Hardhat network.

Running the Script

With our EntryPoint contract ready for deployment, return to your first terminal tab to execute the deployment script:

npx hardhat run scripts/deploy-entry.js

This step initiates the deployment of your contract to the local Hardhat environment.

[Note]
When deploying contracts, especially those involving external dependencies like
account-abstraction/contracts and openzeppelin/contracts, version compatibility is key. If you're using account-abstraction/contracts version 0.6.0, ensure compatibility by including "@openzeppelin/contracts": "^4.2.0" in your package.json.

Contract Code Size Warning

You might encounter a warning about the contract code size exceeding the Spurious Dragon hardfork limit. This is a precautionary measure to ensure your contract can be deployed on the Ethereum Mainnet. To address this, adjust your Hardhat configuration to optimize the contract compilation:

hardhad.config.js

module.exports = {
defaultNetwork: "localhost",
solidity: {
version: "0.8.19",
settings: {
optimizer: {
enabled: true,
runs: 1000,
}
}
}
};

These settings help reduce the compiled contract size, ensuring it fits within the network’s constraints.

Confirming Deployment Success

Upon successful deployment, you’ll receive confirmation in your terminal, similar to this:

Compiled 15 Solidity files successfully (evm target: paris).
EntryPoint deployed to: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512

This message signifies that your EntryPoint contract is now live on your local Hardhat network. You can verify the deployment and see your contract in action by checking the Hardhat network’s output in your terminal, which will include details of the contract call and transaction:

Contract call:       EntryPoint#handleOps
Transaction: 0xd2d02a869fa61717d04a9b60ed9c45adf31ee274e3e1e04982f12b591d1ab53d
From: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
To: 0xe7f1725e7734ce288f8367e1bb143e90bb3f0512
Value: 0 ETH
Gas used: 256147 of 30000000
Block #13: 0x8731778113191b2c6dcbba59774ef72aa29e6f7475b264c2bafd60a501bad31f

This output not only confirms the deployment but also showcases the interaction capabilities of your contract within the local development environment.

And with that, our EntryPoint stands tall on your local network, a testament to our hard work. Take a moment to bask in this achievement.

A Quick Test

Let’s triple check and confirm that we’ve done everything correctly by creating a test.js file in our scripts folder. We'll start by copying our deploy-entry.js file to start and add a couple of lines.

We’ll create a test.js script in our scripts folder, a kind of 'hello world' moment for interacting with our deployed EntryPoint contract. Start by cloning our deploy-entry.js file as a base. Here's how we'll do it:

const hre = require("hardhat");
// This is our EntryPoint contract address
const EP_ADDR = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512";
async function main() {
// This will show our code behind the contract
const code = await hre.ethers.provider.getCode(EP_ADDR);
console.log(code);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

Execute our new script with:

npx hardhat run scripts/test.js

And voilà! You should see a cascade of code flowing down your terminal :

0x6080604052600436106101635760003560e01c80638f41ec5a116100c0578063bb9fe6bf11610074578063d6383f9411610059578063d6383f94146104af578063ee219423146104cf578063fc7e286d146104ef57600080fd5b8063bb9fe6bf1461047a578063c23a5cea1461048f57600080fd5b80639b249f69116100a55780639b249f6914610427578063a619353114610447578063b760faf91461046757600080fd5b80638f41ec5a146103f25 ...

This isn’t just any code; it’s your EntryPoint contract's bytecode - a digital blueprint of your creation. While this output is just a sneak peek, rest assured, your contract is now a living, breathing part of the blockchain cosmos (at least on your machine).

Account.sol

Now let’s move on to the next step writing our account function code. This next phase is where our project starts to show the flexibility and power of Account Abstraction.

We move forward with the crucial validateUserOp function, a requirement set forth by the IAccount.sol interface. This function acts as the gatekeeper, determining which operations are authorized to proceed based on predefined logic.

Here’s the essence of what we’re implementing, directly from the IAccount.sol interface.

function validateUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds)
external returns (uint256 validationData);

from node_modules/@account-abstraction/contracts/interfaces/IAccount.sol

In our Account.sol contract , we start simple. By returning 0, we're essentially saying, "All clear!" for every operation at this stage. Here's how that looks:

Account.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;
import "@account-abstraction/contracts/core/EntryPoint.sol";contract Account is IAccount {
function validateUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds)
external returns (uint256 validationData) {
return 0; // A green light for now
}
}

Tracking State Changes

What’s a smart contract without some state to manage? Let’s add a simple counter to our contract. It’s a small step, but it introduces us to state management in a smart contract:

contract Account is IAccount {
    uint256 public count; // Our humble counter    function validateUserOp(UserOperation calldata , bytes32, uint256) external pure returns (uint256 validationData){
return 0;
}
// Time to count up!
function increment() external {
count++;
}
}

Adding Ownership

Ownership is a cornerstone in the world of smart contracts. By integrating an ownership model into our Account.sol, we're not merely tracking who deployed the contract; we're unlocking a lot of possibilities for custom logic and decision-making power based on the owner's unique actions.

Why Ownership Matters

In the realm of Account Abstraction, the concept of ownership takes on an even more significant role. It gives us unparalleled flexibility in tailoring the contract’s behavior — think of it as customizing the core of your account. From specifying who can initiate certain transactions to defining intricate rules around signature verification, ownership allows us to set the rules of engagement for our account’s interaction with the wider Ethereum ecosystem.

Let’s cement this concept within our contract by introducing a constructor for ownership assignment:

address public owner;

// Constructor for initializing owner upon deployment
constructor (address _owner){
owner = _owner; // setting the owner
}

This code snippet does more than just assign an owner; it lays the foundation for creating a contract that knows who its master is, right from deployment. This level of control is instrumental in deploying more secure, flexible, and robust smart contracts.

The Power of Ownership in Account Abstraction

With ownership clearly defined, our Account.sol steps into a realm where it can make intelligent decisions based on the owner's directives. This capability is pivotal in account abstraction, where the blending of user accounts and smart contract functionalities demands a nuanced approach to access control and permissions.

Ownership in this context is about empowering users with the ability to dictate the terms of their interactions within the blockchain space, ensuring that the account behaves in ways that align with their intentions, securely and efficiently.

Progress Checkpoint: Where We Stand

Let’s take a short pause and look back, we’ve covered significant ground in our journey through Account Abstraction with Hardhat:

  • Set Up Our Project Environment: We initialized our project, smart-accounts, and prepared our development environment with Hardhat, setting the stage for the magic of smart contract development.
  • Deployed the EntryPoint Contract: We dove into Account Abstraction by deploying our EntryPoint contract, a crucial piece enabling advanced blockchain functionalities.
  • Integrated Ownership into Account.sol: We enhanced our Account.sol contract with an ownership model, unlocking a new layer of security and flexibility for our smart contract.

These steps are foundational blocks, not just for our project but for mastering Ethereum smart contract development. As we move forward, we’ll build on this foundation, exploring the creation of an Account Factory, further refining our Account.sol, and preparing for deployment. Ready to take the next leap? Let’s go!

Creating an Account Factory

The Account Factory is crucial in Account Abstraction, enabling the dynamic creation of account contracts. It utilizes a factory pattern to efficiently deploy contracts with specific logic and state, tailored for individual user requirements. This approach significantly improves the scalability and flexibility of applications on Ethereum.

Understanding the Interaction with EntryPoint.sol

Before constructing the Account Factory, it’s important to examine its interaction with the EntryPoint contract, particularly for the initial account creation.

The _createSenderIfNeeded Function

Within EntryPoint.sol, there's a function named _createSenderIfNeeded:

function _createSenderIfNeeded(uint256 opIndex, UserOpInfo memory opInfo, bytes calldata initCode) internal {
if (initCode.length != 0) {
address sender = opInfo.mUserOp.sender;
if (sender.code.length != 0) revert FailedOp(opIndex, "AA10 sender already constructed");
address sender1 = senderCreator.createSender{gas : opInfo.mUserOp.verificationGasLimit}(initCode);
if (sender1 == address(0)) revert FailedOp(opIndex, "AA13 initCode failed or OOG");
if (sender1 != sender) revert FailedOp(opIndex, "AA14 initCode must return sender");
if (sender1.code.length == 0) revert FailedOp(opIndex, "AA15 initCode must create sender");
address factory = address(bytes20(initCode[0 : 20]));
emit AccountDeployed(opInfo.userOpHash, sender, factory, opInfo.mUserOp.paymaster);
}
}

This function is essential for processing user operations that require the creation of a new sender contract.

The Role of SenderCreator

If we take a deeper look at the SenderCreator portion of our function we can see that the SenderCreator which is where our initCode blueprint becomes an actual contract.

SenderCreator private immutable senderCreator = new SenderCreator();

We can see it’s a new smart contract that is initialized in the entry point. The SenderCreator is tasked with converting initCode into a deployed contract:

Exploring SenderCreator

The SenderCreator contract is engineered to execute the initCode, effectively bringing account contracts from concept to reality.

contract SenderCreator {
    function createSender(bytes calldata initCode) external returns (address sender) {
address factory = address(bytes20(initCode[0 : 20]));
bytes memory initCallData = initCode[20 :];
bool success;
/* solhint-disable no-inline-assembly */
assembly {
success := call(gas(), factory, 0, add(initCallData, 0x20), mload(initCallData), 0, 32)
sender := mload(0)
}
if (!success) {
sender = address(0);
}
}
}

Understanding these interactions between EntryPoint.sol, SenderCreator, and initCode is fundamental to grasping how Account Abstraction facilitates the creation of account contracts. This knowledge is pivotal as we proceed to develop and deploy our Account Factory, leveraging these principles to enable sophisticated account management capabilities on Ethereum.

The SenderCreator function is taking the initCode takes the first 20 bytes of the initCode and that'll be the address of the factory. As we see below. So we have to keep this in mind when when we use the initCode and use it in the user operation.

address factory = address(bytes20(initCode[0 : 20]));

The rest of the initCode will be the initCallData

bytes memory initCallData = initCode[20 :];

which will be what we are sending the say some data or method arguments etc.

Delving into Assembly for Contract Deployment

A critical aspect of the SenderCreator's functionality is its use of Ethereum's assembly language to deploy contracts directly from initCode. This process is key to the dynamic creation of account contracts:

contract SenderCreator {
function createSender(bytes calldata initCode) external returns (address sender) {
address factory = address(bytes20(initCode[0 : 20]));
bytes memory initCallData = initCode[20 :];
bool success;

// Direct interaction with the EVM to deploy a contract
assembly {
success := call(gas(), factory, 0, add(initCallData, 0x20), mload(initCallData), 0, 32)
sender := mload(0)
}
if (!success) {
sender = address(0);
}
}
}
  • Initiating Contract Deployment: The assembly block within createSender performs a low-level call, deploying a new contract. The first 20 bytes of initCode specify the factory address, and the remaining bytes contain the data necessary for contract initialization.
  • Efficient Execution: This approach allows for efficient and direct deployment of contracts, leveraging the full capabilities of the EVM.
  • Outcome Handling: The assembly code also handles the deployment outcome, ensuring that the address of the newly created contract is returned only if the deployment succeeds.

Phew, that was quite the deep dive into the mechanics behind the scenes, wasn’t it? But let’s not linger too long in the theoretical — coding awaits! With a better grasp of how SenderCreator and assembly magic play their parts, it's time to circle back to our Account.sol contract. There’s more to be done, and with our newfound knowledge, we’re more equipped than ever to tackle it. Let’s jump back into the world of Solidity and continue building our Account Abstraction framework.

Enhancing Account.sol with AccountFactory

With our foundational knowledge of dynamic contract deployment via the SenderCreator, it's time to apply these concepts directly within our project. Our next objective involves enriching the Account.sol contract by introducing a method to create new account contracts—enter the AccountFactory.

Crafting the AccountFactory Contract

The AccountFactory serves as a pivotal component in our Account Abstraction framework, enabling the streamlined creation of accounts with bespoke configurations. Specifically, we aim to implement a function, createAccount, that facilitates the instantiation of new accounts with a designated owner. Here's how we implement this functionality:

contract AccountFactory {
// A function to create a new Account contract instance with an assigned owner
function createAccount(address owner) external returns (address) {
Account acc = new Account(owner);
return address(acc); // Returns the address of the newly created Account contract
}
}

This function is marked external to ensure it's callable from outside the contract, specifically from our EntryPoint contract during the user operation handling process. By passing in the owner parameter, we enable each newly created account to have a clear ownership from its inception, aligning with the customizable nature of Account Abstraction.

The Role of AccountFactory in Account Creation

By deploying a new account and assigning the owner, the AccountFactory not only simplifies the account creation process but also embeds a layer of ownership and control right from the start. This method will be instrumental when called from the EntryPoint, enabling the dynamic deployment of user-specific account contracts.

With the AccountFactory in place, we've bridged the gap between theoretical concepts and practical implementation. Our next steps involve deploying the AccountFactory and integrating it within the broader Account Abstraction framework, further enhancing the flexibility and capability of our Ethereum applications.

Deploying the Account Factory

With our AccountFactory ready to facilitate the creation of account contracts, the next step is deployment. To streamline this process, we'll craft a deployment script named deploy-af.js in our scripts directory. This script will mirror the structure of our previous deploy-entry.js, with adjustments to target the AccountFactory.

deploy-af.js

const hre = require("hardhat");
async function main() {
// Deploy the AccountFactory contract
const af = await hre.ethers.deployContract("AccountFactory");
await af.waitForDeployment(); console.log(`AccountFactory deployed to: ${af.target}`);
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

This script employs Hardhat’s deployment mechanisms, specifically using deployContract to compile and deploy the AccountFactory contract.

Deploying with Hardhat

Execute the deployment by running:

npx hardhat run scripts/deploy-af.js

Upon successful deployment, Hardhat will output details of the transaction in the terminal, similar to:

Contract deployment: AccountFactory
Contract address: 0x5fbdb2315678afecb367f032d93f642f64180aa3
Transaction: 0x2651ae14b0575612e9454e1064f63033a01314936cd2c1d3469c3c5b5bf7867d
From: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Value: 0 ETH
Gas used: 246746 of 30000000
Block #1: 0xe070414db37ec918729edebc6574eb86158004d9088fc892bb665b35ff53fdfe

Next Steps

With the AccountFactory now live on the network, we're set to explore its integration into user operations, paving the way for dynamic account creation. Let's proceed to crafting and executing user operation scripts, leveraging our newly deployed factory to its potential.

Creating ouruserOp Execution Script

It’s time to move forward by creating a new script in our scripts folder. We'll name this script execute.js. As a starting point, we can use the structure from our previous scripts, such as deploy-entry.js, which have laid the groundwork for interacting with our smart contracts.

Preparing the userOp

The first step in crafting our script involves constructing a userOp, utilizing the UserOperation struct found in UserOperation.sol. This struct, essential for defining user operations within the account abstraction framework, includes several key parameters:

UserOperation.sol

// Snippet from `UserOperation.sol`
struct UserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
uint256 callGasLimit;
uint256 verificationGasLimit;
uint256 preVerificationGas;
uint256 maxFeePerGas;
uint256 maxPriorityFeePerGas;
bytes paymasterAndData;
bytes signature;
}

This struct encapsulates all necessary information to execute a user operation, from identifying the sender to specifying gas limits and transaction fees.

execute.js

const hre = require("hardhat");
async function main() {  const ep = await hre.ethers.deployContract("EntryPoint");  const userOp = {
sender,
nonce,
initCode,
callData,
callGasLimit: 200_000,
verficationGasLimit: 200_000,
preVerificationGas: 50_000,
maxFeePerGas: hre.ethers.parseUnits("10", "gwei"),
maxPriorityFeePerGas: hre.ethers.parseUnits("5", "gwei"),
paymasterAndData: "0x",
signature: "0x"
};
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

We’ll fill in the easy bits first, using gas values and 0x to specify an empty bytecode.

Key Parameters Explained

In execute.js, accurately defining each parameter of the userOp is vital for its successful execution:

  • sender: This is the address of the contract or account initiating the operation. It plays a crucial role in identifying the operation's origin.
  • nonce: Used to ensure transactions are processed in order and to prevent replay attacks.
  • initCode: Contains the bytecode for deploying a new contract as part of the operation, if necessary.
  • callData: Specifies the function call to execute, including any arguments, within the target contract.

By assembling these parameters, we create a comprehensive userOp that can securely and efficiently interact with the Ethereum blockchain.

Defining the sender

To accurately define our sender, which represents the smart account address from which operations will be initiated, we first need to establish a few preliminary variables: the nonce associated with our factory contract, the factory's address, and the entry point address. These elements are crucial for computing the sender address in the context of Ethereum's Account Abstraction.

Here’s how we set up these essential variables in our script:

// Set the starting nonce, typically 1 for contracts post-Spurious Dragon
const FACTORY_NONCE = 1;
// The deployed Account Factory contract address
const FACTORY_ADDRESS = "0x5fbdb2315678afecb367f032d93f642f64180aa3";
// The address of the EntryPoint contract
const EP_ADDRESS = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512";

With these variables defined, we can proceed to calculate the sender address. The sender is essentially the address of the smart account that will be generated by the Account Factory when a new account is created. This calculation can be performed using a utility function from the ethers library, which takes in the factory's address and its nonce:

const sender = await hre.ethers.getCreateAddress({
from: FACTORY_ADDRESS,
nonce: FACTORY_NONCE
});

Defining the nonce

With our sender defined, the next critical element in our user operation is the nonce. The nonce ensures the uniqueness and order of transactions, a fundamental aspect of Ethereum's security and functionality. To manage this, we turn to the NonceManager functionality embedded within our EntryPoint.sol contract.

The Role of NonceManager

The EntryPoint contract incorporates several key components, including the NonceManager, which is instrumental in handling nonces for user operations:

// Snippet from EntryPoint.sol showing NonceManager inclusion
contract EntryPoint is IEntryPoint, StakeManager, NonceManager, ReentrancyGuard {
    // contract code...
}

Diving into the NonceManager, we find a crucial mapping:

// Inside NonceManager.sol
mapping(address => mapping(uint192 => uint256)) public nonceSequenceNumber;
    function getNonce(address sender, uint192 key)
public view override returns (uint256 nonce) {
return nonceSequenceNumber[sender][key] | (uint256(key) << 64);
}

This mapping tracks the nonce sequence for each smart account, offering the flexibility to use different nonce sequences (identified by key) for the same account. For simplicity, we'll use a key of 0 for our operations.

Fetching the nonce

To dynamically obtain the nonce for our user operation, we’ll use the getNonce method, providing the sender address and a key of 0:

// Fetching the nonce for our user operation
nonce: await entryPoint.getNonce(sender, 0)

Adjusting the EntryPoint Reference

Previously, we prepared to deploy the EntryPoint contract each time. However, since our contract is already deployed, we should instead retrieve the existing contract instance using the EP_ADDRESS:

const entryPoint = await hre.ethers.getContractAt("EntryPoint", EP_ADDRESS);

This adjustment ensures we interact with our deployed EntryPoint contract, allowing us to fetch the nonce dynamically as needed for our user operations.

Defining the initCode

Defining the initCode introduces complexity but is essential for our smart contract operation. Recall our discussion about the SenderCreator and its use of initCode, where the first 20 bytes are interpreted as the factory address, and the remainder as call data for the factory. Our task now is to specify this call data, focusing on the createAccount function within our AccountFactory.

The createAccount Function

Our AccountFactory contract includes the createAccount function, which we aim to invoke through our initCode:

contract AccountFactory {
function createAccount(address owner) external returns(address){
Account acc = new Account(owner);
return address(acc);
}
}

Encoding the Call Data

To encode this call data, we leverage the AccountFactory contract's ABI through the getContractFactory method, constructing our initCode by combining the FACTORY_ADDRESS with the encoded function call:

// Preparing the AccountFactory contract instance
const AccountFactory = await hre.ethers.getContractFactory("AccountFactory");
// Encoding the call data for createAccount function
const initCode = FACTORY_ADDRESS + AccountFactory.interface.encodeFunctionData("createAccount", [])

The encodeFunctionData method requires the name of the function and the parameters it expects. Since we're focusing on specifying the owner address for the new account, this data needs to be included in our call.

Selecting the Signer

To complete our initCode, we must select a signer, we'll use the first account from our local blockchain environment provided by hardhat node and we can get that account address by doing this:

// getting first signer
const [signer0] = hre.ethers.getSigners();
// getting address
const addresss0 = await signer0.getAddress();

Finalizing the initCode

With the signer’s address at hand, we finalize our initCode by incorporating the address as the argument to our createAccount function call:

const AccountFactory = await hre.ethers.getContractFactory("AccountFactory");
const [signer0] = hre.ethers.getSigner();
const address0 = await signer0.getAddress();
//adding `address0`
const initCode = FACTORY_ADDRESS + AccountFactory.interface.encodeFunctionData("createAccount", [address0])

This step completes our preparation of the initCode, enabling the dynamic creation of accounts through our smart contract infrastructure. With initCode defined, we can proceed to specify the callData, further advancing our implementation of Account Abstraction.

Defining the callData

The journey to defining our userOp concludes with the callData, an essential component that dictates the actions of our smart account. It's crucial to distinguish this callData from that used in traditional Ethereum transactions; here, it's utilized by the smart account for specific operations.

Target Function: execute

In our scenario, we desire for the smart account to execute a function named increment, which we've defined in our Account.sol contract. While our example uses a simple increment operation, the flexibility of callData allows for a wide range of interactions, such as dealing with external contracts like Uniswap or conducting more complex operations.

Preparing the callData

Encoding our callData requires a method similar to what we've done previously, using the getContractFactory for the Account contract. This step ensures we can interact with our Account contract's functions, such as increment, within our smart account operations.

const Account = await hre.ethers.getContractFactory("Account");

When we define the initCode using the FACTORY_ADDRESS and concatenate it with the encoded function data, the Ethereum address needs to be correctly formatted. Ethereum addresses and bytecode often include a '0x' prefix indicating hexadecimal format. However, when concatenating strings for smart contract operations, this prefix must be removed to prevent formatting errors. This is where .slice(2) comes into play; it removes the first two characters of the string, effectively stripping the '0x' prefix.

// Encoding call data for the increment function, ensuring address format is correct 
const initCode = FACTORY_ADDRESS + Account.interface.encodeFunctionData("createAccount", [address0]).slice(2);

In this snippet, .slice(2) is applied to the result of encodeFunctionData, ensuring that when we append this data to the FACTORY_ADDRESS, the hexadecimal string is correctly formatted without the '0x' prefix. This step is crucial for the initCode to be correctly interpreted by the Ethereum Virtual Machine (EVM).

With the Account contract ready, we encode our desired function call:

callData: Account.interface.encodeFunctionData("increment");

Our userOp is now fully prepared, encapsulating all necessary information for execution:

const userOp = {
sender,
nonce: await entryPoint.getNonce(sender, 0),
initCode,
callData: Account.interface.encodeFunctionData("increment"),
callGasLimit: 200_000,
verficationGasLimit: 200_000,
preVerificationGas: 50_000,
maxFeePerGas: hre.ethers.parseUnits("10", "gwei"),
maxPriorityFeePerGas: hre.ethers.parseUnits("5", "gwei"),
paymasterAndData: "0x",
signature: "0x"
};

Interacting with EntryPoint

Typically, userOp would be submitted to a bundler, akin to a node, for processing. For simplicity, we direct our userOp to the EntryPoint contract deployed locally, bypassing the need for a bundler:

We’ll be using the handleOps method which takes an array of UserOperation as well as an address of the beneficiary of who should receive the fees, this address might typically be the bundler, but again in our case because we are not using a bundler we'll just use the address0 that we defined earlier.

entryPoint.handleOps([userOp], address0);

let’s also add a reciept and console.log

const tx = await entryPoint.handleOps([userOp], address0);
const receipt = await tx.wait();
console.log(receipt);

Prefunding the Account

Before diving into executing our smart contract operations, it’s crucial to ensure that our account is adequately funded. This step is particularly important within the Account Abstraction framework, as executing transactions and operations often incurs gas fees. To facilitate this, we can utilize the depositTo method available in our EntryPoint contract. This method allows us to deposit funds directly into our smart account, ensuring it has enough Ether to cover the execution costs.

// Pre-funding the smart account with Ether to cover transaction fees 
await entryPoint.depositTo(sender, {
value: hre.ethers.parseEther("100")
});

In this code snippet, we’re depositing 100 Ether into our smart account identified by sender. This is done by calling the depositTo method on our EntryPoint contract instance, specifying the recipient sender address and the amount to deposit. The hre.ethers.parseEther("100") function is used to convert the Ether value into the appropriate unit for the Ethereum Virtual Machine (EVM).

This prefunding step is vital for ensuring that our smart account has the necessary funds to perform the intended actions without any hiccups. By depositing a significant amount like 100 Ether, we aim to eliminate the concern of running out of funds during our testing or operational phase, especially when experimenting in a local or testnet environment.

let’s take a complete look at what we have done so far:

// Import the Hardhat runtime environment (HRE) to interact with Ethereum
const hre = require("hardhat");
// Define constants for the factory contract's nonce and addresses
const FACTORY_NONCE = 1;
const FACTORY_ADDRESS = "0x5fbdb2315678afecb367f032d93f642f64180aa3";
const EP_ADDRESS = "0xe7f1725e7734ce288f8367e1bb143e90bb3f0512";
async function main() {
// Retrieve the deployed EntryPoint contract
const entryPoint = await hre.ethers.getContractAt("EntryPoint", EP_ADDRESS);
// Calculate the expected sender (smart account) address using the factory address and nonce
const sender = await hre.ethers.getCreateAddress({
from: FACTORY_ADDRESS,
nonce: FACTORY_NONCE,
});
// Get the AccountFactory contract to encode its functions
const AccountFactory = await hre.ethers.getContractFactory("AccountFactory");
// Retrieve the first signer from the hardhat environment
const [signer0] = hre.ethers.getSigner();
// Get the address of the first signer
const address0 = await signer0.getAddress();
// Prepare the initCode by combining the factory address with encoded createAccount function, removing the '0x' prefix
const initCode = FACTORY_ADDRESS + AccountFactory.interface
.encodeFunctionData("createAccount", [address0])
.slice(2);
// Deposit funds to the sender account to cover transaction fees
await entryPoint.depositTo(sender, {
value: hre.ethers.parseEther("100")
});
// Define the user operation (userOp) with necessary details for execution
const userOp = {
sender,
nonce: await entryPoint.getNonce(sender, 0), // Fetching the current nonce for the sender
initCode,
callData: Account.interface.encodeFunctionData("increment"), // Encoding the call to the increment function
callGasLimit: 200_000,
verificationGasLimit: 200_000,
preVerificationGas: 50_000,
maxFeePerGas: hre.ethers.parseUnits("10", "gwei"),
maxPriorityFeePerGas: hre.ethers.parseUnits("5", "gwei"),
paymasterAndData: "0x",
signature: "0x"
};
// Execute the user operation via the EntryPoint contract, passing the userOp and the fee receiver address
const tx = await entryPoint.handleOps([userOp], address0);
// Wait for the transaction to be confirmed
const receipt = await tx.wait();
// Log the transaction receipt to the console
console.log(receipt);
}
// Execute the main function and handle any errors
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

So now let’s run our code by inputting into our terminal:

npx hardhat run scripts/execute.js

Success!

After executing the command, your terminal should display a transaction receipt resembling the following:

ContractTransactionReceipt {
...
to: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512',
from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
...
status: 1,
...
}

if we look at our hardhat node we should see an output like this:

Contract call:       EntryPoint#handleOps
Transaction: 0xd2d02a869fa61717d04a9b60ed9c45adf31ee274e3e1e04982f12b591d1ab53d
...

This indicates that our setup works as intended. However, to truly verify success, we should confirm the expected state change — in our case, an increment in a counter within our smart contract.

Adjustments for Clarity and Efficiency

Before we proceed, let’s refine our script with a couple of updates for better clarity and efficiency:

  1. Logging Addresses: For better transparency and debugging, we’ll add a console log for both the contract address and the sender address. This will aid in tracking transactions and interactions. Additionally, we will leverage this logging functionality to monitor the progression of our increment function within the smart contract..
  2. Optimizing initCode Usage: After the initial deployment, our initCode doesn't need to be reinitialized. To reflect this, we'll simplify the initCode assignment to "0x", indicating no further deployment actions are required.
  3. Managing Account Funding: Initially, we funded our account to cover transaction costs. Moving forward, we’ll omit repeated funding to avoid redundancy. While a dynamic check for sufficient funds would be ideal, we’ll simplify for this tutorial by commenting out the funding code.

Incorporating these changes, our script now includes practical enhancements to streamline operations and improve understanding:

const hre = require("hardhat");
const FACTORY_NONCE = 1;
const FACTORY_ADDRESS = "0x5fbdb2315678afecb367f032d93f642f64180aa3";
const EP_ADDRESS = "0xe7f1725e7734ce288f8367e1bb143e90bb3f0512";
async function main() { const entryPoint = await hre.ethers.getContractAt("EntryPoint", EP_ADDRESS); const sender = await hre.ethers.getCreateAddress({
from: FACTORY_ADDRESS,
nonce: FACTORY_NONCE,
});
const AccountFactory = await hre.ethers.getContractFactory("AccountFactory");
const [signer0] = await hre.ethers.getSigners();
const address0 = await signer0.getAddress();
// Init code is set to "0x" after initial use, to avoid reinitialization
const initCode = "0x";
// adding a console log for the account
console.log(account);
// Commented out the deposit code after initial funding
// await entryPoint.depositTo(sender, {
// value: hre.ethers.parseEther("100")
// })
const Account = await hre.ethers.getContractFactory("Account");
const userOp = {
sender,
nonce: await entryPoint.getNonce(sender, 0),
initCode,
callData: Account.interface.encodeFunctionData("increment"),
callGasLimit: 200_000,
verificationGasLimit: 200_000,
preVerificationGas: 50_000,
maxFeePerGas: hre.ethers.parseUnits("10", "gwei"),
maxPriorityFeePerGas: hre.ethers.parseUnits("5", "gwei"),
paymasterAndData: "0x",
signature: "0x"
};
const tx = await entryPoint.handleOps([userOp], address0);
const receipt = await tx.wait();
console.log(receipt);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

Running the Execution Script Again

With our setup complete and adjustments made, it’s time to test the changes. Execute the script again with:

npx hardhat run scripts/execute.js

Upon successful execution, look for the sender address output early in the process. It should appear similar to this: 0xa16E02E87b7454126E5E10d957A927A7F5B5d2be.

Verifying the increment Function

To confirm our smart contract’s state change, specifically that the increment function is operational, we update and run a simple test script. The script queries the count variable from our Account contract, expecting its value to reflect the number of successful increment operations.

Update the test.js script as follows to incorporate the sender address and retrieve the current count value:

const hre = require("hardhat");
// address of the account that get's logged
const ACCOUNT_ADDR = "0xa16E02E87b7454126E5E10d957A927A7F5B5d2be";
async function main() { const account = await hre.ethers.getContractAt("Account", ACCOUNT_ADDR);
const count = await account.count();

console.log(count);

}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

If everything is configured correctly, the output should be 2n, (recall we ran it once before) indicating the increment function has been successfully called twice. Further executions of execute.js followed by test.js should show an incrementally increasing count, validating the functionality of your setup.

[note]
The
2n in the output indicates a BigInt in JavaScript, used for large integers beyond JavaScript's Number limit. It matches Solidity's uint256 capacity, ensuring precision in blockchain operations.

Future Considerations

While our primary goal — executing user operations — has been achieved, it’s important to note that validation mechanisms, particularly ensuring only the owner can execute certain operations, are not yet implemented. This aspect is crucial for production-ready contracts and should be addressed in subsequent iterations.

Wrapping Up and Looking Ahead: Bridging Theory and Practice in Account Abstraction

And that’s a wrap on Part 2 of our deep dive into Account Abstraction with ERC-4337! We’ve transitioned from the theoretical underpinnings introduced in Part 1 to a hands-on exploration of setting up, deploying, and interacting with smart contracts within the Ethereum ecosystem. Throughout this guide, we meticulously walked through the steps of initializing a project environment with Hardhat, deploying the foundational EntryPoint contract, and enhancing our setup with Account.sol and AccountFactory to facilitate the dynamic creation and management of account contracts.

By diving deep into the mechanics of Account Abstraction, from the pivotal role of the EntryPoint contract to the strategic deployment of an Account Factory and the execution of user operations, we’ve laid a comprehensive groundwork for developers. This hands-on tutorial not only aimed to equip you with the practical skills necessary for implementing Account Abstraction in your Ethereum projects but also to inspire a deeper understanding of the transformative potential it holds for user experience and security in blockchain applications.

As we look ahead, the journey into Account Abstraction continues to unfold with intriguing possibilities. In the next installment of this series, we will venture into the development and deployment of a Paymaster contract in Solidity. This concept introduces an innovative way to manage gas payments, where a Paymaster can sponsor transaction fees on behalf of a smart account, further enhancing the flexibility and accessibility of blockchain interactions.

The Paymaster component stands as a testament to the sophistication and adaptability of Account Abstraction, offering a glimpse into a future where blockchain applications can achieve greater user-friendliness without compromising on security or decentralization. Stay tuned as we explore this advanced topic, expanding our toolkit and pushing the boundaries of what’s possible with Ethereum and Account Abstraction.

Thank you for joining us on this journey. The road to mastering Ethereum’s Account Abstraction is both challenging and rewarding, filled with opportunities to innovate and redefine the user experience in decentralized applications. Get ready for more learning, and blockchain fun in our next installment. The future of Account Abstraction is exciting, and together, we’re just getting started!

--

--

Extropy.IO

Oxford-based blockchain and zero knowledge consultancy and auditing firm