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:
- Gasless/Meta-transactions: Relayers need complex nonce management across multiple users
- Parallel submission: Users cannot submit multiple independent transactions simultaneously
- 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:
nowis the current block timestampMAX_EXPIRY_SECS = 30seconds
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
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:
-
Validate expiry window: Reject if
validBefore <= noworvalidBefore > now + 30 -
Replay check: Read
expiringNonceSeen[txHash]- If entry exists and
expiry > now, reject as replay
- If entry exists and
-
Get buffer position: Read
expiringNonceRingPtr, computeidx = ptr % CAPACITY -
Read existing entry: Read
expiringNonceRing[idx]to getoldHash -
Eviction check (safety): If
oldHash != 0:- Read
expiringNonceSeen[oldHash] - If
expiry > now, reject (buffer full of valid entries) - Clear
expiringNonceSeen[oldHash] = 0
- Read
-
Insert new entry:
- Write
expiringNonceRing[idx] = txHash - Write
expiringNonceSeen[txHash] = validBefore
- Write
-
Advance pointer: Write
expiringNonceRingPtr = ptr + 1
Pseudocode
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 + WARM_SLOAD_COST + 3 * WARM_SSTORE_RESET
= 2 * 2100 + 100 + 3 * 2900
= 13,000 gas
Included operations:
- 2 cold SLOADs:
seen[txHash],ring[idx](unique slots per tx) - 1 warm SLOAD:
seen[oldHash](warm because we just readring[idx]which points to it) - 3 SSTOREs at RESET price:
seen[oldHash]=0,ring[idx],seen[txHash]
Excluded operations (amortized):
ring_ptrSLOAD/SSTORE: Accessed by almost every expiring nonce tx in a block, so amortized cost approaches ~200 gas. May be moved out of EVM storage in the future.
Why SSTORE_RESET (2,900) instead of SSTORE_SET (20,000) for seen[txHash]:
- SSTORE_SET cost exists to penalize permanent state growth
- Expiring nonce data is ephemeral: evicted within 30 seconds, fixed-size buffer (300k entries)
- No permanent state growth, so the 20k penalty doesn't apply
Transaction Pool Validation
The transaction pool performs preliminary validation:
- Verify
nonceKey == uint256.max - Verify
nonce == 0 - Verify
validBeforeis present - Verify
validBefore > currentTime(not expired) - Verify
validBefore <= currentTime + MAX_EXPIRY_SECS(within window) - 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 keysnonceKey = 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
| ID | Invariant | Description |
|---|---|---|
| E1 | No replay | A transaction hash can never be executed twice (changing validBefore produces a different hash) |
| E2 | Expiry enforcement | Transactions with validBefore <= now must be rejected |
| E3 | Window bounds | Transactions with validBefore > now + MAX_EXPIRY_SECS must be rejected |
| E4 | Nonce must be zero | Expiring nonce transactions must have nonce == 0 |
| E5 | Valid before required | Expiring nonce transactions must have validBefore set |
| E6 | No nonce mutation | Expiring nonce txs do not increment protocol nonce or any 2D nonce |
| E7 | Concurrent independence | Multiple expiring nonce txs from same sender can execute in same block |
Invariant Tests
These invariants are tested in the Foundry invariant test suite (TempoTransactionInvariant.t.sol):
| Handler | Tests | Description |
|---|---|---|
handler_expiringNonceBasic | Basic flow | Execute valid expiring nonce tx |
handler_expiringNonceReplay | E1 | Replay must be rejected |
handler_expiringNonceExpired | E2 | Tx with validBefore <= now must be rejected |
handler_expiringNonceWindowTooFar | E3 | Tx with validBefore > now + 30s must be rejected |
handler_expiringNonceNonZeroNonce | E4 | Tx with nonce != 0 must be rejected |
handler_expiringNonceMissingValidBefore | E5 | Tx without validBefore must be rejected |
handler_expiringNonceNoNonceMutation | E6 | Protocol and 2D nonces unchanged after execution |
handler_expiringNonceConcurrent | E7 | Multiple concurrent txs from same sender succeed |
Test Cases
-
Basic flow: Submit transaction, verify execution, attempt replay (should fail)
-
Expiry validation:
validBeforein past → rejectvalidBefore = now→ rejectvalidBefore = now + 31→ rejectvalidBefore = now + 30→ accept
-
Nonce validation:
nonce = 0→ acceptnonce > 0→ reject
-
Required fields:
validBeforemissing → rejectnonceKey != uint256.max→ not expiring nonce (uses 2D nonce rules)
-
Post-expiry replay: Submit tx, wait for expiry, submit same tx with new
validBefore(should succeed) -
Buffer eviction: Fill buffer, verify old entries are evicted when expired
-
Concurrent transactions: Submit multiple transactions with same
validBefore, verify all succeed
Benchmark Results
Benchmarks were run to measure state savings from expiring nonces compared to 2D nonces.
Key Findings
| Metric | Value |
|---|---|
| Per-transaction state savings | ~100 bytes |
| Circular buffer capacity | 300,000 entries |
| Buffer fills at 5k TPS | ~60 seconds |
Controlled Benchmark (100k transactions at 5k TPS)
| Nonce Type | Final DB Size | Transactions |
|---|---|---|
| 2D Nonces | 4,342.85 MB | 100,000 |
| Expiring Nonces | 4,332.18 MB | 100,000 |
| Difference | -10.67 MB | - |
The ~107 bytes per transaction overhead includes MPT node overhead, MDBX metadata, and RLP encoding.
Scaling Projections
| TPS | Daily Transactions | Daily State Savings |
|---|---|---|
| 5,000 | 432M | 43.2 GB |
| 10,000 | 864M | 86.4 GB |
After the circular buffer fills, expiring nonces maintain constant storage while 2D nonces grow by ~100 bytes per transaction.
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:
- Kept with current gas accounting (not charged for the extra SLOAD)?
- Removed entirely, trusting the capacity sizing?
- 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?