Skip to main content

tempo_precompiles/nonce/
mod.rs

1//! 2D nonce management precompile and expiring nonce replay protection,
2//! enabling concurrent transaction execution as part of [Tempo Transactions].
3//!
4//! [Tempo Transactions]: <https://docs.tempo.xyz/protocol/transactions>
5
6pub mod dispatch;
7
8pub use tempo_contracts::precompiles::INonce;
9use tempo_contracts::precompiles::{NonceError, NonceEvent};
10use tempo_precompiles_macros::contract;
11
12use crate::{
13    NONCE_PRECOMPILE_ADDRESS,
14    error::Result,
15    storage::{Handler, Mapping},
16};
17use alloy::primitives::{Address, B256, U256};
18
19/// Capacity of the expiring nonce seen set (supports 10k TPS for 30 seconds).
20pub const EXPIRING_NONCE_SET_CAPACITY: u32 = 300_000;
21
22/// Maximum allowed skew for expiring nonce transactions (30 seconds).
23/// Transactions must have valid_before in (now, now + MAX_EXPIRY_SECS].
24pub const EXPIRING_NONCE_MAX_EXPIRY_SECS: u64 = 30;
25
26/// NonceManager contract for managing 2D nonces as per the AA spec
27///
28/// Storage Layout (similar to Solidity contract):
29/// ```solidity
30/// contract Nonce {
31///     mapping(address => mapping(uint256 => uint64)) public nonces;      // slot 0
32///
33///     // Expiring nonce storage (for hash-based replay protection)
34///     mapping(bytes32 => uint64) public expiringNonceSeen;               // slot 1: txHash => expiry
35///     mapping(uint32 => bytes32) public expiringNonceRing;               // slot 2: circular buffer of tx hashes
36///     uint32 public expiringNonceRingPtr;                                // slot 3: current position (wraps at CAPACITY)
37/// }
38/// ```
39///
40/// - Slot 0: 2D nonce mapping - keccak256(abi.encode(nonce_key, keccak256(abi.encode(account, 0))))
41/// - Slot 1: Expiring nonce seen set - txHash => expiry timestamp
42/// - Slot 2: Expiring nonce circular buffer - index => txHash
43/// - Slot 3: Circular buffer pointer (current position, wraps at CAPACITY)
44///
45/// Note: Protocol nonce (key 0) is stored directly in account state, not here.
46/// Only user nonce keys (1-N) are managed by this precompile.
47///
48/// The struct fields define the on-chain storage layout; the `#[contract]` macro generates the
49/// storage handlers which provide an ergonomic way to interact with the EVM state.
50#[contract(addr = NONCE_PRECOMPILE_ADDRESS)]
51pub struct NonceManager {
52    nonces: Mapping<Address, Mapping<U256, u64>>,
53    expiring_nonce_seen: Mapping<B256, u64>,
54    expiring_nonce_ring: Mapping<u32, B256>,
55    expiring_nonce_ring_ptr: u32,
56}
57
58impl NonceManager {
59    /// Initializes the nonce manager precompile storage layout.
60    pub fn initialize(&mut self) -> Result<()> {
61        self.__initialize()
62    }
63
64    /// Returns the current nonce for `account` at the given `nonceKey`.
65    ///
66    /// # Errors
67    /// - `ProtocolNonceNotSupported` — nonce key 0 is the protocol nonce and cannot be read here
68    pub fn get_nonce(&self, call: INonce::getNonceCall) -> Result<u64> {
69        // Protocol nonce (key 0) is stored in account state, not in this precompile
70        // Users should query account nonce directly, not through this precompile
71        if call.nonceKey == 0 {
72            return Err(NonceError::protocol_nonce_not_supported().into());
73        }
74
75        // For user nonce keys, read from precompile storage
76        self.nonces[call.account][call.nonceKey].read()
77    }
78
79    /// Increments the 2D nonce for `account` at `nonce_key` and returns the new value, enabling
80    /// concurrent transaction execution. Key `0` is reserved for the protocol nonce.
81    ///
82    /// # Errors
83    /// - `InvalidNonceKey` — `nonce_key` is 0, which is reserved for the protocol nonce
84    /// - `NonceOverflow` — the current nonce value is `u64::MAX` and cannot be incremented
85    pub fn increment_nonce(&mut self, account: Address, nonce_key: U256) -> Result<u64> {
86        if nonce_key == 0 {
87            return Err(NonceError::invalid_nonce_key().into());
88        }
89
90        let current = self.nonces[account][nonce_key].read()?;
91
92        let new_nonce = current
93            .checked_add(1)
94            .ok_or_else(NonceError::nonce_overflow)?;
95
96        self.nonces[account][nonce_key].write(new_nonce)?;
97
98        self.emit_event(NonceEvent::NonceIncremented(INonce::NonceIncremented {
99            account,
100            nonceKey: nonce_key,
101            newNonce: new_nonce,
102        }))?;
103
104        Ok(new_nonce)
105    }
106
107    /// Checks if a hash has been seen and is still valid (not expired).
108    /// NOTE: internally used by the transaction pool.
109    pub fn is_expiring_nonce_seen(&self, hash: B256, now: u64) -> Result<bool> {
110        let expiry = self.expiring_nonce_seen[hash].read()?;
111        Ok(expiry != 0 && expiry > now)
112    }
113
114    /// Validates and records an expiring nonce transaction. Uses a
115    /// circular buffer that overwrites expired entries as the pointer
116    /// advances. The hash is `keccak256(encode_for_signing || sender)`,
117    /// invariant to fee payer changes.
118    ///
119    /// Uses a circular buffer that overwrites expired entries as the pointer advances.
120    ///
121    /// The `expiring_nonce_hash` parameter is
122    /// (`keccak256(encode_for_signing || sender)`), which is invariant to fee payer changes.
123    ///
124    /// This is called during transaction execution to:
125    /// 1. Validate the expiry is within the allowed window
126    /// 2. Check for replay (hash already seen and not expired)
127    /// 3. Check if we can evict the entry at current pointer (must be expired or empty)
128    /// 4. Mark the hash as seen
129    ///
130    /// # Errors
131    /// - `InvalidExpiringNonceExpiry` — `valid_before` not in (now, now + EXPIRING_NONCE_MAX_EXPIRY_SECS]
132    /// - `ExpiringNonceReplay` — transaction hash is already recorded and has not yet expired
133    /// - `ExpiringNonceSetFull` — the circular buffer slot holds an unexpired entry that can't be evicted
134    pub fn check_and_mark_expiring_nonce(
135        &mut self,
136        expiring_nonce_hash: B256,
137        valid_before: u64,
138    ) -> Result<()> {
139        let now: u64 = self.storage.timestamp().saturating_to();
140
141        // 1. Validate expiry window: must be in (now, now + max_skew]
142        if valid_before <= now || valid_before > now.saturating_add(EXPIRING_NONCE_MAX_EXPIRY_SECS)
143        {
144            return Err(NonceError::invalid_expiring_nonce_expiry().into());
145        }
146
147        // 2. Replay check: reject if hash is already seen and not expired
148        let seen_expiry = self.expiring_nonce_seen[expiring_nonce_hash].read()?;
149        if seen_expiry != 0 && seen_expiry > now {
150            return Err(NonceError::expiring_nonce_replay().into());
151        }
152
153        // 3. Get current pointer (bounded in [0, CAPACITY)) and use directly as index
154        let ptr = self.expiring_nonce_ring_ptr.read()?;
155        let idx = ptr;
156        let old_hash = self.expiring_nonce_ring[idx].read()?;
157
158        // 4. If there's an existing entry, check if it's expired (can be evicted)
159        // Safety check: buffer is sized so entries should always be expired, but verify
160        // in case TPS exceeds expectations.
161        if old_hash != B256::ZERO {
162            let old_expiry = self.expiring_nonce_seen[old_hash].read()?;
163            if old_expiry != 0 && old_expiry > now {
164                // Entry is still valid, cannot evict - buffer is full
165                return Err(NonceError::expiring_nonce_set_full().into());
166            }
167            // Clear the old entry from seen set
168            self.expiring_nonce_seen[old_hash].write(0)?;
169        }
170
171        // 5. Insert new entry
172        self.expiring_nonce_ring[idx].write(expiring_nonce_hash)?;
173        self.expiring_nonce_seen[expiring_nonce_hash].write(valid_before)?;
174
175        // 6. Advance pointer (wraps at CAPACITY, not u32::MAX)
176        let next = if ptr + 1 >= EXPIRING_NONCE_SET_CAPACITY {
177            0
178        } else {
179            ptr + 1
180        };
181        self.expiring_nonce_ring_ptr.write(next)?;
182
183        Ok(())
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use crate::{
190        error::TempoPrecompileError,
191        storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider},
192    };
193
194    use super::*;
195    use alloy::primitives::address;
196
197    #[test]
198    fn test_get_nonce_returns_zero_for_new_key() -> eyre::Result<()> {
199        let mut storage = HashMapStorageProvider::new(1);
200        StorageCtx::enter(&mut storage, || {
201            let mgr = NonceManager::new();
202
203            let account = address!("0x1111111111111111111111111111111111111111");
204            let nonce = mgr.get_nonce(INonce::getNonceCall {
205                account,
206                nonceKey: U256::from(5),
207            })?;
208
209            assert_eq!(nonce, 0);
210            Ok(())
211        })
212    }
213
214    #[test]
215    fn test_get_nonce_rejects_protocol_nonce() -> eyre::Result<()> {
216        let mut storage = HashMapStorageProvider::new(1);
217        StorageCtx::enter(&mut storage, || {
218            let mgr = NonceManager::new();
219
220            let account = address!("0x1111111111111111111111111111111111111111");
221            let result = mgr.get_nonce(INonce::getNonceCall {
222                account,
223                nonceKey: U256::ZERO,
224            });
225
226            assert_eq!(
227                result.unwrap_err(),
228                TempoPrecompileError::NonceError(NonceError::protocol_nonce_not_supported())
229            );
230            Ok(())
231        })
232    }
233
234    #[test]
235    fn test_increment_nonce() -> eyre::Result<()> {
236        let mut storage = HashMapStorageProvider::new(1);
237        StorageCtx::enter(&mut storage, || {
238            let mut mgr = NonceManager::new();
239
240            let account = address!("0x1111111111111111111111111111111111111111");
241            let nonce_key = U256::from(5);
242
243            let new_nonce = mgr.increment_nonce(account, nonce_key)?;
244            assert_eq!(new_nonce, 1);
245            assert_eq!(mgr.emitted_events().len(), 1);
246
247            let new_nonce = mgr.increment_nonce(account, nonce_key)?;
248            assert_eq!(new_nonce, 2);
249            mgr.assert_emitted_events(vec![
250                INonce::NonceIncremented {
251                    account,
252                    nonceKey: nonce_key,
253                    newNonce: 1,
254                },
255                INonce::NonceIncremented {
256                    account,
257                    nonceKey: nonce_key,
258                    newNonce: 2,
259                },
260            ]);
261
262            Ok(())
263        })
264    }
265
266    #[test]
267    fn test_different_accounts_independent() -> eyre::Result<()> {
268        let mut storage = HashMapStorageProvider::new(1);
269        StorageCtx::enter(&mut storage, || {
270            let mut mgr = NonceManager::new();
271
272            let account1 = address!("0x1111111111111111111111111111111111111111");
273            let account2 = address!("0x2222222222222222222222222222222222222222");
274            let nonce_key = U256::from(5);
275
276            for _ in 0..10 {
277                mgr.increment_nonce(account1, nonce_key)?;
278            }
279            for _ in 0..20 {
280                mgr.increment_nonce(account2, nonce_key)?;
281            }
282
283            let nonce1 = mgr.get_nonce(INonce::getNonceCall {
284                account: account1,
285                nonceKey: nonce_key,
286            })?;
287            let nonce2 = mgr.get_nonce(INonce::getNonceCall {
288                account: account2,
289                nonceKey: nonce_key,
290            })?;
291
292            assert_eq!(nonce1, 10);
293            assert_eq!(nonce2, 20);
294            Ok(())
295        })
296    }
297
298    // ========== Expiring Nonce Tests ==========
299
300    #[test]
301    fn test_expiring_nonce_basic_flow() -> eyre::Result<()> {
302        let mut storage = HashMapStorageProvider::new(1);
303        let now = 1000u64;
304        storage.set_timestamp(U256::from(now));
305        StorageCtx::enter(&mut storage, || {
306            let mut mgr = NonceManager::new();
307
308            let tx_hash = B256::repeat_byte(0x11);
309            let valid_before = now + 20; // 20s in future, within 30s window
310
311            // First tx should succeed
312            mgr.check_and_mark_expiring_nonce(tx_hash, valid_before)?;
313
314            // Same tx hash should fail (replay)
315            let result = mgr.check_and_mark_expiring_nonce(tx_hash, valid_before);
316            assert_eq!(
317                result.unwrap_err(),
318                TempoPrecompileError::NonceError(NonceError::expiring_nonce_replay())
319            );
320
321            Ok(())
322        })
323    }
324
325    #[test]
326    fn test_expiring_nonce_expiry_validation() -> eyre::Result<()> {
327        let mut storage = HashMapStorageProvider::new(1);
328        let now = 1000u64;
329        storage.set_timestamp(U256::from(now));
330        StorageCtx::enter(&mut storage, || {
331            let mut mgr = NonceManager::new();
332
333            let tx_hash = B256::repeat_byte(0x22);
334
335            // valid_before in the past should fail
336            let result = mgr.check_and_mark_expiring_nonce(tx_hash, now - 1);
337            assert_eq!(
338                result.unwrap_err(),
339                TempoPrecompileError::NonceError(NonceError::invalid_expiring_nonce_expiry())
340            );
341
342            // valid_before exactly at now should fail
343            let result = mgr.check_and_mark_expiring_nonce(tx_hash, now);
344            assert_eq!(
345                result.unwrap_err(),
346                TempoPrecompileError::NonceError(NonceError::invalid_expiring_nonce_expiry())
347            );
348
349            // valid_before too far in future should fail (uses EXPIRING_NONCE_MAX_EXPIRY_SECS = 30)
350            let result = mgr.check_and_mark_expiring_nonce(tx_hash, now + 31);
351            assert_eq!(
352                result.unwrap_err(),
353                TempoPrecompileError::NonceError(NonceError::invalid_expiring_nonce_expiry())
354            );
355
356            // valid_before at exactly max_skew should succeed
357            mgr.check_and_mark_expiring_nonce(tx_hash, now + 30)?;
358
359            Ok(())
360        })
361    }
362
363    #[test]
364    fn test_expiring_nonce_expired_entry_eviction() -> eyre::Result<()> {
365        let mut storage = HashMapStorageProvider::new(1);
366        let now = 1000u64;
367        let valid_before = now + 20;
368        storage.set_timestamp(U256::from(now));
369        StorageCtx::enter(&mut storage, || {
370            let mut mgr = NonceManager::new();
371
372            let tx_hash1 = B256::repeat_byte(0x33);
373
374            // Insert first tx
375            mgr.check_and_mark_expiring_nonce(tx_hash1, valid_before)?;
376
377            // Verify it's seen
378            assert!(mgr.is_expiring_nonce_seen(tx_hash1, now)?);
379
380            // After expiry, it should no longer be "seen" (expired)
381            assert!(!mgr.is_expiring_nonce_seen(tx_hash1, valid_before + 1)?);
382
383            Ok::<_, eyre::Report>(())
384        })?;
385
386        // Insert second tx after first has expired - should evict first
387        let new_now = valid_before + 1;
388        let new_valid_before = new_now + 20;
389        storage.set_timestamp(U256::from(new_now));
390        StorageCtx::enter(&mut storage, || {
391            let mut mgr = NonceManager::new();
392
393            let tx_hash2 = B256::repeat_byte(0x44);
394            mgr.check_and_mark_expiring_nonce(tx_hash2, new_valid_before)?;
395
396            // tx_hash1 should now be fully evicted (since it was at ring position 0)
397            // and tx_hash2 replaces it
398            assert!(mgr.is_expiring_nonce_seen(tx_hash2, new_now)?);
399
400            Ok(())
401        })
402    }
403
404    #[test]
405    fn test_ring_buffer_pointer_wraps_at_capacity() -> eyre::Result<()> {
406        let mut storage = HashMapStorageProvider::new(1);
407        let now = 1000u64;
408        storage.set_timestamp(U256::from(now));
409        StorageCtx::enter(&mut storage, || {
410            let mut mgr = NonceManager::new();
411
412            // Manually set pointer to just before capacity to test wrap
413            mgr.expiring_nonce_ring_ptr
414                .write(EXPIRING_NONCE_SET_CAPACITY - 1)?;
415
416            // Insert a tx - pointer should wrap to 0
417            let tx_hash = B256::repeat_byte(0x77);
418            let valid_before = now + 20;
419            mgr.check_and_mark_expiring_nonce(tx_hash, valid_before)?;
420
421            // Pointer should now be 0 (wrapped at capacity)
422            let ptr = mgr.expiring_nonce_ring_ptr.read()?;
423            assert_eq!(ptr, 0, "Pointer should wrap to 0 at capacity");
424
425            // Insert another tx - pointer should be 1
426            let tx_hash2 = B256::repeat_byte(0x88);
427            mgr.check_and_mark_expiring_nonce(tx_hash2, valid_before)?;
428
429            let ptr = mgr.expiring_nonce_ring_ptr.read()?;
430            assert_eq!(ptr, 1, "Pointer should increment to 1 after wrap");
431
432            Ok(())
433        })
434    }
435
436    #[test]
437    fn test_initialize_sets_storage_state() -> eyre::Result<()> {
438        let mut storage = HashMapStorageProvider::new(1);
439        StorageCtx::enter(&mut storage, || {
440            let mut mgr = NonceManager::new();
441
442            // Before initialization, contract should not be initialized
443            assert!(!mgr.is_initialized()?);
444
445            // Initialize
446            mgr.initialize()?;
447
448            // After initialization, contract should be initialized
449            assert!(mgr.is_initialized()?);
450
451            // Re-initializing a new handle should still see initialized state
452            let mgr2 = NonceManager::new();
453            assert!(mgr2.is_initialized()?);
454
455            Ok(())
456        })
457    }
458}