gaslessWalletExecute
Run any contract call from the deterministic gasless wallet, with no native gas — an atomic batch of arbitrary { target, value, data } calls relayed by GaslessQ.
import { gaslessWalletExecute } from "@symmio/trading-core";
import { erc20Abi } from "viem";
// Approve a router, then call it — one atomic, gas-free batch.
const receipt = await gaslessWalletExecute(config, {
calls: [
{ target: usdcAddress, abi: erc20Abi, functionName: "approve", args: [router, amount] },
{ target: router, data: routeCalldata },
],
operationType: "bridgeWithdraw",
});The SDK reads getGaslessWalletAddress(owner, walletId) and walletOperationNonces(owner, walletId, signerAccount) from the GaslessLayer, encodes execute((address,uint256,bytes)[]), signs, and submits through the same relay-instant endpoint with walletIds: ["<walletId>"].
walletId selects everything at once — the signed target, the nonce stream and the relayed id — so a positive id must match the wallet the operation was signed against. Changing the id to pick a different wallet needs a new target and a new signature; the SDK derives all three from the same walletId, so that only goes wrong if you sign by hand.
This action is a submit: it resolves with the 202 acceptance. React consumers do not need to follow it by hand —
the matching hook in @symmio/trading-react confirms the request before it resolves and invalidates the reads this
action changes. See the React gasless guide.
For the end-to-end story — funding the wallet, paying the fee, building a batch — see the Run any contract call gaslessly guide.
Two ways to give a call
Every entry in calls is either raw calldata or an ABI-level call the SDK encodes. Both forms mix freely in one batch, and value defaults to 0n.
calls: [
// ABI form — encoded by the SDK with viem's `encodeFunctionData`.
{ target: usdcAddress, abi: erc20Abi, functionName: "approve", args: [router, amount] },
// Raw form — for calldata you already have (a bridge quote, a router route).
{ target: router, data: routeCalldata },
];The ABI form types args as readonly unknown[], so arguments are validated when they are encoded rather than by the compiler — a wrong arity or type throws before any signature prompt or network call. When you want compile-time-checked arguments, encode with viem yourself and pass the result as a raw call:
import { encodeFunctionData, erc20Abi } from "viem";
// `args` is fully inferred here.
const data = encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [router, amount] });
calls: [{ target: usdcAddress, data }];Who pays
The wallet balance never pays the fee. The GaslessLayer prices the operation from the inner call selectors — not from the execute wrapper and not from operationType — and charges it to the account it resolves as the billing parent, bounded by that account’s diamond-side operational-fee allowance. A per-account daily free quota (resetting at 00:00 UTC) can waive it. Collateral parked at the wallet is a thing you can move, not a source of gas.
The payer is signerAccount (a virtual account rolls up to its parent sub-account), and the fee is taken through chargeOperationalFee on the core diamond.
signerAccount defaults to the owner EOA, and an EOA holds no SYMMIO collateral — so the default is payable only
while the daily free quota covers it. Pass a funded sub-account as signerAccount to make the fee chargeable, and
grant its allowance with approveOperationalFee first. A rejection body
carrying FEE_POLICY_WOULD_REVERT / INSUFFICIENT_ALLOWANCE is this problem.
Signing with a session key
A signer that is not the wallet’s owner is a delegate. The GaslessLayer then requires active InstantLayer delegations for the wallet-execution sentinel and every inner call selector — build the set with getGaslessWalletExecuteSelectors.
The delegation has to live on a sub-account: grantDelegation is onlyAccountOwner, and the AccountLayer reports no owner for a bare EOA, so an EOA delegator can never be authorized. Pass that sub-account as signerAccount — the wallet it resolves to is unchanged, because the gateway derives the wallet from ownerOf(signerAccount), which is the same owner and therefore the same address.
const calls = [{ target: usdcAddress, abi: erc20Abi, functionName: "transfer", args: [to, amount] }];
// Once, owner-signed (itself relayable, so it costs no gas either).
await grantDelegation(config, {
account: { addr: subAccount, isPartyB: false },
delegatedSigner: sessionKey,
selectors: getGaslessWalletExecuteSelectors(calls),
expiryTimestamp,
});
// Then, unattended — no wallet prompt.
await gaslessWalletExecute(config, { calls, signerAccount: subAccount, from: sessionKey });The SDK probes those delegations before the signature prompt and throws GASLESS_SIGNER_NOT_DELEGATED naming the missing selectors, rather than letting the relayer fail with an opaque WalletDelegationMissing. Disable that probe with execution.preflightDelegation: false. The owner-signed path short-circuits with no extra reads.
Note the nonce moves with the account: walletOperationNonces is keyed on signerAccount, so a sub-account has its own counter, separate from the owner’s.
What the wallet can and cannot do
- Inner targets and selectors are unrestricted. Neither the SDK nor the relayer keeps an allow-list for wallet calls; authority comes from the signature. (This is unlike the transparent gasless mode, which relays a fixed selector set.) A delegated signer is the exception — it is confined to the selectors it was granted.
- The operation
targetmust be the owner’s own wallet. Anything else reverts withInvalidWalletOperationTarget. - The batch is atomic. If any call reverts, the whole operation reverts and the fee charge rolls back with it — a failed batch costs nothing but time.
valueis0nin practice. The wallet holds no native token unless someone funded it directly, so a non-zerovaluereverts the batch.- Custody is bound by construction. The address is a pure
CREATE2view ofowner, so collateral at it can only ever be moved by that owner.
A deliberately different signature scheme
Wallet operations do not reuse the ordinary operation signing:
- the EIP-712 domain is
{ name: "GaslessGateway", version: "1" }with the GaslessLayer asverifyingContract— while the operation’stargetis the wallet. Swapping the two yieldsInvalidWalletOperationTargetfrom simulation. (The domain string deliberately kept the contract’s old name; do not “fix” it.) - the signed
SignedOperationstruct has five fields — noflexFields, nomaxUses. The relay body still carriesflexFields: []andmaxUses: 1, appended after signing; signing the 7-field InstantLayer struct produces a digest the gateway rejects. - the nonce is
walletOperationNonces(owner, walletId, signerAccount) + 1— a separate counter from InstantLayer nonces, and a separate stream per wallet id. Wallet0keeps its original per-signer-account counter; every positive id counts on its own wallet. signerAccount.addrdefaults to the owner wallet itself rather than a sub-account, and the deadline is 10 minutes.
The wallet address is a pure CREATE2 view and doubles as the owner’s deposit
address; collateral at it can only ever reach a sub-account owned by
owner, and only this gateway-signed path can move it out without gas.
Parameters
callsreadonly GaslessWalletCall[]requiredThe calls to execute from the wallet, in order. Each is either a GaslessWalletRawCall ({ target, value?, data })
or a GaslessWalletContractCall ({ target, value?, abi, functionName, args? }). The batch is atomic.
calls[].valuebigintdefault 0noptionalNative value forwarded with the call. The wallet holds no native balance, so a non-zero value reverts the batch.
operationTypestringdefault "gaslessqWalletExecute"optionalFree-form workflow label (1–128 characters) stored with the request for metrics and vendor support. Fees are not keyed from it.
walletIdbigintdefault 0noptionalWhich of the owner’s GaslessWallets the calls execute from. Each id is an independent wallet with its own address,
balance and nonce stream, deployed lazily on first use — its one-time creation fee
(getGaslessWalletCreationFee) is charged to the signer account
then. Ids are an application convention, so persist your assignment under
(environment, protocolInstance, owner, walletId).
signerAccountAddressoptionalAccount whose delegations authorize the operation, whose wallet-operation nonce is consumed, and which the gateway
bills. Defaults to owner. Pass a sub-account whenever a session key signs.
ownerAddressoptionalOwner of the deterministic wallet. Defaults to signerAccount, or to the signing wallet’s address when that is
omitted too; read from the AccountLayer when signerAccount names a sub-account. An explicit owner that does not
own signerAccount throws GASLESS_WALLET_OWNER_MISMATCH before the signature prompt — the contract derives
the wallet from ownerOf(signerAccount), so such an operation could only ever revert.
metadataRecord<string, unknown>optionalNon-secret client correlation data stored with the request.
chainIdnumberoptionalTarget chain id. Defaults to the config’s defaultChainId.
fromAddressoptionalSigner override for the wallet-client resolver.
Returns
Promise<GaslessSubmitReceipt>The acceptance receipt — persist requestId and poll it to a terminal status.
The idempotency key is minted per call, not a parameter. This action signs a fresh operation every time, so a key
a caller could reuse would describe a different payload than the one it first keyed, and the service would answer 409 IDEMPOTENCY_KEY_CONFLICT. The key it used comes back on the receipt — persist it, and replay a lost response with
resubmitGaslessRequest. See idempotency
keys.
Throws
SymmError("config", "GASLESS_NOT_CONFIGURED")/("config", "GASLESS_UNSUPPORTED_CONTRACTS_VERSION").SymmError("api", "GASLESS_WALLET_UNAVAILABLE")— the gateway returned the zero address for the owner’s wallet (an unwired deployment answers0x0instead of reverting).- A viem
AbiEncodingLengthMismatchError(or similar) when an ABI-level call’sargsdo not matchfunctionName. Thrown while encoding, before anything is signed or sent. SymmError("validation", "GASLESS_SIGNER_NOT_DELEGATED")— a non-owner signer is missing a delegation for the sentinel or an inner selector.SymmError("validation", "GASLESS_WALLET_OWNER_MISMATCH")— an explicitownerdoes not ownsignerAccount.SymmError("validation", "GASLESS_WALLET_ID_INVALID")— awalletIdoutside0 … 2^256 − 1, or a JavaScriptnumber.SymmError("api", "GASLESS_NONCE_STREAM_BUSY")— an earlier relay on this(walletId, signerAccount)stream is still unaccounted for; see nonce streams.SymmApiErrorwith codeGASLESS_RELAY_SUBMIT_FAILED— HTTP failure;InvalidWalletOperationTargetin the decoded revert means the operation’s target was not the owner’s wallet.SymmApiErrorwith codeGASLESS_SUBMIT_UNCONFIRMED— the outcome could not be established. The signature is spent either way, so replay the submit withresubmitGaslessRequest; signing a new operation would risk executing the batch twice.