Summary
Gasless Layer is a UUPS-upgradeable contract between an off-chain relayer and three SYMMIO contracts. The relayer pays the network fee and submits the transaction. InstantLayer still verifies signed protocol operations, Gasless Layer verifies its own native-top-up and wallet-operation signatures, Core owns operational-fee allowances, and AccountLayer supplies account ownership and Virtual Account relationships.
The contract exposes six user journeys:
| Journey | Entrypoint | Authority checked by |
|---|---|---|
| Independent signed operations | relayInstantBatch |
InstantLayer, or Gasless Layer for wallet-targeted operations |
| Registered operation template | relayInstantTemplate |
InstantLayer |
| Delegation setup | relayInstantBatch / relayInstantTemplate |
InstantLayer |
| Native gas top-up | relayNativeGasTopUp |
Gasless Layer |
| Bridged collateral settlement | settleDepositToNewAccount or settleDepositToExistingAccount |
Gasless Layer plus AccountLayer ownership checks |
| Owner withdrawal without a relayer | withdrawWalletFunds |
Gasless Layer derives the wallet from the submitting owner and wallet ID |
Architecture and authority
flowchart LR
User[User or delegated signer] -- signed intent --> Relayer[Approved relayer]
Relayer -- pays chain gas --> Layer[Gasless Layer proxy]
Owner[Wallet owner] -- pays gas for direct withdrawal --> Layer
Layer -- signed operations --> Instant[InstantLayer]
Layer -- fees and deposits --> Core[SYMMIO Core]
Layer -- ownership and account creation --> Accounts[AccountLayer]
Bridge[Bridge] -- collateral --> Wallet[Deterministic GaslessWallet]
Layer -- deploys, sweeps, or executes --> Wallet
Only an address with RELAYER_ROLE can call a relay or deposit-settlement entrypoint. This lets the service
simulate requests, apply off-chain abuse controls, and decide which transactions it will fund. The role does not replace the
signature and ownership checks described below.
Signed operation relays
InstantLayer batches and templates
relayInstantBatch forwards independent SignedOperation payloads. A batch may contain ordinary
InstantLayer operations, GaslessWallet operations, or both. Pure InstantLayer batches use one executeBatch call.
Mixed batches preserve order and execute one operation at a time so wallet calls and InstantLayer calls remain in the same
atomic transaction.
relayInstantTemplate forwards a registered InstantLayer template. The InstantLayer owns template shape, result
chaining, signature checks, flex-field checks, nonces, and deadlines. Templates cannot contain GaslessWallet operations.
Delegation through signed operations
The owner signs a regular SignedOperation targeting InstantLayer with grantDelegation or
grantDelegations calldata. Submit it through relayInstantBatch with wallet ID 0, or
through a registered InstantLayer template. Later operations in the same batch can use the granted permissions.
Each grant operation consumes one free daily operation or pays its selector-configured fee, regardless of the number of
selectors or delegates granted inside that operation. The grant must be owner-signed, have maxUses = 1, and
contain no flex fields. Normal batch quoting and signed fee limits apply.
GaslessWallet operations
Each owner can use multiple GaslessWallet addresses, derived with CREATE2 from the Gasless Layer proxy. The
wallet may receive bridged collateral before deployment. When first needed, the layer deploys the wallet at the precomputed
address. Only the Gasless Layer proxy can make it transfer assets or execute calls.
A wallet operation reuses the SignedOperation envelope, but its target must equal the owner's selected
GaslessWallet and its calldata must encode GaslessWallet.execute(Call[]). Gasless Layer verifies the
gateway-domain signature, deadline, and sequential nonce for that wallet and signer account before executing the calls.
- The account owner may sign directly.
- A delegated signer needs an active InstantLayer delegation for the wallet-execution sentinel and every inner call selector.
- Wallet calls have no target allowlist. The owner's signature binds the complete call list. A delegated signer is checked by selector, not by target, so a grant authorizes that selector wherever the wallet can call it.
- PartyB wallet operations are rejected.
- Any failed inner call reverts the whole wallet operation and the surrounding relay transaction.
Independent transfer wallets
Each owner has wallets numbered 0, 1, 2, and so on. Wallet 0 is the existing owner-only GaslessWallet, with the same address, balance, signature format, and nonce stream. Every positive uint256 index derives another wallet controlled by the same owner. There is no wallet kind, purpose registration, or on-chain wallet-count limit. Wallets deploy on first use and may receive tokens beforehand.
// Select a wallet explicitly. Index 0 keeps the original wallet address.
const wallet0 = await gateway.getGaslessWalletAddress(owner, 0n)
const wallet1 = await gateway.getGaslessWalletAddress(owner, 1n)
const wallet2 = await gateway.getGaslessWalletAddress(owner, 2n)
// The service can assign wallet 1 to a deposit and wallet 2 to a withdrawal.
await gateway.settleDepositToExistingAccount(owner, 1n, subAccount)
// Or create an account from the same selected wallet:
await gateway.settleDepositToNewAccount(owner, 1n, affiliate, accountData)
// Choose wallet2 as the Core withdrawal receiver before funds move.
// The getter returns the last consumed nonce, including the legacy stream for index 0.
const nonce = (await gateway.walletOperationNonces(owner, 2n, signerAccount)) + 1n
// Sign the usual wallet SignedOperation with target = wallet2 and that nonce.
const walletIds = [2n]
const callData = gateway.interface.encodeFunctionData("relayInstantBatch", [[signedOp], [signature], [], [], walletIds])
const quote = await gateway.previewFeeQuote(callData, 0)
// quote.totalFee18 includes operational and wallet creation fees.
// quote.payments identifies the accounts and balances that pay.
await gateway.relayInstantBatch([signedOp], [signature], [], [], walletIds)
The relay requires one wallet index per operation, including batches containing only InstantLayer operations. Use 0 for InstantLayer and index-zero wallet operations in a mixed batch, including an operational-fee allowance approval. The existing target identifies which of those routes to use. A positive index requires a wallet operation whose signed target equals the address derived from the account owner and that index. The relayer cannot redirect a valid signature to another wallet by changing the index.
Relay execution passes the identified call selectors to billing: inner calls for GaslessWallet operations and the outer selector for InstantLayer operations. Billing applies the fee schedule, quota and payer rules without using a wallet index. Standalone quote entry points use the supplied index to identify wallet targets, including wallets that have not been deployed. The quote validates targets and decodes calls before applying quota and payer rules.
All wallet indices, including 0, emit
GaslessWalletDeployed, WalletDepositSettled and WalletNonCollateralTokenRecovered for
deployment, deposit settlement and token recovery respectively. WalletDepositSettled.destination distinguishes a
new-account settlement from an existing-account settlement. Recovery identifies the source GaslessWallet address.
Index 0 retains the original nonce mapping in proxy storage. Raw nonce mappings are not part of the public interface. The
walletOperationNonces(owner, walletId, signerAccount) getter selects the correct stream for every wallet. Each
stream is sequential, but operations for different wallets can execute in either order. The signing domain remains
GaslessGateway version 1. Fees and free-operation quotas still belong to the existing billing account. Owner and
delegate permissions apply across its wallets.
A signature deadline does not expire a wallet balance. Relayed recovery uses a fresh signature with the current nonce and, for
bridging, a fresh valid quote. Owners can also withdraw directly by paying transaction gas.
Signed calls and previously granted token allowances can move funds. Admin recovery through
recoverNonCollateralToken(owner, walletId, token, recipient)
excludes the configured collateral token, including at index 0. No automatic expiry or cleanup sweep is introduced.
Upgrade the GaslessLayer implementation and its linked libraries on the existing compatible proxy without reinitializing it,
then update the service and frontend to the wallet-aware API. This is a breaking API change; wallet addresses, stored state,
and signatures remain valid. Existing transfers can use index 0 without moving their funds. Pass one wallet ID per operation
to relayInstantBatch, including an all-zero list for InstantLayer-only batches. Preview that same encoded call
with previewFeeQuote(callData, 0). Update event consumers for every index, including zero, to read
GaslessWalletDeployed, WalletDepositSettled, and WalletNonCollateralTokenRecovered. All
relayed wallet execution continues to emit WalletOperationRelayed with the actual wallet address.
Withdraw without a relayer
The owner can call withdrawWalletFunds(walletId, token, recipient, amount) directly on GaslessLayer and pay
transaction gas. This works whenever the owner needs it, including when the bot is unavailable. GaslessLayer derives the
wallet from msg.sender and walletId, deploys it if necessary, and transfers the requested funds to
the chosen recipient. No relayer role, SYMMIO account, delegation or operational-fee allowance is required.
Use the ERC20 address as token, including the collateral token, or address(0) for native funds.
Amounts use the token's decimals or wei. Pass type(uint256).max to withdraw the entire balance remaining after
any creation fee. The recipient and resulting withdrawal amount must be nonzero. The function returns the amount sent and
emits WalletFundsWithdrawn(owner, walletId, token, recipient, amount).
Direct withdrawals charge no operational or deposit fee. First deployment still charges walletCreationFee from
wallet collateral. The token argument selects the asset to withdraw. The fee always uses
collateralToken, which GaslessLayer reads from core.getCollateral() during initialization. The owner
withdrawal path does not compare these addresses to select or price the fee.
When deployment incurs a fee, GaslessLayer first transfers that collateral amount from the wallet to treasury, then transfers the requested token to the recipient. Insufficient collateral makes the fee transfer revert, rolling back deployment and leaving the wallet's funds in place. An already deployed wallet pays no creation fee. The owner's transaction account needs native gas before calling and pays gas even if the transaction reverts.
import { MaxUint256 } from "ethers"
import { quoteGaslessFee } from "./fee-quote"
const ownerAddress = await owner.getAddress()
const callData = gateway.interface.encodeFunctionData("withdrawWalletFunds", [
walletId, tokenAddress, ownerAddress, MaxUint256,
])
const result = await quoteGaslessFee({
gateway, callData, mode: "exact", from: ownerAddress,
})
if (result.status !== "quoted") throw new Error(result.errorName ?? result.data)
await gateway.connect(owner).executeWithFeeLimit(callData, result.quote.totalDebit18)
Supply the owner's address as from in both preview and exact quotes. This selects the same wallet as submission.
The fee-limit wrapper retains that caller and reverts all transfers and deployment if the cap is exceeded. Withdrawals cover
funds already held at the GaslessWallet address. Funds still in Core or an in-flight bridge follow those systems' withdrawal
and settlement rules. A direct withdrawal does not revoke earlier signed operations or token allowances.
Native gas top-ups
A user signs NativeGasTopUpRequest with the payer account, recipient wallet, collateral budget, minimum native
amount, nonce, and deadline. The relayer supplies the native currency as msg.value. Gasless Layer checks the
signature and sends that value only to the signed recipient.
The actual native amount must meet minNativeAmountOut and must not exceed maxNativeGasTopUpAmount.
Sponsorship is tracked per resolved payer and UTC day. While the request fits inside dailySponsoredNativeLimit,
Core charges no collateral. Once it does not fit, policy either reverts or charges the signed
collateralAmount plus collateralAmount * nativeGasTopUpFeeBps / 10000 through Core.
Cross-chain deposit settlement
A frontend reads getGaslessWalletAddress(owner, walletId) and routes bridged collateral to that address. The
relayer later settles the wallet's entire collateral balance through one of two paths:
-
settleDepositToNewAccountcreates an owner-held SubAccount under the supplied affiliate, then deposits the net collateral. The relayer supplies the account name, metadata, isolation type, and single-VA setting. -
settleDepositToExistingAccountdeposits only when AccountLayer reports that the owner owns the target SubAccount.
Gasless Layer sends the flat depositFee and any walletCreationFee to treasury and
deposits the remainder into Core. The creation fee applies only when settlement deploys the wallet. The gross balance must
cover minimumDeposit and exceed the sum of both fees, leaving a positive net deposit.
minimumDeposit must remain greater than depositFee. A balance below the minimum stays at the
deterministic wallet until more collateral arrives, governance changes the fee policy, or the owner withdraws directly.
Operational fees and billing
Wallet creation fee
A config admin sets the flat walletCreationFee with setWalletCreationFee(amount), in collateral
token units. It defaults to zero. Deployment recipes accept the optional gaslessLayer.walletCreationFee field;
omitting it keeps the fee at zero. New proxies set this value inside initialization, so there is no unconfigured transaction
between deployment and fee activation. Existing proxies can configure it after upgrading without running the initializer
again.
The creation fee is a fixed quantity of the configured collateral token. For 6-decimal USDC,
walletCreationFee = 3_000_000 charges 3 USDC. If Core uses WETH as collateral, the configured quantity is in WETH
base units and its dollar value changes with WETH's price. GaslessLayer uses no price oracle or exchange rate for this fee and
does not convert it into the token being withdrawn or recovered.
- Deposit settlement deducts the fee from the wallet's incoming collateral and sends it to treasury.
- Wallet execution charges the SYMMIO billing account after the batch, using its operational-fee allowance and the usual Virtual Account fallback. The wallet can spend its full withdrawal balance.
- Direct owner withdrawals take the fee from wallet collateral and send it to treasury.
- Admin token recovery never charges a creation fee or moves collateral. It may deploy the wallet solely to sweep the requested non-collateral token.
The fee applies once per wallet, including wallet ID zero, except when admin recovery performs the first deployment without
charging it. Reusing a wallet never charges it again, even if the fee was zero or waived for recovery when that wallet was
deployed. Free-operation quotas and selector-fee multipliers do not change the creation fee. Execution converts the fee to
Core's 18-decimal accounting units; deposit and owner-withdrawal payments use the token's decimals.
getWalletCreationFee(owner, walletId) returns the fee due for that wallet at the current state, or zero if
deployed. previewFeeQuote includes execution creation fees once per distinct undeployed wallet in the supplied
batch, assigned to its first operation. Its totalFee18 already includes those fees; do not add them again.
WalletCreationFeeCollected(wallet, payer, amount) reports the creation fee in collateral token units. For
execution, OperationalFeeRouted and InstantBatchRelayed.totalFee18 include the converted fee in
Core's 18 decimals. Core sends execution fees to its configured operational-fee receiver. Any failure rolls back the
deployment, fee, and other actions in that transaction.
Selector pricing
The relayer never submits an operational-fee amount. Gasless Layer reads each operation selector, applies an explicit
selectorFeeConfigs value when configured, and otherwise uses defaultSelectorFee. Wallet operations
sum the prices of their inner call selectors. Core then applies the payer's approved multiplier for the Gasless Layer charger.
The first dailyFreeOpsLimit operations for a billing account in each UTC day waive their base fee. A zero limit
means there is no free quota. After the quota, revertWhenFreeQuotaExhausted either blocks the relay or lets
normal selector pricing continue. A wallet envelope consumes one quota use even when it contains several inner calls.
Billing identity and fallback
A SubAccount, PartyB, EOA, or unknown address pays as itself. A Virtual Account normally bills its parent SubAccount. If the parent cannot cover an operation with both its remaining allowance and its free plus allocated balance, an existing signer Virtual Account may pay that operation from its own allowance and balance. When neither can pay, the charge stays assigned to the parent and Core reverts.
Billing and authorization deliberately use different account resolution. A deleted Virtual Account keeps its parent for billing because its returned funds sit there. It does not inherit the parent's wallet or delegation authority after deletion.
Fee timing and atomicity
Batches and templates execute before Gasless Layer collects their operational fees. This lets an operation approve or increase
the Gasless Layer allowance used by the same transaction. Gasless Layer then consolidates each payer's amount and calls
core.chargeOperationalFee once per distinct payer.
The complete transaction remains atomic. If fee settlement fails after execution, every InstantLayer action, wallet call, quota write, nonce change, and fee charge rolls back. The relayer still spends gas on the failed transaction, and an earlier operation that consumes balance or changes account status can cause the final fee charge to fail.
Frontend fee quotes
A user wants to deposit 100 USDC into their SYMMIO account. Their GaslessWallet is still empty and has not been deployed. Suppose the configured deposit fee is 2 USDC and wallet creation costs 3 USDC. The frontend needs to show the cost before the user sends money.
Before the money arrives: show the price
Encode settleDepositToExistingAccount(owner, walletId, subAccount) and pass that calldata to
previewFeeQuote(callData, 0). It reads the fee settings and checks whether the wallet is already deployed. It can
return 5 USDC in fees, taken from the selected GaslessWallet, even though that wallet is empty. The frontend
can show: “Deposit 100 USDC. Fees: 5 USDC. Your SYMMIO account will receive 95 USDC.”
Preview has calculated a price; it has not attempted the deposit. If you call simulateFeeQuote now, the
settlement fails because the wallet has no funds to sweep. The same planned action can therefore have a valid preview price
and still fail simulation.
The money has arrived: try the settlement without committing it
The wallet now holds exactly 100 USDC. Call simulateFeeQuote(callData) through eth_call, using the
relayer's address as from.
eth_call asks the RPC node to run the call without submitting a blockchain transaction.
The user pays no transaction fee for this check.
Assuming the destination, relayer permissions, minimum deposit, and other checks are satisfied, simulation actually runs the settlement: it deploys the wallet, collects 5 USDC for treasury, and credits 95 USDC to the account. It records the fees it observed, then rolls all of those changes back.
The result is a 5-USDC quote. On-chain, the wallet still holds the original 100 USDC and is still undeployed. Treasury has received nothing, and the SYMMIO account has received no deposit. If a check fails instead, simulation returns that failure rather than a successful quote. The helper below handles the result decoding.
The relayer submits the real deposit
Submit the actual settleDepositToExistingAccount call. If the conditions are unchanged, this transaction really
deploys the wallet, collects 5 USDC, and deposits 95 USDC.
Neither quote call submitted the deposit for the user. A later transaction can encounter different fees or
balances, so use the applicable fee limit to enforce the accepted maximum.
The frontend flow is: preview to show the price → prepare the funds and any required signatures → simulate the completed request → submit the real action. Set signed fee limits before collecting signatures. For simulation, use the actual submitting address, completed signatures and fills where required, and intended native value. This deposit route does not require an extra user-signed operation; relayed batches do.
Both methods receive the action calldata independently. Simulation does not call preview or take its quote as input, and you
can use either method on its own. Preview reads current state; simulation also sees changes made inside the action, such as an
approval that changes the final fee or payer. That is why simulation runs state-changing code through eth_call,
while preview is a Solidity view method.
These amounts are examples, assuming USDC is collateral. Settlement sweeps the wallet's whole collateral balance, so the example assumes exactly 100 USDC arrives and no other USDC is present. Quotes cover GaslessLayer charges; add transaction gas, bridge fees, and Core trading fees separately when displaying the full cost.
Which balance needs the money?
Each entry in quote.payments gives you a payer address and a source:
-
source = 0: the fee is charged topayer's balance in SYMMIO Core. This is not the ERC20 balance in the user's browser wallet. The account also needs sufficient operational-fee allowance for GaslessLayer. -
source = 1: the fee is taken from collateral tokens held by the GaslessWallet atpayer. The user needs those tokens at that wallet address.
Use payer when telling the user where funds are needed. Do not assume it equals account: a Virtual
Account operation can be paid by its parent or by the Virtual Account itself. For a batch, inspect every payment row and group
amounts by payer and source if several accounts pay.
Display every quote amount with formatUnits(amount, 18), including USDC fees. For example,
5000000000000000000n means 5 USDC when USDC is collateral. collateralDecimals describes the token's
raw transfer units; it does not change the quote's 18-decimal units.
A batch has more than one account
Suppose Alice submits two operations and Bob submits one, each costing 1 USDC. Preview the whole batch once:
totalFee18 is 3 USDC, and payments contains one row per operation in batch order. To show Alice's
operations costing 2 USDC, select the rows whose account is Alice's signer account and add
operationalFee18 + walletCreationFee18. To show which balance needs the money, group by payer and
source instead. A Virtual Account's row keeps the Virtual Account as account even when its parent
pays.
For a parent account and its Virtual Accounts together, include the rows for every signer belonging to that parent. Keep the
full batch when requesting the preview: removing other operations can change which operation pays a shared wallet's creation
fee or uses the remaining free quota. freeOpsApplied counts free operations across the whole batch;
dailyFreeOpsRemaining(account) reads one billing account's remaining quota.
If quota exhaustion is configured to block execution, preview reverts with
DailyFreeOpsLimitExceeded(account, limit). The frontend should show that the batch is blocked, not a zero fee.
The quoteGaslessFee helper reports status: "reverted" with errorName and
errorArgs.
The request itself changes the fee multiplier
Suppose the base fee is 2 USDC and the user is approving GaslessLayer with a 50% fee multiplier. For a batch containing only that Core approval, with no flex fields, preview reads the multiplier in the approval calldata and quotes 1 USDC for the billing parent. This works before the signature or allowance is available. The same rule applies to an approval-only template.
This estimates the parent's charge; it does not prove that the parent can pay. Use simulation once the request is ready to check whether execution charges a Virtual Account instead. For approvals mixed with other operations, or approvals using flex fields, preview uses current state; simulation observes the completed approval and subsequent charges.
Code for the deposit preview
Encode settleDepositToExistingAccount and preview it. This tells the frontend how much will be deducted from the
selected wallet before the rest reaches the SYMMIO account. For a new account, encode
settleDepositToNewAccount instead; the fee source is the same.
import { formatUnits, MaxUint256 } from "ethers"
import { quoteGaslessFee } from "./fee-quote"
// gateway is an Ethers Contract using abis/gaslessLayer.json and a provider.
// Copy the helper from scripts/gaslessLayer/fee-quote.ts into your client.
const depositData = gateway.interface.encodeFunctionData("settleDepositToExistingAccount", [
ownerAddress, walletId, subAccountAddress,
])
const depositResult = await quoteGaslessFee({
gateway, callData: depositData, mode: "preview", from: relayerAddress,
}) // Calls previewFeeQuote(depositData, 0).
if (depositResult.status !== "quoted") {
throw new Error(depositResult.errorName ?? depositResult.data)
}
const depositQuote = depositResult.quote
console.log("Deposit fee:", formatUnits(depositQuote.payments[0].depositFee18, 18))
console.log("Wallet creation:", formatUnits(depositQuote.payments[0].walletCreationFee18, 18))
console.log("Total fee:", formatUnits(depositQuote.totalFee18, 18))
console.log("Taken from GaslessWallet:", depositQuote.payments[0].payer)
Example result: deposit fee = 2 USDC, wallet creation = 3 USDC, totalFee18 = 5 USDC, and
source = 1. If the selected wallet holds exactly 100 USDC when settlement executes, 5 USDC goes to treasury and
95 USDC is deposited into the account. If the wallet is already deployed, creation costs zero, so the same deposit fee leaves
98 USDC for the account.
The user wants to withdraw everything from a GaslessWallet
Encode withdrawWalletFunds and preview it with the owner's address as from. The function selects the wallet belonging to the caller, so the caller address matters even for the preview.
const withdrawalData = gateway.interface.encodeFunctionData("withdrawWalletFunds", [
walletId, collateralTokenAddress, recipientAddress, MaxUint256,
])
const withdrawalResult = await quoteGaslessFee({
gateway, callData: withdrawalData, mode: "preview", from: ownerAddress,
})
if (withdrawalResult.status !== "quoted") {
throw new Error(withdrawalResult.errorName ?? withdrawalResult.data)
}
console.log("Withdrawal fee:", formatUnits(withdrawalResult.quote.totalFee18, 18))
console.log("Taken from GaslessWallet:", withdrawalResult.quote.payments[0].payer)
Example result: the undeployed wallet holds 50 USDC and creation costs 3 USDC. The quote reports 3 USDC, paid
from that wallet with source = 1. Withdrawing the full balance sends 47 USDC to the recipient after paying the
creation fee. An already deployed wallet has no GaslessLayer withdrawal fee. The owner still pays transaction gas from their
calling account.
The quote reports the fee; it does not return the withdrawal proceeds. If withdrawing a different token, any creation fee still needs to be paid in the configured collateral token. See Withdraw without a relayer for that case.
The user wants to trade, grant delegation, or execute wallet calls
Encode relayInstantBatch with the planned operations and preview that call. Before signing, supply an empty
signature for each operation. For an ordinary InstantLayer operation, including a delegation grant, use wallet ID
0. For a wallet operation, use the ID matching its signed target. The following example prices one operation with
no fills or flexible fields:
const batchData = gateway.interface.encodeFunctionData("relayInstantBatch", [
[operation], ["0x"], [[]], [[]], [walletId],
])
const batchResult = await quoteGaslessFee({
gateway, callData: batchData, mode: "preview", from: relayerAddress,
})
if (batchResult.status !== "quoted") {
throw new Error(batchResult.errorName ?? batchResult.data)
}
for (const payment of batchResult.quote.payments) {
console.log("Charged to SYMMIO account:", payment.payer)
console.log("Operational fee:", formatUnits(payment.operationalFee18, 18))
console.log("Wallet creation:", formatUnits(payment.walletCreationFee18, 18))
}
Example result: one wallet operation costs 2 USDC in operational fees and 3 USDC to create its wallet. The
payment row says source = 0 and payer = subAccountAddress. Tell the user:
“5 USDC will be charged to your SYMMIO sub-account.”
This fee comes from the SYMMIO account even though the operation executes through a GaslessWallet. If free quota covers the
operation, the operational part becomes zero, but the 3-USDC creation charge remains.
For a registered template, encode relayInstantTemplate and preview that calldata instead. A preview uses current
balances and allowances. An approval or transfer inside the batch can change both the final fee and its payer, which is why
the completed request needs the next check.
The request is ready: simulate it before sending
Re-encode the action with any required real signatures and fills, then call the helper with mode: "exact". Use
the address that will actually submit the transaction: the relayer for a batch or deposit, the owner for a direct withdrawal,
or the config admin for recovery.
// completedCallData contains the actual action and all required signatures.
const checked = await quoteGaslessFee({
gateway, callData: completedCallData, mode: "exact", from: submittingAddress,
value: 0n, // For a native top-up, use the native wei the relayer will send.
})
if (checked.status !== "quoted") {
throw new Error(checked.errorName ?? checked.data) // Fix the request before sending it.
}
console.log("Fee for this completed request:", formatUnits(checked.quote.totalFee18, 18))
console.log("Actual payers in this simulation:", checked.quote.payments)
The helper calls simulateFeeQuote through eth_call. It runs the action and reads its actual charges,
then rolls everything back. It does not send a transaction. A successful simulation comes back as
FeeQuoteResult error data; the helper decodes that into status: "quoted". Failed actions return
status: "reverted"; RPC failures are thrown. Never send simulateFeeQuote as a transaction.
Show the returned fees and payers on the confirmation screen. If they exceed what the user accepted, request approval and new
signatures where needed. The result describes the simulated block, not a reserved future price. Set the
signed fee limits before signing to enforce the user's maximum when the relayer submits. For a
direct owner withdrawal, the owner can submit the inner calldata through
executeWithFeeLimit(callData, maxTotalDebit18) to enforce their accepted maximum.
Other fee questions
| The frontend needs to know… | Call or read… | What to show |
|---|---|---|
| How much collateral a native gas top-up will debit |
Preview relayNativeGasTopUp calldata. Pass the proposed native wei as the helper's
value; it becomes previewFeeQuote's second argument.
|
Use totalDebit18. Buying native gas for 10 USDC with a 0.1-USDC fee means a 10.1-USDC debit, even
though totalFee18 is only 0.1 USDC. The payer's SYMMIO balance covers it. A sponsored top-up has
zero collateral debit; inspect nativeSponsored.
|
| Only the cost to deploy a particular wallet | getWalletCreationFee(ownerAddress, walletId) |
The raw collateral-token amount, or zero if deployed. Format this getter with the token's decimals—for USDC, normally 6. It is only the creation component, not a whole-action quote. |
| How much admin token recovery costs | Preview recoverNonCollateralToken calldata |
No GaslessLayer fee and an empty payments array, even if recovery deploys the wallet. The admin
still pays transaction gas.
|
Signed fee limits
For a batch or template operation, including a delegation grant, use gaslessFeeLimitSalt(maxFee) as
replayAttackHeader.salt before signing. The existing EIP-712 type and relay arguments stay the same. The salt
contains the first eight bytes of keccak256("SYMMIO_GASLESS_FEE_LIMIT_V1"), a 16-byte unsigned fee limit, and
eight random bytes. The limit uses 18 decimals and includes the operation's wallet creation fee, regardless of quota or payer
fallback. It applies per use of that operation; multi-use operations retain their existing replay rules. Set zero to allow
only free execution. Removing or changing the limit invalidates the signature.
For native top-ups, use signCappedNativeGasTopUp(signer, gateway, request, maxTotalCharge). The maximum includes
the collateral exchanged for native gas and the top-up fee, both in 18 decimals. It signs
CappedNativeGasTopUpRequest, which has the existing request fields followed by
uint256 maxTotalCharge, using the existing GaslessGateway version 1 domain. Pass the
returned abi.encode(maxTotalCharge, signature) as the existing relay's signature argument. Stripping that
envelope does not produce a valid legacy signature. A zero limit permits sponsorship only; exhaustion then reverts instead of
charging.
For unsigned deposit settlement, admin recovery or direct owner withdrawals, the submitting account can call
executeWithFeeLimit(callData, maxTotalDebit18). It enforces the caller's aggregate 18-decimal debit limit and
retains the original role checks and caller's wallet identity. For relayer/admin actions, this is a caller limit, not a user
signature or a restriction on other authorized relayer/admin calls. Quote the inner calldata and compare its
totalDebit18 with the chosen limit before using this wrapper. A limit failure reverts the entire action,
including wallet deployment and all transfers.
Deployment and wiring
Gasless Layer depends on deployed Core, AccountLayer, and InstantLayer addresses. The deployment operator deploys five linked libraries, then the implementation and ERC1967 proxy. It records the implementation, libraries, initializer arguments, verification data, and checkpoint state so an interrupted deployment can resume.
The quote interface adds GaslessFeeQuoteLib, linked to GaslessOperationalFeeLib and
GaslessWalletExecutionLib. Deploy compatible updated fee libraries and the implementation, then upgrade the
existing proxy without reinitializing it. When an upgrade first enables creation fees, set
UPGRADE_WALLET_CREATION_FEE so upgradeToAndCall applies the fee in the upgrade transaction. The
signer must hold both the default-admin and config-admin roles. Wallet creation code, deterministic addresses, existing
storage slots, and existing relay signatures are preserved. Refresh the frontend ABI from
abis/gaslessLayer.json to expose the quote methods and errors.
| System | Required wiring | Why it is needed |
|---|---|---|
| Gasless Layer | Final admin, CONFIG_ADMIN_ROLE, and declared RELAYER_ROLE holders |
Upgrade, policy, and relay authority |
| Core | Register the proxy as an operational-fee charger and route its fee receiver to the treasury | Fee collection without stranding fees on the proxy |
| InstantLayer | Grant the Gasless Layer proxy OPERATOR_ROLE |
Batch and template forwarding |
| AccountLayer | Grant the Gasless Layer proxy ACCOUNT_CREATOR_ROLE |
settleDepositToNewAccount |
Integration checklist
- Read the proxy address and chain ID into every EIP-712 domain. Do not sign against an implementation address.
-
Use
getGaslessWalletAddress(owner, walletId)from the deployed proxy before displaying a deposit or withdrawal address. -
Preview through
previewFeeQuote, sign the accepted fee limit, then usesimulateFeeQuoteon the complete request before submission. Keep the caller, value and calldata identical to the intended transaction. - Track InstantLayer replay headers, native top-up nonces, and wallet-operation nonces as separate replay spaces.
- Index per-operation
OperationalFeeRoutedevents instead of inferring one payer from a batch total. - Keep the off-chain request bound to the affiliate and account settings used for new-account settlement.
Events
| Event | Meaning |
|---|---|
InstantBatchRelayed, InstantTemplateRelayed |
Feature-level success and total fee for a batch or template |
OperationalFeeRouted |
Signer, resolved payer, and fee for each operation |
WalletOperationRelayed |
Accepted delegation and deterministic-wallet execution |
NativeGasTopUpRelayed, DailyNativeGasSponsored |
Native amount sent, collateral charge, and sponsored usage |
WalletDepositSettled |
Owner, wallet ID, destination SubAccount, net deposit, and flat deposit fee |
WalletFundsWithdrawn |
Owner, wallet ID, token, recipient and amount sent by a direct withdrawal |
Security and service boundaries
- The off-chain relayer service is not implemented by the Gasless Layer contract. Owners can withdraw wallet funds directly if the service is unavailable.
- A relayer may refuse or delay a request. It cannot forge a valid InstantLayer, wallet, or native-top-up signature.
-
DEFAULT_ADMIN_ROLEcan authorize UUPS upgrades.CONFIG_ADMIN_ROLEcan change fees, quotas, sponsorship policy, and the deposit-fee treasury. Core's fee admin separately controls the operational-fee receiver. -
The current collateral token cannot be recovered or charged as a fee through
recoverNonCollateralToken. Settlement expects a standard non-rebasing, no-transfer-fee ERC20. - GaslessWallet creation bytecode determines every derived wallet address. Changing that bytecode changes the computed address for wallets that have not yet been deployed.