An EOA, with viem¶
For an EOA, a plain key, that holds ETH and has no smart account. viem's
toSimple7702SmartAccount makes the EOA itself an ERC-4337 account on EntryPoint v0.8, by
EIP-7702: its first op brings its authorization, delegating it to eth-infinitism's
Simple7702Account, and the relay's transaction carries it. Later ops need none. The address
stays the EOA's.
Set the bundler client up as for any bundler, with the relay's chain endpoint (such as
https://bruno.mailam.app/sepolia) as its transport and nothing else. viem doesn't sign the
authorization itself, so the script signs it while the EOA isn't delegated yet, and gives it to
sendUserOperation.
The op pays for its gas in ETH, at the fees viem gives it, from the EOA's ETH: the relay's
estimate sets its preVerificationGas so that what the EntryPoint charges comes to the relay's
price. As at any bundler, the op pays for its gas even if its calls fail. Its comment says which
environment variables to set.
/**
* Sends a token from an EOA through a Bruno relay, as an EIP-7702 smart account (viem's
* toSimple7702SmartAccount, EntryPoint v0.8), with viem's bundler client set up as for any
* bundler, the relay's URL its only setting: the user operation pays for its gas in ETH, at the
* fees viem gives it. The EOA's first operation brings its authorization, delegating it to
* Simple7702Account; later ones need none.
*
* npm install viem
* node userop-viem-7702.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 EOA's key, TOKEN to the token to send, RECIPIENT, and
* AMOUNT in the token's smallest unit.
*/
import {
createPublicClient,
createWalletClient,
defineChain,
encodeFunctionData,
erc20Abi,
type Hex,
http,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { createBundlerClient, toSimple7702SmartAccount } from "viem/account-abstraction";
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 owner = privateKeyToAccount(env("PRIVATE_KEY") as Hex);
const account = await toSimple7702SmartAccount({ client, owner });
const held = await client.getBalance({ address: account.address });
if (held === 0n) {
console.error(`${account.address} holds no ETH: send it some, then run again`);
process.exit(1);
}
// the EOA's authorization of Simple7702Account, until it's delegated
const authorization = await account.isDeployed() ? undefined : await createWalletClient({
account: owner,
chain,
transport: http(rpc),
}).signAuthorization({ contractAddress: account.authorization.address });
// the relay is the bundler
const bundler = createBundlerClient({ account, client, chain, transport: http(relay) });
const hash = await bundler.sendUserOperation({
authorization,
calls: [{
to: token,
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: [recipient, amount],
}),
}],
});
const receipt = await bundler.waitForUserOperationReceipt({ hash });
console.log(JSON.stringify({
hash,
account: account.address,
success: receipt.success,
transactionHash: receipt.receipt.transactionHash,
paid: receipt.actualGasCost.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 wei.
Holding only a token? See An EOA paying in a token, with viem. How an op pays, and the methods the relay answers: ERC-4337 accounts.