Skip to content

ZeroDev, paying in a token

For a ZeroDev Kernel account that holds no ETH, only a token the relay takes, such as USDC, sending with createKernelAccountClient, the relay's chain endpoint (such as https://bruno.mailam.app/sepolia) as its bundlerTransport. No ZeroDev project ID is needed: it's only part of ZeroDev's own URLs.

The op's fees are zero, so the account needs no ETH: the relay pays the gas. Giving the fees also skips the one method the client would otherwise ask the relay for them. Its last call pays the relay's payee in the token, the payee read from the relay's /v1/config. The op pays only if its calls succeed. An undeployed account is deployed by the op.

Before signing, the script prepares the op with a placeholder payment of one unit, which signs nothing, and asks the relay its price for it with postpaid_quoteUserOperation. Then it sends the op paying the quoted amount, so the op is signed once. The quote holds until its validUntilBlock; sent later, an op whose price has risen fails as underpaid, the amount required in the error's data.required, and is quoted again.

The script makes a Kernel v3.2. Its comment says which environment variables to set.

/**
 * Sends a token from a ZeroDev Kernel account through a Bruno relay, with ZeroDev's account
 * client. The relay pays the gas, and the user operation pays the relay in the same token, in its
 * last call, the amount the relay quotes for it before it's signed. The account needs no ETH, and
 * can be undeployed: the operation deploys it.
 *
 *   npm install viem @zerodev/sdk @zerodev/ecdsa-validator tslib
 *   (tslib because @zerodev/ecdsa-validator loads it without depending on it: zerodevapp/sdk#281)
 *   node userop-zerodev.ts
 *
 * Set BRUNO_URL to a chain's endpoint (such as https://bruno.mailam.app/sepolia), RPC_URL to a
 * node on that chain, PRIVATE_KEY to the account owner's key, TOKEN to a token the relay takes
 * (listed by its /v1/config), RECIPIENT, and AMOUNT in the token's smallest unit.
 */

import {
  type Address,
  createClient,
  createPublicClient,
  defineChain,
  encodeFunctionData,
  erc20Abi,
  type Hex,
  http,
  rpcSchema,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { formatUserOperationRequest, type RpcUserOperation } from "viem/account-abstraction";
import { signerToEcdsaValidator } from "@zerodev/ecdsa-validator";
import { createKernelAccount, createKernelAccountClient } from "@zerodev/sdk";
import { getEntryPoint, KERNEL_V3_2 } from "@zerodev/sdk/constants";

function env(name: string): string {
  const value = process.env[name];
  if (value === undefined || value === "") throw new Error(`${name} isn't set`);
  return value;
}

const relay = env("BRUNO_URL");
const rpc = env("RPC_URL");
const token = env("TOKEN") as Hex;
const recipient = env("RECIPIENT") as Hex;
const amount = BigInt(env("AMOUNT"));

const chain = defineChain({
  id: await createPublicClient({ transport: http(rpc) }).getChainId(),
  name: "chain",
  nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
  rpcUrls: { default: { http: [rpc] } },
});
const client = createPublicClient({ chain, transport: http(rpc) });

const entryPoint = getEntryPoint("0.7");
const account = await createKernelAccount(client, {
  entryPoint,
  kernelVersion: KERNEL_V3_2,
  plugins: {
    sudo: await signerToEcdsaValidator(client, {
      signer: privateKeyToAccount(env("PRIVATE_KEY") as Hex),
      entryPoint,
      kernelVersion: KERNEL_V3_2,
    }),
  },
});

const held = await client.readContract({
  address: token,
  abi: erc20Abi,
  functionName: "balanceOf",
  args: [account.address],
});
if (held <= amount) {
  console.error(`${account.address} holds ${held}: send it more than ${amount}, then run again`);
  process.exit(1);
}

// the relay is the bundler
const bundler = createKernelAccountClient({
  account,
  client,
  chain,
  bundlerTransport: http(relay),
});
const { payee } = await (await fetch(`${relay}/v1/config`)).json() as { payee: Hex };

// the relay's price for an operation paying by its calls, asked before it's signed
const quoting = createClient({
  transport: http(relay),
  rpcSchema: rpcSchema<[{
    Method: "postpaid_quoteUserOperation";
    Parameters: [RpcUserOperation, Address];
    ReturnType: { validUntilBlock: Hex; quotes: { token: Address; amount: Hex }[] };
  }]>(),
});

const transfer = (to: Hex, value: bigint) => ({
  to: token,
  data: encodeFunctionData({ abi: erc20Abi, functionName: "transfer", args: [to, value] }),
});
// its last call pays the relay
const calls = (paying: bigint) => [transfer(recipient, amount), transfer(payee, paying)];
// the relay pays the gas: the operation's fees are zero
const fees = { maxFeePerGas: 0n, maxPriorityFeePerGas: 0n };

// the operation paying a placeholder of one unit, prepared (not signed), and quoted
const draft = await bundler.prepareUserOperation({ calls: calls(1n), ...fees });
const { quotes } = await quoting.request({
  method: "postpaid_quoteUserOperation",
  params: [formatUserOperationRequest(draft), entryPoint.address],
});
const quote = quotes.find((q) => q.token.toLowerCase() === token.toLowerCase());
if (quote === undefined) throw new Error(`the relay doesn't take ${token}`);
const paying = BigInt(quote.amount);

// sent paying what's quoted, signed once
const hash = await bundler.sendUserOperation({ calls: calls(paying), ...fees });
const receipt = await bundler.waitForUserOperationReceipt({ hash });
console.log(JSON.stringify({
  hash,
  account: account.address,
  success: receipt.success,
  transactionHash: receipt.receipt.transactionHash,
  paid: paying.toString(),
}));
process.exit(receipt.success ? 0 : 1);

It prints the op's hash, the account, whether it succeeded, its transaction and what it paid in the token's smallest unit.

Holding ETH? See ZeroDev, paying in ETH. How an op pays, and the methods the relay answers: ERC-4337 accounts.