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.
An ERC-20 workflow can require an approval transaction before a contract transfers tokens. EIP-2612 lets supported tokens accept a signed approval, allowing an integration to combine permit submission with its action.
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.
An integration can call permit() and transferFrom() in one transaction, but signing the permit and submitting a transaction are separate wallet actions. A permit sets an allowance; it does not guarantee that the allowance is fully spent or authorize an arbitrary downstream action. Anyone can submit the signature first, so an integration must account for a permit that has already been used. See the EIP-2612 security considerations.
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 };
}Use the token implementation's exact EIP-712 domain parameters, including name, version, chain ID, and verifying contract. DOMAIN_SEPARATOR() returns a hash; it does not return those fields in readable form. A mismatch invalidates the signature.
Step 3: Submit Permit and Execute in One Transaction
The simplified example below calls permit() before transferring tokens. It will revert if someone has already submitted the permit. Production integrations need a reviewed path that tolerates that case, checks the transfer, and binds the action to the intended caller. OpenZeppelin documents these permit-integration considerations.
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:
- 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. - From then on, any protocol that integrates Permit2 can request permit-style signatures from the user without requiring additional
approvetransactions.
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 parameters must match the token. Obtain them from the verified implementation or supported domain-introspection interface and compare the derived separator with DOMAIN_SEPARATOR(). Do not assume every permit-enabled token uses the same schema: DAI's original permit differs from EIP-2612.
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.
Discuss permit integration and token compatibility with LinkIT.