Stack Exploder: Account Abstraction + No-fee transactions — Protocol Native Account Abstraction

Leonardo Simone Digiorgio
Sagaxyz
Published in
18 min readApr 30, 2024

In the blog posts Stack Exploder: Unblocking Web3 Products With Costless Transactions, we saw how Saga’s revolutionary token flows enable Chainlet developers to achieve costless transactions.

We also showcased how to easily implement this flow into your dApp in Stack Exploder: Implementing Costless Transactions Using Recycled Gas Tokens

However, it’s important to note that while Saga provides protocol-native costless transactions, there might be scenarios where users require additional functionalities, such as account abstraction.

In this blog post, we deep-dive into how Account Abstraction can be implemented and utilized on your chainlet.

Introduction Account Abstraction (ERC-4337)

The Ethereum ERC-4337, which is an EIP related to Account Abstraction,enhances the user interaction with decentralized applications (DApps), thereby making the key to unlock mass adoption of web3 technologies.

Account Abstraction enables assets to be exclusively managed by smart contracts instead of externally-owned accounts (EOAs). At the forefront of this evolution is the ERC-4337 standard, a token standard empowering the capabilities of smart contract crypto wallets on the Ethereum blockchain.

From the perspective of an Ethereum user, the integration of account abstraction represents a significant advancement, as it conceals the intricate technical details involved in Web3 interactions. This not only streamlines wallet design but also significantly elevates the overall user experience.

Note: This tutorial is focused on the implementation of Account Abstraction in the Chainlet. If you want to read more about Account Abstraction head over to: https://www.erc4337.io/docs

Understanding the Mechanics of ERC-4337

ERC-4337 introduces a novel concept called a “UserOperation” which serves as a pseudo-transaction object facilitating the execution of transactions on behalf of users. Unconfirmed UserOperation transactions are temporarily stored in an “alt mempool”, which is special mempool different from the main Ethereum mempool.

Within the Ethereum network, certain nodes can assume the role of a “Bundler,” responsible for aggregating multiple UserOperations from the alt mempool into a single transaction referred to as a bundle transaction. This makes the transaction more efficieint, thereby reducing gas fees.

These bundle transactions are then transmitted to a singular global smart contract known as the “EntryPoint” on the Ethereum blockchain, emphasizing that there is only one entry point in the Ethereum network.

The Bundler triggers a function call on the EntryPoint smart contract called “handleOps.” In this function, the bundle transaction is received, and subsequently, a distinctive function, “validateUserOp,” is invoked for each account.

The primary objective of “validateUserOp” is to authenticate the signature of the operation and, upon confirmation that the operation aligns with the account’s criteria, process the associated gas fee payment. Subsequently, each smart contract wallet is required to implement an additional function to execute the operation sent in by the EntryPoint contract. This transformation renders the entire process programmatic, eliminating the need for private key dependencies and simplifying the user experience.

Implementing Account Abstraction on your chainlet

In this tutorial, we give you an easy approach on how to implement Account Abstraction for your Saga chainlet.

Because of the complexity of implementing Account Abstraction, we recommend taking small steps. Hence this tutorial is broken down into three parts:

  1. Implement Simple Account Abstraction.
  2. Implement Paymaster.
  3. Implement Account Validation.

Implementing a Simple Account Abstraction

To start off, we are going to build a Hardhat project to test if everything is working as expected (using a Hardhat node first), and only then deploy it to the chainlet. If everything is working fine in the Hardhat project, we will then implement it on the frontend of the dApp.

We have created a github repo with the project used for this tutorial. We recommend to use it along with this tutorial.

Let’s kickstart by installing these contracts in hardhat: https://github.com/eth-infinitism/account-abstraction (if you’re using npm you can do npm i @account-abstraction/contracts@0.6.0).

npm i @account-abstraction/contracts@0.6.0

Also you’ll need to install openZeppelin

npm i @openzeppelin/contracts@4.2.0

Deploying the EntryPoint is your first step. This is the heart of the Account Abstraction as it manages the execution logic for transactions, ensuring they’re processed correctly.

You’ll find that in the repository under node_modules/@account-abstraction/contracts/core/EntryPoint.sol.

deployEP.js

Sometimes, when deploying this contract, you might encounter an error indicating that the contract code size exceeds the limit. To resolve this, ensure that the compiler settings are configured to utilize the optimizer with 1000 runs.

hardhat.config.js

Build the Account and AccountFactory Contract

We’re going to create two smart contracts: the Account and the AccountFactory.

The Account contract is the smart account (or smart wallet) and AccountFactory is the smart contract that creates the smart account. Another way to look at Account Factory is as the smart contract that generates the smart contracts.

Let us start with the Account Factory! You’ll be working closely with the EntryPoint. Here’s our first look! We need to find the code where the EntryPoint is creating the smart account.

node_modules/@account-abstraction/contracts/EntryPoint.sol

Specifically, in the line 6:

address sender1 = senderCreator.createSender{gas : opInfo.mUserOp.verificationGasLimit}(initCode);

It calls out to senderCreator to create the smart contract. Let’s dive into senderCreator and explore its functionality!

node_modules/@account-abstraction/contracts/SenderCreator.sol

Notice that the factory address is the beginning part of the initCode, specifically the first 20 bytes. Later on in this tutorial, we’ll be creating this initCode ourselves, so remember this detail.

The remaining part of the initCode is called initCallData, and it’s sent to the factory within the assembly block. It is important to note that there is no fixed method to call on the factory. Instead, the contract sends all the calldata directly to the factory, which means we’ll have to decide which method to call ourselves.

Another thing to note is that the outcome of the call should be the address of the newly created smart account. The EntryPoint anticipates this, so it’s crucial to ensure that we return it. So, our factory should look something like this:

contract AccountFactory {
function createAccount(address _owner) public returns (address) {
Account newAccount = new Account(_owner);
return address(newAccount);
}
}

How would you like your account to work?

You have a lot of freedom here! Are there any other constructor arguments you want to pass to your account? Using _owner seems like a good idea, but it’s entirely up to you how you want to design your account. You can name the function createAccount as we did, but that’s entirely your choice. Additionally, we’ve opted for the CREATE opcode here, which deploys the address using the hash(factory, nonce). However, if you prefer to use CREATE2, feel free to do so.

Once you’ve created the structure of your factory, it’s time to create your Account. The Account smart contract will need to accept the constructor arguments that you defined in your factory.

Additionally, it will need to implement the account interface here: https://github.com/eth-infinitism/account-abstraction/blob/ver0.6.0/contracts/interfaces/IAccount.sol — specifically the validateUserOp method. For now, let’s keep it simple and Return 0 so that the EntryPoint knows the user op it sent is valid, and can be executed.

contracts/Account.sol

Deploy Account Factory and EntryPoint

To deploy the smart contract, simply create a scripts inside the ‘scripts’ folder. In our project, we created a script called deployAF.js and deployEP.js

deployAF.js
deployEP.js

Run npx hardhat node to create an hardhat node (As mentioned earlier, we test first on the local node before using our chainlet)

Then run this command to deploy the two smart contracts

npx hardhat run scripts/deployAF.js
npx hardhat run scripts/deployEP.js

Before moving on to the next section, we will also deploy this simple smart contract to test if our Account Abstraction worked and to execute the desired smart contract.

SimpleStorage.sol

Build & Send the UserOp

UserOp represents transaction details to be executed on behalf of a user, encapsulating all necessary information for execution.

Before diving into the structure of the UserOp, let’s start by creating the script. Create a file named ‘execute.js’ and begin by adding the contract address variable at the beginning.

Let’s just take a look at all the fields of a user operation:

scripts/execute.js

It does appear to be quite a few fields. However, we’ll focus solely on the initial four fields.
For the rest, we’ll estimate gas values and mark ‘0x’ for ‘paymasterAndData’ and signature. As mentioned earlier, we are taking small steps at a time, and ‘paymasterAndData’ and signature will be covered in the Paymaster and Account Validation chapters.

So for now the user operation will look like:

scripts/execute.js

Sender (Smart Wallet/Smart Account)

One aspect of the EntryPoint’s structure is the sender’s address that we’ll have to pre-calculate to include it in the user operation.

We can compute it locally, preferably leveraging a library (such as ethers or web3) with methods for obtaining a contract address using both CREATE and CREATE2.

If CREATE was utilized, as we did, then factoring in the factory’s nonce will be necessary in the calculation to acquire the sender’s address (which is hash(deployer + nonce)). Initially, when deploying a smart account, the nonce for address calculation will be 1, incrementing by 1 with each subsequent contract deployment.

scripts/execute.js

Nonce

Another interesting aspect of the EntryPoint is its internal handling of nonces (NonceManager).

Each sender can possess multiple nonces, facilitating transactions with various keys without necessitating sequential ordering, yet still ensuring replay protection. Let’s designate our nonce key as 0. Consequently, to fetch the nonce for our sender, we can simply invoke the entryPoint.

scripts/execute.js

InitCode

We’ve already examined this field in the SenderCreator contract, observing that the initial 20 bytes represent the factory address, followed by the calldata to be sent to the factory. Now, let’s proceed to write the code to determine what our initCode will be!

You’ll need to encode the calldata for the factory’s method for creating the account. In the example provided earlier, we used createAccount. Therefore, for this example, the calldata will be the keccak hash of createAccount(address), concatenated with the address we intend to use for the owner, padded to 32 bytes. Most libraries like viem, ethers have methods to assist you in encoding function data, so manual encoding isn’t necessary.

Once you have these two components, you have to merge them. The calldata should be the factory address + the encoded calldata to send along to the factory. Take caution when manipulating strings to avoid appending “0x” from the encoded calldata, ensuring you achieve “0x{factory}{calldata}” rather than “0x{factory}0x{calldata}”.

scripts/execute.js

However we only need the initCode once. That is, when we create the sender (smart wallet/smart account) for the first time (so when nonce is ≥ 1). After that, the initCode will be ‘0x’.

scripts/execute.js

Calldata

Finally, it’s time to encode what we actually want to do with the smart wallet!
Similar to encoding the calldata for the factory’s method, you’ll likely want to use a library to handle this encoding for you.

In the Account example provided earlier, this would involve encoding the calldata to call the state-changing function execute().

The function execute essentially invokes the smart contract that we are going to pass (in our case, the SimpleStorage contract). It requires the smart contract address, value, and the encoded ABI.

Account.sol
scripts/execute.js

Pre-Fund the Account

After calculating the sender address, you’ll need to pre-fund this address with some ether on the EntryPoint. The EntryPoint utilizes this deposited ether for gas fees, so ensuring there is ether available is crucial before attempting to execute a user operation.

scripts/execute.js

Finally, after we have all the necessary “variables” for the userOps, we are going to fill the userOp and use it as an argument for the ‘entryPoint.handleOps’ method. Then, we will put the address as the second argument (which will be the beneficiary to receive fees).

In the end, your execute.js file should look like this:

Now the moment of the truth — it is time to execute the scripts using

npx hardhat run scripts/execute.js

If everything is okay in the hardhat node you will have

Also, create a test to check if the smart contract that we put in the callData worked (the set function, line 38–41 of the gist above).

testSimpleStorage.js

Implementing a Simple Account Abstraction on your chainlet

To get the account abstraction on your chainlet, you need to launch a chainlet.
If you need help to launch your chainlet, you can visit the Saga documentation and this blog post for step-by-step guidance.

Config your hardhat with your chainlet info

Before deploy the necessary smart contract, you need to config your hardhat, add a network entry to your hardhat.config.js file and add your own chain like this:

require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();

const PRIVATE_KEY = process.env.PRIVATE_KEY;

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
defaultNetwork: "localhost",
networks: {
tutorialworldV2: {
chainId: 2705143118829000,
// If you use Chainlet Testnet it will be with --> .jsonrpc.testnet.sagarpc.io
// If you use Mainnet it will be with --> .jsonrpc.sagarpc.io
url: "https://tutorialworldtwo-2705143118829000-1.jsonrpc.testnet.sagarpc.io",
accounts: [PRIVATE_KEY], // Private Key of your metamask wallet
},
},
solidity: {
version: "0.8.23",
settings: {
optimizer: {
enabled: true,
runs: 1000,
},
},
},
};

In your case will be:

Where PRIVATE_KEY is the private key of the your Metamask wallet.
To get your private key follow this guide from Metamask.

⚠️ Note: Please ensure that you keep your private key hidden from anyone by adding it to the .env file and including the .env file in your .gitignore.

If you are a beginner check this guide: How to Avoid Uploading Your Private Key to GitHub and use your dev/test wallet for your projects NOT your main one.

Deploy EntryPoint and AccountFactory

To deploy the smart contract, simply you are going to use the scripts deployAF.js and deployEP.js. The only difference is adding the network flag followed by your chainlet network.

npx hardhat run scripts/deployAF.js --network <chainlet>
npx hardhat run scripts/deployEP.js --network <chainlet>

Also you can see the contracts deployed on your chainlet explorer

Testing Account Abstraction in your chainlet

To test the account abstraction we are going to deploy SimpleStorage.sol using the command

npx hardhat run scripts/deploySimpleStorage.js --network <chainlet>

Then change all the contract addresses of AccountFactory, EntryPoint and SimpleStorage in the execute.js.

And finally run

npx hardhat run scripts/execute.js --network <chainlet>

If everything is okay you have in your chainlet explorer the following

And if you test the SimpleStorage contract (or any other contract that you deploy), you should notice a change in its state.

Prepare your dApp for Account Abstraction

To implement Account Abstraction, we are going to use as an example an Erc1155 nft claim (Github).

To do this first of all we are going to use thirdweb to deploy our erc1155.

Create your repo using the command:

npx thirdweb create

Wrap your app in the ThirdwebProvider to access the SDK’s functionality from anywhere in your app.

To use the ThirdwebProvider, you need a client ID. You can obtain one by going to https://thirdweb.com/dashboard/settings → CREATE API KEY and then copying the client ID and storing it in the .env file.

For read/write contracts, we are going to use Viem. To install it, use the following command:

yarn add Viem

In your dApp, create a folder to store all the necessary files for your Account Abstraction. These include the address contracts and ABIs of EntryPoint, AccountFactory, Account (only the ABI for this one) and your deployed contract (in our case, the ERC1155).

To obtain the ABIs, navigate to the hardhat project and access the artifacts folder. You can find the ABIs in the <contract>.json file.

To get the ABI on your contract deployed on thirdweb, you need to go on your thirdweb contracts deploy dashboard → click to your contract deployed → Sources -> ABI (should be the last one)

In the end, your folder (which I called constant) should look like this:

Next, create a ‘lib’ folder and within it, create a ‘chainlet.ts’ file. Add your chainlet information to this file like this (we need this for Viem later).

Lastly as in the hardhat project we need the dev private key and store .env (we need this for Viem later).

Now it is time to prepare App.tsx for Account Abstraction

Implement Account Abstraction in your dApp

Now that we have everything we need, let’s start by creating createPublicClient (for reading from smart contracts in your chainlet) and createWalletClient, which will execute the handleOps of the entryPoint. Here, we are going to use the dev wallet using its private key (stored in the .env file to avoid theft).

Prepare the UserOps

Sender
Let’s start with the sender as in the hardhat project, we are going to use CREATE op to create the sender.

NOTE: in this project we are going to create and use only one Smart Account, with the owner being the dev account. However, in the future, you may want to use a smart account associated with each user. For that, you can follow the Account and AccountFactory samples in this repo.

Nonce

To get the nonce simply read from the entryPoint:

InitCode

Here we are going to use the encodeFunctionData from Viem to get the encoded calldata of createAddress to send along to the factory.

CallData

Here we are going to encodeFunctionData the write function “claim” of the Erc1155 contract and “execute” of the Account contract.

Pre-Fund the Account

Before fulfilling the userOps, we need to fund the Smart Account, like we did in the Hardhat project.

UserOps and handleOps

Finally, after assembling all the necessary components, we’ll populate the userOp (with a TUPLE, not an Object) and use it as an argument for the handleOps method. Subsequently, we’ll specify the dev address as the second argument and execute the transaction using the dev account.

If everything is okay, your chainlet explorer will show all the same events that occurred in the section: Testing Account Abstraction in your chainlet.

Implement Paymaster

The Paymaster contract in ERC 4337 Account Abstraction enables someone else to cover the gas costs on behalf of the smart account. This feature grants flexibility in managing gas payments, allowing for sponsorship or alternative payment methods. Let’s proceed by deploying our own Paymaster contract to delve deeper into its functionality.

Create and Deploy Paymaster Contract

In this step, we’ll need to implement the Paymaster interface found here: https://github.com/eth-infinitism/account-abstraction/blob/releases/v0.6/contracts/interfaces/IPaymaster.sol

Let’s proceed with creating an generous paymaster:

This paymaster considers every user op valid, hence it will cover the expenses for any operation!
This behavior is evident in the validatePaymasterUserOp function, which the EntryPoint invokes for each user op it receives. Additionally, we’ve implemented a postOp function, although we’ve kept its body blank for now.

In the validatePaymasterUserOp function, you’ll notice that we have a couple of values we can return:

  • context: additional context which is supplied to the postOp
  • validationData: which comprises a packed set of three variables. The entry point parses this data to ensure that the first packed value, the sigAuthorizer, is set to 0. Additionally, it verifies that the block.timestamp falls within the range specified by the other two values: validAfter and validUntil. Zero represents a special value indicating a signature that is valid indefinitely with no expiration.

Once you have your Paymaster ready, go ahead and deploy it to your local hardhat project (unless you turn off the Hardhat node, in that case you need to re-run hardhat node and re-deploy EntryPoint, AccountFactory).

Update the PaymasterAndData field

In this last step, you need simply do a small changes in the execute.js file:

execute.js

Execute the User Operation

Once again, test it all out by executing the user operation! This time the gas should be covered by the paymaster.

If you’re able to set a variable in the SimpleStorage smart contract, just like in the step 1, you’re doing amazing!

Now let’s try with your chainlet, deploy Paymaster (remember to use the flag — network your_chainlet) and run the execute.js.

If everything is okay, you should have the same event flow as described in the section: Testing Account Abstraction in your chainlet.

Implement Paymaster on your dApp

On the frontend side, just like the hardhat project, you only need to make some changes. (Github branch with_paymaster)

You need to add the contract address and ABI to the folder where you store the other contract addresses and ABIs, such as EntryPoint, AccountFactory, etc.

Then you need to make the same changes as in the execute.js file.

Implement Account Validation

If we examine the smart account from step 1, we’ll notice that there’s currently no restriction preventing anyone from utilizing the smart account.
We’ll need to establish a method to validate each user operation and confirm whether the provided signature demonstrates the user’s authorization to execute the operation against this account.
In step 1, we included an owner in the constructor of the smart account; let’s explore how we can leverage it now!

OpenZeppelin provides an audited utility library for elliptic curve digital signature algorithm (ECDSA) operations.

For this step, we’ll need to install OpenZeppelin’s contract library using the command:

yarn add @openzeppelin/contracts@4.9.5

Then, in your smart account, you can import the ECDSA utility from /contracts/utils/cryptography/ECDSA.sol.

Account.sol

We are checking to see if the owner signed a message userOpHash (we are going to explain what this is soon) and, if they did, returning a 0 indicating that this is a valid signature.

We’ll need to sign the message userOpHash using ECDSA and pass it in the user op signature field. Both viem and ethers provide a signMessage utility that can be utilized for this purpose.

The signMessage concatenates the “\x19Ethereum Signed Message:\n” prefix, along with the length of the string, and finally, the string itself. Subsequently, it computes the keccak hash of this concatenated message, which is equivalent to the toEthSignedMessageHash function in the OpenZeppelin ECDSA library on-chain.

But what is the userOpHash? As the name suggests, the userOpHash is basically the hash of the userOp. We can obtain the hash by calling the ‘entryPoint.getUserOpHash(userOp)’ method, passing in the userOp struct (with the signature: “0x”) as the only argument.

After obtaining the userOpHash, we will sign it using the ‘signMessage’ function and replace the signature ‘0x’ with the actual signature obtained.

Now, use the ‘handleOps’ function as usual.

Note: Since the Account contract has been changed, you will need to redeploy AccountFactory.sol and update the account address of AccountFactory in execute.js

To test this out, simply run ‘npx hardhat run scripts/execute.js’. Then, in the execute.js file, add a new signer and sign with the new signer. You should get ‘FailedOp(0, “AA24 signature error”)’.

Implement Account Validation in your dApp

On the frontend side, you need to redeploy AccountFactory in your chainlet. Then, update the contract address of the AccountFactory and the ABI of the Account

Then, just like the Hardhat project, get the userOpHash and sign the hash using Viem signMessage.

Conclusion

Congratulations! Now that you have taken your first steps on Account Abstraction on your chainlet, feel free to personalize it and give your dApp some unique features.

For more details around how you can evolve your dApp, follow us on X, join our Discord and Telegram and subscribe to our Medium.

--

--

Leonardo Simone Digiorgio
Sagaxyz
Editor for

Web3 Software Engineer | Wagmi | Ethers | React.js | Next.js | Smart Contracts | Solidity | Ethereum | I help build the future of the internet.