relayGaslessBatch
Relay several gasless actions as one request: one relay-instant submit, one atomic transaction the relayer broadcasts and pays gas for. Describe each action by name — the SDK encodes it, resolves its contract, sequences the nonces, prices the batch and asks for the signatures.
import { relayGaslessBatch, waitForGaslessRequest } from "@symmio/trading-core";
import { parseUnits } from "viem";
// Approve the gateway's fee budget and use it in the same request.
const receipt = await relayGaslessBatch(config, {
account: subAccount,
calls: [
{
functionName: "approveOperationalFeeWithMultiplier",
args: [[gaslessLayerAddress], [parseUnits("5", 18)], [10_000n]],
},
{ functionName: "allocate", args: [parseUnits("100", 18)] },
],
});
const record = await waitForGaslessRequest(config, { requestId: receipt.requestId });Each entry is one of three forms, freely mixed:
- A relayable write by name —
{ functionName, args }. The SDK already knows every relayable write, so no ABI is needed; passabi(the SDK’ssymmioAbi,accountLayerAbiorinstantLayerAbi) only if you hold it anyway.GASLESS_RELAYABLE_FUNCTIONSlists the names and their inputs. - Raw calldata of a relayable write —
{ data }, for arguments you encoded yourself with compile-time checks. - A GaslessWallet call list —
{ walletCalls, walletId }: calls the owner’s gasless wallet makes, as one operation. They run as the wallet contract, not as the SYMMIO account.
The target contract of a write is never passed: it is resolved from the encoded selector — the core diamond, the AccountLayer or the InstantLayer. A call whose selector is not a relayable write throws GASLESS_NOT_RELAYABLE before any network call; run anything else as a walletCalls entry.
What happens
- Every call is encoded and checked — nothing touches the network or the wallet yet.
- A session key’s delegations are checked before any prompt (
execution.preflightDelegation, on by default). A missing selector throwsGASLESS_SIGNER_NOT_DELEGATED, naming what to grant. - Fresh nonces are read and sequenced per stream: the account’s InstantLayer nonces for the writes, each wallet id’s own nonces for its wallet entries.
- The batch is priced on-chain (
execution.preflightFee, on by default) — the same previewgetGaslessBatchFeeQuotereturns. A quote the contract refuses throws before any prompt; a quote that fails for transport reasons does not block, because the relayer simulates the full transaction anyway. - Each operation is signed, in order.
- One request carries them all, with one wallet id per operation —
0for every write.
One signature per call. The protocol has no batch signature: the InstantLayer verifies each operation’s signature
against that operation alone. A browser wallet therefore shows one prompt per call, in order; a session key passed as
from signs them silently. Nothing is submitted until every prompt is signed, so rejecting one abandons the batch and
consumes no nonce.
What a batch buys is shared fate — one revert rolls back every call and every fee — and one request to track. It also changes when the fee is due: the GaslessLayer collects fees after the whole batch executes, so an operational-fee approval early in the batch already covers the calls after it.
The account
account is the sub-account every operation runs under. It is each write’s signerAccount — whose InstantLayer nonces the batch consumes and whose delegations authorize a session key — and the billing account: a virtual account bills its parent. For a wallet entry it also selects the wallet: the GaslessLayer derives it from ownerOf(account), so a sub-account resolves to its owner’s wallets.
Margin writes run under the virtual account’s parent: pass the parent as account and the virtual account as the call’s argument, exactly as the transparent mode does.
Session keys
A session key signs a batch when it holds a delegation for every selector the batch runs, granted on account. getGaslessBatchSelectors builds that set from the calls — for a wallet entry it includes the wallet-execution sentinel.
await grantDelegation(config, {
account: { addr: subAccount, isPartyB: false },
delegatedSigner: sessionKey,
selectors: getGaslessBatchSelectors(calls),
expiryTimestamp,
});
await relayGaslessBatch(config, { account: subAccount, calls, from: sessionKey });A batch that contains grantDelegation must be signed by the account’s owner. The InstantLayer rejects a
delegate-signed grant, so no delegation makes it relayable by a session key.
Nonces and concurrency
The batch takes every nonce stream it signs on at once, so a concurrent relay on any of them — a transparent write, a gaslessWalletExecute, another batch — waits for it instead of signing the same nonce. As with every relay, the streams are released at the 202, and the next caller waits for the signed nonces lazily. See nonce streams.
The idempotency key is minted per call, because every call signs fresh operations. Replay a submit whose response was lost with resubmitGaslessRequest, never by signing the batch again.
A 202 is the point of no return: the workflow exists server-side and may execute even if polling later fails.
Persist the receipt’s requestId, owner, walletIds and idempotencyKey immediately. React consumers use
useRelayGaslessBatch, which confirms the batch before it resolves and
refreshes what its calls changed.
Parameters
accountAddressrequiredThe sub-account every operation runs under and is billed to. For margin writes, the virtual account’s parent.
callsreadonly GaslessBatchCall[]requiredThe calls, in execution order. An empty list throws GASLESS_EMPTY_BATCH.
calls[].functionNamestringA relayable write’s name, with args and an optional abi.
calls[].dataHexA relayable write as raw calldata.
calls[].walletCallsreadonly GaslessWalletCall[]Calls the owner’s GaslessWallet makes, each raw { target, data } or { target, abi, functionName, args }.
calls[].walletIdbigintdefault 0noptionalWhich of the owner’s GaslessWallets runs a walletCalls entry. Range 0n through 2^256 - 1.
operationTypestringoptionalFree-form label (1–128 characters) stored with the request. Defaults to the calls’ own names joined with +
("approveOperationalFee+allocate"), or "gaslessBatch" when that is too long. Fees are not keyed from it.
metadataRecord<string, unknown>optionalNon-secret client correlation data stored with the request.
fromAddressoptionalThe signer to resolve the wallet client with — a session key, for example.
chainIdnumberoptionalTarget chain id. Defaults to the config’s defaultChainId.
Returns
Promise<GaslessSubmitReceipt>The 202 acceptance, with walletIds holding one id per operation (0n for every write). The fields are the same as
relayInstantOperations’ — persist requestId, owner,
walletIds and idempotencyKey together.
Throws
SymmError("config", "GASLESS_NOT_CONFIGURED")/("config", "GASLESS_UNSUPPORTED_CONTRACTS_VERSION").SymmError("validation", "GASLESS_EMPTY_BATCH"),("validation", "GASLESS_NOT_RELAYABLE")or("validation", "GASLESS_WALLET_ID_INVALID"): thrown before any network call.- Viem’s encoding error when an ABI-level call’s arguments do not encode.
SymmError("config", "GASLESS_CONFIG_INCOHERENT"): the GaslessLayer verifies another InstantLayer than the chain config signs for.SymmError("validation", "GASLESS_SIGNER_NOT_DELEGATED"): a session key is missing a selector. GrantgetGaslessBatchSelectors(calls)onaccount.SymmError("api", "GASLESS_FREE_QUOTA_EXHAUSTED"),("validation", "GASLESS_FEE_QUOTE_REVERTED")or("config", "GASLESS_LAYER_INTERFACE_UNSUPPORTED")from the fee pre-flight, before any prompt.SymmError("api", "GASLESS_NONCE_STREAM_BUSY"): an earlier relay on one of the batch’s nonce streams is still unaccounted for.SymmApiErrorwith codeGASLESS_RELAY_SUBMIT_FAILEDon HTTP failure, andGASLESS_SUBMIT_UNCONFIRMEDwhen the outcome could not be established — replay it withresubmitGaslessRequest.- The wallet’s own error when the user rejects a prompt. Nothing is submitted.