Fix: Uniswap Permit2 InvalidNonce and SignatureExpired Reverts in High-Throughput Relayers
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.
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.