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::nonce_incremented(account, nonce_key, new_nonce))?;
99
100        Ok(new_nonce)
101    }
102
103    /// Checks if a hash has been seen and is still valid (not expired).
104    /// NOTE: internally used by the transaction pool.
105    pub fn is_expiring_nonce_seen(&self, hash: B256, now: u64) -> Result<bool> {
106        let expiry = self.expiring_nonce_seen[hash].read()?;
107        Ok(expiry != 0 && expiry > now)
108    }
109
110    /// Validates and records an expiring nonce transaction. Uses a
111    /// circular buffer that overwrites expired entries as the pointer
112    /// advances. The hash is `keccak256(encode_for_signing || sender)`,
113    /// invariant to fee payer changes.
114    ///
115    /// Uses a circular buffer that overwrites expired entries as the pointer advances.
116    ///
117    /// The `expiring_nonce_hash` parameter is
118    /// (`keccak256(encode_for_signing || sender)`), which is invariant to fee payer changes.
119    ///
120    /// This is called during transaction execution to:
121    /// 1. Validate the expiry is within the allowed window
122    /// 2. Check for replay (hash already seen and not expired)
123    /// 3. Check if we can evict the entry at current pointer (must be expired or empty)
124    /// 4. Mark the hash as seen
125    ///
126    /// # Errors
127    /// - `InvalidExpiringNonceExpiry` — `valid_before` not in (now, now + EXPIRING_NONCE_MAX_EXPIRY_SECS]
128    /// - `ExpiringNonceReplay` — transaction hash is already recorded and has not yet expired
129    /// - `ExpiringNonceSetFull` — the circular buffer slot holds an unexpired entry that can't be evicted
130    pub fn check_and_mark_expiring_nonce(
131        &mut self,
132        expiring_nonce_hash: B256,
133        valid_before: u64,
134    ) -> Result<()> {
135        let now: u64 = self.storage.timestamp().saturating_to();
136
137        // 1. Validate expiry window: must be in (now, now + EXPIRING_NONCE_MAX_EXPIRY_SECS]
138        if valid_before <= now || valid_before > now.saturating_add(EXPIRING_NONCE_MAX_EXPIRY_SECS)
139        {
140            return Err(NonceError::invalid_expiring_nonce_expiry().into());
141        }
142
143        // 2. Replay check: reject if hash is already seen and not expired
144        let seen_expiry = self.expiring_nonce_seen[expiring_nonce_hash].read()?;
145        if seen_expiry != 0 && seen_expiry > now {
146            return Err(NonceError::expiring_nonce_replay().into());
147        }
148
149        // 3. Get current pointer (bounded in [0, CAPACITY)) and use directly as index
150        let ptr = self.expiring_nonce_ring_ptr.read()?;
151        let idx = ptr;
152        let old_hash = self.expiring_nonce_ring[idx].read()?;
153
154        // 4. If there's an existing entry, check if it's expired (can be evicted)
155        // Safety check: buffer is sized so entries should always be expired, but verify
156        // in case TPS exceeds expectations.
157        if old_hash != B256::ZERO {
158            let old_expiry = self.expiring_nonce_seen[old_hash].read()?;
159            if old_expiry != 0 && old_expiry > now {
160                // Entry is still valid, cannot evict - buffer is full
161                return Err(NonceError::expiring_nonce_set_full().into());
162            }
163            // Clear the old entry from seen set
164            self.expiring_nonce_seen[old_hash].write(0)?;
165        }
166
167        // 5. Insert new entry
168        self.expiring_nonce_ring[idx].write(expiring_nonce_hash)?;
169        self.expiring_nonce_seen[expiring_nonce_hash].write(valid_before)?;
170
171        // 6. Advance pointer (wraps at CAPACITY, not u32::MAX)
172        let next = if ptr + 1 >= EXPIRING_NONCE_SET_CAPACITY {
173            0
174        } else {
175            ptr + 1
176        };
177        self.expiring_nonce_ring_ptr.write(next)?;
178
179        Ok(())
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use crate::{
186        error::TempoPrecompileError,
187        storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider},
188    };
189
190    use super::*;
191    use alloy::primitives::address;
192
193    #[test]
194    fn test_get_nonce_returns_zero_for_new_key() -> eyre::Result<()> {
195        let mut storage = HashMapStorageProvider::new(1);
196        StorageCtx::enter(&mut storage, || {
197            let mgr = NonceManager::new();
198
199            let account = address!("0x1111111111111111111111111111111111111111");
200            let nonce = mgr.get_nonce(INonce::getNonceCall {
201                account,
202                nonceKey: U256::from(5),
203            })?;
204
205            assert_eq!(nonce, 0);
206            Ok(())
207        })
208    }
209
210    #[test]
211    fn test_get_nonce_rejects_protocol_nonce() -> eyre::Result<()> {
212        let mut storage = HashMapStorageProvider::new(1);
213        StorageCtx::enter(&mut storage, || {
214            let mgr = NonceManager::new();
215
216            let account = address!("0x1111111111111111111111111111111111111111");
217            let result = mgr.get_nonce(INonce::getNonceCall {
218                account,
219                nonceKey: U256::ZERO,
220            });
221
222            assert_eq!(
223                result.unwrap_err(),
224                TempoPrecompileError::NonceError(NonceError::protocol_nonce_not_supported())
225            );
226            Ok(())
227        })
228    }
229
230    #[test]
231    fn test_increment_nonce() -> eyre::Result<()> {
232        let mut storage = HashMapStorageProvider::new(1);
233        StorageCtx::enter(&mut storage, || {
234            let mut mgr = NonceManager::new();
235
236            let account = address!("0x1111111111111111111111111111111111111111");
237            let nonce_key = U256::from(5);
238
239            let new_nonce = mgr.increment_nonce(account, nonce_key)?;
240            assert_eq!(new_nonce, 1);
241            assert_eq!(mgr.emitted_events().len(), 1);
242
243            let new_nonce = mgr.increment_nonce(account, nonce_key)?;
244            assert_eq!(new_nonce, 2);
245            mgr.assert_emitted_events(vec![
246                INonce::NonceIncremented {
247                    account,
248                    nonceKey: nonce_key,
249                    newNonce: 1,
250                },
251                INonce::NonceIncremented {
252                    account,
253                    nonceKey: nonce_key,
254                    newNonce: 2,
255                },
256            ]);
257
258            Ok(())
259        })
260    }
261
262    #[test]
263    fn test_different_accounts_independent() -> eyre::Result<()> {
264        let mut storage = HashMapStorageProvider::new(1);
265        StorageCtx::enter(&mut storage, || {
266            let mut mgr = NonceManager::new();
267
268            let account1 = address!("0x1111111111111111111111111111111111111111");
269            let account2 = address!("0x2222222222222222222222222222222222222222");
270            let nonce_key = U256::from(5);
271
272            for _ in 0..10 {
273                mgr.increment_nonce(account1, nonce_key)?;
274            }
275            for _ in 0..20 {
276                mgr.increment_nonce(account2, nonce_key)?;
277            }
278
279            let nonce1 = mgr.get_nonce(INonce::getNonceCall {
280                account: account1,
281                nonceKey: nonce_key,
282            })?;
283            let nonce2 = mgr.get_nonce(INonce::getNonceCall {
284                account: account2,
285                nonceKey: nonce_key,
286            })?;
287
288            assert_eq!(nonce1, 10);
289            assert_eq!(nonce2, 20);
290            Ok(())
291        })
292    }
293
294    // ========== Expiring Nonce Tests ==========
295
296    #[test]
297    fn test_expiring_nonce_basic_flow() -> eyre::Result<()> {
298        let mut storage = HashMapStorageProvider::new(1);
299        let now = 1000u64;
300        storage.set_timestamp(U256::from(now));
301        StorageCtx::enter(&mut storage, || {
302            let mut mgr = NonceManager::new();
303
304            let tx_hash = B256::repeat_byte(0x11);
305            let valid_before = now + 20; // 20s in future, within 30s window
306
307            // First tx should succeed
308            mgr.check_and_mark_expiring_nonce(tx_hash, valid_before)?;
309
310            // Same tx hash should fail (replay)
311            let result = mgr.check_and_mark_expiring_nonce(tx_hash, valid_before);
312            assert_eq!(
313                result.unwrap_err(),
314                TempoPrecompileError::NonceError(NonceError::expiring_nonce_replay())
315            );
316
317            Ok(())
318        })
319    }
320
321    #[test]
322    fn test_expiring_nonce_expiry_validation() -> eyre::Result<()> {
323        let mut storage = HashMapStorageProvider::new(1);
324        let now = 1000u64;
325        storage.set_timestamp(U256::from(now));
326        StorageCtx::enter(&mut storage, || {
327            let mut mgr = NonceManager::new();
328
329            let tx_hash = B256::repeat_byte(0x22);
330
331            // valid_before in the past should fail
332            let result = mgr.check_and_mark_expiring_nonce(tx_hash, now - 1);
333            assert_eq!(
334                result.unwrap_err(),
335                TempoPrecompileError::NonceError(NonceError::invalid_expiring_nonce_expiry())
336            );
337
338            // valid_before exactly at now should fail
339            let result = mgr.check_and_mark_expiring_nonce(tx_hash, now);
340            assert_eq!(
341                result.unwrap_err(),
342                TempoPrecompileError::NonceError(NonceError::invalid_expiring_nonce_expiry())
343            );
344
345            // valid_before too far in future should fail (uses EXPIRING_NONCE_MAX_EXPIRY_SECS = 30)
346            let result = mgr.check_and_mark_expiring_nonce(tx_hash, now + 31);
347            assert_eq!(
348                result.unwrap_err(),
349                TempoPrecompileError::NonceError(NonceError::invalid_expiring_nonce_expiry())
350            );
351
352            // valid_before at exactly EXPIRING_NONCE_MAX_EXPIRY_SECS should succeed
353            mgr.check_and_mark_expiring_nonce(tx_hash, now + 30)?;
354
355            Ok(())
356        })
357    }
358
359    #[test]
360    fn test_expiring_nonce_expired_entry_eviction() -> eyre::Result<()> {
361        let mut storage = HashMapStorageProvider::new(1);
362        let now = 1000u64;
363        let valid_before = now + 20;
364        storage.set_timestamp(U256::from(now));
365        StorageCtx::enter(&mut storage, || {
366            let mut mgr = NonceManager::new();
367
368            let tx_hash1 = B256::repeat_byte(0x33);
369
370            // Insert first tx
371            mgr.check_and_mark_expiring_nonce(tx_hash1, valid_before)?;
372
373            // Verify it's seen
374            assert!(mgr.is_expiring_nonce_seen(tx_hash1, now)?);
375
376            // After expiry, it should no longer be "seen" (expired)
377            assert!(!mgr.is_expiring_nonce_seen(tx_hash1, valid_before + 1)?);
378
379            Ok::<_, eyre::Report>(())
380        })?;
381
382        // Insert second tx after first has expired - should evict first
383        let new_now = valid_before + 1;
384        let new_valid_before = new_now + 20;
385        storage.set_timestamp(U256::from(new_now));
386        StorageCtx::enter(&mut storage, || {
387            let mut mgr = NonceManager::new();
388
389            let tx_hash2 = B256::repeat_byte(0x44);
390            mgr.check_and_mark_expiring_nonce(tx_hash2, new_valid_before)?;
391
392            // tx_hash1 should now be fully evicted (since it was at ring position 0)
393            // and tx_hash2 replaces it
394            assert!(mgr.is_expiring_nonce_seen(tx_hash2, new_now)?);
395
396            Ok(())
397        })
398    }
399
400    #[test]
401    fn test_ring_buffer_pointer_wraps_at_capacity() -> eyre::Result<()> {
402        let mut storage = HashMapStorageProvider::new(1);
403        let now = 1000u64;
404        storage.set_timestamp(U256::from(now));
405        StorageCtx::enter(&mut storage, || {
406            let mut mgr = NonceManager::new();
407
408            // Manually set pointer to just before capacity to test wrap
409            mgr.expiring_nonce_ring_ptr
410                .write(EXPIRING_NONCE_SET_CAPACITY - 1)?;
411
412            // Insert a tx - pointer should wrap to 0
413            let tx_hash = B256::repeat_byte(0x77);
414            let valid_before = now + 20;
415            mgr.check_and_mark_expiring_nonce(tx_hash, valid_before)?;
416
417            // Pointer should now be 0 (wrapped at capacity)
418            let ptr = mgr.expiring_nonce_ring_ptr.read()?;
419            assert_eq!(ptr, 0, "Pointer should wrap to 0 at capacity");
420
421            // Insert another tx - pointer should be 1
422            let tx_hash2 = B256::repeat_byte(0x88);
423            mgr.check_and_mark_expiring_nonce(tx_hash2, valid_before)?;
424
425            let ptr = mgr.expiring_nonce_ring_ptr.read()?;
426            assert_eq!(ptr, 1, "Pointer should increment to 1 after wrap");
427
428            Ok(())
429        })
430    }
431
432    #[test]
433    fn test_initialize_sets_storage_state() -> eyre::Result<()> {
434        let mut storage = HashMapStorageProvider::new(1);
435        StorageCtx::enter(&mut storage, || {
436            let mut mgr = NonceManager::new();
437
438            // Before initialization, contract should not be initialized
439            assert!(!mgr.is_initialized()?);
440
441            // Initialize
442            mgr.initialize()?;
443
444            // After initialization, contract should be initialized
445            assert!(mgr.is_initialized()?);
446
447            // Re-initializing a new handle should still see initialized state
448            let mgr2 = NonceManager::new();
449            assert!(mgr2.is_initialized()?);
450
451            Ok(())
452        })
453    }
454}