Gasless approvals through the Tirio API: EIP-2612 and Permit2
Pass a sender and each quote says whether the wallet must approve or can just sign. How to handle permit and approval and splice the signature.
The worst part of a first swap with a new token is the extra approval transaction: one more wallet popup, one more fee and one more wait before the swap itself. Tirio's API removes it wherever the token or the wallet allows. Pass the user's address as sender, and the quote comes back with either a permit to sign (no gas, no extra transaction) or an approval to send first. This guide shows how to handle both with viem, including the checks to run on the typed data before the user signs it.
If you are new to the three mechanisms, our guide to approvals, EIP-2612 and Permit2 explains them from the user's side.
What the quote tells you
Request /quote with sender set to the wallet that will send the transaction. For a token input, one of three things happens:
| The quote has | It means | What to do |
|---|---|---|
| neither | the wallet's allowance already covers the swap | send tx as it is |
permit | the wallet can sign instead of approving | sign permit.eip712, splice the signature into tx.data, send |
approval | the wallet must approve first | approve approval.spender, then ask for a fresh quote |
Native BNB needs none of this. The amount travels in tx.value.
A permit comes in two kinds:
eip2612: the token itself supports signed approvals, so the user signs the token's ownPermitmessage naming Tirio's Router as spender. No prior approval is needed at all.permit2: the token has already been approved to Uniswap's Permit2 contract (its address is in the code below), so the user signs a Permit2PermitSinglenaming the Router as spender.
An approval with permit2: true is the recommended one-time approval of the token to Permit2. After it, every later quote for that token carries a Permit2 permit instead. Smart contract wallets are asked to approve the Router directly.
What the live API returns
We checked both paths on 2026-10-01 with sender addresses that had approved nothing.
- USD1 to USDT. USD1 supports EIP-2612, so the quote came with a
permitof kindeip2612. Its typed data named the domain "World Liberty Financial USD", version 1, chain id 56, with USD1 as the verifying contract, and a message with the owner, the Router as spender, the value, a nonce of 0 and the quote's deadline. Every number in the message is a decimal string. - USDT to BNB. USDT on BNB Chain has no permit function, so the quote came with an
approvalto Permit2 withpermit2: true,requiredequal to the swap amount andactual0.
Step 1: approve when asked
When the quote has an approval, send it and quote again. For Permit2, approving the maximum makes it a genuine one-time step, which is what the Tirio app does by default.
import { erc20Abi, maxUint256 } from "viem";
if (quote.approval) {
const amount = quote.approval.permit2 ? maxUint256 : BigInt(quote.approval.required);
const hash = await wallet.writeContract({
account,
address: quote.tokenIn,
abi: erc20Abi,
functionName: "approve",
args: [quote.approval.spender, amount],
});
await publicClient.waitForTransactionReceipt({ hash });
quote = await tirio<Quote>("quote", params);
}Step 2: check the typed data before anyone signs it
A signature is as powerful as a transaction, so treat the typed data like calldata and verify it. The spender must be the Router, the chain must be BNB Chain, and the signing domain must be the token itself for an EIP-2612 permit or Permit2 for a Permit2 one.
const PERMIT2: Address = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
function checkPermit(permit: Permit, tokenIn: Address) {
const { domain, message } = permit.eip712;
const same = (a: unknown, b: string) => String(a).toLowerCase() === b.toLowerCase();
if (!same(permit.spender, ROUTER)) throw new Error("unexpected spender");
if (Number(domain.chainId) !== 56) throw new Error("wrong chain");
if (permit.kind === "eip2612" && (!same(domain.verifyingContract, tokenIn) || !same(message.spender, ROUTER) || !same(message.owner, account)))
throw new Error("unexpected EIP-2612 permit");
if (permit.kind === "permit2" && (!same(domain.verifyingContract, PERMIT2) || !same(message.spender, ROUTER)))
throw new Error("unexpected Permit2 permit");
}The Tirio app goes further and also checks the amount against the swap, the deadline against the quote and, for Permit2, that the allowance expires within about a month. Our security documentation lists every field it verifies.
Step 3: sign, normalise and splice
Two details trip up most integrations.
- Numbers arrive as decimal strings. Convert every integer field of the message to
BigIntbefore signing. The helper below walks the types so it also works for Permit2's nestedPermitDetails. - Some wallets return
vas 0 or 1 instead of 27 or 28. Normalise it, then write the 65-byte signature intotx.dataat bytesignatureOffset, where the API left 65 zero bytes.
import { concat, size, slice } from "viem";
function typed(types: Record<string, Field[]>, type: string, value: unknown): unknown {
if (types[type]) return Object.fromEntries(types[type].map((f) => [f.name, typed(types, f.type, (value as Record<string, unknown>)[f.name])]));
return /^u?int\d*$/.test(type) ? BigInt(value as string) : value;
}
function withSignature(data: Hex, offset: number, signature: Hex): Hex {
if (size(signature) !== 65) throw new Error("the signature must be 65 bytes");
const v = Number.parseInt(signature.slice(-2), 16);
const canonical = v < 27 ? (`${signature.slice(0, -2)}${(v + 27).toString(16)}` as Hex) : signature;
const end = offset + 65;
return concat([slice(data, 0, offset), canonical, end < size(data) ? slice(data, end) : "0x"]);
}
let data = quote.tx.data;
if (quote.permit) {
checkPermit(quote.permit, quote.tokenIn);
const { types, primaryType, domain, message } = quote.permit.eip712;
const signature = await wallet.signTypedData({
account,
types,
primaryType,
domain,
message: typed(types, primaryType, message) as Record<string, unknown>,
});
data = withSignature(quote.tx.data, quote.permit.signatureOffset, signature);
}We ran this exact signing and splicing code on 2026-10-01 against a live USD1 quote with a throwaway key: the signature recovered to the owner in the typed data, and after splicing, the permit bytes inside the calldata ended with that signature while the calldata kept its length.
Step 4: simulate and send
From here it is the same as any swap: simulate the spliced transaction from the user's account, then send it.
const transaction = { account, to: quote.tx.to, data, value: BigInt(quote.tx.value) };
await publicClient.call(transaction);
const hash = await wallet.sendTransaction({ ...transaction, gas: BigInt(quote.tx.gas) });The gas estimate in the quote already includes the cost of applying the permit.
What the signatures allow
It helps to know exactly what your users are granting:
- An EIP-2612 permit sets an allowance for the Router of exactly the swap's amount. The signature can be used only until the quote's deadline, and the swap uses the allowance up.
- A Permit2 signature grants the Router an allowance through Permit2 that lasts 30 days, so later swaps with the same token need no new signature. The Router can use it only inside a swap the user sends, only for the token being sold and only up to that swap's amount.
Either way the Router never holds a standing right to move tokens outside a swap the user signed. The contracts page describes those rules, and the API documentation has the full permit and approval reference.