How to Send Multiple Transactions into a Single User Operation

One advantage of ERC-4337 is that multiple transactions can be sent in a single action. This allows you to turn any experience in crypto into one click.

This example shows one common use case: sending multiple ERC-20 tokens in the same action using a builder’s executeBatch method.

1. Import Libraries

As with other examples, you will import ethers and userop.

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

2. Build account preset

Userop.js uses a builder pattern for creating user operations. Rather than create a user operation completely from scratch, we can use one of the presets. In this example we’ll use the simpleAccount preset, which is configured to match the simpleAccount example from the Ethereum Foundation.

const simpleAccount = await Presets.Builder.SimpleAccount.init(
  signer, // An ethers.js signer
  rpcUrl, // URL for the node
  entryPoint, // EntryPoint contract address
  simpleAccountFactory, // simpleAccount factory contract address
  paymaster // OPTIONAL: paymaster information
);

This initializes a simpleAccount user operation builder. This preset includes an executeBatch function, which can be used to execute multiple transactions.

3. Create the execution data

We will assume that you have a list of recipients, the tokens you are sending, and the amount of the tokens each will receive.

// Vectors containing the transaction information
dest = ["0x000...", "0x000...", ...]; // Addresses of the ERC-20 tokens that will be called
tokenRecipients = ["0x000...", "0x000...", ...]; // Recipients of the ERC-20 tokens
tokenAmounts = [0, 0, ...]; // Amount that will be sent to each recipient

We will loop through the list of recipients and put the transaction data into a data array. Each element of the data array will be encoded function data that will be executed by the smart account.

// First, create a provider that you will use to retrieve each ERC-20 token data
const provider = new ethers.provider.JsonRpcProvider(rpcUrl);

// Loop through each of the recipients, encoding the transaction data into a data array.
let data: Array<string> = [];
for (let i=0; i<tokenAddresses.length(); i++) {
  // Get the symbol and decimals of each ERC-20 token
  var erc20 = new ethers.Contract(
      ethers.utils.getAddress(tokenAddresses[i]),
      ERC20_ABI,
      provider);
  var [symbol, decimals] = await Promise.all([
      erc20.symbol(),
      erc20.decimals(),
      ]);

  // Encode the data
  var to = ethers.utils.getAddress(tokenRecipients[i]);
  var amount = ethers.utils.parseUnits(tokenAmounts[i], decimals);
 
  // Check our homework
  console.log("Creating transaction to send ${amount} ${symbol} tokens to ${to}")
	data = [
	    ...data,
	    erc20.interface.encodeFunctionData("transfer", [to, amount])
      ];
}

4. Ship it

Then simply create and send the user operation:

// Create the user operation
const userOp = simpleAccount.executeBatch(dest, data);

// Send the User Operation
const res = await client.sendUserOperation(userOp);

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

Full Example

You can view an example ERC-20 token transfer in the ERC-4337 Example Repository.

import { ethers } from "ethers";
import { Client, Presets } from "userop";
import { ERC20_ABI, CLIOpts } from "../../src";
// @ts-ignore
import config from "../../config.json";

// This example requires several layers of calls:
// EntryPoint
//  ┕> sender.executeBatch
//    ┕> token.transfer (recipient 1)
//    ⋮
//    ┕> token.transfer (recipient N)
export default async function main(
  tkn: string,
  t: Array,
  amt: string,
  opts: CLIOpts
) {
  const paymaster = opts.withPM
    ? Presets.Middleware.verifyingPaymaster(
        config.paymaster.rpcUrl,
        config.paymaster.context
      )
    : undefined;
  const simpleAccount = await Presets.Builder.SimpleAccount.init(
    new ethers.Wallet(config.signingKey),
    config.rpcUrl,
    config.entryPoint,
    config.simpleAccountFactory,
    paymaster
  );
  const client = await Client.init(config.rpcUrl, config.entryPoint);

  const provider = new ethers.providers.JsonRpcProvider(config.rpcUrl);
  const token = ethers.utils.getAddress(tkn);
  const erc20 = new ethers.Contract(token, ERC20_ABI, provider);
  const [symbol, decimals] = await Promise.all([
    erc20.symbol(),
    erc20.decimals(),
  ]);
  const amount = ethers.utils.parseUnits(amt, decimals);

  let dest: Array = [];
  let data: Array = [];
  t.map((addr) => addr.trim()).forEach((addr) => {
    dest = [...dest, erc20.address];
    data = [
      ...data,
      erc20.interface.encodeFunctionData("transfer", [
        ethers.utils.getAddress(addr),
        amount,
      ]),
    ];
  });
  console.log(
    `Batch transferring ${amt} ${symbol} to ${dest.length} recipients...`
  );

  const res = await client.sendUserOperation(
    simpleAccount.executeBatch(dest, data),
    {
      dryRun: opts.dryRun,
      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}`);
}