From 9e7bf997670ff2eeceed8fcd0cd270ddf359a4f7 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:45:12 +1100 Subject: [PATCH] docs: TIP-1009 Expiring Nonces Ported from tempoxyz/tempo#2135 by @danrobinson --- src/pages/protocol/tips/tip-1009.mdx | 268 +++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 src/pages/protocol/tips/tip-1009.mdx diff --git a/src/pages/protocol/tips/tip-1009.mdx b/src/pages/protocol/tips/tip-1009.mdx new file mode 100644 index 00000000..04798567 --- /dev/null +++ b/src/pages/protocol/tips/tip-1009.mdx @@ -0,0 +1,268 @@ +--- +id: TIP-1009 +title: Expiring Nonces +description: Time-bounded replay protection using transaction hashes instead of sequential nonce management. +authors: Daniel Robinson +status: Draft +related: TIP-20, Transactions +--- + +# TIP-1009: Expiring Nonces + +## Abstract + +TIP-1009 introduces expiring nonces, an alternative replay protection mechanism where transactions are valid only within a specified time window. Instead of tracking sequential nonces, the protocol uses transaction hashes with expiry timestamps to prevent replay attacks. This enables use cases like gasless transactions, meta-transactions, and simplified UX where users don't need to manage nonce ordering. + +## Motivation + +Traditional sequential nonces require careful ordering—if transaction N fails or is delayed, all subsequent transactions (N+1, N+2, ...) are blocked. This creates friction for: + +1. **Gasless/Meta-transactions**: Relayers need complex nonce management across multiple users +2. **Parallel submission**: Users cannot submit multiple independent transactions simultaneously +3. **Recovery from failures**: Stuck transactions require explicit cancellation with the same nonce + +Expiring nonces solve these problems by using time-based validity instead of sequence-based ordering. Each transaction is uniquely identified by its hash and is valid only until a specified `validBefore` timestamp. + +--- + +# Specification + +## Nonce Key + +Expiring nonce transactions use a reserved nonce key: + +``` +TEMPO_EXPIRING_NONCE_KEY = uint256.max (2^256 - 1) +``` + +When a Tempo transaction specifies `nonceKey = uint256.max`, the protocol treats it as an expiring nonce transaction. + +## Transaction Fields + +Expiring nonce transactions require: + +| Field | Type | Description | +|-------|------|-------------| +| `nonceKey` | `uint256` | Must be `uint256.max` to indicate expiring nonce mode | +| `nonce` | `uint64` | Must be `0` (unused, validated for consistency) | +| `validBefore` | `uint64` | Unix timestamp (seconds) after which the transaction is invalid | + +## Validity Window + +The `validBefore` timestamp must satisfy: + +``` +now < validBefore <= now + MAX_EXPIRY_SECS +``` + +Where: +- `now` is the current block timestamp +- `MAX_EXPIRY_SECS = 30` seconds + +Transactions with `validBefore` in the past or more than 30 seconds in the future are rejected. + +## Replay Protection + +Replay protection uses a **circular buffer** data structure in the Nonce precompile: + +### Storage Layout + +```solidity +contract Nonce { + // Existing 2D nonce storage + mapping(address => mapping(uint256 => uint64)) public nonces; // slot 0 + + // Expiring nonce storage + mapping(bytes32 => uint64) public expiringNonceSeen; // slot 1: txHash => expiry + mapping(uint32 => bytes32) public expiringNonceRing; // slot 2: circular buffer + uint32 public expiringNonceRingPtr; // slot 3: buffer pointer +} +``` + +### Circular Buffer Design + +The circular buffer has a fixed capacity: + +``` +EXPIRING_NONCE_SET_CAPACITY = 300,000 +``` + +This capacity is sized for 10,000 TPS × 30 seconds = 300,000 transactions, ensuring entries expire before being overwritten. + +### Algorithm + +When processing an expiring nonce transaction: + +1. **Validate expiry window**: Reject if `validBefore <= now` or `validBefore > now + 30` + +2. **Replay check**: Read `expiringNonceSeen[txHash]` + - If entry exists and `expiry > now`, reject as replay + +3. **Get buffer position**: Read `expiringNonceRingPtr`, compute `idx = ptr % CAPACITY` + +4. **Read existing entry**: Read `expiringNonceRing[idx]` to get `oldHash` + +5. **Eviction check** (safety): If `oldHash != 0`: + - Read `expiringNonceSeen[oldHash]` + - If `expiry > now`, reject (buffer full of valid entries) + - Clear `expiringNonceSeen[oldHash] = 0` + +6. **Insert new entry**: + - Write `expiringNonceRing[idx] = txHash` + - Write `expiringNonceSeen[txHash] = validBefore` + +7. **Advance pointer**: Write `expiringNonceRingPtr = ptr + 1` + +### Pseudocode + +```solidity +function checkAndMarkExpiringNonce( + bytes32 txHash, + uint64 validBefore, + uint64 now +) internal { + // 1. Validate expiry window + require(validBefore > now && validBefore <= now + 30, "InvalidExpiry"); + + // 2. Replay check + uint64 seenExpiry = expiringNonceSeen[txHash]; + require(seenExpiry == 0 || seenExpiry <= now, "Replay"); + + // 3-4. Get buffer position and existing entry + uint32 ptr = expiringNonceRingPtr; + uint32 idx = ptr % CAPACITY; + bytes32 oldHash = expiringNonceRing[idx]; + + // 5. Eviction check (safety) + if (oldHash != bytes32(0)) { + uint64 oldExpiry = expiringNonceSeen[oldHash]; + require(oldExpiry == 0 || oldExpiry <= now, "BufferFull"); + expiringNonceSeen[oldHash] = 0; + } + + // 6. Insert new entry + expiringNonceRing[idx] = txHash; + expiringNonceSeen[txHash] = validBefore; + + // 7. Advance pointer + expiringNonceRingPtr = ptr + 1; +} +``` + +## Gas Costs + +The intrinsic gas cost for expiring nonce transactions includes: + +``` +EXPIRING_NONCE_GAS = 2 * COLD_SLOAD_COST + 3 * WARM_SSTORE_RESET + = 2 * 2100 + 3 * 2900 + = 12,900 gas +``` + +**Included operations:** +- 2 cold SLOADs: `seen[txHash]`, `ring[idx]` +- 3 warm SSTOREs: `seen[oldHash]=0`, `ring[idx]`, `seen[txHash]` + +**Excluded operations (cached/amortized):** +- `ring_ptr` SLOAD/SSTORE: Cached since accessed almost every transaction + +## Transaction Pool Validation + +The transaction pool performs preliminary validation: + +1. Verify `nonceKey == uint256.max` +2. Verify `nonce == 0` +3. Verify `validBefore` is present +4. Verify `validBefore > currentTime` (not expired) +5. Verify `validBefore <= currentTime + MAX_EXPIRY_SECS` (within window) +6. Query `expiringNonceSeen[txHash]` storage slot to check for existing entry + +Transactions failing these checks are rejected before entering the pool. + +## Interaction with Other Features + +### 2D Nonces + +Expiring nonces and 2D nonces are mutually exclusive: +- `nonceKey = 0`: Protocol nonce (standard sequential) +- `nonceKey = 1..uint256.max-1`: 2D nonce keys +- `nonceKey = uint256.max`: Expiring nonce mode + +### Access Keys (Keychain) + +Expiring nonces work with access key signatures. The `validBefore` provides an additional security boundary—even if an access key is compromised, transactions signed with it become invalid after the expiry window. + +### Fee Tokens + +Expiring nonce transactions pay fees in TIP-20 fee tokens like any other Tempo transaction. + +--- + +# Invariants + +## Must Hold + +1. **No replay within validity window**: A transaction hash cannot be executed twice while `validBefore > now` + +2. **Expiry enforcement**: Transactions with `validBefore <= now` must be rejected + +3. **Window bounds**: Transactions with `validBefore > now + MAX_EXPIRY_SECS` must be rejected + +4. **Buffer integrity**: The circular buffer maintains exactly `CAPACITY` slots + +5. **Seen set consistency**: An entry in `expiringNonceSeen` implies the hash exists in `expiringNonceRing` + +## Test Cases + +1. **Basic flow**: Submit transaction, verify execution, attempt replay (should fail) + +2. **Expiry validation**: + - `validBefore` in past → reject + - `validBefore = now` → reject + - `validBefore = now + 31` → reject + - `validBefore = now + 30` → accept + +3. **Post-expiry replay**: Submit tx, wait for expiry, submit same tx with new `validBefore` (should succeed) + +4. **Buffer eviction**: Fill buffer, verify old entries are evicted when expired + +5. **Concurrent transactions**: Submit multiple transactions with same `validBefore`, verify all succeed + +--- + +# Open Questions + +## Safety Check for Buffer Eviction + +The current implementation includes a safety check that reads `expiringNonceSeen[oldHash]` before evicting an entry from the ring buffer. This check verifies the entry is actually expired before overwriting. + +**Rationale for keeping the check:** +- Protects against unexpected TPS spikes that could cause the buffer to fill with valid entries +- Defense-in-depth: prevents replay attacks if capacity assumptions are violated +- Cost is only incurred in the rare case when eviction is needed + +**Rationale for removing the check:** +- The buffer is sized (300k entries) to guarantee entries expire before being overwritten at 10k TPS +- Removes 1 SLOAD (2,100 gas) from the critical path +- Simplifies the algorithm + +**Current decision**: Keep the check but exclude it from gas accounting (charged as if it won't trigger in normal operation). + +**Question**: Should this safety check be: +1. Kept with current gas accounting (not charged for the extra SLOAD)? +2. Removed entirely, trusting the capacity sizing? +3. Kept and fully charged (add 2,100 gas to `EXPIRING_NONCE_GAS`)? + +## Buffer Capacity Sizing + +The current capacity of 300,000 assumes: +- Maximum 10,000 TPS sustained +- 30 second expiry window + +**Question**: Should the capacity be configurable per-chain or hardcoded? What happens if TPS requirements increase significantly? + +## Transaction Hash Computation + +The transaction hash used for replay protection must be computed before signature recovery. + +**Question**: Should the spec explicitly define the hash computation (which fields, encoding) or reference the Tempo Transaction spec?