Skip to Content
Symmio Trading-SDK — the SDK surface for builders on Arbitrum
CoreGaslessOverview

Gasless

Integration with the GaslessQ relayer: the user signs an EIP-712 InstantLayer operation, a relayer broadcasts the transaction and pays the gas, and the GaslessLayer contract charges an operational fee from the user’s SYMMIO collateral in the same atomic transaction. If the operation reverts, the fee reverts with it — a failed relay never costs the signer.

Two ways to use it:

  • Transparent execution mode — existing write actions (initiateWithdraw, addMargin, requestToCancelQuote, …) gain an optional gasless parameter and a config-level default. When active, the SDK signs the same calldata as an InstantLayer operation, relays it, waits for the relayer’s broadcast, and returns the transaction hash — the action’s signature, return type, receipt-waiting and cache invalidation are unchanged. Consumers opt in per call (gasless: true) or globally (execution: { mode: "gasless" }).
  • Explicit primitives — the pages in this module: relay several actions as one batch, relay pre-signed operations, poll request status, grant delegations, execute from the gasless wallet, and settle onboarding deposits.
// Transparent: same call, no gas — the hash behaves like any wallet-submitted hash. const hash = await initiateWithdraw(config, { account, parts, gasless: true });

Availability

Chain-level and optional, like Pools: a chain either carries a gasless config block or it does not, and the service additionally requires the perps-core ("0.8.6") contracts generation — backward compatibility with older deployments is intentionally unsupported. Gate UI with supportsGaslessService; actions fail fast with GASLESS_NOT_CONFIGURED / GASLESS_UNSUPPORTED_CONTRACTS_VERSION.

Multi-wallet GaslessLayer only. The SDK targets the GaslessLayer interface where a gasless wallet is identified by (owner, walletId) and fees are quoted with previewFeeQuote — the pinned ABI. A GaslessLayer proxy that has not been upgraded to it is unsupported: its multi-wallet reads revert with empty data, and the fee quote throws GASLESS_LAYER_INTERFACE_UNSUPPORTED. The production proxy 0x8347953D80037b8d82827246f37EC7442AD188B4 is in that state until the vendor upgrades it. The staging proxy 0x386EF97D913acf02B3C9452da4Cd4aaEc82eFBca runs the supported interface.

No chain ships a built-in gasless block, so supportsGaslessService is false everywhere until a consumer supplies one. Arbitrum (42161) is the only chain on the perps-core generation, so it is where a block goes — the complete block (url, protocolInstance, gaslessLayerAddress), plus the execution policy and, only on a server that holds a partner key, an apiKey:

const config = createConfig({ getClient, symmioConfig: { [SymmioSupportedChainId.ARBITRUM]: { addresses: { affiliatesAddress: "0x…" }, gasless: { url: "https://gaslessq-staging.symmio.foundation", protocolInstance: "<protocol-instance>", gaslessLayerAddress: "0x…", // a multi-wallet GaslessLayer — see the callout above // apiKey: process.env.GASLESSQ_API_KEY, // optional partner key, server-side only — see below execution: { mode: "gasless", fallback: "wallet" }, }, }, }, });

With no built-in block to inherit from, an override missing url or gaslessLayerAddress throws GASLESS_OVERRIDE_INCOMPLETE at createConfig. url, protocolInstance and gaslessLayerAddress must all come from the same deployment, and addresses.instantLayerAddress must match it too, since the gateway verifies signatures against its own InstantLayer (GASLESS_CONFIG_INCOHERENT). Staging and production share chain id 42161, so a staging relayer also needs the staging contract addresses.

Connection model & authentication

The gateway is instance-routed: {origin}/v1/instances/{protocolInstance}/{operations|deposits}. The instance key selects the deployment (staging and production can share a chain id), so url + protocolInstance must come from the same environment. The gateway stamps every response it routes with an X-GasLessQ-Protocol-Instance header; the SDK checks it on every successful response against the instance it expects and fails closed on a mismatch (GASLESS_INSTANCE_MISMATCH) — the guard against a staging/production cross-wire. Point url at the gateway origin and pass protocolInstance; resolveGaslessService lists the other accepted forms.

Anonymous access is the default. Leave apiKey out and the SDK sends no Authorization header at all. The gateway then serves the request under its anonymous profile: 10 requests per second per caller IP and protocol instance, shared across operations and deposits and by every client behind the same public IP. Past the limit the gateway answers 429 — see Gateway statuses.

A browser app can call the gateway directly, with no proxy, once the gateway allows the app’s Origin for that environment. Like the URL and the instance key, the allowed Origins are a deployment-handoff fact. A disallowed Origin surfaces in the browser as a CORS network error: a SymmApiError with status 0.

Where anonymous access is on, a partner apiKey only raises those limits. A deployment can turn anonymous access off; keyless requests then get 401, and a server-side partner key is required. Fees, wallet selection and signatures are unchanged either way. The SDK sends the key as Authorization: Bearer <key>, and it must be authorized for the protocol instance and service it calls.

Keep a partner key on a server: a Node consumer, or a backend-for-frontend proxy that url points at. A key in a browser bundle is readable by anyone. An invalid, disabled or unauthorized key is rejected (401 / 403) and never falls back to anonymous access — remove it to go anonymous where the deployment allows it. Errors the SDK throws never carry the key: a gasless HTTP failure keeps no request headers. Its responseData is still the raw response body, which a 422 can fill with echoed request fields — see Errors & fallback.

The request lifecycle

Every relay submit is fire-and-forget: it returns HTTP 202 with a request_id, and the record is the only source of truth afterwards. Follow it with confirmGaslessRequest, which resolves only on succeeded. React consumers get that for free — every relay hook confirms by default.

queued → submitted → succeeded | reverted | failed queued → rejected

submitted means a transaction hash exists — it is not success; only succeeded is. Poll getGaslessRequest until isGaslessRequestTerminal(status) (a jittered 1–2 s while queued, 3–5 s after submitted), or await waitForGaslessRequest in plain code. A 202 is the point of no return — the relayed transaction may execute even if your polling fails, so never re-submit the same intent through the wallet; the only safe wallet retry is a definitive pre-broadcast rejection (see Errors & fallback).

There is no list-by-wallet endpoint: persist each requestId (with its service and protocol instance) if users must resume polling after a reload. The transparent mode surfaces them through the execution.onEvent observer.

Streaming instead of polling

Where a deployment enables the gateway’s status WebSocket, the SDK follows a workflow over it and polls only while the stream is not delivering. Declare the opt-in per deployment — a staging confirmation does not cover production:

gasless: { url: "https://<gateway-origin>", protocolInstance: "<protocol-instance>", gaslessLayerAddress: "0x…", statusStream: { enabled: true }, }

With that in place, waitForGaslessRequest and confirmGaslessRequest subscribe automatically (transport: "auto", the default; pass "poll" to force HTTP), and React’s useGaslessRequest stops polling while the stream is live and reports stream.status so a UI can show “live” versus “polling”. watchGaslessRequest is the raw subscription for non-React layers; it opens a socket and never issues HTTP, so pair it with the polling read yourself.

The stream changes nothing about outcomes: it carries the same records the reads return, an unavailable stream is never an error, and the fallback is the behavior you already have. What it does change is cost — a healthy relayed write is followed entirely over the socket and issues no status reads at all. Tune the two windows that govern the handover with execution.streamSettleMs and execution.streamStaleMs.

Two EIP-712 domains, three nonce counters

Ordinary operationsGasless-wallet execute
Domain nameSymmioInstantLayerGaslessGateway (kept deliberately after the contract’s rename)
verifyingContractInstantLayerGaslessLayer (while the operation’s target is the wallet)
Signed struct7 fields (with flexFields, maxUses)5 fields (without them — the relay body still carries flexFields: [], maxUses: 1)
NonceInstantLayer.nonces(account) + 1walletOperationNonces(owner, walletId, signerAccount) + 1

Nonces are sequential and read immediately before signing; a batch must carry strictly consecutive nonces. (topUpNonces is the third counter, reserved for the native gas top-up flow — documented by the service but not implemented in this SDK yet.)

Nonce streams

The service forwards signed nonces; it never reserves, replaces or repairs them. So two relays that sign the same nonce guarantee that one of them reverts, and the SDK serializes read–sign–submit per stream:

StreamKeyWhy
InstantLayer operations(chainId, signerAccount)InstantLayer.nonces counts per account.
Gasless-wallet execute, id 0(chainId, signerAccount)Wallet 0 keeps the original per-signer-account counter, shared across owners.
Gasless-wallet execute, id > 0(chainId, walletId, owner, signerAccount)Each positive id counts on its own wallet, so its nonces never apply to another.

The lock is released at the 202, not at broadcast — holding it until a relay lands would stall every later write behind one slow request. Instead the SDK remembers what it signed on that stream, and the next caller waits for it: for the pending request to reach any terminal status (a failed one frees the nonce again), or for the on-chain nonce to move past it, bounded by the signature’s own deadline and at most two minutes. If neither happens in that budget — which is what an unconfirmed submit looks like — it throws GASLESS_NONCE_STREAM_BUSY rather than signing a nonce that is probably already in flight.

A submit that fails is remembered the same way whenever it leaves the nonce spendable: an ambiguous transport failure (no response, or a 5xx), where the service may still have accepted the batch, and a 202 whose body carried no request_id (GASLESS_ACCEPTANCE_INVALID), where it certainly did and only the handle was lost. Any other rejection accepted nothing, so it frees the nonce immediately.

This guard is per Config, so it is per tab. Another tab, another device or a backend signing for the same account shares the on-chain counter but not this bookkeeping. Coordinate pending requests across them yourself before signing — the SDK cannot see them.

Fees

fee = base(selector) × feeMultiplier / 10000, with a per-account daily free quota (UTC reset) and a one-time creation fee when a relayed operation first deploys a GaslessWallet, whatever its id (wallet 0 included). Two reads on two contracts:

  • getGaslessBatchFeeQuote — the GaslessLayer’s previewFeeQuote for a batch described by name, exactly as relayGaslessBatch would relay it: one 18-decimal payment per call, before any signature. The way to show a fee next to any action.
  • getGaslessFeeQuote — the same preview for operations you built yourself. It is a preview (exact is always false); the relayer’s simulation is authoritative. An exhausted daily free quota is a revert, surfaced as GASLESS_FREE_QUOTA_EXHAUSTED. The transparent mode runs it before every signature prompt (preflightFee).
  • getOperationalFeeAllowance — the payer’s allowance for the charger, on the core diamond. Grant it with approveOperationalFee before relying on gasless execution.

Fees, allowances and the payer’s Core balance are all 18-decimal Core units. Token transfers and the GaslessLayer’s deposit terms use the collateral token’s decimals. Convert between the two with collateralToCore18 and core18ToCollateral; see Core 18-decimal units. An allowance only permits a charge and adds no balance. The fee is collected when the batch executes, so a relay’s acceptance receipt reports the service’s acceptance-time figures, not the charge.

gaslessWalletExecute is priced the same way, from its inner call selectors rather than the execute wrapper — and it bills its signerAccount, never the collateral sitting at the wallet it executes from. That defaults to the owner EOA, which holds no collateral, so pass a funded sub-account to make the fee chargeable.

Batching

relayGaslessBatch relays several actions as one request — one atomic transaction, one fee charge per payer. Name each relayable write ({ functionName, args }, no ABI needed) and mix in GaslessWallet calls ({ walletCalls, walletId }); the SDK resolves each contract, sequences the nonces on every stream, prices the batch and asks for the signatures:

await relayGaslessBatch(config, { account: subAccount, calls: [ { functionName: "approveOperationalFeeWithMultiplier", args: [[gaslessLayerAddress], [budget18], [10_000n]] }, { functionName: "allocate", args: [amount] }, ], });

The protocol has no batch signature — the InstantLayer verifies each operation’s signature against that operation alone — so a browser wallet prompts once per call, and a session key signs them all silently. What a batch buys is shared fate (one revert rolls back every call and fee) and fee timing: fees are collected after the whole batch runs, so an approval early in the batch covers the calls after it. Price the same calls first with getGaslessBatchFeeQuote, and grant a session key getGaslessBatchSelectors(calls).

Reads are RPC, not endpoints

The service exposes no read endpoints: wallet addresses, deposit policy, fee quotes, allowances, and nonces are all direct contract reads on the GaslessLayer / diamond. The GaslessLayer address is a config constant with no runtime discovery — the SDK cross-checks it against the chain’s InstantLayer (GASLESS_CONFIG_INCOHERENT) before the first relay, so a stale pairing fails loudly instead of producing signatures the gateway rejects.

Methods

Last updated on