Permit, Permit2, and Gasless Approvals
approve followed by transferFrom is the standard ERC-20 spend pattern, and it has well-known drawbacks: two transactions, two gas fees, and a long-standing UX where users grant unlimited approval to dApps because re-approving is expensive. EIP-2612 Permit and Uniswap's Permit2 address both problems by moving approval into an off-chain signed message.
They also introduce a new set of bugs that every modern audit must understand.
EIP-2612 Permit
EIP-2612 standardizes a permit function on ERC-20 tokens that lets a user authorize a spender via signature instead of an on-chain transaction:
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external;
The user signs an EIP-712 Permit message off-chain. Any caller can then submit permit(...) to set the allowance — typically immediately followed by transferFrom in the same transaction. The user pays no gas; the relayer (often the dApp itself) covers it.
Adoption Reality
Permit is implemented by most modern ERC-20s but is not implemented by USDC, USDT, DAI (old version), WETH9, or many older tokens. Auditing a contract that assumes "every ERC-20 supports permit" is a recipe for a runtime revert on the major liquidity tokens.
Permit-Specific Pitfalls
Front-Runnable Permits
A permit signature is a publicly observable message that grants spending power. Anyone who sees the signed message in the mempool can call permit(...) themselves (the function is permissionless). Normally this is fine — the signer wanted the allowance set — but it creates a denial-of-service vector:
// dApp's intended flow (single transaction):
// permit(owner, dApp, value, deadline, sig)
// transferFrom(owner, dApp, value)
// Attacker's race:
// permit(owner, dApp, value, deadline, sig) ← attacker calls permit first
// ...dApp's permit() then reverts because nonce already consumed
The attacker doesn't gain anything financially, but they grief the dApp's transaction. The dApp's transferFrom still works (allowance is set), but the bundle as written reverts because the permit call inside it fails.
Fix: Wrap permit in a try/catch (or check current allowance and skip permit if already set):
function _ensureAllowance(IERC20Permit token, address owner, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) internal {
if (token.allowance(owner, address(this)) >= value) return;
try token.permit(owner, address(this), value, deadline, v, r, s) {} catch {}
}
This pattern is now standard. A contract that calls permit without a try/catch is fragile.
DAI's Non-Standard Permit
DAI implemented permit before EIP-2612 was finalized. Its signature is different:
function permit(
address holder,
address spender,
uint256 nonce,
uint256 expiry,
bool allowed,
uint8 v,
bytes32 r,
bytes32 s
) external;
Calling DAI's permit with the EIP-2612 signature will fail. Contracts that need to support both must detect which token they're dealing with and call the right function. Routers (Uniswap's V2 SwapRouter, etc.) often have two methods for this; some don't, and only support EIP-2612.
Nonce Increment Order
The standard pattern:
uint256 currentNonce = nonces[owner];
nonces[owner] = currentNonce + 1;
// then verify signature against currentNonce
Some implementations get this backwards: verify first, then increment. If the verification or any prior code can re-enter, the same nonce can be consumed twice. OpenZeppelin's Permit does this correctly via _useNonce.
Permit2: Uniswap's Cross-Token Approval
EIP-2612 Permit lives in the token contract. A token without Permit support is unusable in permit-based flows. Permit2, deployed by Uniswap at a deterministic address (0x000000000022D473030F116dDEE9F6B43aC78BA3) on most major chains, sidesteps this by sitting in the middle:
- The user approves Permit2 once, for unlimited spending of a token (the only "real" on-chain approval).
- After that, the user signs Permit2 messages off-chain authorizing specific spenders to pull specific amounts of specific tokens — without further on-chain approvals.
Permit2 effectively retrofits permit-like behavior onto every ERC-20.
Permit2 Signature Types
Permit2 supports two main signature types:
- PermitSingle / PermitBatch: "Approve spender to pull up to
amountof token untilexpiration, withnonce." Sets an allowance that the spender then drains viatransferFrom. - PermitTransferFrom / PermitBatchTransferFrom: "Allow this specific transfer of
amountof token totofornonce, expiring atdeadline." Direct one-shot transfer authorization.
The former is approval-style (allowance is set, then drained); the latter is transfer-style (one signature = one transfer). Auditors should distinguish them and check the appropriate constraints for each.
Permit2 Bug Classes
Reusing a SignatureTransfer Across Multiple Transfers
PermitTransferFrom is one-shot — the nonce is consumed on use. But a careless integrator might:
- Verify the signature once, then call
transferFrommultiple times in a loop. - Pass the same
permitstruct to multiple internal functions.
The nonce gets consumed only once (the second call would fail at Permit2), but the first transfer might happen N times if the integrator bypasses Permit2 after the first verify. This is unusual but has appeared.
Mismatched transferDetails
PermitTransferFrom signatures include a permitted field (token, amount) and a separate transferDetails parameter at execution. The contract calling Permit2 supplies the transferDetails.to and requestedAmount. If the integrator doesn't validate that requestedAmount <= permitted.amount, or that transferDetails.to is the intended recipient, an attacker can route the transfer to themselves or pull more than authorized.
Specifically:
- The signer's signature covers
permitted.amount(the maximum) and the consuming contract's address (the recipient implicit in the signature). - The consuming contract picks
requestedAmount(must be ≤permitted.amount) andto(in some flows; in others, fixed tomsg.sender).
A common bug: the consuming contract trusts an untrusted parameter to set to, letting the caller redirect the transfer.
Bitmap Nonce Confusion
Permit2 uses bitmap nonces (a (nonceKey, nonceBit) pair) rather than sequential nonces. A user can have many in-flight signatures with non-conflicting nonces. Audit notes:
- Nonces should be derived deterministically by the signing UI to avoid collisions in legitimate use.
- The user should be able to cancel a specific nonce on-chain (Permit2 supports
invalidateNonces). - A contract that signs for the user must not reuse nonce slots accidentally; this is a "signing infrastructure" concern more than a contract-level one but appears in audit scope when wallet contracts are reviewed.
Permit2 + Front-Running
Because Permit2 is a public, permissionless contract, anyone can submit a signed PermitTransferFrom to it on the signer's behalf — and the transfer goes to whatever recipient is encoded in the consuming contract's call to Permit2. This is fine when the recipient is hard-coded; it's a problem when the consuming contract's call is parameter-driven and the parameter is attacker-controlled.
The Uniswap V3 / V4 / Permit2-aware routers handle this correctly. Custom integrations often don't.
Auditing a Permit2 Integration
Checklist:
-
The integration uses the correct Permit2 signature type (
PermitSinglevs.PermitTransferFrom) for its semantics. - The signed message includes the consuming contract's address (so a signature for contract A can't be replayed against contract B).
-
The signed
permitted.amountis correctly compared torequestedAmount. -
The recipient of any
transferDetails.tocannot be set by an unauthorized party. - Deadlines are enforced on every signed message.
- Nonce semantics are appropriate (bitmap collisions handled in the signing UI).
- The integration handles the case where Permit2 reverts (token transfer failure, expired signature, etc.) without leaving stranded state.
Comparison: Permit, Permit2, and Approve
| Property | Approve | EIP-2612 Permit | Permit2 |
|---|---|---|---|
| On-chain approval needed | Yes | No (after first use) | Yes, but only once (to Permit2) |
| Works on every ERC-20 | Yes | No (token must implement) | Yes |
| Gasless for end user | No | Yes (relayer pays) | Yes (relayer pays) |
| Cancel support | Yes (set to 0) | Yes (increment nonce) | Yes (invalidateNonces) |
| Bitmap nonces | No | No | Yes |
| Cross-token batching | No | No | Yes (PermitBatch) |
Choose deliberately. Permit is simplest where supported; Permit2 is more flexible and works universally but adds a dependency on the Uniswap-deployed contract.
Audit Summary
The major Permit / Permit2 finding classes:
- Missing try/catch around
permit→ DoS by front-runner. - Assuming all ERC-20s support permit → revert on USDC, USDT, etc.
- Wrong permit ABI for DAI → silent failure or revert on DAI.
- Permit2
requestedAmount > permitted.amount→ unauthorized over-pull. - Permit2 recipient mismatch → funds routed to attacker.
- No deadline check → indefinite signature reuse.
- Cached domain separator without chain-id rebuild → cross-chain replay.
Modern audits catch most of these with templated checks; a contract that fails any of them has a clear remediation path.