Skip to main content

nautilus_derive/signing/
nonce.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Process-wide `(wallet, subaccount)` nonce manager for Derive self-custodial requests.
17//!
18//! Derive's [venue schema] defines a unique nonce as UTC milliseconds followed
19//! by a suffix of up to three digits and illustrates the suffix as `001`. The
20//! [reference SDK] accepts caller-selected suffixes in `0..=999`. The allocator
21//! uses a fixed three-digit suffix field: `utc_ms * 1000 + suffix`.
22//!
23//! Manager guarantees:
24//!
25//! 1. Unique and monotonically increasing per `(wallet, subaccount)` across
26//!    every manager in the process, including during clock rollback.
27//! 2. Suffixes stay within the venue's documented `0..=999` range.
28//! 3. Atomic allocation per key under contention. A process-wide `DashMap`
29//!    shards the state and a `compare_exchange` loop serialises allocators.
30//!
31//! [venue schema]: https://docs.derive.xyz/reference/private-replace
32//! [reference SDK]: https://github.com/derivexyz/v2-action-signing-python/blob/d1914d61985e33559244da242892c7255b6fd0ca/derive_action_signing/utils.py#L19-L29
33
34use std::sync::{
35    Arc, OnceLock,
36    atomic::{AtomicU64, Ordering},
37};
38
39use dashmap::DashMap;
40use thiserror::Error;
41
42use crate::signing::encoding::utc_now_ms;
43
44const NONCE_SUFFIX_BASE: u64 = 1_000;
45const NONCE_SUFFIX_MAX: u64 = NONCE_SUFFIX_BASE - 1;
46const NONCE_UNINITIALIZED: u64 = u64::MAX;
47
48/// Errors raised by [`NonceManager`].
49#[derive(Debug, Error, PartialEq, Eq)]
50pub enum NonceError {
51    /// The system clock is before the UNIX epoch.
52    #[error("system clock is before UNIX epoch")]
53    ClockBeforeEpoch,
54    /// All suffixes for the current logical millisecond have been allocated.
55    #[error("nonce suffix range exhausted for millisecond {millisecond}")]
56    SuffixExhausted { millisecond: u64 },
57    /// The millisecond timestamp cannot fit in the nonce's fixed-width prefix.
58    #[error("millisecond timestamp {milliseconds} overflows the nonce format")]
59    TimestampOverflow { milliseconds: u64 },
60    /// The next nonce cannot fit in an unsigned 64-bit integer.
61    #[error("next nonce exceeds u64::MAX")]
62    NonceOverflow,
63}
64
65/// Thread-safe process-wide nonce allocator keyed by `(wallet, subaccount_id)`.
66#[derive(Debug, Default)]
67pub struct NonceManager;
68
69impl NonceManager {
70    /// Constructs a manager backed by the process-wide nonce registry.
71    #[must_use]
72    pub fn new() -> Self {
73        Self
74    }
75
76    /// Allocates the next nonce for `(wallet, subaccount_id)` using the
77    /// system clock as the millisecond reference.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error when the system clock is invalid, the timestamp
82    /// cannot be encoded, or the suffix range is exhausted.
83    pub fn next_nonce(&self, wallet: &str, subaccount_id: u64) -> Result<u64, NonceError> {
84        let now_ms = utc_now_ms().map_err(|_| NonceError::ClockBeforeEpoch)?;
85        self.next_nonce_at(wallet, subaccount_id, now_ms)
86    }
87
88    /// Allocates the next nonce for `(wallet, subaccount_id)` with an
89    /// injected `now_ms`, suitable for deterministic testing.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error when the timestamp cannot be encoded or all suffixes
94    /// for the current logical millisecond have been allocated.
95    pub fn next_nonce_at(
96        &self,
97        wallet: &str,
98        subaccount_id: u64,
99        now_ms: u64,
100    ) -> Result<u64, NonceError> {
101        let initial =
102            now_ms
103                .checked_mul(NONCE_SUFFIX_BASE)
104                .ok_or(NonceError::TimestampOverflow {
105                    milliseconds: now_ms,
106                })?;
107        let state = self.state_for(wallet, subaccount_id);
108
109        loop {
110            let last = state.load(Ordering::Acquire);
111            let candidate = if last == NONCE_UNINITIALIZED || initial > last {
112                initial
113            } else {
114                if last % NONCE_SUFFIX_BASE == NONCE_SUFFIX_MAX {
115                    return Err(NonceError::SuffixExhausted {
116                        millisecond: last / NONCE_SUFFIX_BASE,
117                    });
118                }
119                let next = last.checked_add(1).ok_or(NonceError::NonceOverflow)?;
120                if next == NONCE_UNINITIALIZED {
121                    return Err(NonceError::NonceOverflow);
122                }
123                next
124            };
125
126            if state
127                .compare_exchange_weak(last, candidate, Ordering::AcqRel, Ordering::Acquire)
128                .is_ok()
129            {
130                return Ok(candidate);
131            }
132        }
133    }
134
135    /// Returns the most recently issued nonce for a key, if any.
136    #[must_use]
137    pub fn last_issued(&self, wallet: &str, subaccount_id: u64) -> Option<u64> {
138        Self::states()
139            .get(&Self::normalize_key(wallet, subaccount_id))
140            .map(|s| s.load(Ordering::Acquire))
141            .filter(|n| *n != NONCE_UNINITIALIZED)
142    }
143
144    fn state_for(&self, wallet: &str, subaccount_id: u64) -> Arc<AtomicU64> {
145        let entry = Self::states()
146            .entry(Self::normalize_key(wallet, subaccount_id))
147            .or_insert_with(|| Arc::new(AtomicU64::new(NONCE_UNINITIALIZED)));
148        entry.value().clone()
149    }
150
151    // Lowercase the wallet hex so checksum and lowercase forms of the same
152    // EVM address share a single nonce stream; mixing them would otherwise
153    // issue duplicate nonces for the same on-chain account. All read and
154    // write paths must route through here to stay symmetrical.
155    fn normalize_key(wallet: &str, subaccount_id: u64) -> (String, u64) {
156        (wallet.to_ascii_lowercase(), subaccount_id)
157    }
158
159    fn states() -> &'static DashMap<(String, u64), Arc<AtomicU64>> {
160        static STATES: OnceLock<DashMap<(String, u64), Arc<AtomicU64>>> = OnceLock::new();
161        STATES.get_or_init(DashMap::new)
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use std::{
168        sync::{Arc as StdArc, Barrier},
169        thread,
170    };
171
172    use rstest::rstest;
173
174    use super::*;
175
176    const NOW_MS: u64 = 1_700_000_000_000;
177    const NONCE_START: u64 = NOW_MS * NONCE_SUFFIX_BASE;
178    const WALLET_A: &str = "0x000000000000000000000000000000000000aaaa";
179    const WALLET_B: &str = "0x000000000000000000000000000000000000bbbb";
180
181    #[rstest]
182    fn test_next_nonce_at_first_call_uses_zero_suffix() {
183        let mgr = NonceManager::new();
184        let nonce = mgr.next_nonce_at(WALLET_A, 1, NOW_MS).unwrap();
185
186        assert_eq!(nonce, 1_700_000_000_000_000);
187    }
188
189    #[rstest]
190    fn test_sequential_calls_within_same_ms_are_monotonic() {
191        let mgr = NonceManager::new();
192        let nonces = [
193            mgr.next_nonce_at(WALLET_A, 2, NOW_MS).unwrap(),
194            mgr.next_nonce_at(WALLET_A, 2, NOW_MS).unwrap(),
195            mgr.next_nonce_at(WALLET_A, 2, NOW_MS).unwrap(),
196        ];
197
198        assert_eq!(nonces, [NONCE_START, NONCE_START + 1, NONCE_START + 2]);
199    }
200
201    #[rstest]
202    fn test_separate_managers_share_state() {
203        let first = NonceManager::new()
204            .next_nonce_at(WALLET_A, 3, NOW_MS)
205            .unwrap();
206        let second = NonceManager::new()
207            .next_nonce_at(WALLET_A, 3, NOW_MS)
208            .unwrap();
209
210        assert_eq!(first, NONCE_START);
211        assert_eq!(second, NONCE_START + 1);
212    }
213
214    #[rstest]
215    #[expect(
216        clippy::needless_collect,
217        reason = "all threads must start before any can pass the barrier"
218    )]
219    fn test_simultaneous_managers_allocate_unique_ordered_range() {
220        const THREADS: u64 = 8;
221
222        let barrier = StdArc::new(Barrier::new(THREADS as usize));
223        let handles: Vec<_> = (0..THREADS)
224            .map(|_| {
225                let barrier = StdArc::clone(&barrier);
226
227                thread::spawn(move || {
228                    let mgr = NonceManager::new();
229                    barrier.wait();
230                    mgr.next_nonce_at(WALLET_A, 4, NOW_MS).unwrap()
231                })
232            })
233            .collect();
234        let mut nonces: Vec<_> = handles
235            .into_iter()
236            .map(|handle| handle.join().unwrap())
237            .collect();
238        nonces.sort_unstable();
239
240        let expected: Vec<_> = (0..THREADS).map(|suffix| NONCE_START + suffix).collect();
241        assert_eq!(nonces, expected);
242    }
243
244    #[rstest]
245    fn test_advancing_clock_starts_new_suffix_range() {
246        let mgr = NonceManager::new();
247        let first = mgr.next_nonce_at(WALLET_A, 5, NOW_MS).unwrap();
248        let second = mgr.next_nonce_at(WALLET_A, 5, NOW_MS + 1).unwrap();
249
250        assert_eq!(first, NONCE_START);
251        assert_eq!(second, NONCE_START + NONCE_SUFFIX_BASE);
252    }
253
254    #[rstest]
255    fn test_clock_rollback_advances_last_logical_millisecond() {
256        let first = NonceManager::new()
257            .next_nonce_at(WALLET_A, 6, NOW_MS + 10)
258            .unwrap();
259        let second = NonceManager::new()
260            .next_nonce_at(WALLET_A, 6, NOW_MS)
261            .unwrap();
262
263        assert_eq!(first, (NOW_MS + 10) * NONCE_SUFFIX_BASE);
264        assert_eq!(second, first + 1);
265    }
266
267    #[rstest]
268    fn test_distinct_wallets_track_independent_state() {
269        let mgr = NonceManager::new();
270        let first_a = mgr.next_nonce_at(WALLET_A, 7, NOW_MS).unwrap();
271        let first_b = mgr.next_nonce_at(WALLET_B, 7, NOW_MS).unwrap();
272        let second_a = mgr.next_nonce_at(WALLET_A, 7, NOW_MS).unwrap();
273
274        assert_eq!(first_a, NONCE_START);
275        assert_eq!(first_b, NONCE_START);
276        assert_eq!(second_a, NONCE_START + 1);
277        assert_eq!(mgr.last_issued(WALLET_A, 7), Some(second_a));
278        assert_eq!(mgr.last_issued(WALLET_B, 7), Some(first_b));
279    }
280
281    #[rstest]
282    fn test_distinct_subaccounts_track_independent_state() {
283        let mgr = NonceManager::new();
284        let first = mgr.next_nonce_at(WALLET_A, 8, NOW_MS).unwrap();
285        let second = mgr.next_nonce_at(WALLET_A, 9, NOW_MS).unwrap();
286
287        assert_eq!(first, NONCE_START);
288        assert_eq!(second, NONCE_START);
289    }
290
291    #[rstest]
292    fn test_checksum_and_lowercase_wallet_share_state() {
293        let lowercase = "0x000000000000000000000000000000000000abcd";
294        let checksum = "0x000000000000000000000000000000000000ABCD";
295        let first = NonceManager::new()
296            .next_nonce_at(lowercase, 10, NOW_MS)
297            .unwrap();
298        let second = NonceManager::new()
299            .next_nonce_at(checksum, 10, NOW_MS)
300            .unwrap();
301
302        assert_eq!(first, NONCE_START);
303        assert_eq!(second, NONCE_START + 1);
304        assert_eq!(NonceManager::new().last_issued(lowercase, 10), Some(second),);
305        assert_eq!(NonceManager::new().last_issued(checksum, 10), Some(second),);
306    }
307
308    #[rstest]
309    fn test_last_issued_reports_latest_value() {
310        let mgr = NonceManager::new();
311        assert_eq!(mgr.last_issued(WALLET_A, 11), None);
312
313        let nonce = mgr.next_nonce_at(WALLET_A, 11, NOW_MS).unwrap();
314
315        assert_eq!(nonce, NONCE_START);
316        assert_eq!(mgr.last_issued(WALLET_A, 11), Some(NONCE_START));
317    }
318
319    #[rstest]
320    fn test_suffix_exhaustion_stops_after_suffix_999() {
321        let mgr = NonceManager::new();
322        for suffix in 0..=NONCE_SUFFIX_MAX {
323            let nonce = mgr.next_nonce_at(WALLET_A, 12, NOW_MS).unwrap();
324            assert_eq!(nonce, NONCE_START + suffix);
325        }
326
327        assert_eq!(
328            mgr.next_nonce_at(WALLET_A, 12, NOW_MS),
329            Err(NonceError::SuffixExhausted {
330                millisecond: NOW_MS,
331            }),
332        );
333        assert_eq!(
334            mgr.last_issued(WALLET_A, 12),
335            Some(NONCE_START + NONCE_SUFFIX_MAX),
336        );
337    }
338
339    #[rstest]
340    fn test_suffix_exhaustion_during_clock_rollback_reports_logical_millisecond() {
341        let mgr = NonceManager::new();
342        for suffix in 0..=NONCE_SUFFIX_MAX {
343            let nonce = mgr.next_nonce_at(WALLET_A, 13, NOW_MS).unwrap();
344            assert_eq!(nonce, NONCE_START + suffix);
345        }
346
347        assert_eq!(
348            NonceManager::new().next_nonce_at(WALLET_A, 13, NOW_MS - 1),
349            Err(NonceError::SuffixExhausted {
350                millisecond: NOW_MS,
351            }),
352        );
353    }
354
355    #[rstest]
356    fn test_timestamp_overflow_does_not_create_stream_state() {
357        let now_ms = (u64::MAX / NONCE_SUFFIX_BASE) + 1;
358        let mgr = NonceManager::new();
359
360        assert_eq!(
361            mgr.next_nonce_at(WALLET_A, 14, now_ms),
362            Err(NonceError::TimestampOverflow {
363                milliseconds: now_ms,
364            }),
365        );
366        assert_eq!(mgr.last_issued(WALLET_A, 14), None);
367    }
368
369    #[rstest]
370    fn test_epoch_first_call_uses_zero_nonce() {
371        let mgr = NonceManager::new();
372        let first = mgr.next_nonce_at(WALLET_A, 17, 0).unwrap();
373        let second = mgr.next_nonce_at(WALLET_A, 17, 0).unwrap();
374
375        assert_eq!(first, 0);
376        assert_eq!(second, 1);
377        assert_eq!(mgr.last_issued(WALLET_A, 17), Some(1));
378    }
379
380    #[rstest]
381    fn test_nonce_overflow_does_not_emit_uninitialized_sentinel() {
382        let now_ms = u64::MAX / NONCE_SUFFIX_BASE;
383        let start = now_ms * NONCE_SUFFIX_BASE;
384        let mgr = NonceManager::new();
385        for suffix in 0..(u64::MAX - start) {
386            let nonce = mgr.next_nonce_at(WALLET_A, 15, now_ms).unwrap();
387            assert_eq!(nonce, start + suffix);
388        }
389
390        assert_eq!(
391            mgr.next_nonce_at(WALLET_A, 15, now_ms),
392            Err(NonceError::NonceOverflow),
393        );
394        assert_eq!(mgr.last_issued(WALLET_A, 15), Some(u64::MAX - 1));
395    }
396
397    #[rstest]
398    fn test_next_nonce_uses_system_clock_when_called_without_injection() {
399        let mgr = NonceManager::new();
400        let nonce = mgr.next_nonce(WALLET_A, 16).unwrap();
401
402        assert!(nonce > NONCE_START);
403        assert_eq!(nonce % NONCE_SUFFIX_BASE, 0);
404    }
405}