To fix Uniswap Permit2 InvalidNonce() (0x756688fe) and expiration collision reverts, advance the permit word position in the 248-bit nonce bitmap and synchronize deadline timestamps with UTC block time. Reverts occur when applications submit permit signatures with expired deadlines, or when multiple concurrent trades attempt to write to the same single-bit nonce index without incrementing the bitmap word, causing immediate transaction failure.
The Uniswap Permit2 protocol (0x000000000022D473030F116dDEE9F6B43aC78BA3) is the standard execution layer for gasless token approvals across modern decentralized exchanges, aggregators, and account abstraction relayers. By decoupling ERC-20 allowances from direct state-modifying transactions, dApps permit token transfers via off-chain EIP-712 signatures.
However, developers building automated market makers, arbitrage bots, intent relayers, and batching infrastructure frequently encounter sudden transaction halts returning two cryptic custom error selectors:
InvalidNonce()(0x756688fe) — emitted byPermit2.permit()orPermit2.permitTransferFrom().SignatureExpired(uint256 deadline)(0xcd09804b) — emitted when the signature validity window lapses prior to miner/sequencer execution.
If your architecture involves emerging delegation standards, also review our forensic breakdown of EIP-7702 Set Code Authorization Replay Attacks & Cross-Chain Delegation Front-Running.
[!TIP] Diagnostic Sandbox: Decode Permit2 EIP-712 signature payloads and inspect execution calldata in our ERC-4337 Calldata Decoder. Also review our security guide on ERC-7739 Smart Account Signature Replay Fixes.
Architectural Deep Dive: How Permit2 Manages Nonces
Unlike standard ERC-20 permit (EIP-2612), which enforces a strictly monotonic nonces(address owner) counter that increments by 1 with each call, Permit2 supports two distinct authorization modes:
- AllowanceTransfer (Monotonic Nonce Sequence): Used by
permitSingleandpermitBatch. Each token approval has an associated(uint160 amount, uint48 expiration, uint48 nonce)packed in storage. Nonces must increment monotonically per(owner, token, spender). - SignatureTransfer (Unordered Nonce Bitmap): Used by
permitTransferFromandpermitWitnessTransferFrom. Nonces are tracked in a sparse 256-bit word bitmap mapping:
// SignatureTransfer.sol (Uniswap Permit2)
mapping(address => mapping(uint256 => uint256)) public nonceBitmap;
The Bitmap Mathematics
For any 256-bit unsigned integer nonce, Permit2 computes its position in storage using bitwise shifts:
$$\text{wordPos} = \text{nonce} \gg 8 \quad (\text{equivalent to } \lfloor\text{nonce} / 256\rfloor)$$
$$\text{bitPos} = \text{nonce} \ & \ \text{0xFF} \quad (\text{equivalent to } \text{nonce} \pmod{256})$$
$$\text{mask} = 1 \ll \text{bitPos}$$
When permitTransferFrom processes the signature:
- It loads
word = nonceBitmap[owner][wordPos]. - It verifies that
(word & mask) == 0. If the bit is already1, the call reverts withInvalidNonce(). - It sets
nonceBitmap[owner][wordPos] = word | mask.
Root Cause 1: Relayer Worker Race Conditions & Unordered Nonce Collisions
In high-throughput relayers or multi-threaded trading bots, multiple worker threads generate and broadcast UserOperations or transactions concurrently.
[Worker A] Reads bitmap: bitPos 12 is 0 -> Generates Sig with nonce 12
[Worker B] Reads bitmap: bitPos 12 is 0 -> Generates Sig with nonce 12
[Mempool] Worker A tx mined first: bit 12 flipped to 1
[Mempool] Worker B tx mined second: Reverts with InvalidNonce() (0x756688fe)
Because both workers read the state of the blockchain at block $N$, both observe nonceBitmap[owner][wordPos] as clean. When Worker A’s transaction is included in block $N+1$, the bit flips. Worker B’s transaction hits the mempool seconds later and immediately fails, draining gas and stalling execution queues.
Defensive Pattern: In-Memory Redis Bitmap Reservation
To eliminate relayer collisions, implement an atomic distributed lock and nonce reservation service using Redis bitfield operations before signing the Permit2 payload:
// permit2-nonce-manager.ts
import { createPublicClient, http, type Address } from 'viem';
import { mainnet } from 'viem/chains';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const PERMIT2_ADDRESS: Address = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
const publicClient = createPublicClient({
chain: mainnet,
transport: http(process.env.RPC_URL),
});
export async function allocatePermit2Nonce(owner: Address): Promise<bigint> {
const key = `permit2:nonces:${owner.toLowerCase()}`;
// Attempt to allocate from the active word counter
for (let attempt = 0; attempt < 50; attempt++) {
// 1. Fetch current local word offset
const currentWord = await redis.incr(`${key}:word_offset`);
const wordPos = BigInt(currentWord);
// 2. Fetch on-chain bitmap state for this wordPos
const onChainWord = await publicClient.readContract({
address: PERMIT2_ADDRESS,
abi: [
{
name: 'nonceBitmap',
type: 'function',
stateMutability: 'view',
inputs: [
{ name: 'owner', type: 'address' },
{ name: 'wordPos', type: 'uint256' },
],
outputs: [{ name: '', type: 'uint256' }],
},
],
functionName: 'nonceBitmap',
args: [owner, wordPos],
});
// 3. Scan for first unset bit in the 256-bit word
for (let bitPos = 0; bitPos < 256; bitPos++) {
const bitMask = 1n << BigInt(bitPos);
if ((onChainWord & bitMask) === 0n) {
// Reserve bit in Redis using atomic SETNX on a specific slot key
const slotKey = `${key}:${wordPos}:${bitPos}`;
const reserved = await redis.set(slotKey, 'locked', 'EX', 300, 'NX');
if (reserved === 'OK') {
// Compute full 256-bit nonce
const allocatedNonce = (wordPos << 8n) | BigInt(bitPos);
return allocatedNonce;
}
}
}
}
throw new Error('Failed to allocate unique Permit2 nonce: bitmap saturation');
}
Root Cause 2: L2 Sequencer Clock Skew & Tight sigDeadline
On Layer-2 networks (Arbitrum One, Base, Optimism, zkSync Era), transactions are sequenced by centralized or federated sequencers whose block timestamp (block.timestamp) may differ from standard NTP-synchronized host servers by $\pm 15$ seconds.
Furthermore, during network congestion or L1 gas spikes, a transaction may sit in the sequencer mempool for 45 to 90 seconds before inclusion.
If your client application constructs Permit2 signatures using a strict 30-second deadline:
// VULNERABLE PATTERN: Overly tight deadline without L2 skew margin
const deadline = Math.floor(Date.now() / 1000) + 30; // 30 seconds from now
The sequencer evaluates:
$$\text{if } (\text{block.timestamp} > \text{sigDeadline}) \implies \mathbf{revert} \ \texttt{SignatureExpired(deadline)}$$
Mitigation: Dynamic Blocktime-Anchored Deadline Calculation
Always query the latest block header timestamp from the target execution node rather than relying solely on local system clocks, and apply a 20-minute safety buffer for normal user operations:
// secure-permit2-deadline.ts
import { type PublicClient } from 'viem';
export async function getSafePermit2Deadline(
client: PublicClient,
bufferSeconds: number = 1200 // 20 minutes standard
): Promise<bigint> {
const latestBlock = await client.getBlock({ blockTag: 'latest' });
const blockTime = latestBlock.timestamp;
const localTime = BigInt(Math.floor(Date.now() / 1000));
// Detect sequencer timestamp skew
const skew = blockTime > localTime ? blockTime - localTime : localTime - blockTime;
if (skew > 300n) {
console.warn(`[Permit2 Warning] Significant node clock skew detected: ${skew}s`);
}
// Anchor to maximum of block time or local time + buffer
const anchorTime = blockTime > localTime ? blockTime : localTime;
return anchorTime + BigInt(bufferSeconds);
}
Root Cause 3: Spender vs Router Address Mismatch in EIP-712 Domain
A third subtle trigger for InvalidNonce() occurs when interacting with Uniswap Universal Router.
Developers often pass the Universal Router address (0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD) as the token spender in the EIP-712 signature, but pass the Permit2 contract address (0x000000000022D473030F116dDEE9F6B43aC78BA3) in the allowance check, or vice-versa.
The domain separator for Permit2 must explicitly target the Permit2 contract itself, while the spender field within the message struct must target the consumer contract (e.g., Universal Router):
// Correct EIP-712 Domain and Message Structure for Permit2 Single Allowance
const domain = {
name: 'Permit2',
chainId: activeChainId,
verifyingContract: '0x000000000022D473030F116dDEE9F6B43aC78BA3' as const, // Must be Permit2!
};
const types = {
PermitSingle: [
{ name: 'details', type: 'PermitDetails' },
{ name: 'spender', type: 'address' },
{ name: 'sigDeadline', type: 'uint256' },
],
PermitDetails: [
{ name: 'token', type: 'address' },
{ name: 'amount', type: 'uint160' },
{ name: 'expiration', type: 'uint48' },
{ name: 'nonce', type: 'uint48' }, // Incremental monotonic nonce for AllowanceTransfer
],
};
Step-by-Step Diagnostic & Recovery Checklist
[Diagnostic Phase]
├── 1. Check Revert Selector
│ ├── 0x756688fe -> InvalidNonce()
│ └── 0xcd09804b -> SignatureExpired(uint256)
├── 2. For InvalidNonce:
│ ├── Identify if using AllowanceTransfer (monotonic) or SignatureTransfer (bitmap)
│ ├── If AllowanceTransfer: Query permit2.allowance(owner, token, spender) to fetch current nonce
│ └── If SignatureTransfer: Query permit2.nonceBitmap(owner, nonce >> 8) and inspect bit (nonce & 0xff)
└── 3. For SignatureExpired:
├── Compare tx receipt block.timestamp with sigDeadline in calldata
└── Increase buffer to >= 1200 seconds (20 minutes)
Verification Script: Verify Permit2 Nonce Status via CLI
Run this quick Viem diagnostic script to inspect the exact status of any owner’s Permit2 nonces on-chain:
// check-permit2-status.ts
import { createPublicClient, http, type Address } from 'viem';
import { mainnet } from 'viem/chains';
const client = createPublicClient({ chain: mainnet, transport: http() });
const PERMIT2 = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
async function verifyNonce(owner: Address, testNonce: bigint) {
const wordPos = testNonce >> 8n;
const bitPos = Number(testNonce & 0xffn);
const bitmap = await client.readContract({
address: PERMIT2,
abi: [{
name: 'nonceBitmap',
type: 'function',
stateMutability: 'view',
inputs: [{ name: 'owner', type: 'address' }, { name: 'wordPos', type: 'uint256' }],
outputs: [{ name: '', type: 'uint256' }]
}],
functionName: 'nonceBitmap',
args: [owner, wordPos]
});
const isConsumed = (bitmap & (1n << BigInt(bitPos))) !== 0n;
console.log(`Word Position: ${wordPos}`);
console.log(`Bit Position: ${bitPos}`);
console.log(`Nonce Status: ${isConsumed ? 'CONSUMED (Will Revert)' : 'AVAILABLE (Valid)'}`);
}
verifyNonce('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', 1024n);
By enforcing atomic nonce allocation in distributed workers, padding signature deadlines against sequencer skew, and validating EIP-712 domain configurations, dApps and relayers eliminate Permit2 reverts and safeguard user transaction flow.