WritingGasless Token Approvals with EIP-2612 Permit: A Developer's Implementation Guide — Clixo
6 min readeip-2612, gasless, permit, token-approvals, defi

Gasless Token Approvals with EIP-2612 Permit: A Developer's Implementation Guide

Learn how to implement gasless token approvals using EIP-2612 permit signatures in your dApp, with code examples covering signing, verification, and Permit2.

The standard ERC-20 approval flow requires two transactions: one approve and one transferFrom. On a busy day, that is two gas payments, two wallet confirmations, and a meaningful drop-off rate before the user completes the action they came to your dApp to take. EIP-2612 is the cleanest way to collapse this into a single transaction, and it has been live in production on tokens like USDC, DAI, and UNI for years.

Here is how it works and how to implement it correctly.

What EIP-2612 Permit Does

EIP-2612 adds a permit() function to the ERC-20 standard. This function lets a token holder authorize a spender by submitting a signed message rather than a separate approve transaction. The signed message is an off-chain EIP-712 typed data structure — it costs zero gas to create, because no chain interaction is needed.

The spender contract then calls permit() and transferFrom() in the same transaction. The user sees one wallet confirmation, pays gas once, and the action completes. The approval is atomic with the transfer — it cannot be front-run and does not leave a standing allowance that can be drained later.

How to Implement Gasless Approvals with EIP-2612

Step 1: Check if the Token Supports Permit

Not every ERC-20 token implements EIP-2612. Before building this flow, verify the token you are integrating has a permit() function and a DOMAIN_SEPARATOR() function.

import { erc20Abi, publicClient } from 'viem';
 
const permitAbi = [
  {
    name: 'permit',
    type: 'function',
    inputs: [
      { name: 'owner', type: 'address' },
      { name: 'spender', type: 'address' },
      { name: 'value', type: 'uint256' },
      { name: 'deadline', type: 'uint256' },
      { name: 'v', type: 'uint8' },
      { name: 'r', type: 'bytes32' },
      { name: 's', type: 'bytes32' },
    ],
    outputs: [],
  },
  {
    name: 'nonces',
    type: 'function',
    inputs: [{ name: 'owner', type: 'address' }],
    outputs: [{ name: '', type: 'uint256' }],
  },
  {
    name: 'DOMAIN_SEPARATOR',
    type: 'function',
    inputs: [],
    outputs: [{ name: '', type: 'bytes32' }],
  },
] as const;

Step 2: Build and Sign the Permit Message

The permit message is an EIP-712 typed data structure. The user signs this off-chain with zero gas cost.

import { signTypedData } from 'viem/actions';
 
async function signPermit({
  walletClient,
  tokenAddress,
  ownerAddress,
  spenderAddress,
  value,
  chainId,
}) {
  // Fetch the current nonce for this owner from the token contract
  const nonce = await publicClient.readContract({
    address: tokenAddress,
    abi: permitAbi,
    functionName: 'nonces',
    args: [ownerAddress],
  });
 
  // Set a deadline 20 minutes from now
  const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20);
 
  const signature = await walletClient.signTypedData({
    account: ownerAddress,
    domain: {
      name: 'USD Coin',       // must match the token's EIP-712 domain name
      version: '2',           // must match the token's domain version
      chainId,
      verifyingContract: tokenAddress,
    },
    types: {
      Permit: [
        { name: 'owner', type: 'address' },
        { name: 'spender', type: 'address' },
        { name: 'value', type: 'uint256' },
        { name: 'nonce', type: 'uint256' },
        { name: 'deadline', type: 'uint256' },
      ],
    },
    primaryType: 'Permit',
    message: {
      owner: ownerAddress,
      spender: spenderAddress,
      value,
      nonce,
      deadline,
    },
  });
 
  return { signature, deadline };
}

The domain name and version must exactly match what the token contract returns from its DOMAIN_SEPARATOR. Mismatches produce invalid signatures that revert on-chain with no helpful error.

Step 3: Submit Permit and Execute in One Transaction

On the contract side, call permit() first, then execute your action. This is typically done in a single function call on your protocol contract.

function depositWithPermit(
    address token,
    uint256 amount,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) external {
    IERC20Permit(token).permit(
        msg.sender,
        address(this),
        amount,
        deadline,
        v,
        r,
        s
    );
    IERC20(token).transferFrom(msg.sender, address(this), amount);
    // ... rest of deposit logic
}

On the client, pass the signature components alongside the transaction:

const { v, r, s } = parseSignature(signature);
 
await walletClient.writeContract({
  address: yourProtocolAddress,
  abi: yourProtocolAbi,
  functionName: 'depositWithPermit',
  args: [tokenAddress, amount, deadline, v, r, s],
});

When Tokens Do Not Support EIP-2612: Permit2

Not all ERC-20 tokens have a permit() function — many were deployed before the standard existed. Uniswap's Permit2 contract solves this by creating a generalized approval layer on top of any ERC-20 token.

The flow with Permit2:

  1. The user approves Permit2 (a canonical, audited contract at the same address on all EVM chains) to spend the token. This is a one-time standard approve, done once per token regardless of how many protocols use Permit2.
  2. From then on, any protocol that integrates Permit2 can request permit-style signatures from the user without requiring additional approve transactions.

For DeFi protocols handling many tokens, integrating Permit2 gives you the single-transaction UX of EIP-2612 across the full ERC-20 ecosystem.

Key Implementation Caveats

Domain name and version must be exact. The EIP-712 domain parameters are part of the signed payload. If your domain name has a typo or the version does not match, the on-chain permit() call will revert. Fetch DOMAIN_SEPARATOR() directly from the token and cross-reference your domain parameters.

Deadlines should be reasonable, not distant. A permit signature with a deadline 10 years in the future is nearly as risky as unlimited approval. Use 15-30 minute deadlines for interactive flows.

Nonce must be current at signing time. The nonce in the signed message must match the current on-chain nonce. If the user has signed another permit for the same token between when you fetched the nonce and when they submit, the nonce will be stale and the transaction will revert. Fetch the nonce as close to the signing step as possible.

Signature format varies by wallet. Some wallets return a concatenated 65-byte signature; others return an object with v, r, s separately. Normalize the format before splitting into components.


EIP-2612 permit integration is one of the highest-leverage UX improvements available in DeFi. If your team is building a DeFi product and wants the permit flow implemented correctly end to end, Start a build with Clixo.