viem, paying in a token¶
For a smart account that holds no ETH, only a token the relay takes, such as USDC, sending with
viem's createBundlerClient, the relay's chain endpoint (such as
https://bruno.mailam.app/sepolia) as its transport. No API key or paymaster is needed.
The op's fees are zero, so the account needs no ETH: the relay pays the gas. 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 ZeroDev Kernel v3.2 with @zerodev/sdk; any viem smart account is used the
same way. Its comment says which environment variables to set.
/**
* Sends a token from a ZeroDev Kernel account through a Bruno relay, with viem's bundler 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-viem.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 {
createBundlerClient,
formatUserOperationRequest,
type RpcUserOperation,
} from "viem/account-abstraction";
import { signerToEcdsaValidator } from "@zerodev/ecdsa-validator";
import { createKernelAccount } 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 = createBundlerClient({ account, client, chain, transport: 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 viem, paying in ETH. How an op pays, and the methods the relay answers: ERC-4337 accounts.