Building with Account Abstraction (Part 1) - Creating Smart Contract Accounts

Emmanuel Oaikhenan
Emmanuel Oaikhenan
Technical Writer
Understanding the Sender Field Using Smart Contract Accounts in ERC 4337.

Learning about Account Abstraction (AA) presents a steep learning curve for developers who want to understand the relationship between wallet addresses and the way transactions function in the new paradigm. This confusion is mostly due to the fact that Account Abstraction was not proposed as a Protocol Level change to Ethereum; rather, it is a proposal that relies on an Alternative Mempool to bundle UserOperations, i.e. UserOp (replacing transactions) into a bundler, which then receives all the UserOperations via an EntryPoint Smart Contract, and adds it to a block.

To simplify, consider these key points:

  • UserOperations replace transactions.

  • The sender field in a UserOp is a Smart Contract Account (SCA) and not an Externally Owned Account (EOA).

  • The sender is created from a Smart Contract using a mix of ECDSA and algorithms.

In this guide, we will walk through how the sender field in a userOp is created as a Smart Contract Account in Account Abstraction.  To get the best out of this article, I recommend reading the ERC 4337 and the section on First Time Account Creation. In this article, we will explore common SDKs that developers can use to create a Smart Contracts Account using code walkthroughs. At the end of this article, you will learn enough to understand how Smart Contracts Accounts, which are represented as the sender field in UserOp, are created.

Prerequisites

  • Read the ERC 4337 proposal.

  • Basic familiarity with the concept of Account Abstraction. You can read the Covalent Guide on Account Abstraction.

  • An Understanding of what Node Operators do.

  • NodeJS is installed on your machine. v18+ recommended.

  • An IDE, e.g. VSCode.

I will provide a little background on some concepts, which will form blocks (pun intended) that link together in understanding the primary difference between EOAs and SCAs, how they are each created, and the importance of the sender field in userOps.

Who/What is a User in Ethereum?

In Ethereum, activities are primarily divided into READ and WRITE operations. Users can perform READ operations to access data from the blockchain, whether it's their own data or data owned by others. Conversely, WRITE operations allow users to submit or 'POST' their own data to the blockchain. These activities ensure that data ownership, which is the premise of Blockchain technology, is achieved. To identify ownership on-chain, every user is associated with a unique Ethereum ACCOUNT.

To contextualize, think of Ethereum accounts as email addresses. The email (Public Address) can be shared, but only you, with the right password (Private Key), can access its contents. Famously put as, "Not your keys? Not your wallet!" So, you need an ACCOUNT to interact with the Ethereum blockchain.

But how do you get this account? Two primary ways:

  1. Wallet Services: Think of these as Gmail provided by Google. It's a platform where you can access your email (account). Examples in Ethereum are MetaMask or Trust Wallet. They give you a Key Phrase to prove ownership, but if you lose it, access is gone forever. Such services are "Non-Custodial,” meaning they don't own your data or assets.

  2. Create Your Own: Some users prefer a more hands-on approach. They directly create their account, maintaining all keys. This is also deemed "Non-Custodial,” signifying total ownership.

How Wallet Addresses (EOA) are Generated in Web3:

We can use the Go-Ethereum Library Geth to generate an Ethereum Address. Geth provides us with a module clef that is used for account management. It goes without saying that you need to be running a client node to access this feature.

Given the complexities of running an Ethereum node, other options for generating an Ethereum Address include using Ethereum Libraries Web3.js or Ethers.js.

Web3.js

Web3.js contains the web3.eth.accounts method, which contains functions to generate Ethereum accounts and sign transactions and data.

Running this script in a NodeJS app:

const web3 = require('web3'); const account = web3.eth.accounts.create(); console.log(account);

Will log the following response to the console:

{ address: '0xA588CE075A5ab367245c508FEDcAe799BDa80Fa1', privateKey: '0x-yourPrivateKeyString', signTransaction: [Function: signTransaction], sign: [Function: sign], encrypt: [Function: encrypt] }

Ethers.js

To create an Account in Ethers.js, run this script:

var ethers = require('ethers'); var crypto = require('crypto'); var id = crypto.randomBytes(32).toString('hex'); var privateKey = "0x" + id; console.log("SAVE BUT DO NOT SHARE THIS:", privateKey); var wallet = new ethers.Wallet(privateKey); console.log("Address: " + wallet.address);

The crypto module, which comes with NodeJS, provides cryptographic functionality that includes a set of wrappers for OpenSSL's hash, HMAC, cipher, decipher, sign, and verify functions.

The result is logged to the console:

SAVE BUT DO NOT SHARE THIS: 0xyourPrivateKeyString Address: 0xd9e3bB87e6c77edC675D955718A6fE90c9d55815

You can take the Private Key, import it into your MetaMask Wallet Address and test it!

It is relevant to understand that generating an Ethereum address happens off-chain through a mix of ECDSA and Keccak256. Here's an example of the steps:

  1. A random private key of 64 (hex) characters (256 bits / 32 bytes) is generated first, like:

0xf4a2b939592564feb35ab10a8e04f6f2fe0943579fb3c9c33505298978b74893

  1. A 128 (hex) character (64 bytes) public key is then derived from the generated private key using the Elliptic Curve Digital Signature Algorithm (ECDSA):

0x04345f1a86ebf24a6dbeff80f6a2a574d46efaa3ad3988de94aa68b695f09db9ddca37439f99548da0a1fe4acf4721a945a599a5d789c18a06b20349e803fdbbe3

  1. The Keccak-256 hash function is then applied to the 128 characters (64 bytes) public key to obtain a 64-character (32 bytes) hash string. The last 40 characters (20 bytes) of this string prefixed with 0x become the final Ethereum address, e.g.:

0xd5e099c71b797516c10ed0f0d895f429c2781142

💡 Note: 0x in coding indicates that the number/string is written in hex.

Now that we understand what happens during EOA creation, we can differentiate between EOAs and SCAs. To understand SCAs, we have to take a cursory look at UserOp which contains the sender: an account created by a Smart Contract for our use. Next, we'll cover creating a Smart Contract Account with Account Abstraction.

How Accounts are Created with Account Abstraction

As outlined at the beginning of this article, you should be familiar with the ERC 4337 specification before proceeding.. This section will focus on First Time Account Creation

The goal of Account Abstraction is to create an account for any user who hasn’t had one before, enabling them to use the blockchain. For an EOA, offline algorithms generate a Private Key with an Associated Public address. For an SCA, a Factory Contract creates a “deterministic address”. This means we can determine the public address from the algorithm before deploying it on-chain. As stated, a Factory Contract performs the Account creation. It uses the Ethereum OpCode CREATE2 (not CREATE) to ensure that the order of creation of Accounts doesn’t interfere with the generated addresses. A Factory Contract should create an address using:

  • The address of the contract calling CREATE2

  • A PrivateKey (any 32-byte value)

  • The init code of the contract being deployed

Before continuing, let's introduce the initCode. This UserOp property verifies if an SCA exists for a Smart Contract in a UserOp. Here is a reminder of the fields of the UserOp, with a focus on the initCode and sender fields.

FieldData TypeDescription
senderaddressThe account making the operation
nonceuint256Anti-replay parameter (see “Semi-abstracted Nonce Support” )
initCodebytesThe initCode of the account (needed if and only if the account is not yet on-chain and needs to be created)
callDatabytesThe data to pass to the sender during the main execution call
callGasLimituint256The amount of gas to allocate the main execution call
verificationGasLimituint256The amount of gas to allocate for the verification step
preVerificationGasuint256The amount of gas to pay for to compensate the bundler for pre-verification execution and calldata
maxFeePerGasuint256Maximum fee per gas (similar to EIP-1559 max_fee_per_gas)
maxPriorityFeePerGasuint256Maximum priority fee per gas (similar to EIP-1559 max_priority_fee_per_gas)
paymasterAndDatabytesAddress of paymaster sponsoring the transaction, followed by extra data to send to the paymaster (empty for self-sponsored transaction)
signaturebytesData passed into the account along with the nonce during the verification step

InitCode

When a UserOp is sent on-chain by a Smart Contract, the sender field is not yet present and is created by initCode. A Factory Contract is used to create the Account from the initCode operation. According to the ERC 4337 Standard, there is an available RPC method for sending the UserOperation. The method call is eth_sendUserOperation. Calls made to the eth_sendUserOperation method go to the EntryPoint Contract, where a UserOp is first validated before subsequent execution. In the validation process, a call to the Factory Contract by the initCode takes place, generating an SCA if it hasn't been made previously. This SCA can be accessed in the EntryPoint Contract by invoking entryPoint.getSenderAddress(), wherein the EntryPoint Contract manages calls to the Factory Contract.

In a sequential order:

  1. When a UserOp is deployed to an EntryPoint contract using the eth_sendUserOperation like so:

{ "jsonrpc": "2.0", "id": 1, "method": "eth_sendUserOperation", "params": [ { "sender": "sender_value", "initCode": "initCode_value", ... }, "entryPoint" ] }

If the call is being made for the first time, the already deployed factory contract invokes the initCode, which specifies the CREATE2 OpCode, returning an account, the sender address. An example of such an implementation might follow this pattern:

contract Factory { function deployContract(bytes data) public returns (address); }
  1. The entryPoint.getSenderAddress() method call will then return the generated address. You can find an example implementation of a Factory Smart Contract by Infinitism here.

The Infinitism SimpleAccountFactory.sol Smart Contract was conceived as a demonstration Smart Contract that supports the Smart Contract Account creation feature of ERC 4337. A number of SDKs are available for developers who want to implement Account Abstraction in their dApps without the need to deploy their own Factory Smart Contracts for Account creation. We'll explore a few SDKs and delve into the creation of Account addresses using each.

A critical learning aspect for developers in grasping SCAs is that an SCA can be created away from a UserOp.

Creating Smart Contract Accounts (SCA) using Developer SDKs

Currently, in the Ethereum Ecosystem, there are SDKs provided by StackUp, Biconomy and Alchemy. In this section, we will focus on the StackUp and Biconomy SDKs, as those are already being used in production. Keep in mind that our primary focus here is on creating Smart Contract Accounts, it is not on the EntryPoint, Bundler or PayMaster Contracts. StackUp and Biconomy are Node Operators who provide services to developers who want to build on Blockchains.

StackUp

StackUp provides a range of Account Abstraction services to developers via their SDK and a Javascript Library that enables ERC 4337 UserOp called userop.js.

We will create a Smart Contract Account using the Ethers.js and UserOp.js libraries. Here’s how:

Step 1: Setting Up Your Development Environment

Open VSCode or any IDE of your choice. Create a folder and change the directory into the folder:

$ mkdir stackup-sh-aa && cd stackup-sh-aa

Step 2: Initializing the Project

Initialize a new npm project, creating a package.json file for dependencies:

$ npm init -y

Step 3: Installing Libraries

Download the relevant libraries:

$ npm install [email protected] useropjs

💡 Note: We are installing this specific version of Ethers.js because the BaseWallet object in v6 currently returns errors with the StackUp implementation.

Step 4: Configuring TypeScript

Install Typescript and ts-node as developer dependencies:

$ npm i -D typescript ts-node

Create a Typescript configuration file:

./node_modules/.bin/tsc --init

After running the command, you'll get a message indicating the successful creation of tsconfig.json. Open the ts-config.json file and uncomment the line "resolveJsonModule": true. This will enable us to import JSON files to read from.

Step 5: Writing the Initial Script

Create an index.ts file and add the following code:

import { ethers } from "ethers"; import { Presets } from "userop"; export default async function main() { const simpleAccount = await Presets.Builder.SimpleAccount.init( new ethers.Wallet(<Signing_Key>), <RPC_Url> ); const address = simpleAccount.getSender(); console.log(`SimpleAccount address: ${address}`); }

This script relies on the Ethers.js Library Wallet method call that enables us to create a non-custodial wallet from a PrivateKey string. Recall that creating an SCA requires a PrivateKey string that the owner of the SCA can use to prove ownership of the SCA.

StackUp encapsulates a call to the InitCode via the init method call and, using the PrivateKey string creates the SCA and propagates it on-chain via the Node URL. The address is generated here and can thus be used in a UserOp subsequently via another SDK call for creating a UserOp.

You will see that we need two things for our code: a Signing_Key, which is a private key string for creating our Smart Contract Account, and an RPC_Url to be provided by a node provider. In this instance, StackUp also serves as a node provider, and we will secure an RPC URL from them.

Step 6: Preparing Configuration File

Create a config.json file to store the necessary credentials. Create a placeholder for the keys:

{ "RPC_Url": "...", "Signing_Key": "" }

Step 7: Generating a Private Key

To generate a PrivateKey, which is any random 32-byte string preceded by “0x” using ethers.utils.randomBytes(32), we’ll use a script via a method provided to us by Ethers.js and log it to the console. After it has been generated, we will copy it and comment out the line of code from the script.

Add these two lines to the bottom of the code:

const randomPrivateKey = ethers.Wallet.createRandom().privateKey; console.log(`Random Private Key: ${randomPrivateKey}`);

It is a const where we store a random key that is generated by calling the ethers.Wallet.createRandom().privateKey method, and then we log it to the console.

After generating the key, copy it into the "Signing_Key" field in the config.json file. With this PrivateKey, we can proceed to generate an SCA.

Step 8: Acquiring StackUp Node RPC URL

Visit the StackUp account by visiting https://app.stackup.sh/sign-in.

Log in to go to your Dashboard. Create a New Instance and set it to Polygon Mumbai Testnet:

Copy the RPC URL by clicking on the Node button:

Update your config.json file with the copied RPC URL.

Now, we are ready to generate a smart contract address!

Step 9: Integrating Config into Script

Update the index.ts file by importing the config path into the index.ts file, and update the two paths to point to the correct Signing_Key and RPC_URL:

import config from "./config.json"; // Later in the code... const simpleAccount = await Presets.Builder.SimpleAccount.init( new ethers.Wallet(config.Signing_Key), config.RPC_Url );

Step 10: Running the Script

Add the following script to your package.json file:

"scripts": { "start": "node --loader ts-node/esm index.ts" }

Comment out or remove the line in index.ts that creates a new PrivateKey using ethers.Wallet and update the file to enable main() to be called as a method by adding main() to the bottom of the file.

Run the script to generate the SCA:

$ npm start

There! Look at the Console! You have generated a SCA! This SCA can be used to communicate with any StackUp EntryPoint Contract as the sender field in a UserOp. Copy the address from the console and paste it into the Mumbai Explorer. You will see that the address exists!

Biconomy

The Biconomy Modular SDK is a package for common Account Abstraction use cases. The SDK operates in a non-custodial manner, thereby giving developers flexibility over their implementation.

To create an SCA using the Biconomy SDK, follow these steps:

Step 1: Setting Up the Workspace

Open VSCode or any IDE of your choice. Create a folder and change the directory into the folder:

$ mkdir biconomy-aa && cd biconomy-aa

Initialize a new npm project, creating a package.json file for dependencies:

$ npm init -y

Step 2: Installing Dependencies

Install necessary dependencies:

$ npm install [email protected] @biconomy/account @biconomy/common @biconomy/paymaster

💡 Note: Paymaster is a required dependency as Biconomy is heavy on the implementation of Gasless transactions.

Install typescript and ts-node as developer dependencies:

$ npm i -D typescript ts-node

Step 3: Configuring Typescript

Create a ts-config.json file:

./node_modules/.bin/tsc --init

A success message is shown in the terminal:

Created a new tsconfig.json with

Update the generated ts-config.json file. Find and uncomment:

"resolveJsonModule": true

This will enable us to import JSON files.

Step 4: Coding the Implementation

Create an index.ts file and add the following code:

import { ethers } from "ethers"; import { ChainId } from "@biconomy/core-types"; import { BiconomySmartAccount, BiconomySmartAccountConfig, } from "@biconomy/account"; import config from "./config.json" assert { type: "json" }; const { signingKey } = config; const biconomySmartAccountConfig: BiconomySmartAccountConfig = { signer: new ethers.Wallet(signingKey), chainId: ChainId.POLYGON_MUMBAI, }; const biconomyAccount = new BiconomySmartAccount(biconomySmartAccountConfig); const biconomySmartAccount = await biconomyAccount.init(); const smartAccountInfo = await biconomySmartAccount.getSmartAccountAddress(); console.log(`Smart Account address: ${smartAccountInfo}`);

💡 Note: You can use the same SigningKey from the previous StackUp build.

If you look closely, at this point, you will notice the use of the Ethers.js Wallet method for creating a wallet from a PrivateKey string. Again, this key is used in the SCA creation process in Biconomy.

Add the following script to your package.json file:

"scripts": { "start": "node --loader ts-node/esm index.ts" }

If you are feeling particularly curious, you may also log the response in the biconomySmartAccount to the console:

console.log( biconomySmartAccount);

Step 5: Generating the Smart Contract Address (SCA)

Run your script:

$ npm start

There! Look at the Console! You have generated an SCA. Copy the address from the console and paste it into the Mumbai Explorer. You will see that the address exists! This SCA can be funded, and you can start using it to carry in UserOperations as the sender field value!

Conclusion

Current documentation doesn't distinguish between Smart Contract Account (SCA) creation and UserOperations. It's absent from the Proposal because SCAs' first-time creation occurs during the validation phase bundled under the eth_sendUserOperation RPC method. This means many developers remain unaware that SCAs can be pre-created before sending a UserOp using the InitCode, based on the Create2 Opcode.

To reiterate, an SCA is essentially created using a Factory Contract, implemented with Create2 opcode. The basic requirements are a PrivateKey (a 32-byte value) and the initCode of the deploying contract. This method is evident in the SDKs we explored: StackUp GitHub & Biconomy GitHub.

The key takeaways here from learning the importance of creating an SCA outside of a bundled UserOp are:

  • Developers have a choice of creating an SCA for their users and funding the SCA with tokens that can then be used to pay gas for their UserOperations in the instance where developers do not want to handle gas payment using a Paymaster Contract.

  • This gives developers an option to build wallet systems that are non-custodial in nature, given that the owner of an SCA has access to the Primary Key for creating the SCA and, thus, still possesses ownership like in the EOA kind of behavior.

  • Developers are able to build Social Login experiences for users who aren’t either aware of custodianship in Web3 or who simply want to use a dAPP with just their email addresses. This is how Social recovery is thus enabled using Account Abstraction

  • Understanding how SCAs are created away from a UserOp lays a foundation for developers to learn how to create bundled “transactions,” such as calling approve and transfer in one UserOp

Congratulations on making it to the end of this article, I hope you have learned a whole lot from reading this. I enjoyed writing this and also learned a whole lot in the process.

References and Further Reading

Read more