LayerZeroFault
wallet security-fixes

Fix: Uniswap Permit2 InvalidNonce and SignatureExpired Reverts in High-Throughput Relayers

VV

Written by

Fact-Checked on September 11, 2026

Verified Expert

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:

  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.


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;

Placeholder: Permit2 Nonce Bitmap Word Calculation and Bitwise Flipping Lifecycle

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)}$$

Placeholder: Block Timestamp vs Local NTP Drift Diagram Showing Permit2 Rejection Point

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.

Partner Spotlight: Gate.io

Trade Securely on Gate.io

Don't risk your assets on centralized silos or unverified endpoints. Trade securely on Gate.io with deep liquidity and institutional-grade security protocols.

Claim $100 Sign-up Bonus

Official Partner Referral Link

Related Inquiries

Why does Uniswap Permit2 throw InvalidNonce() when transactions are broadcast concurrently?

Permit2 uses a 256-bit word bitmap for unordered nonces (wordPos = nonce >> 8, bitPos = nonce & 0xff) or monotonic sequential nonces. If two parallel transactions or worker processes select the same nonce or write to the same bitmap index before the on-chain state updates, the second transaction fails with InvalidNonce() (selector 0x756688fe).

What causes SignatureExpired(uint256) on Arbitrum and Base rollups even when the client timestamp seems valid?

L2 sequencers maintain block timestamps that can desynchronize by several seconds from UTC client clocks. If a client sets a tight deadline (e.g., 30–60 seconds) based on local system time, sequencer clock drift or transaction queue delays cause block.timestamp to exceed the signed sigDeadline, triggering SignatureExpired(uint256) (selector 0xcd09804b).

How do you check if a specific Permit2 unordered nonce has already been consumed?

Query the Permit2 contract's nonceBitmap(address owner, uint256 wordPos) view function. Calculate wordPos = nonce >> 8 and bitPos = nonce & 0xff. If (bitmap & (1 << bitPos)) != 0, the nonce has already been consumed on-chain.