LayerZeroFault
wallet security-fixes Updated:

Uniswap Permit2 Nonce Bitmap Collision

VV

Written by

Updated:

Technical Guide

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:

  1. InvalidNonce() (0x756688fe) — emitted by Permit2.permit() or Permit2.permitTransferFrom().
  2. 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:

  1. AllowanceTransfer (Monotonic Nonce Sequence): Used by permitSingle and permitBatch. Each token approval has an associated (uint160 amount, uint48 expiration, uint48 nonce) packed in storage. Nonces must increment monotonically per (owner, token, spender).
  2. SignatureTransfer (Unordered Nonce Bitmap): Used by permitTransferFrom and permitWitnessTransferFrom. 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:

  1. It loads word = nonceBitmap[owner][wordPos].
  2. It verifies that (word & mask) == 0. If the bit is already 1, the call reverts with InvalidNonce().
  3. 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.

Interactive Diagnostic Sandbox

Local UserOp & Protocol Security Sandbox

Decode UserOperation calldata, simulate prefund accounting, and test bundler validation rules in your local browser.

Companion Diagnostic Guide EIP-7702 Delegation Replay Vulnerability →

Prevent cross-chain replay attacks on EIP-7702 account delegation designations. Validate chain ID binding and nonce management in authorization tuples.

Read Guide
Contextual Architecture

Related Diagnostic Guides

View All in Silo →

Related Inquiries

What does the Permit2 InvalidNonce() error indicate?

InvalidNonce() indicates that the requested nonce bit in Permit2's 248-bit bitmap has already been flipped (used), or that the signature nonce parameter does not match the on-chain bitmap state for the token owner.

How does Permit2's bitmap nonce system differ from sequential nonces?

Permit2 divides nonces into 248-bit words. A single word can store 248 independent nonces represented by bitmasks, allowing parallel transactions without strict sequential ordering, but requiring bit-level collision management.

How do you fix Permit2 deadline expired reverts in automated bots?

Query latest block.timestamp via RPC and set permit deadlines to at least 120–180 seconds in the future, accounting for mempool propagation latency and sequencer batching intervals.

Can Permit2 signatures be replayed across different dApps?

No, because Permit2 EIP-712 domain separators include the spender address and the Permit2 contract address (0x000000000022D473030F116dDEE9F6B43aC78BA3), preventing cross-protocol signature reuse.