Introduction

Ethereum's ERC-4337 is a pivotal standard designed to simplify user onboarding into blockchain technology. It eliminates the need for users to grapple with key phrases and private keys. Central to this standard is the concept of Account Abstraction, detailed in the Covalent Docs. Grasping ERC-4337 is vital for developers as it enhances dApp development, offering a streamlined user onboarding experience.

The essence of ERC-4337, titled Account Abstraction Using Alt Mempool, lies in its avoidance of consensus-layer protocol changes, opting instead for a higher-layer infrastructure. Here's what you need to know:

  • Transactions & Mempool: When an Ethereum node receives a transaction, it's temporarily held in a Mempool. Following a series of validity checks, if deemed authentic, the transaction transitions from the Mempool to a block.

  • ERC-4337 Highlights:

    • Operates on a higher-level infrastructure with an Alternate Mempool, abstracting Externally Owned Accounts (EOAs) in favor of Smart Contract Accounts (SCAs).

    • The Alternate Mempool is managed by Bundlers who aggregate UserOperations, ensuring integrity via reputation scoring and staking.

    • UserOperations are revolutionary, allowing for batching of requests like APPROVE and TRANSFER events. These operations are enabled by SCAs, distinct from EOAs, and created using the CREATE2 opCode.

    • UserOperations, after verification for validity, are combined into a Bundle Transaction and appended to a block by Bundlers. Think of Bundlers as transaction verifiers who pass confirmed transactions to the Entry Point Contract.

To realize a UserOperation, four entities play vital roles: the Sender Contract, Paymaster Contract, Bundler, and EntryPoint Contracts. The provided image offers a visual representation of their interplay.

In this guide, I’m going to walk you through how developers can create UserOperations using the Stackup and Biconomy SDKs tailored for Account Abstraction. At the end of this guide, you will have learned to send simple Transfer UserOperation and bundle Staking UserOperations involving Approve and Send functions. I’ll also show you a comparison of the two SDKs.

Prerequisites

  • Understand the ERC-4337 proposal.

  • An Understanding of what Node Operators do.

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

  • An IDE, e.g. VSCode.

RPC Method

Interaction with the Ethereum Blockchain typically occurs via nodes broadcasting transactions on-chain using Remote Procedural Call functions. ERC-4337 leverages several RPC methods, but the most crucial is the method call for creating UserOperations, which is eth_sendUserOperation.

The eth_sendUserOperation function submits a User Operation object to the User Operation pool of the client (Bundler). The client MUST validate the UserOperation and return a result based on that validation.

If the User Operation passes validation and is deemed a successful UserOp by the Bundler, the returned result is the hash — userOpHash (analogous to a transaction hash). Conversely, if the User Operation fails, a userOpHash will NOT be returned; instead, a string describing the failure reason will be provided by the Bundler.

A User Operation dispatched with the eth_sendUserOperation RPC method comprises the following object key-value pairs:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "eth_sendUserOperation",
  "params": [
    {
      "sender": "address",
      "nonce": "uint256",
      "initCode": "bytes",
      "callData": "bytes",
      "callGasLimit": "uint256",
      "verificationGasLimit": "uint256",
      "preVerificationGas": "uint256",
      "maxFeePerGas": "uint256",
      "maxPriorityFeePerGas": "uint256",
      "paymasterAndData": "bytes",
      "signature": "bytes"
    },
    "entryPoint": "address"
  ]
}

Here, the entryPoint refers to the address in the EntryPoint Smart Contract.

Upon successful execution, the response looks like:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": "0x1234...5678"
}

The result in this response signifies the userOpHash, and it's analogous to a Transaction hash.

Available SDKs for Creating User Operations

A basic understanding of the RPC method call sets the background for the available SDKs that can be relied upon by Developers to send a User Operation. These SDKs eliminate the need for developers to set up their own infrastructure for the implementation of UserOp.

Stackup

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

In this code walkthrough, I explain in steps the process which you can follow to create a UserOp, which is sent to a Bundler. In this first example, we will send a UserOp for a transfer; it is not a transfer for native tokens such as ETH or MATIC; it is the transfer of any ERC-20 token. The same logic can be applied to a native transfer of tokens.

UserOp for Transfers

Step 1: Setup TypeScript Environment

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

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

Create a package.json file for dependencies.

Bash
$ npm init -y

Install the necessary dependencies:

Bash
$ npm install [email protected] useropjs
$ npm i -D typescript ts-node

We are specifically installing this version of EthersJS, as the BaseWallet object in v6 currently returns errors with the StackUp implementation.

Initialize TypeScript configuration:

Bash
./node_modules/.bin/tsc --init

In the new ts-config.json file, uncomment the line:

json
"resolveJsonModule": true

This will enable us to import JSON files which we will read from.

Step 2: Create TypeScript Files and Set Up Configurations

Create an index.ts file.

Declare the methods we need from the dependencies installed:

TypeScript
import { ethers } from "ethers";
import { Client, Presets } from "userop";

Create a config.json file where we will store the Signing Key and the RPC URL we will use. Create a placeholder for the keys:

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

To get your StackUp node RPC URL, create an account and login to go to your Dashboard. Create a New Instance and set it to the Polygon Mumbai Testnet. Copy the RPC URL by clicking on the Node button.

You can generate a PrivateKey, which is any random 32-byte string preceded by 0x using ethers.utils.randomBytes(32). Alternatively, you can copy the Private Keys to any EOA account you own.

info
💡 Please NOTE! Exposed Keys can lead to your EOA being compromised. I recommend using Private Keys to an EOA you use for Development purposes only or copying a key from Ganache.

You can use this script to generate a random key in your browser console or terminal with curl.

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

Add the Private Key to the config.json file and import it into index.ts.

TypeScript
import config from "./config.json" assert { type: "json" };

Seeing as we are transferring an ERC-20 token and not a Native token, we are going to require the contract address and ABI of a deployed ERC-20 token to interact with. For this purposes of your learning, I have deployed a simple ERC-20 token to the Polygon Mumbai Network here: https://mumbai.polygonscan.com/address/0x317e4c04c7fdf44fa72bc997aece1b691159f95f#writeContract.

You can access the contract, mint some for yourself, and copy the ABI, which we are going to use in the next step.

Create a file called abi.json to store the deployed token ABI, and import it into index.ts.

TypeScript
import ERC20_ABI from "./abi.json";

Step 3: Create a Smart Contract Account (SCA)

To carry out a UserOp, we need to create a Smart Contract Account (SCA); you can read my previous article on the importance of SCAs and how they are created here.

An SCA is a core component of a UserOp; in the next step, we will create an SCA and log in to the console. In this code walkthrough, we will not be using a PayMaster to sponsor UserOp; we are going to paste the generated SCA into a faucet and get some Matic tokens to pay gas for the UserOp.

To create an SCA, implement the main function:

TypeScript
export default async function main() {
  const simpleAccount = await Presets.Builder.SimpleAccount.init(
    new ethers.Wallet(config.Signing_Key),
    config.rpcUrl
  );
  const address = simpleAccount.getSender();
  console.log(`SimpleAccount address: ${address}`);
}

Fund the SCA with test Matic from the Polygon Faucet. Also, mint some MTK (our demo token) to the created SCA.

Step 4: Implement the Transfer UserOp

To create a Transfer UserOp, we are going to update the main method to accept token address, recipient address, and amount.

TypeScript
async function main(tkn: string, t: string, amt: string) {

Update the method with client initialization from the UserOp library by parsing the StackUp RPC_Url.

Using the EthersJS Library, we will use the available provider's method to access the same RPC_Url and interact with the deployed ERC-20 token.

TypeScript
const client = await Client.init(config.RPC_Url);

const provider = new ethers.providers.JsonRpcProvider(config.RPC_Url);

const token = ethers.utils.getAddress(tkn);

const to = ethers.utils.getAddress(t);

const erc20 = new ethers.Contract(token, ERC20_ABI, provider);

Obtain the token details and prepare the transfer:

TypeScript
const [symbol, decimals] = await Promise.all([
  erc20.symbol(),
  erc20.decimals(),
]);
const amount = ethers.utils.parseUnits(amt, decimals);

console.log(`Transferring ${amt} ${symbol}...`);

Step 5: Send the UserOp

The client instance relies on the Stackup method call sendUserOperation to batch the transaction, assign it to the SCA and broadcast it to the bundler.

TypeScript
const res = await client.sendUserOperation(
  simpleAccount.execute(
    erc20.address,
    0,
    erc20.interface.encodeFunctionData("transfer", [to, amount])
  ),
  {
    onBuild: (op) => console.log("Signed UserOperation:", op),
  }
);

Monitor the transaction’s progress:

TypeScript
console.log(`UserOpHash: ${res.userOpHash}`);
console.log("Waiting for transaction...");
const ev = await res.wait();
console.log(`Transaction hash: ${ev?.transactionHash ?? null}`);

To execute the UserOp, you will parse arguments to the method call at the end of the code. We will be sending some MTK to any EOA. All arguments are strings.

TypeScript
main(
  "0x317E4C04C7fDf44fa72bC997AeCe1b691159F95F",
  "any-EOA-address",
  "5"
);

Step 6: Package Configuration and Execution

  1. Update the package.json with a script to run your project:

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

Remove the line to create a Private Key using ethers.Wallet or comment it out and update the file to enable main to be called as a method by adding main() to the bottom of the file.

Then, run the application:

Bash
$ npm start

There! Look at the console! You have sent a UserOp!

UserOp for Approve and Send

Step 1: Import Necessary Libraries and Configurations

TypeScript
import { ethers } from "ethers";
import { Presets, Client } from "userop";
import config from "./config.json" assert { type: "json" };

const signingKey = config.SIGNING_KEY;
const rpcUrl = config.RPC_URL;

Step 2: Understand the Context

Much like the example above, this will take three arguments. It is useful, for instance, where a deployed contract needs to spend your tokens on your behalf, for example, in a SWAP. Typically, with EOAs, you must first APPROVE and then carry out a TRANSFER. With a UserOp, both APPROVE and TRANSFER can be bundled into UserOp and carried out simultaneously! Therein lies the beauty of Account Abstraction.

Step 3: Implement the Approve and Send Function

This is the first function that will be called in the next. It takes three arguments, which will be fixed and then parses it to the sendUserOperation method. In this step, we use the EthersJS library to set the various required arguments.

TypeScript
async function approveAndSend(token: string, to: string, value: string): Promise<any[]> {
    const ERC20_ABI = require("./abi.json");
    const provider = new ethers.providers.JsonRpcProvider(rpcUrl);
    const erc20 = new ethers.Contract(token, ERC20_ABI, provider);
    
    const decimals = await erc20.decimals();
    const amount = ethers.utils.parseUnits(value, decimals);
    
    const approve = {
        to: token,
        value: ethers.constants.Zero,
        data: erc20.interface.encodeFunctionData("approve", [to, amount]),
    };

    const send = {
        to: token,
        value: ethers.constants.Zero,
        data: erc20.interface.encodeFunctionData("transfer", [to, amount]),
    };
    
    return [approve, send];
}

Step 4: Create the Main Execution Function

Unlike in the prior example, to create an SCA here, we use the Kernel option available in the StackUp SDK, which gives us access to an executeBatch method call for executing multiple UserOp.

TypeScript
async function main() {
    // Initialize the UserOp builder
    const signer = new ethers.Wallet(signingKey);
    const builder = await Presets.Builder.Kernel.init(signer, rpcUrl);
    const address = builder.getSender();
    console.log("address: ", address);
    
    // Prepare the calls
    const to = "0x74F73c34EA89c6A8b4de1Bc35017F6542D9419CB";
    const token = "0x317E4C04C7fDf44fa72bC997AeCe1b691159F95F";
    const value = "1";
    const calls = await approveAndSend(token, to, value);
    
    // Execute the batched calls
    builder.executeBatch(calls);
    console.log(builder.getOp());
}

Step 5: Send the UserOp Using the Client Instance

TypeScript
// Send the user operation
const client = await Client.init(rpcUrl);
const res = await client.sendUserOperation(builder, {
    onBuild: (op) => console.log("Signed UserOperation: ", op),
});

console.log(`UserOpHash: ${res.userOpHash}`);
console.log("Waiting for transaction...");
const ev = await res.wait();
console.log(`Transaction hash: ${ev?.transactionHash ?? null}`);
TypeScript
export default main;
main();  // Invoke the main function

Step 6: Update Package Script and Run

Add this script to your package.json:

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

Comment out or remove any redundant code related to creating a PrivateKey with ethers.Wallet and update the file to enable main() to be called as a method by adding main() to the bottom of the file.

Then, run:

Bash
$ npm start

There! Look at the Console! You have sent a Batch Call UserOp!

Biconomy

The Biconomy Modular SDK is a package for common account abstraction use cases. The SDK operates in a non-custodial manner, giving developers flexibility over their implementation using the SDK.

UserOp for Transfers

Step 1: Initial Setup

Refer to Steps 1 and 2 in the "UserOp for Transfers" section from the StackUp example, since this is also a TypeScript project.

Step 2: Install Dependencies and Set Up Configuration

Building with an SDK typically requires installing the dependencies where we will access certain methods in our build. Biconomy provides AA-specific dependencies that enable SCA creation, bundler access, PayMaster contracts, etc.

Install the necessary Biconomy dependencies:

Bash
npm i [email protected] @biconomy/bundler @biconomy/core-types @biconomy/account

Create an index.ts file and add the necessary imports:

TypeScript
import { IBundler, Bundler } from "@biconomy/bundler";
import { ChainId } from "@biconomy/core-types";
import {
    BiconomySmartAccount,
    BiconomySmartAccountConfig,
    DEFAULT_ENTRYPOINT_ADDRESS,
} from "@biconomy/account";
import { Wallet } from "ethers";

Prepare a config.json file to store your Signing Key and Bundler URL:

json
{
    "Signing_Key": "...",
    “Bundler_Url”: “...”
}

Add a Private Key to the config.json file and import it into index.ts:

TypeScript
import config from "./config.json" assert { type: "json" };

Step 3: Create the Smart Contract Account (SCA)

To carry out a UserOp, we need to create a Smart Contract Account SCA. To create an SCA in Biconomy, we need to use the available method calls and the Private Key.

Set up the configuration:

TypeScript
const wallet = new Wallet(config.Signing_Key);
const bundler: IBundler = new Bundler({
    bundlerUrl: config.Bundler_Url,
    chainId: ChainId.POLYGON_MUMBAI,
    entryPointAddress: DEFAULT_ENTRYPOINT_ADDRESS,
});

const biconomySmartAccountConfig: BiconomySmartAccountConfig = {
    signer: wallet,
    chainId: ChainId.POLYGON_MUMBAI,
    bundler: bundler,
};

async function createAccount() {
    const biconomyAccount = new BiconomySmartAccount(biconomySmartAccountConfig);
    const biconomySmartAccount = await biconomyAccount.init();
    console.log("owner: ", biconomySmartAccount.owner);
    console.log("address: ", await biconomySmartAccount.getSmartAccountAddress());
    return biconomyAccount;
}

An instance of the BiconomySmartAccountConfiguration takes in the key-value pairs to designate the Private Key and the chain where the SCA is deployed. It also takes in the bundler instance where we can access methods such as estimateUserOpGas when the user is carrying out the UserOp.

Step 4: Implement the Main Execution Function

The main function below is where the UserOp is carried out. We call the createAccount method to get access to the smartAccount, and the validation is carried out in the buildUserOp method where we confirm that the user has enough gas to carry out the UserOp as we are not using a PayMaster.

TypeScript
async function main() {
    console.log("creating account");
    const smartAccount = await createAccount();
    const transaction = {
        to: "0xD1bC7D2e1D5463CA115b8C999ef78B100cf0dF23",
        data: "0x",
        value: ethers.utils.parseEther("0.01"),
    };

    const userOp = await smartAccount.buildUserOp([transaction]);
    userOp.paymasterAndData = "0x";
    const userOpResponse = await smartAccount.sendUserOp(userOp);
    const transactionDetail = await userOpResponse.wait();
    console.log("transaction detail below");
    console.log(transactionDetail);
}

main();

You can paste the created SCA in the Polygon Faucet to get some MATIC, and also the MTK contract shared above to MINT some MTK.

Step 5: Update Package Script and Run

Add the following script to your package.json file:

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

Remove the line to create a Private Key using ethers.Wallet or comment it out. You've already added the main() function invocation to the end of your script.

Finally, run your script:

Bash
$ npm start

There! Look at the Console!

UserOp for Approve and Send

Step 1: Initial Setup

Follow Steps 1 and 2 in the "UserOp for Transfers" section from the StackUp example, given its similarity as a TypeScript project.

Step 2: Installation, Configuration and Contract Interactions

Install the following dependecies by running this command:

Bash
npm i [email protected] @biconomy/bundler @biconomy/core-types @biconomy/account

Create an index.ts file and declare the imports:

TypeScript
import { IBundler, Bundler } from "@biconomy/bundler";
import { ChainId } from "@biconomy/core-types";
import {
    BiconomySmartAccount,
    BiconomySmartAccountConfig,
    DEFAULT_ENTRYPOINT_ADDRESS,
} from "@biconomy/account";
import { Wallet, ethers } from "ethers";

Create a config.json file to store the Signing Key, RPC URL and Bundler URL. Create a placeholder for the keys:

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

Incorporate the configuration into index.ts:

TypeScript
import config from "./config.json" assert { type: "json" };

Like we did in the Stackup example above, we can use the demo MTK token I deployed for the transfer. Fetch the ABI from the provided Polygon Mumbai Network link, save it in abi.json, and then import it:

TypeScript
import ERC20ABI from "./abi.json" assert { type: "json" };

Step 3: Biconomy SDK Initialization

Initialize the Biconomy Smart Account SDK:

TypeScript
let signer = new ethers.Wallet(config.Signing_Key);
const eoa = await signer.getAddress();
console.log(`EOA address: ${eoa}`);

Create bundler instance:

TypeScript
const bundler = new Bundler({
    bundlerUrl: config.Bundler_Url,
    chainId: ChainId.POLYGON_MUMBAI,
    entryPointAddress: DEFAULT_ENTRYPOINT_ADDRESS,
});

Now, Biconomy smart account configuration. Note that paymaster and bundler are optional. You can choose to create new instances of this later.

TypeScript
const biconomySmartAccountConfig = {
    signer: signer,
    chainId: ChainId.POLYGON_MUMBAI,
    rpcUrl: config.RPC_Url,
    bundler: bundler,
};

const biconomyAccount = new BiconomySmartAccount(biconomySmartAccountConfig);
const biconomySmartAccount = await biconomyAccount.init();
const smartAccountInfo = await biconomySmartAccount.getSmartAccountAddress();
console.log(`SimpleAccount address: ${smartAccountInfo}`);

Step 4: Construct the UserOp

Build the UserOp for the ERC-20 transfer by generating ERC-20 transfer data and encode an ERC-20 token transfer to the recipient with the specified amount:

TypeScript
const readProvider = new ethers.providers.JsonRpcProvider(config.RPC_Url);
const tokenContract = new ethers.Contract(tokenAddress, ERC20ABI, readProvider);
let decimals = 18;

try {
    decimals = await tokenContract.decimals();
} catch (error) {
    throw new Error("invalid token address supplied");
}

const amountGwei = ethers.utils.parseUnits(amount.toString(), decimals);
const data = (await tokenContract.populateTransaction.transfer(recipientAddress, amountGwei)).data;
const approve = {
    to: tokenAddress,
    data,
};
const send = {
    to: recipientAddress,
};
const transaction = [approve, send];
const userOp = await biconomySmartAccount.buildUserOp(transaction);

Step 5: Send UserOp to Bundler

Sign the UserOp and send it to the bundler. The below function gets the signature from the user (signer provided in Biconomy Smart Account) and also sends the full Op to the attached bundler instance:

TypeScript
try {
    const userOpResponse = await biconomySmartAccount.sendUserOp(userOp);
    console.log(`userOp Hash: ${userOpResponse.userOpHash}`);
    const transactionDetails = await userOpResponse.wait();
    console.log(`transactionDetails: ${JSON.stringify(transactionDetails, null, "\\t")}`);
} catch (e) {
    console.log("error received ", e);
}

Call the method with the following arguments:

TypeScript
Main(
    "any-EOA-address",
    5,
    "0x317E4C04C7fDf44fa72bC997AeCe1b691159F95F"
);

That's it!

Comparison of Stackup and Biconomy

Both StackUp and Biconomy aim to simplify and streamline the process of account abstraction for Ethereum developers. It is important to note that both SDKs make calls to the standard RPC method calls provided by the ERC-4337 specification.

The method calls that I’ll mention here are:

  • eth_estimateUserOperationGas You can think of this call as responsible for the simulation, i.e. Validation phase of a UserOp.

  • eth_sendUserOperation This is the execution phase of a UserOp where the “Transaction” is sent to a Bundler.

What is primarily different is the approach that each of the SDKs takes in sending a UserOp. Below is a comparison of their features and implementation methods.

Feature / ConsiderationStackUpBiconomy
Core Library for Account AbstractionSingle unified library: userop.js that caters to all AA needs.Modular approach with multiple libraries: @biconomy/account, @biconomy/bundler, @biconomy/common, and @biconomy/paymaster.
Methods for Smart Contract Account (SCA) InitializationTwo methods: SimpleAccount and Kernel. Depending on the method, different features become accessible. Kernel offers a wider range of options for developers.Uses @biconomy/account for SCA creation. Takes in chainId argument to select a network for SCA deployment.
Node & RPC ProvisionActs as a Node Provider, offering its own RPC URL for blockchain interactions.Requires an external RPC provider. However, it provides a distinct bundler URL for specific methods related to validation and execution phases of the UserOp.
Code Complexity for SCA CreationSimplified process with fewer lines of code.More involved with multiple promise queries needed to read the SCA.

Both tools have their advantages. While Stackup offers simplicity and rapid development Biconomy allows for detailed validation and execution, modular development, and can support complex projects.

Conclusion

In this guide, I have extensively walked you through the steps of sending User Operations for sending Native Tokens and ERC-20 Tokens and for making batch Transactions. The critical learning curve is understanding that an APPROVE and TRANSFER transaction can be batched into an array and sent as one UserOp. Grasping this concept opens up the vistas of what is possible with UserOp. Consequently, a developer's capacity to create is virtually limitless once they understand how to batch transactions into a single UserOp.

References

StackUp Docs.

Biconomy Docs.