Get Started

Send your first user operation!

This tutorial walks you though a simple ERC-4337 transaction: sending a User Operation with gas paid by a Paymaster.

You can also follow along to a video explanation, if that's your style.


① Start a New Project

Clone the empty example repository from Github.

git clone -b empty_project [email protected]:JohnRising/userOpExample.git

Enter the project directory, and install the dependencies.

cd userOpExample
npm install

② Choose which SDK you want to use

User operations can be built with a number of different libraries. You can follow along with this example using userop or account-abstraction/sdk.

import { ethers } from "ethers";
import { Presets, Client } from "userop";
import { ethers } from "ethers";
import { JsonRpcProvider } from "@ethersproject/providers";
import { PaymasterAPI, calcPreVerificationGas, SimpleAccountAPI } from "@account-abstraction/sdk";
import { UserOperationStruct } from "@account-abstraction/contracts";
import { HttpRpcClient } from "@account-abstraction/sdk/dist/src/HttpRpcClient";

③ Set Configuration

We're in a rush, so let's just hard-code the configuration at the top of the file. In production, these should be secrets. Create your own bundler URL for faster transactions.

const rpcUrl ="https://public.stackup.sh/api/v1/node/ethereum-sepolia";
const paymasterUrl = ""; // Optional - you can get one at https://app.stackup.sh/
const rpcUrl ="https://public.stackup.sh/api/v1/node/ethereum-goerli";
const paymasterUrl = ""; // Optional - you can get one at https://app.stackup.sh/

We will also create an exportable main function.

async function main() {
  // REMAINDER OF CODE WILL GO HERE
}

main().catch((err) => console.error("Error:", err));
// NOTE: THE MAIN FUNCTION WILL BE CREATED IN THE NEXT STEP BECAUSE
// THIS SDK'S PAYMASTER API NEEDS TO BE EXTENDED.

④ Initialize a paymaster

If you want to make a gasless transaction, set the paymasterUrl and include the below code. If your paymaster field is not empty, the paymaster will be added to the user operation.

const paymasterContext = { type: "payg" };
const paymasterMiddleware = Presets.Middleware.verifyingPaymaster(
  paymasterUrl,
  paymasterContext
);
const opts = paymasterUrl === "" ? {} : {
  paymasterMiddleware: paymasterMiddleware,
}
// Extend the Ethereum Foundation's account-abstraction/sdk's basic paymaster
class VerifyingPaymasterAPI extends PaymasterAPI {
  private paymasterUrl: string;
  private entryPoint: string;
  constructor(paymasterUrl: string, entryPoint: string) {
    super();
    this.paymasterUrl = paymasterUrl;
    this.entryPoint = entryPoint;
  }

  async getPaymasterAndData(
    userOp: Partial<UserOperationStruct>
  ): Promise<string> {
    // Hack: userOp includes empty paymasterAndData which calcPreVerificationGas requires.
    try {
      // userOp.preVerificationGas contains a promise that will resolve to an error.
      await ethers.utils.resolveProperties(userOp);
      // eslint-disable-next-line no-empty
    } catch (_) {}
    const pmOp: Partial<UserOperationStruct> = {
      sender: userOp.sender,
      nonce: userOp.nonce,
      initCode: userOp.initCode,
      callData: userOp.callData,
      callGasLimit: userOp.callGasLimit,
      verificationGasLimit: userOp.verificationGasLimit,
      maxFeePerGas: userOp.maxFeePerGas,
      maxPriorityFeePerGas: userOp.maxPriorityFeePerGas,
      // A dummy value here is required in order to calculate a correct preVerificationGas value.
      paymasterAndData:"0x0101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000001010101010100000000000000000000000000000000000000000000000000000000000000000101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101",
      signature: ethers.utils.hexlify(Buffer.alloc(65, 1)),
    };
    const op = await ethers.utils.resolveProperties(pmOp);
    op.preVerificationGas = calcPreVerificationGas(op);
    op.verificationGasLimit = ethers.BigNumber.from(op.verificationGasLimit).mul(3);

    // Ask the paymaster to sign the transaction and return a valid paymasterAndData value.
    const params = [await OptoJSON(op), this.entryPoint, {"type": "payg"}];
    const provider = new ethers.providers.JsonRpcProvider(paymasterUrl);
    const response = await provider.send("pm_sponsorUserOperation", params);

    return response.data.result.toString();
  }
}

async function OptoJSON(op: Partial<UserOperationStruct>): Promise<any> {
    const userOp = await ethers.utils.resolveProperties(op);
    return Object.keys(userOp)
        .map((key) => {
            let val = (userOp as any)[key];
            if (typeof val !== "string" || !val.startsWith("0x")) {
                val = ethers.utils.hexValue(val);
            }
            return [key, val];
        })
        .reduce(
            (set, [k, v]) => ({
                ...set,
                [k]: v,
            }),
            {}
        );
}

// MAIN FUNCTION
async function main() {
  // Create the paymaster API
  const entryPointAddress = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789";
  const paymaster = new VerifyingPaymasterAPI(paymasterUrl, entryPointAddress);
  
  // REMAINDER OF CODE WILL GO HERE
  
}

main().catch((err) => console.error("Error:", err));

You can get a Stackup paymasterUrl by claiming an API key. Click the below button, create an Ethereum Sepolia instance, and copy the paymaster URL.

⑤ Initialize an account

Next, we will start building the user operation by specifying the type of account. This is needed so that the user operation can create the account if needed, and knows which function to call to execute the transaction.

This example will use the Ethereum Foundation's SimpleAccount, which is one of the simplest types of accounts that you can create. This account has a single signer to approve transactions. This example hard codes a private key for the account, but any ethers signer object can be passed to this function.

// Initialize the account
const signingKey = "0x4337433743374337433743374337433743374337433743374337433743374337";
const signer = new ethers.Wallet(signingKey);
var builder = await Presets.Builder.SimpleAccount.init(signer, rpcUrl, opts);
const address = builder.getSender();
console.log(`Account address: ${address}`);
// Initialize the account
const provider = new JsonRpcProvider(rpcUrl);
const factoryAddress = "0x9406Cc6185a346906296840746125a0E44976454";
const signingKey = "0x4337433743374337433743374337433743374337433743374337433743374337";
const owner = new ethers.Wallet(signingKey);
const accountAPI = new SimpleAccountAPI({
  provider,
  entryPointAddress,
  owner,
  factoryAddress
});

const address = await accountAPI.getCounterFactualAddress();
console.log(`Account address: ${address}`);

At this point you can run the script with npm run dev. You will see the address of the account in the console.

Account address: 0x90583F2C1A3b35552fCAc8DB672e064E4B58944A

⑥ Create the call data

Call data contain the instructions that will be executed on-chain.

This user operation contains two contract calls: one to approve an ERC-20 token for transfer, and then the transfer of the token.

// Create the call data
const to = address; // Receiving address, in this case we will send it to ourselves
const token = "0x3870419Ba2BBf0127060bCB37f69A1b1C090992B"; // Address of the ERC-20 token
const value = "0"; // Amount of the ERC-20 token to transfer

// Read the ERC-20 token contract
const ERC20_ABI = require("./erc20Abi.json"); // ERC-20 ABI in json format
const erc20 = new ethers.Contract(token, ERC20_ABI, provider);
const decimals = await Promise.all([erc20.decimals()]);
const amount = ethers.utils.parseUnits(value, decimals);

// Encode the calls
const callTo = [token, token];
const callData = [erc20.interface.encodeFunctionData("approve", [to, amount]),
                  erc20.interface.encodeFunctionData("transfer", [to, amount])]
// Create the call data
const to = address; // Receiving address, in this case we will send it to ourselves
const token = "0x3870419Ba2BBf0127060bCB37f69A1b1C090992B"; // Address of the ERC-20 token
const value = "0"; // Amount of the ERC-20 token to transfer

// Read the ERC-20 token contract
const ERC20_ABI = require("./erc20Abi.json"); // ERC-20 ABI in json format
const erc20 = new ethers.Contract(token, ERC20_ABI, provider);
const decimals = await Promise.all([erc20.decimals()]);
const amount = ethers.utils.parseUnits(value, decimals);

// Encode the calls
const callTo = [token, token];
const callData = [erc20.interface.encodeFunctionData("approve", [to, amount]),
                  erc20.interface.encodeFunctionData("transfer", [to, amount])];

⑦ Build and send the user operation

Create a Bundler client and send the User Operation.

// Send the User Operation to the ERC-4337 mempool
const client = await Client.init(rpcUrl);
const res = await client.sendUserOperation(builder.executeBatch(callTo, callData), {
  onBuild: (op) => console.log("Signed UserOperation:", op),
});

// Return receipt
console.log(`UserOpHash: ${res.userOpHash}`);
console.log("Waiting for transaction...");
const ev = await res.wait();
console.log(`Transaction hash: ${ev?.transactionHash ?? null}`);
console.log(`View here: https://jiffyscan.xyz/userOpHash/${res.userOpHash}`);
// Build the user operation
const accountContract = await accountAPI._getAccountContract();
const fee = await provider.send("eth_maxPriorityFeePerGas", []);
const block = await provider.getBlock("latest");
const tip = ethers.BigNumber.from(fee);
const buffer = tip.div(100).mul(13);
const maxPriorityFeePerGas = tip.add(buffer);
const maxFeePerGas = block.baseFeePerGas
? block.baseFeePerGas.mul(2).add(maxPriorityFeePerGas)
: maxPriorityFeePerGas;

const op = await accountAPI.createSignedUserOp({
  target: address,
  data: accountContract.interface.encodeFunctionData("executeBatch", [callTo, callData]),
  ... {maxFeePerGas, maxPriorityFeePerGas}
});

console.log("Signed User Operation: ");
console.log(op);

// Send the user operation
const chainId = await provider.getNetwork().then((net => net.chainId));
const client = new HttpRpcClient(rpcUrl, entryPointAddress, chainId);
const userOpHash = await client.sendUserOpToBundler(op);

console.log("Waiting for transaction...");
const transactionHash = await accountAPI.getUserOpReceipt(userOpHash);
console.log(`Transaction hash: ${transactionHash}`);
console.log(`View here: https://jiffyscan.xyz/userOpHash/${userOpHash}`);

Run the script

In your terminal, run npm run dev. Your output should look something like this:

Account address: 0x90583F2C1A3b35552fCAc8DB672e064E4B58944A
Signed UserOperation: {
  sender: '0x90583F2C1A3b35552fCAc8DB672e064E4B58944A',
  nonce: '0x2',
  initCode: '0x',
  callData: '0x18dfb3c7000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000003870419ba2bbf0127060bcb37f69a1b1c090992b0000000000000000000000003870419ba2bbf0127060bcb37f69a1b1c090992b0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000090583f2c1a3b35552fcac8db672e064e4b58944a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb00000000000000000000000090583f2c1a3b35552fcac8db672e064e4b58944a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
  callGasLimit: '0x5c15',
  verificationGasLimit: '0xbebb',
  preVerificationGas: '0xca0d',
  maxFeePerGas: '0x1b',
  maxPriorityFeePerGas: '0x9',
  paymasterAndData: '0x',
  signature: '0x794046f54739107fef2fe64a3536f52cfcae05da05ee56d8643ad96e78e6e5e10f146e62a52b983397b5165bb89ce19a9a62f3206a49f924f00fe19687c7bab11c'
}
UserOpHash: 0x1c380bd6088ae7347311ad44d601336b2455458a7deb70e46acdc16f66ef7960
Waiting for transaction...
Transaction hash: 0x9e32ffb483c950b26182d2e57e418fdd1dbe370ac0a6dc3a2e28d245759275ef
View here: https://jiffyscan.xyz/userOpHash/0x1c380bd6088ae7347311ad44d601336b2455458a7deb70e46acdc16f66ef7960

🚧

Public RPCs tend to be slow

If you are using the default public bundler URL, it may take you longer to get your transaction on-chain.

Click here for a private bundler URL from Stackup.

What's next?