ERC-2612 Permit: Gasless Token Approvals and Transfers in Solidity (2026)
TL;DR: EIP-2612 allows token transfers without paying network fees by enabling permit-based approvals. This standardizes the process of approving token transfers via signatures, allowing users to authorize transfers off-chain and have them executed on-chain without spending gas. It enhances user experience and reduces friction in decentralized applications.
Table of Contents
- ERC-2612 Overview
- How does ERC-2612 work?
- How is ERC-2612 implemented?
- Security Considerations for ERC-2612
- Frequently Asked Questions
- Extra resources
ERC-2612 Overview
ERC-2612 is a Ethereum smart contract standard that introduces a new standard for token approvals, allowing users to authorize token transfers via signatures without needing to pay gas fees.
This is achieved through the permit function, which enables off-chain approvals that can be executed on-chain by another party.
Basically, it improved some aspects of the ERC-20 token standard and solve one of the biggest problems in DEFI industry, the need for users to pay gas fees to approve token transfers before they can execute them. With ERC-2612, users can sign a message off-chain to approve a token transfer, and then a relayer can submit the transaction on-chain on their behalf, paying the gas fees.
To understand correctly how ERC-2612 works, it is important to have a good understanding of how signing data in smart contracts works, and the EIP-712 standard for signing typed structured data. You can check the post about EIP-712: Signing typed structured data in smart contracts to learn more about this topic, which is essential to understand how ERC-2612 enables gasless token transfers.
How does ERC-2612 work?
To understand what ERC-2612 solves, you first need to understand the classic ERC-20 approval problem.
The traditional two-transaction flow
In a standard ERC-20 token, before a dApp (e.g., a DEX or lending protocol) can move tokens on your behalf, you must explicitly approve it. This requires two separate on-chain transactions, both paid by the user:
approve(spender, amount)— the user authorises the dApp contract to spend up toamounttokens.transferFrom(owner, recipient, amount)— the dApp executes the actual transfer.
This means every new interaction with a protocol costs extra gas just for the approval step, which is a terrible user experience and a real barrier for newcomers.
The ERC-2612 solution: gasless approvals via permit
ERC-2612 extends ERC-20 with a permit function that replaces the on-chain approve call with an off-chain EIP-712 signature. The flow becomes:
- The user signs a
Permitmessage off-chain (no gas, no transaction). - The relayer (or the dApp itself) bundles the signature and send to to the token contract, which verifies the signature and sets the allowance.
Furthermore, the major part of the tokens that implement ERC-2612 combine the permit() function with the transferFrom() function into a single on-chain transaction, so the user only needs to sign once and the dApp can execute both steps atomically.
Generally, those tokens smart contract have a function called transferWithAuthorization or similar which internally calls permit() to set the allowance and then executes the transfer in the same transaction.
The result: the user never pays gas for the approval or transfer tokens. They only produce a signature.
This is the USDC token in polygon network, which implements ERC-2612 and allows gasless transfers: https://polygonscan.com/token/0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359#readProxyContract
How is ERC-2612 implemented?
In the following sections, we will explore how ERC-2612 is implemented both on-chain (the smart contract) and off-chain (the signature generation process). Understanding both sides is crucial to grasp how ERC-2612 enables gasless token transfers and how to interact with such tokens in your dApps.
Smart Contract Implementation
The core of ERC-2612 is the permit function, which allows users to set allowances via signatures. The smart contract implementation of this function involves several key components:
- Nonce Management: Each token holder has a nonce that increments with every successful
permitcall, preventing replay attacks. - EIP-712 Domain Separator: This is used to ensure that the signature is valid for the specific token contract and chain, preventing cross-chain replay attacks.
- Signature Verification: The contract verifies that the provided signature matches the expected format and was produced by the token holder.
The permit function interface declared by Solidity ERC-2612 standard in the following:
/// @notice Sets `value` as allowance of `spender` over `owner`'s tokens using owner's signature.
/// @param owner The token holder (signer).
/// @param spender The address allowed to spend the tokens.
/// @param value The allowance amount.
/// @param deadline Unix timestamp after which the signature is invalid.
/// @param v, r, s Components of the EIP-712 signature.
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external;
Furthermore, there is an OpenZeppelin implementation of the permit function in their ERC-20 Permit extension, which is widely used and serves as a reference for many tokens implementing ERC-2612:
The contract verifies that the provided signature was produced by owner over the following typed struct (the PERMIT_TYPEHASH):
bytes32 public constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
Key fields:
- nonce — a per-owner counter that increments after each successful
permitcall, preventing replay attacks. - deadline — a Unix timestamp that makes the signature expire, so a leaked signature cannot be used indefinitely.
Off-chain permit signing with viem
The following code snippet shows how a user signs a Permit message off-chain before handing it to a relayer or bundling it into a dApp call:
import { createWalletClient, http, publicActions } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { USER_PRIVATE_KEY } from '../constants'
import { polygon } from 'viem/chains'
// Minimal ABI to read the current nonce from the token contract
const erc2612Abi = [
{
name: 'nonces',
type: 'function',
stateMutability: 'view',
inputs: [{ name: 'owner', type: 'address' }],
outputs: [{ name: '', type: 'uint256' }]
}
] as const
async function signPermit(
tokenAddress: `0x${string}`,
spender: `0x${string}`,
value: bigint
) {
const owner = privateKeyToAccount(USER_PRIVATE_KEY)
const walletClient = createWalletClient({
account: owner,
chain: polygon,
transport: http('https://polygon.drpc.org')
}).extend(publicActions)
// Read the current nonce for this owner from the token contract
const nonce = await walletClient.readContract({
address: tokenAddress,
abi: erc2612Abi,
functionName: 'nonces',
args: [owner.address]
})
// Deadline: 1 hour from now
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600)
// Sign the Permit struct following the EIP-712 standard
const signature = await walletClient.signTypedData({
domain: {
name: 'MyToken', // must match the token contract's EIP-712 domain
version: '1',
chainId: polygon.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' }
]
},
primaryType: 'Permit',
message: {
owner: owner.address,
spender,
value,
nonce,
deadline
}
})
console.log('Permit signature:', signature)
// → send { owner, spender, value, deadline, signature } to the relayer or dApp
}
The signature can then be decomposed into its v, r, s components and forwarded to the token's permit() function on-chain, which verifies it and sets the allowance atomically — no separate approve transaction needed.
Contract Architecture Deep Dive
The TokenERC2612 contract in this post implements several design decisions worth examining closely. Understanding them helps both auditors and integrators reason about correctness and security.
Two permit overloads
The contract exposes two public permit signatures:
// Overload 1 — split (v, r, s) as required by the ERC-2612 interface
function permit(address owner, address spender, uint256 value, uint256 deadline,
uint8 v, bytes32 r, bytes32 s) external override;
// Overload 2 — packed 65-byte signature (r ++ s ++ v) for convenience
function permit(address owner, address spender, uint256 value, uint256 deadline,
bytes memory signature) external;
The first overload satisfies the IERC20Permit interface (as defined in OpenZeppelin), making the token compatible with any protocol that expects the canonical ERC-2612 ABI. The second overload accepts a packed 65-byte signature, which is the format produced directly by signTypedData in viem and ethers.js — removing the need for clients to manually split the byte array into v, r, s. Internally both overloads call the same validateSignature function, so there is no logic duplication.
Two separate TypeHash constants
The contract defines two distinct TypeHash constants, one for permit and one for transferWithPermit:
// Standard ERC-2612 permit: only sets an allowance
bytes32 public constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
// Atomic transfer: commits the destination address inside the signed message
bytes32 public constant TRANSFER_WITH_PERMIT_TYPEHASH = keccak256(
"TransferWithPermit(address from,address to,uint256 value,uint256 nonce,uint256 deadline)"
);
This separation is a deliberate security measure. A Permit signature only authorises a spender to pull tokens — it never commits to a destination. An attacker who intercepts a permit() signature can only do what the signer already approved: set an allowance for spender. They cannot redirect tokens to an arbitrary address because the to field does not exist in the Permit struct.
TransferWithPermit, by contrast, buries the destination address inside the signed message. A caller cannot reuse a transferWithPermit signature to direct funds elsewhere, and they also cannot reuse a permit() signature to trigger a transfer — each function checks a different TypeHash, and both checks are cryptographically enforced.
validateSignature: layered defensive checks
The internal validateSignature function guards against three classes of malformed input before calling ECDSA.recover:
function validateSignature(address signer, bytes32 structHash, bytes memory signature) internal {
if (structHash == bytes32(0)) revert TokenERC2612__InvalidStructHash();
if (signature.length != SIGNATURE_LENGTH) revert TokenERC2612__InvalidSignature();
if (signer == address(0)) revert TokenERC2612__InvalidSigner();
bytes32 digest = _hashTypedDataV4(structHash);
address recoveredSigner = digest.recover(signature);
if (recoveredSigner != signer) revert TokenERC2612__InvalidSignature();
emit SignatureVerified(signer, structHash);
}
- Guard ① rejects a zero
structHash. A zero hash is the initial value of any unsetbytes32and could indicate a bug in the caller. Accepting it could allow an attacker to forge a "valid" digest in degenerate edge cases. - Guard ② enforces exactly 65 bytes (
r+s+v). A shorter signature would causeECDSA.recoverto revert with an opaque error; a longer one could be a padding attack. Failing fast with a named custom error makes on-chain debugging easier. - Guard ③ rejects signatures that unexpectedly recover to
address(0). OpenZeppelin'sECDSA.recoveralready reverts on malformed signatures, but this guard is an explicit check that ensures a logically invalid zero-address signer is caught regardless of library behaviour.
Custom errors vs. require strings
The contract uses Solidity custom errors (error TokenERC2612__InvalidSignature()) throughout instead of require("string"). Custom errors are cheaper to deploy (no string storage), cheaper to revert (no ABI-encoding a string), and are machine-readable by indexers and front-ends without string parsing.
buildStructHash helper
function buildStructHash(
address owner, address spender, uint256 value,
uint256 nonce, uint256 deadline, bytes32 typeHash
) internal pure returns (bytes32) {
return keccak256(abi.encode(typeHash, owner, spender, value, nonce, deadline));
}
This single helper is reused by all three public functions (permit ×2, transferWithPermit). The typeHash parameter is what makes the same encoding logic serve radically different semantics — PERMIT_TYPEHASH produces a permit struct hash, TRANSFER_WITH_PERMIT_TYPEHASH produces a transfer struct hash. Centralising the abi.encode call eliminates a class of copy-paste bugs where field order diverges between functions.
transferWithPermit: atomic approve and transfer
Many real-world ERC-2612 integrations bundle the permit() call together with transferFrom() inside the receiving protocol contract. TokenERC2612 takes this one step further by offering transferWithPermit directly on the token itself — a single on-chain call that both verifies the signature and moves the tokens:
function transferWithPermit(
address from, address to, uint256 value,
uint256 deadline, bytes memory signature
) external {
if (block.timestamp > deadline) revert TokenERC2612__ExpiredDeadline();
bytes32 structHash = buildStructHash(
from, to, value, _nonces[from], deadline, TRANSFER_WITH_PERMIT_TYPEHASH
);
validateSignature(from, structHash, signature);
_nonces[from]++;
_transfer(from, to, value); // ← direct transfer, no allowance set or consumed
}
Notice that _transfer is called — not _approve followed by transferFrom. The token goes directly from from to to, and no intermediate allowance is ever written to state.
This saves one SSTORE operation and keeps the allowance mapping unaffected, which is useful for integrators who track user allowances.
Off-chain, the user must sign a TransferWithPermit struct (not the standard Permit struct):
import { createWalletClient, http, publicActions } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { USER_PRIVATE_KEY } from '../constants'
import { polygon } from 'viem/chains'
const erc2612Abi = [
{ name: 'nonces', type: 'function', stateMutability: 'view',
inputs: [{ name: 'owner', type: 'address' }],
outputs: [{ name: '', type: 'uint256' }] }
] as const
async function signTransferWithPermit(
tokenAddress: `0x${string}`,
to: `0x${string}`,
value: bigint
) {
const from = privateKeyToAccount(USER_PRIVATE_KEY)
const walletClient = createWalletClient({
account: from,
chain: polygon,
transport: http('https://polygon.drpc.org')
}).extend(publicActions)
const nonce = await walletClient.readContract({
address: tokenAddress,
abi: erc2612Abi,
functionName: 'nonces',
args: [from.address]
})
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600)
// Note: primaryType is 'TransferWithPermit', NOT 'Permit'
const signature = await walletClient.signTypedData({
domain: {
name: 'MyToken',
version: '1',
chainId: polygon.id,
verifyingContract: tokenAddress
},
types: {
TransferWithPermit: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' }
]
},
primaryType: 'TransferWithPermit',
message: { from: from.address, to, value, nonce, deadline }
})
console.log('TransferWithPermit signature:', signature)
// → call tokenAddress.transferWithPermit(from, to, value, deadline, signature)
}
A critical integration detail: the domain.name passed to signTypedData must exactly match the name used when the token contract was deployed (the name_ constructor argument passed to EIP712). Any mismatch — including whitespace or casing — produces a different domain separator and causes signature verification to fail silently.
Security Considerations for ERC-2612
ERC-2612 shifts the security surface from on-chain transaction authorisation to off-chain signature handling. The following vectors are the most relevant for implementors and integrators.
Permit front-running is largely harmless — but understand why
When a user broadcasts a relayer transaction that calls permit(…, signature), any observer can extract the signature from the mempool and submit their own permit transaction with the same parameters. This sounds dangerous but is almost always harmless: the permit function only sets an allowance, it does not move tokens. An attacker who front-runs the permit call merely delivers exactly what the user already signed for. The subsequent transferFrom — which actually moves tokens — is still only executable by spender, unaffected by the front-run.
The one scenario where front-running matters: if the calling contract uses a try/catch pattern around permit and falls back gracefully (as recommended by EIP-7597), front-running the permit could cause the protocol's try block to revert with a nonce-already-used error. Well-designed smart contracts handle this by checking whether the allowance is already sufficient before calling permit.
Keep deadlines short
A permit signature that never expires is as dangerous as a private key exposure — anyone who obtains the signature bytes can use them any time until the nonce changes. Use deadlines of 5–60 minutes for interactive UX flows. For automated pipelines (e.g., batch relayers), consider whether an hour is the right trade-off between operational resilience and signature longevity.
Separate TypeHash prevents cross-function signature reuse
The PERMIT_TYPEHASH and TRANSFER_WITH_PERMIT_TYPEHASH constants differ in both the type name and the field list. Because the TypeHash is hashed into the digest, a valid permit() signature is cryptographically incompatible with transferWithPermit() and vice versa — even if all other fields happen to match. This is a concrete benefit of EIP-712's typed-data model: the type string, not just the data values, is part of what is signed.
Infinite allowances and ERC-2612
It is tempting to sign a Permit with value = type(uint256).max so the relayer never needs to create a new permit. Avoid this pattern. If the spender contract is later compromised or has a bug, an unlimited allowance turns into an unlimited loss. Favour exact amounts aligned with the immediate operation.
SIGNATURE_LENGTH == 65 prevents non-standard length attacks
Compact ECDSA signatures (EIP-2098) are 64 bytes — one byte shorter. Allowing both 64 and 65-byte signatures without explicit handling can lead to inconsistency: some library versions treat them identically, others do not. The contract enforces exactly 65 bytes, which avoids ambiguity and is consistent with abi.encodePacked(r, s, v).
[WARNING]
The code provided in this article is intended for educational purposes. Always perform thorough security audits before deploying token contracts handling real funds.
Frequently Asked Questions
What is ERC-2612?
ERC-2612 (also referred to as EIP-2612) is an extension of the ERC-20 token standard that adds a permit function, allowing token holders to set spending allowances via an off-chain EIP-712 signature instead of an on-chain approve transaction. This eliminates the classic "two-transaction approval flow" and enables gasless token interactions.
What is the difference between ERC-20 approve and ERC-2612 permit?
approve is an on-chain transaction that requires the token holder to pay gas. permit replaces that with an off-chain signature: the holder signs a typed message, and any third party (a relayer, a dApp, or the recipient) can submit that signature to the token contract to set the allowance — paying gas themselves. The allowance produced is identical; only the authorisation mechanism differs.
What is the PERMIT_TYPEHASH and why is it important?
PERMIT_TYPEHASH is the keccak256 hash of the string "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)". It is included in every digest that the permit signer produces, ensuring that the signature is cryptographically bound to this specific struct definition. Any deviation — a different field name, a different field order, an extra field — produces a completely different TypeHash and causes signature verification to fail.
What is transferWithPermit and how is it different from permit + transferFrom?
transferWithPermit is an additional function (not part of the ERC-2612 standard itself) that combines signature verification and token transfer in a single call. Unlike permit + transferFrom, it uses a separate TRANSFER_WITH_PERMIT_TYPEHASH that commits the to (destination) address inside the signed message. This means the caller cannot redirect the transfer to a different address, and the permit() signature cannot be reused for this function.
Can permit signatures be front-run?
Yes, but front-running a permit call is almost always harmless. An attacker who submits your signed permit before you only sets the allowance you already authorised — they cannot move tokens themselves because they are not the designated spender. The only edge case is if the calling protocol reverts when permit is called with an already-used nonce (e.g., because the allowance was already set by the front-runner). Well-written protocols handle this by wrapping permit in a try/catch and checking the existing allowance as a fallback.
How do nonces prevent replay attacks in ERC-2612?
Each address has a nonce counter stored in the token contract. The current nonce is included in every permit message and is incremented on-chain after a successful call. If someone tries to resubmit the same signed message again, the nonce in the signature no longer matches the current on-chain nonce, so the recovered digest is different and signature verification fails.
Does ERC-2612 work with hardware wallets?
Compatibility depends on the firmware version and the wallet software. Modern versions of MetaMask and Ledger support EIP-712 signTypedData and will display the permit fields (spender, value, deadline) in a human-readable format. Older firmware may fall back to raw byte signing (eth_sign), which is less safe. Always test your signing flow against the target wallet.
What popular tokens implement ERC-2612?
Notable examples include USDC (on multiple chains), DAI, Uniswap's UNI, AAVE, and most ERC-20 tokens deployed via OpenZeppelin's ERC20Permit extension. The full list is growing as the two-transaction approval UX becomes increasingly unacceptable for mainstream users.
How do I read the current domain separator of a deployed token?
Most ERC-2612 tokens expose a public DOMAIN_SEPARATOR() view function (as seen in the TokenERC2612 contract). Call it with eth_call or a publicClient.readContract call in viem. Note that if the token uses OpenZeppelin's EIP712 base, the domain separator is recomputed dynamically whenever the chain ID changes — so what you read is always current.
Extra resources
If you want to learn how to write, test, and deploy smart contracts in Solidity, you can check the posts related to the most popular Solidity development tool, Foundry: