Skip to content

Instantly share code, notes, and snippets.

@nikicat
Created October 16, 2026 10:17
Show Gist options
  • Select an option

  • Save nikicat/f347789e1c82ab87354caf8f7a600b63 to your computer and use it in GitHub Desktop.

Select an option

Save nikicat/f347789e1c82ab87354caf8f7a600b63 to your computer and use it in GitHub Desktop.
EIP-5792 wallet_sendCalls with sequential fallback, never forcing a 7702 upgrade (viem)

EIP-5792 batch with sequential fallback (viem)

sendBatch sends calls with as few wallet prompts as the wallet allows, without ever forcing an account upgrade (EIP-7702).

wallet_getCapabilities → atomic.status What happens
ready (MetaMask EOA, would prompt the smart-account upgrade) own sequential loop: one eth_sendTransaction per call
supported, unsupported, missing, or getCapabilities throws wallet_sendCalls with forceAtomic: false; if the wallet rejects it as unsupported (4200/57xx) → own sequential loop

Why not gate on atomic.status === 'supported': Ambire EOAs report unsupported but still batch non-atomically behind one sign screen, on any chain.

Per wallet:

  • Ambire EOA: one prompt; Ambire broadcasts the txs in order itself.
  • Ambire smart account / Safe / already-upgraded MetaMask: one atomic batch.
  • MetaMask EOA on a 7702 chain (ready): our loop; MetaMask never sees sendCalls, so it never shows the upgrade prompt.
  • MetaMask on a non-7702 chain, or older wallets: sendCalls errors → our loop.

A user rejection (4001) is thrown, not retried. Partial failures (from a non-atomic batch or from our loop) throw BatchError with the hashes that went through and the index to resume from.

Usage

try {
  const { mode, hashes } = await sendBatch(walletClient, publicClient, base, [
    { to: token, data: approveData },
    { to: router, data: swapData },
  ])
} catch (e) {
  if (e instanceof BatchError) {
    // e.hashes went through; offer "retry from step e.failedIndex + 1"
  } else {
    throw e // user rejected, wrong chain, …
  }
}

Caveats (not yet tested against real wallets)

  • Needs viem 2.x with EIP-5792 v2 (getCapabilities({ chainId }), forceAtomic, waitForCallsStatus). Check that waitForCallsStatus accepts timeout in your version.
  • WALLET_CANNOT_BATCH is based on the specs. Check which codes MetaMask (on a non-7702 chain) and older wallets actually return.
  • If you call wallet_getCapabilities without viem, also check the "0x0" (all-chains) key.
  • Ambire ignores atomicRequired and may upgrade an EOA to 7702 while the user signs, on chains that support it. That's Ambire's own behaviour; the dapp can't turn it off.
import {
type Call,
type Chain,
type Hex,
type PublicClient,
type WalletClient,
} from 'viem'
/** How the batch was actually sent. */
export type BatchMode = 'wallet' | 'sequential'
export type BatchResult = { mode: BatchMode; hashes: Hex[] }
/**
* A batch that stopped partway. `hashes` are the calls that went through,
* so the UI can resume from `failedIndex`.
*/
export class BatchError extends Error {
constructor(
readonly mode: BatchMode,
readonly failedIndex: number,
readonly hashes: Hex[],
options?: { cause?: unknown },
) {
super(`batch (${mode}) failed at call ${failedIndex}`, options)
}
}
/**
* Error codes meaning the wallet can't handle this request; any other error
* (notably 4001 "user rejected") is passed on to the caller.
* 4200 method not supported (EIP-1193); 5700 unsupported capability,
* 5710 unsupported chain, 5750 upgrade rejected, 5760 atomicity not
* supported (EIP-5792).
*/
const WALLET_CANNOT_BATCH = new Set([4200, 5700, 5710, 5750, 5760])
/** Upper bound on waiting for a wallet-side batch to land on chain. */
const BATCH_TIMEOUT_MS = 10 * 60_000
/**
* Sends `calls` with as few wallet prompts as the wallet allows, without
* ever asking the user to upgrade their account:
* - the wallet can batch on its own (Ambire EOA or smart account, Safe,
* an already-upgraded MetaMask account) → one `wallet_sendCalls`;
* - the wallet would ask to upgrade the account (MetaMask `ready`), or
* doesn't support batching → one `eth_sendTransaction` per call, each
* mined before the next is sent.
*/
export async function sendBatch(
wallet: WalletClient,
client: PublicClient,
chain: Chain,
calls: readonly Call[],
): Promise<BatchResult> {
if ((await atomicStatus(wallet, chain.id)) !== 'ready') {
try {
const { id } = await wallet.sendCalls({
account: wallet.account!,
chain,
calls: [...calls],
forceAtomic: false,
})
return { mode: 'wallet', hashes: await waitWalletBatch(wallet, id) }
} catch (e) {
if (!WALLET_CANNOT_BATCH.has(rpcErrorCode(e) ?? -1)) throw e
}
}
return { mode: 'sequential', hashes: await sendSequential(wallet, client, chain, calls) }
}
/** `undefined` when the wallet reports no atomic capability for this chain. */
async function atomicStatus(wallet: WalletClient, chainId: number) {
try {
const caps = await wallet.getCapabilities({ account: wallet.account!, chainId })
return caps?.atomic?.status as 'supported' | 'ready' | 'unsupported' | undefined
} catch {
return undefined
}
}
/** A non-atomic wallet batch can partly succeed; any failed call fails the batch. */
async function waitWalletBatch(wallet: WalletClient, id: string): Promise<Hex[]> {
const res = await wallet.waitForCallsStatus({ id, timeout: BATCH_TIMEOUT_MS })
const receipts = res.receipts ?? []
const hashes = receipts.filter(r => r.status === 'success').map(r => r.transactionHash)
if (res.status !== 'success' || hashes.length !== receipts.length) {
const failed = receipts.findIndex(r => r.status !== 'success')
throw new BatchError('wallet', failed >= 0 ? failed : hashes.length, hashes)
}
return hashes
}
/**
* One transaction per call. Each is mined before the next is sent,
* because later calls usually depend on earlier ones (approve → swap).
*/
async function sendSequential(
wallet: WalletClient,
client: PublicClient,
chain: Chain,
calls: readonly Call[],
): Promise<Hex[]> {
const hashes: Hex[] = []
for (const [i, call] of calls.entries()) {
try {
const hash = await wallet.sendTransaction({ ...call, account: wallet.account!, chain })
const receipt = await client.waitForTransactionReceipt({ hash })
if (receipt.status !== 'success') throw new Error(`reverted: ${hash}`)
hashes.push(hash)
} catch (cause) {
throw new BatchError('sequential', i, hashes, { cause })
}
}
return hashes
}
/** viem wraps RPC errors; the wallet's numeric code sits somewhere in the cause chain. */
function rpcErrorCode(e: unknown): number | undefined {
for (let err: any = e; err; err = err.cause) {
if (typeof err.code === 'number') return err.code
}
return undefined
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment