Gasless Swaps with Permit2
Execute same-chain and cross-chain swaps through Permit2 and Tachyon.
Gasless xPath routes use Permit2 typed-data authorization and Tachyon relay execution. The user signs the route authorization, while the relay submits the execution transaction.
"Gasless" applies to swap execution. The input token may still require a one-time onchain ERC-20 approval to Permit2.
Prerequisites
- EVM wallet that supports EIP-712 typed-data signing
- xPath API key
- Input ERC-20 balance
- Source network with
gaslessSwapsandpermit2enabled - Native gas only if Permit2 approval is still required
Gasless Same-Chain Swap
Request a Gasless Quote
Use acquisition mode 2 for Permit2 or 3 for Permit2 Witness:
const API_URL = 'https://api.xpath.rath.fi'
const API_KEY = process.env.XPATH_API_KEY!
const sender = walletClient.account.address
async function xpath(path: string, init?: RequestInit) {
const response = await fetch(`${API_URL}${path}`, {
...init,
headers: {
'api-key': API_KEY,
'Content-Type': 'application/json',
...init?.headers,
},
})
if (!response.ok) throw new Error(await response.text())
const body = await response.json()
if (body.code !== 0) throw new Error(body.message)
return body.data
}
const params = new URLSearchParams({
fromToken: '0x4200000000000000000000000000000000000006',
toToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
amount: '1000000000000000',
fromChain: '8453',
toChain: '8453',
sender,
receiver: sender,
acquisitionMode: '2',
rankingMode: 'balanced',
slippage: '1',
deadlineMinutes: '20',
})
const quotes = await xpath(`/gasless/quote?${params}`)
const selected = quotes.find(
(candidate) => candidate.quote.routeKind === 'sameChainSwap'
)
if (!selected?.quoteId || !selected?.eip712) {
throw new Error('No complete gasless quote was returned')
}Check Permit2 Allowance
The EIP-712 domain's verifying contract is the Permit2 contract for the selected quote.
import { erc20Abi } from 'viem'
const inputToken = selected.quote.fromToken.address
const permit2 = selected.eip712.domain.verifyingContract
const requiredAmount = BigInt(selected.eip712.message.permitted.amount)
const allowance = await publicClient.readContract({
address: inputToken,
abi: erc20Abi,
functionName: 'allowance',
args: [sender, permit2],
})
if (allowance < requiredAmount) {
const approvalHash = await walletClient.writeContract({
address: inputToken,
abi: erc20Abi,
functionName: 'approve',
args: [permit2, requiredAmount],
})
await publicClient.waitForTransactionReceipt({ hash: approvalHash })
}Sign the EIP-712 Payload
Sign exactly the payload returned by xPath:
const typedData = selected.eip712
if (Date.now() >= Number(selected.deadline) * 1000) {
throw new Error('Gasless quote expired; request a new quote')
}
const signature = await walletClient.signTypedData({
domain: {
...typedData.domain,
chainId: Number(typedData.domain.chainId),
},
types: typedData.types,
primaryType: typedData.primaryType,
message: typedData.message,
})Do not change the token, amount, spender, nonce, deadline, witness, or receiver after requesting the quote. Request a new quote instead.
Submit the Signed Swap
const swap = await xpath('/gasless/submit-swap', {
method: 'POST',
body: JSON.stringify({
quoteId: selected.quoteId,
signature,
}),
})
console.log('Swap ID:', swap.swapId)
console.log('Tachyon transaction ID:', swap.tachyonTxId)
console.log('Status:', swap.status)Track Relay Execution
const terminalStatuses = new Set(['executed', 'failed'])
const timeoutAt = Date.now() + 10 * 60 * 1000
let status
while (Date.now() < timeoutAt) {
status = await xpath(
`/gasless/status?${new URLSearchParams({ id: swap.swapId })}`
)
console.log('Gasless status:', status.status)
if (terminalStatuses.has(status.status.toLowerCase())) break
await new Promise((resolve) => setTimeout(resolve, 3000))
}
if (!status || !terminalStatuses.has(status.status.toLowerCase())) {
throw new Error('Gasless status polling timed out')
}
if (status.status.toLowerCase() === 'failed') throw new Error('Gasless execution failed')
if (!status.executionTxHash) throw new Error('Missing source transaction hash')
console.log('Execution transaction:', status.executionTxHash)Full Gasless Swap Script
npm install viem
export XPATH_API_KEY="YOUR_API_KEY"
export PRIVATE_KEY="0xYOUR_PRIVATE_KEY"
export RPC_URL="https://mainnet.base.org"import {
createPublicClient,
createWalletClient,
erc20Abi,
http,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { base } from 'viem/chains'
const API_URL = 'https://api.xpath.rath.fi'
const API_KEY = process.env.XPATH_API_KEY!
const account = privateKeyToAccount(process.env.PRIVATE_KEY! as `0x${string}`)
const publicClient = createPublicClient({
chain: base,
transport: http(process.env.RPC_URL),
})
const walletClient = createWalletClient({
account,
chain: base,
transport: http(process.env.RPC_URL),
})
async function xpath(path: string, init?: RequestInit) {
const response = await fetch(`${API_URL}${path}`, {
...init,
headers: {
'api-key': API_KEY,
'Content-Type': 'application/json',
...init?.headers,
},
})
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`)
const body = await response.json()
if (body.code !== 0) throw new Error(body.message)
return body.data
}
async function main() {
const sender = account.address
const params = new URLSearchParams({
fromToken: '0x4200000000000000000000000000000000000006',
toToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
amount: '1000000000000000',
fromChain: String(base.id),
toChain: String(base.id),
sender,
receiver: sender,
acquisitionMode: '2',
rankingMode: 'balanced',
slippage: '1',
deadlineMinutes: '20',
})
const quotes = await xpath(`/gasless/quote?${params}`)
const selected = quotes.find(
(candidate) => candidate.quote.routeKind === 'sameChainSwap'
)
if (!selected) throw new Error('No gasless same-chain route is available')
console.log('Expected output:', selected.quote.amountOut)
console.log('Gasless fee:', selected.fee.feeAmount)
const typedData = selected.eip712
const inputToken = selected.quote.fromToken.address as `0x${string}`
const permit2 = typedData.domain.verifyingContract as `0x${string}`
const requiredAmount = BigInt(typedData.message.permitted.amount)
const allowance = await publicClient.readContract({
address: inputToken,
abi: erc20Abi,
functionName: 'allowance',
args: [sender, permit2],
})
if (allowance < requiredAmount) {
const approvalHash = await walletClient.writeContract({
address: inputToken,
abi: erc20Abi,
functionName: 'approve',
args: [permit2, requiredAmount],
})
await publicClient.waitForTransactionReceipt({ hash: approvalHash })
}
if (Date.now() >= Number(selected.deadline) * 1000) {
throw new Error('Gasless quote expired; request a new quote')
}
const signature = await walletClient.signTypedData({
domain: {
...typedData.domain,
chainId: Number(typedData.domain.chainId),
},
types: typedData.types,
primaryType: typedData.primaryType,
message: typedData.message,
})
const swap = await xpath('/gasless/submit-swap', {
method: 'POST',
body: JSON.stringify({ quoteId: selected.quoteId, signature }),
})
console.log('Swap ID:', swap.swapId)
console.log('Tachyon transaction ID:', swap.tachyonTxId)
const terminal = new Set(['executed', 'failed'])
const timeoutAt = Date.now() + 10 * 60 * 1000
while (Date.now() < timeoutAt) {
const status = await xpath(
`/gasless/status?${new URLSearchParams({ id: swap.swapId })}`
)
console.log('Status:', status.status)
if (terminal.has(status.status.toLowerCase())) {
console.log('Execution transaction:', status.executionTxHash)
if (status.status.toLowerCase() === 'failed') process.exitCode = 1
return
}
await new Promise((resolve) => setTimeout(resolve, 3000))
}
throw new Error('Gasless swap status polling timed out')
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})Gasless Cross-Chain Swap
A gasless cross-chain swap uses the same Permit2 and Tachyon flow with different source and destination chains. Select a route whose routeKind is directBridge or swapBridge.
Request a Gasless Cross-Chain Quote
const params = new URLSearchParams({
fromToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
toToken: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
amount: '1000000',
fromChain: '8453',
toChain: '42161',
sender,
receiver: sender,
acquisitionMode: '2',
rankingMode: 'balanced',
slippage: '1',
deadlineMinutes: '20',
})
const quotes = await xpath(`/gasless/quote?${params}`)
const selected = quotes.find((candidate) =>
['directBridge', 'swapBridge'].includes(candidate.quote.routeKind)
)
if (!selected) throw new Error('No gasless cross-chain route is available')Approve Permit2 and Sign
Use the same approval and EIP-712 signing flow as the gasless swap. The permitted amount must come from the returned EIP-712 message:
const typedData = selected.eip712
const requiredAmount = BigInt(typedData.message.permitted.amount)
// Check/approve the input token to typedData.domain.verifyingContract,
// then sign typedData without changing any field.Submit and Track the Cross-Chain Swap
const swap = await xpath('/gasless/submit-swap', {
method: 'POST',
body: JSON.stringify({ quoteId: selected.quoteId, signature }),
})
const relayTimeoutAt = Date.now() + 10 * 60 * 1000
let relayStatus
while (Date.now() < relayTimeoutAt) {
relayStatus = await xpath(
`/gasless/status?${new URLSearchParams({ id: swap.swapId })}`
)
if (['executed', 'failed'].includes(relayStatus.status)) break
await new Promise((resolve) => setTimeout(resolve, 3000))
}
if (!relayStatus || !['executed', 'failed'].includes(relayStatus.status)) {
throw new Error('Relay status polling timed out')
}
if (relayStatus.status === 'failed') throw new Error('Relay execution failed')
if (!relayStatus.executionTxHash) throw new Error('Missing source transaction hash')
console.log('Source transaction:', relayStatus.executionTxHash)The gasless status reaches executed when Tachyon executes the source-chain transaction. The current gasless status API does not expose a separate destination-completion state, so treat executionTxHash as source execution only and do not report destination completion from this response.
Full Gasless Cross-Chain Swap Script
import {
createPublicClient,
createWalletClient,
erc20Abi,
http,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { base } from 'viem/chains'
const API_URL = 'https://api.xpath.rath.fi'
const API_KEY = process.env.XPATH_API_KEY!
const account = privateKeyToAccount(process.env.PRIVATE_KEY! as `0x${string}`)
const publicClient = createPublicClient({
chain: base,
transport: http(process.env.RPC_URL),
})
const walletClient = createWalletClient({
account,
chain: base,
transport: http(process.env.RPC_URL),
})
async function xpath(path: string, init?: RequestInit) {
const response = await fetch(`${API_URL}${path}`, {
...init,
headers: {
'api-key': API_KEY,
'Content-Type': 'application/json',
...init?.headers,
},
})
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`)
const body = await response.json()
if (body.code !== 0) throw new Error(body.message)
return body.data
}
async function main() {
const sender = account.address
const params = new URLSearchParams({
fromToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
toToken: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
amount: '1000000',
fromChain: '8453',
toChain: '42161',
sender,
receiver: sender,
acquisitionMode: '2',
rankingMode: 'balanced',
slippage: '1',
deadlineMinutes: '20',
})
const quotes = await xpath(`/gasless/quote?${params}`)
const selected = quotes.find((candidate) =>
['directBridge', 'swapBridge'].includes(candidate.quote.routeKind)
)
if (!selected) throw new Error('No gasless cross-chain route is available')
console.log('Route:', selected.quote.routeKind)
console.log('Providers:', selected.quote.providers.map((provider) => provider.name))
console.log('Expected output:', selected.quote.amountOut)
console.log('Gasless fee:', selected.fee.feeAmount)
const typedData = selected.eip712
const token = selected.quote.fromToken.address as `0x${string}`
const permit2 = typedData.domain.verifyingContract as `0x${string}`
const requiredAmount = BigInt(typedData.message.permitted.amount)
const allowance = await publicClient.readContract({
address: token,
abi: erc20Abi,
functionName: 'allowance',
args: [sender, permit2],
})
if (allowance < requiredAmount) {
const approvalHash = await walletClient.writeContract({
address: token,
abi: erc20Abi,
functionName: 'approve',
args: [permit2, requiredAmount],
})
await publicClient.waitForTransactionReceipt({ hash: approvalHash })
}
if (Date.now() >= Number(selected.deadline) * 1000) {
throw new Error('Gasless quote expired; request a new quote')
}
const signature = await walletClient.signTypedData({
domain: {
...typedData.domain,
chainId: Number(typedData.domain.chainId),
},
types: typedData.types,
primaryType: typedData.primaryType,
message: typedData.message,
})
const swap = await xpath('/gasless/submit-swap', {
method: 'POST',
body: JSON.stringify({ quoteId: selected.quoteId, signature }),
})
console.log('Swap ID:', swap.swapId)
console.log('Tachyon transaction ID:', swap.tachyonTxId)
const relayTerminal = new Set(['executed', 'failed'])
const relayTimeoutAt = Date.now() + 10 * 60 * 1000
let executionTxHash: `0x${string}` | undefined
while (Date.now() < relayTimeoutAt) {
const status = await xpath(
`/gasless/status?${new URLSearchParams({ id: swap.swapId })}`
)
const normalizedStatus = status.status.toLowerCase()
console.log('Relay status:', status.status)
if (relayTerminal.has(normalizedStatus)) {
if (normalizedStatus === 'failed') throw new Error('Relay execution failed')
if (!status.executionTxHash) throw new Error('Missing source transaction hash')
executionTxHash = status.executionTxHash
break
}
await new Promise((resolve) => setTimeout(resolve, 3000))
}
if (!executionTxHash) throw new Error('Relay status polling timed out')
console.log('Source transaction:', executionTxHash)
console.log('The current gasless status API reports source execution, not destination completion.')
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})Security Checklist
- Verify the EIP-712 domain chain and verifying contract before prompting the wallet.
- Display token, amount, receiver, fee, minimum output, and deadline.
- Reject expired quotes and signatures.
- Keep API keys in a backend or protected proxy for public applications.
- Persist
quoteId,swapId, andtachyonTxIdfor support and recovery. - Never ask users to sign opaque typed data without showing its effect.