Account Abstraction Part 2: From Theory to Execution
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
intoAccount.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 likeaccount-abstraction/contracts
andopenzeppelin/contracts
, version compatibility is key. If you're usingaccount-abstraction/contracts
version0.6.0
, ensure compatibility by including"@openzeppelin/contracts": "^4.2.0"
in yourpackage.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 ofinitCode
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:
- 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.. - Optimizing
initCode
Usage: After the initial deployment, ourinitCode
doesn't need to be reinitialized. To reflect this, we'll simplify theinitCode
assignment to"0x"
, indicating no further deployment actions are required. - 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]
The2n
in the output indicates aBigInt
in JavaScript, used for large integers beyond JavaScript's Number limit. It matches Solidity'suint256
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!