Stackup Blog

How to send an ERC-20 token with ERC-4337

You don’t need to be an expert with ERC-4337 to create user operations. In this example, we’ll use userop.js to send an ERC-20 token.

ERC-4337 introduces a new transaction object for smart accounts called user operations. These are like regular transactions, but contain some extra information specific to ERC-4337.

Sending an ERC-20 token takes only five steps:

1. Import userop.js and ethers.js

You only need two libraries - 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. You can return the address of the account with the function simpleAccount.getSender():

console.log("Simple Account Address: ", simpleAccount.getSender());

3. Import ERC-20 interface

The user operation will call the ERC-20 token’s transfer function. To do this, you will need to use the ERC-20 ABI.

// ERC-20 ABI
const ERC20_ABI = [
  // Read-Only Functions
  "function balanceOf(address owner) view returns (uint256)",
  "function decimals() view returns (uint8)",
  "function symbol() view returns (string)",

  // Authenticated Functions
  "function transfer(address to, uint amount) returns (bool)",
  "function approve(address spender, uint amount) returns (bool)",

  // Events
  "event Transfer(address indexed from, address indexed to, uint amount)",

4. Create the user operation

You will then use ethers to create the function data that will be used in the user operation, userOp:

// Create User Operation
const client = await Client.init(rpcUrl, entryPoint);
const provider = new ethers.provider.JsonRpcProvider(rpcUrl);
const erc20 = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
const functionData = erc20.interface.encodeFunctionData("transfer", [to, amount]);
const userOp = simpleAccount.execute(erc20.address, 0, functionData);

5. Send the user operation

Now that you have your user operation, simply send it with client.sendUserOperation.

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

// Get the response
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 { ERC20_ABI } from "../../src";
// @ts-ignore
import config from "../../config.json";
import { Client, Presets } from "userop";
import { CLIOpts } from "../../src";

export default async function main(
  tkn: string,
  t: string,
  amt: string,
  opts: CLIOpts
) {
  const paymaster = opts.withPM
    ? Presets.Middleware.verifyingPaymaster(
    : undefined;
  const simpleAccount = await Presets.Builder.SimpleAccount.init(
    new ethers.Wallet(config.signingKey),
  const client = await Client.init(config.rpcUrl, config.entryPoint);

  const provider = new ethers.providers.JsonRpcProvider(config.rpcUrl);
  const token = ethers.utils.getAddress(tkn);
  const to = ethers.utils.getAddress(t);
  const erc20 = new ethers.Contract(token, ERC20_ABI, provider);
  const [symbol, decimals] = await Promise.all([
  const amount = ethers.utils.parseUnits(amt, decimals);
  console.log(`Transferring ${amt} ${symbol}...`);

  const res = await client.sendUserOperation(
      erc20.interface.encodeFunctionData("transfer", [to, amount])
      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}`);