LI.FI supports three token approval strategies on EVM chains. In addition to the classic approve() transaction, integrators can use EIP-2612 Permits or Uniswap Permit2 to authorize token transfers via off-chain signatures, reducing the number of on-chain transactions required.
If you use the LI.FI SDK or Widget, Permit2 is handled automatically. This guide is for integrators building directly against the API who want to understand or implement the permit flow themselves.
Overview of Approval Strategies
| Strategy | On-chain Transactions | How It Works |
|---|
Classic approve() | 1 approval tx + 1 swap/bridge tx | User sends an ERC-20 approve() to the LI.FI Diamond, then submits the swap/bridge transaction. |
| EIP-2612 Native Permit | 1 swap/bridge tx only | User signs an off-chain EIP-712 message. The signature is submitted alongside the swap calldata to the Permit2Proxy, which calls permit() on the token contract. Only works with tokens that implement EIP-2612. |
| Uniswap Permit2 | 1 one-time approval + 1 swap/bridge tx (signature only) | User approves the Permit2 contract once (unlimited). For each subsequent transaction, the user signs an off-chain EIP-712 message authorizing a specific transfer. Works with any ERC-20 token. |
Why Permit2?
With the classic approval model, every new dApp interaction requires a separate approve() transaction. Permit2 replaces this with a single, one-time unlimited approval to the canonical Permit2 contract. All subsequent authorizations happen through gasless EIP-712 signatures with per-transfer granularity (exact amount, deadline, nonce).
Architecture
All permit-based flows go through the Permit2Proxy periphery contract, which acts as an intermediary between the user and the LI.FI Diamond:
Key Addresses
| Contract | Address | Notes |
|---|
| LI.FI Diamond | 0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE | Most EVM chains. Some networks use a different address — always query GET /v1/chains or check Smart Contract Addresses. |
| Uniswap Permit2 | 0x000000000022D473030F116dDEE9F6B43aC78BA3 | Most EVM chains. Several networks use a different deployment (e.g. zkSync, Abstract, Lens, Flare, Sophon, XDC) — always read permit2 from GET /v1/chains. |
| Permit2Proxy | Chain-specific | Query GET /v1/chains — each chain object includes permit2 and permit2Proxy fields. |
Discovering Addresses via the API
const response = await fetch('https://li.quest/v1/chains');
const { chains } = await response.json();
const arbitrum = chains.find((c) => c.id === 42161);
console.log(arbitrum.permit2); // "0x000000000022D473030F116dDEE9F6B43aC78BA3"
console.log(arbitrum.permit2Proxy); // chain-specific Permit2Proxy address
console.log(arbitrum.diamondAddress); // "0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE"
API Flow: Permit2 (Standard Self-Execute)
This is the most common permit flow. It works with any ERC-20 token on chains where Permit2 is deployed.
Get a quote
Request a quote as usual. The response includes estimate.approvalAddress (the Diamond address for classic approve) and the chain metadata you need.const quote = await fetch('https://li.quest/v1/quote?' + new URLSearchParams({
fromChain: '42161',
toChain: '10',
fromToken: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // USDC on Arbitrum
toToken: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // USDC on Optimism
fromAmount: '10000000', // 10 USDC
fromAddress: '0xYourWalletAddress',
})).then(r => r.json());
Check and set Permit2 allowance (one-time)
The user needs to approve the Permit2 contract (not the Diamond) once. This only needs to happen if the user hasn’t already approved Permit2 for this token.Resolve the Permit2 and Permit2Proxy addresses from the chains API — do not hardcode them, as several networks use non-canonical deployments.import { createPublicClient, createWalletClient, http, maxUint256, parseAbi } from 'viem';
import { arbitrum } from 'viem/chains';
const publicClient = createPublicClient({ chain: arbitrum, transport: http() });
const walletClient = createWalletClient({ chain: arbitrum, transport: http(), account });
const chainsResponse = await fetch('https://li.quest/v1/chains').then(r => r.json());
const fromChain = chainsResponse.chains.find((c) => c.id === quote.action.fromChainId);
const permit2Address = fromChain.permit2;
const permit2ProxyAddress = fromChain.permit2Proxy;
const erc20Abi = parseAbi([
'function allowance(address owner, address spender) view returns (uint256)',
'function approve(address spender, uint256 amount) returns (bool)',
]);
const tokenAddress = quote.action.fromToken.address;
const allowance = await publicClient.readContract({
address: tokenAddress,
abi: erc20Abi,
functionName: 'allowance',
args: [account.address, permit2Address],
});
if (allowance < BigInt(quote.action.fromAmount)) {
const hash = await walletClient.writeContract({
address: tokenAddress,
abi: erc20Abi,
functionName: 'approve',
args: [permit2Address, maxUint256],
});
await publicClient.waitForTransactionReceipt({ hash });
}
Read the next available nonce
Permit2 uses unordered nonces. The Permit2Proxy contract provides a nextNonce() helper that finds the next unused nonce for the signer.const permit2ProxyAbi = parseAbi([
'function nextNonce(address owner) view returns (uint256)',
'function callDiamondWithPermit2(bytes diamondCalldata, ((address token, uint256 amount) permitted, uint256 nonce, uint256 deadline) permit, bytes signature) external',
]);
const nonce = await publicClient.readContract({
address: permit2ProxyAddress,
abi: permit2ProxyAbi,
functionName: 'nextNonce',
args: [account.address],
});
Build and sign the PermitTransferFrom message
Construct the EIP-712 typed data for PermitTransferFrom. The spender is the Permit2Proxy (not the Diamond).const deadline = BigInt(Math.floor(Date.now() / 1000) + 30 * 60); // 30 minutes
const permitTransferFrom = {
permitted: {
token: tokenAddress,
amount: BigInt(quote.action.fromAmount),
},
spender: permit2ProxyAddress,
nonce,
deadline,
};
const signature = await walletClient.signTypedData({
account,
primaryType: 'PermitTransferFrom',
domain: {
name: 'Permit2',
chainId: arbitrum.id,
verifyingContract: permit2Address,
},
types: {
TokenPermissions: [
{ name: 'token', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
PermitTransferFrom: [
{ name: 'permitted', type: 'TokenPermissions' },
{ name: 'spender', type: 'address' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
},
message: permitTransferFrom,
});
Encode and send the transaction to Permit2Proxy
Wrap the Diamond calldata inside a callDiamondWithPermit2 call targeting the Permit2Proxy, not the Diamond.import { encodeFunctionData } from 'viem';
const diamondCalldata = quote.transactionRequest.data;
const txData = encodeFunctionData({
abi: permit2ProxyAbi,
functionName: 'callDiamondWithPermit2',
args: [
diamondCalldata,
[
[permitTransferFrom.permitted.token, permitTransferFrom.permitted.amount],
permitTransferFrom.nonce,
permitTransferFrom.deadline,
],
signature,
],
});
const txHash = await walletClient.sendTransaction({
to: permit2ProxyAddress,
data: txData,
value: BigInt(quote.transactionRequest.value ?? 0),
gasLimit: BigInt(quote.transactionRequest.gasLimit ?? 0),
});
Track the transfer status
Track the transaction status as you normally would using the /status endpoint.const getStatus = async (txHash) => {
const result = await fetch(`https://li.quest/v1/status?txHash=${txHash}`);
return result.json();
};
let status;
do {
status = await getStatus(txHash);
if (status.status === 'PENDING') await new Promise(r => setTimeout(r, 5000));
} while (status.status !== 'DONE' && status.status !== 'FAILED');
API Flow: EIP-2612 Native Permit
EIP-2612 permits are only available for tokens that implement the permit() function (e.g., USDC, AAVE, UNI). No prior approve() transaction is needed at all.
Not all tokens support EIP-2612. DAI uses a non-standard permit signature that LI.FI does not currently support. If the token does not implement EIP-2612, fall back to classic approve() or Permit2.
Detecting EIP-2612 Support
There is no on-chain registry or ERC-165 interface for EIP-2612. The only reliable method is to probe the token contract for the required functions. If nonces() and DOMAIN_SEPARATOR() both return successfully, the token supports EIP-2612.
const eip2612DetectAbi = parseAbi([
'function nonces(address owner) view returns (uint256)',
'function DOMAIN_SEPARATOR() view returns (bytes32)',
]);
async function supportsEIP2612(tokenAddress: string): Promise<boolean> {
try {
await Promise.all([
publicClient.readContract({
address: tokenAddress,
abi: eip2612DetectAbi,
functionName: 'nonces',
args: [account.address],
}),
publicClient.readContract({
address: tokenAddress,
abi: eip2612DetectAbi,
functionName: 'DOMAIN_SEPARATOR',
}),
]);
return true;
} catch {
return false;
}
}
Tokens deployed with OpenZeppelin v4.9+ or v5.x also expose eip712Domain() (EIP-5267), which returns all domain fields in a single call. For older tokens, read name(), version(), and DOMAIN_SEPARATOR() separately and recompute the separator to validate it.
Get a quote and retrieve diamond calldata
Same as the standard flow: request a quote, then use the transactionRequest.data as your diamond calldata.
Read the token's permit nonce
EIP-2612 tokens track nonces per-owner. Read the current nonce from the token contract.const eip2612Abi = parseAbi([
'function nonces(address owner) view returns (uint256)',
'function name() view returns (string)',
'function version() view returns (string)',
'function DOMAIN_SEPARATOR() view returns (bytes32)',
]);
const [nonce, name, version] = await Promise.all([
publicClient.readContract({
address: tokenAddress, abi: eip2612Abi,
functionName: 'nonces', args: [account.address],
}),
publicClient.readContract({
address: tokenAddress, abi: eip2612Abi, functionName: 'name',
}),
publicClient.readContract({
address: tokenAddress, abi: eip2612Abi, functionName: 'version',
}),
]);
Sign the EIP-2612 Permit message
The spender is the Permit2Proxy address.const deadline = BigInt(Math.floor(Date.now() / 1000) + 30 * 60);
const permitSignature = await walletClient.signTypedData({
account,
primaryType: 'Permit',
domain: {
name,
version,
chainId: arbitrum.id,
verifyingContract: tokenAddress,
},
types: {
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
},
message: {
owner: account.address,
spender: permit2ProxyAddress,
value: BigInt(quote.action.fromAmount),
nonce,
deadline,
},
});
Encode and send via Permit2Proxy
import { parseSignature, encodeFunctionData } from 'viem';
const { v, r, s } = parseSignature(permitSignature);
const permit2ProxyEip2612Abi = parseAbi([
'function callDiamondWithEIP2612Signature(address tokenAddress, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s, bytes diamondCalldata) external payable',
]);
const txData = encodeFunctionData({
abi: permit2ProxyEip2612Abi,
functionName: 'callDiamondWithEIP2612Signature',
args: [
tokenAddress,
BigInt(quote.action.fromAmount),
deadline,
Number(v),
r,
s,
quote.transactionRequest.data,
],
});
const txHash = await walletClient.sendTransaction({
to: permit2ProxyAddress,
data: txData,
value: BigInt(quote.transactionRequest.value ?? 0),
});
SDK Usage
The @lifi/sdk handles Permit2 automatically during route execution. No manual signature construction is needed.
import { createConfig, EVM, executeRoute } from '@lifi/sdk';
import { createWalletClient, http } from 'viem';
import { arbitrum } from 'viem/chains';
createConfig({
integrator: 'your-integrator-id',
providers: [
EVM({
getWalletClient: () => Promise.resolve(walletClient),
}),
],
});
// The SDK automatically:
// 1. Checks if Permit2 is deployed on the source chain
// 2. Approves the Permit2 contract if needed (one-time)
// 3. Signs a PermitTransferFrom message per transaction
// 4. Encodes the calldata for Permit2Proxy
await executeRoute({ route });
To disable Permit2 and force classic approve() transactions:
await executeRoute({
route,
executionOptions: {
disableMessageSigning: true,
},
});
When Permit2 Is Not Used
The SDK skips Permit2 and falls back to classic approve() when:
- The source chain does not have Permit2 deployed (
chain.permit2 is not set)
- The source chain does not have a Permit2Proxy (
chain.permit2Proxy is not set)
- The source token is the chain’s native token (ETH, MATIC, etc.)
- Message signing is disabled (
disableMessageSigning: true)
- The transaction uses batched execution (EIP-5792)
- The step’s estimate has
skipApproval: true or skipPermit: true (rare, optional fields only present on certain chain-specific steps such as Hyperliquid)
Reference
Permit2Proxy Contract Functions
| Function | Description |
|---|
callDiamondWithPermit2(diamondCalldata, permit, signature) | Transfers tokens via Permit2 permitTransferFrom, approves the Diamond, and forwards calldata. |
callDiamondWithEIP2612Signature(token, amount, deadline, v, r, s, diamondCalldata) | Calls permit() on the EIP-2612 token, transfers tokens, approves the Diamond, and forwards calldata. |
nextNonce(owner) | Returns the next available Permit2 nonce for the given address. |
EIP-712 Type Definitions
PermitTransferFrom (Permit2 standard flow):
{
"TokenPermissions": [
{ "name": "token", "type": "address" },
{ "name": "amount", "type": "uint256" }
],
"PermitTransferFrom": [
{ "name": "permitted", "type": "TokenPermissions" },
{ "name": "spender", "type": "address" },
{ "name": "nonce", "type": "uint256" },
{ "name": "deadline", "type": "uint256" }
]
}
Permit (EIP-2612 native permit):
{
"Permit": [
{ "name": "owner", "type": "address" },
{ "name": "spender", "type": "address" },
{ "name": "value", "type": "uint256" },
{ "name": "nonce", "type": "uint256" },
{ "name": "deadline", "type": "uint256" }
]
}
EIP-712 Domain
Permit2 (for PermitTransferFrom):
{
"name": "Permit2",
"chainId": "<source chain ID>",
"verifyingContract": "<Permit2 contract address>"
}
EIP-2612 (for native Permit — domain varies per token):
{
"name": "<token name>",
"version": "<token version>",
"chainId": "<source chain ID>",
"verifyingContract": "<token contract address>"
}