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//! Nonces are UNIX nanoseconds, allocated monotonically per `(wallet, subaccount)`
19//! across client instances in the same process. Clock rollback advances the last
20//! issued value, provided it remains inside the inclusive order window
21//! [now minus 90 days, now plus 1 hour].
22//! Local time approximates server time; a clock offset beyond the window still
23//! causes venue rejection. Login timestamps remain milliseconds.
24//!
25//! A process-wide `DashMap` shares state and a compare-exchange loop serializes
26//! allocations under contention. Wallet hex is normalized to lowercase.
27
28use std::{
29    sync::{
30        Arc, OnceLock,
31        atomic::{AtomicU64, Ordering},
32    },
33    time::UNIX_EPOCH,
34};
35
36use dashmap::DashMap;
37use serde::{Deserialize, Deserializer, Serializer, de::Error};
38use thiserror::Error;
39
40const NONCE_PAST_NS: u64 = 90 * 24 * 60 * 60 * 1_000_000_000;
41pub(crate) const NONCE_FUTURE_NS: u64 = 60 * 60 * 1_000_000_000;
42const NONCE_UNINITIALIZED: u64 = u64::MAX;
43
44/// Errors raised by [`NonceManager`].
45#[derive(Debug, Error, PartialEq, Eq)]
46pub enum NonceError {
47    /// The system clock is before the UNIX epoch.
48    #[error("system clock is before UNIX epoch")]
49    ClockBeforeEpoch,
50    /// The clock's nanosecond timestamp exceeds the unsigned 64-bit range.
51    #[error("nanosecond timestamp overflows u64")]
52    TimestampOverflow,
53    /// The candidate is outside the inclusive order nonce window.
54    #[error("nonce {nonce} is outside the order window relative to time {now_ns}")]
55    OutsideWindow {
56        /// Candidate nonce.
57        nonce: u64,
58        /// Reference UNIX time in nanoseconds.
59        now_ns: u64,
60    },
61    /// The next nonce cannot fit in an unsigned 64-bit integer.
62    #[error("next nonce exceeds u64::MAX")]
63    NonceOverflow,
64}
65
66/// Thread-safe process-wide nonce allocator keyed by `(wallet, subaccount_id)`.
67#[derive(Debug, Default)]
68pub struct NonceManager;
69
70impl NonceManager {
71    /// Constructs a manager backed by the process-wide nonce registry.
72    #[must_use]
73    pub fn new() -> Self {
74        Self
75    }
76
77    /// Allocates the next nonce for `(wallet, subaccount_id)` using the
78    /// system clock as the nanosecond reference.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the system clock is invalid, the timestamp cannot be encoded, or
83    /// rollback places the nonce outside the order window.
84    pub fn next_nonce(&self, wallet: &str, subaccount_id: u64) -> Result<u64, NonceError> {
85        let now_ns = nautilus_core::time::wall_clock_now()
86            .duration_since(UNIX_EPOCH)
87            .map_err(|_| NonceError::ClockBeforeEpoch)?
88            .as_nanos()
89            .try_into()
90            .map_err(|_| NonceError::TimestampOverflow)?;
91        self.next_nonce_at(wallet, subaccount_id, now_ns)
92    }
93
94    /// Allocates the next nonce for `(wallet, subaccount_id)` with
95    /// caller-supplied UNIX time in nanoseconds.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if overflow occurs or rollback places the next nonce outside the inclusive
100    /// order window.
101    pub fn next_nonce_at(
102        &self,
103        wallet: &str,
104        subaccount_id: u64,
105        now_ns: u64,
106    ) -> Result<u64, NonceError> {
107        if now_ns == NONCE_UNINITIALIZED {
108            return Err(NonceError::NonceOverflow);
109        }
110
111        let state = self.state_for(wallet, subaccount_id);
112        loop {
113            let last = state.load(Ordering::Acquire);
114
115            let candidate = if last == NONCE_UNINITIALIZED || now_ns > last {
116                now_ns
117            } else {
118                let next = last.checked_add(1).ok_or(NonceError::NonceOverflow)?;
119                if next == NONCE_UNINITIALIZED {
120                    return Err(NonceError::NonceOverflow);
121                }
122
123                next
124            };
125
126            validate_nonce_at(candidate, now_ns)?;
127            if state
128                .compare_exchange_weak(last, candidate, Ordering::AcqRel, Ordering::Acquire)
129                .is_ok()
130            {
131                return Ok(candidate);
132            }
133        }
134    }
135
136    /// Returns the most recently issued nonce for a key, if any.
137    #[must_use]
138    pub fn last_issued(&self, wallet: &str, subaccount_id: u64) -> Option<u64> {
139        Self::states()
140            .get(&Self::normalize_key(wallet, subaccount_id))
141            .map(|s| s.load(Ordering::Acquire))
142            .filter(|n| *n != NONCE_UNINITIALIZED)
143    }
144
145    fn state_for(&self, wallet: &str, subaccount_id: u64) -> Arc<AtomicU64> {
146        let entry = Self::states()
147            .entry(Self::normalize_key(wallet, subaccount_id))
148            .or_insert_with(|| Arc::new(AtomicU64::new(NONCE_UNINITIALIZED)));
149        entry.value().clone()
150    }
151
152    // Lowercase the wallet hex so checksum and lowercase forms of the same
153    // EVM address share a single nonce stream; mixing them would otherwise
154    // issue duplicate nonces for the same on-chain account. All read and
155    // write paths must route through here to stay symmetrical.
156    fn normalize_key(wallet: &str, subaccount_id: u64) -> (String, u64) {
157        (wallet.to_ascii_lowercase(), subaccount_id)
158    }
159
160    fn states() -> &'static DashMap<(String, u64), Arc<AtomicU64>> {
161        static STATES: OnceLock<DashMap<(String, u64), Arc<AtomicU64>>> = OnceLock::new();
162        STATES.get_or_init(DashMap::new)
163    }
164}
165
166pub(crate) fn serialize_nonce<S>(nonce: &u64, serializer: S) -> Result<S::Ok, S::Error>
167where
168    S: Serializer,
169{
170    serializer.collect_str(nonce)
171}
172
173pub(crate) fn deserialize_nonce<'de, D>(deserializer: D) -> Result<u64, D::Error>
174where
175    D: Deserializer<'de>,
176{
177    let value = String::deserialize(deserializer)?;
178    value.parse::<u64>().map_err(D::Error::custom)
179}
180
181fn validate_nonce_at(nonce: u64, now_ns: u64) -> Result<(), NonceError> {
182    if nonce < now_ns.saturating_sub(NONCE_PAST_NS)
183        || nonce > now_ns.saturating_add(NONCE_FUTURE_NS)
184    {
185        return Err(NonceError::OutsideWindow { nonce, now_ns });
186    }
187
188    Ok(())
189}
190
191#[cfg(test)]
192mod tests {
193    use std::{
194        sync::{Arc as StdArc, Barrier},
195        thread,
196    };
197
198    use rstest::rstest;
199
200    use super::*;
201
202    const NOW_NS: u64 = 1_700_000_000_000_000_000;
203    const NONCE_START: u64 = NOW_NS;
204    const WALLET_A: &str = "0x000000000000000000000000000000000000aaaa";
205    const WALLET_B: &str = "0x000000000000000000000000000000000000bbbb";
206
207    #[rstest]
208    fn test_next_nonce_at_first_call_uses_nanoseconds() {
209        let mgr = NonceManager::new();
210        let nonce = mgr.next_nonce_at(WALLET_A, 1, NOW_NS).unwrap();
211
212        assert_eq!(nonce, 1_700_000_000_000_000_000);
213    }
214
215    #[rstest]
216    fn test_sequential_calls_within_same_ns_are_monotonic() {
217        let mgr = NonceManager::new();
218        let nonces = [
219            mgr.next_nonce_at(WALLET_A, 2, NOW_NS).unwrap(),
220            mgr.next_nonce_at(WALLET_A, 2, NOW_NS).unwrap(),
221            mgr.next_nonce_at(WALLET_A, 2, NOW_NS).unwrap(),
222        ];
223
224        assert_eq!(nonces, [NONCE_START, NONCE_START + 1, NONCE_START + 2]);
225    }
226
227    #[rstest]
228    fn test_separate_managers_share_state() {
229        let first = NonceManager::new()
230            .next_nonce_at(WALLET_A, 3, NOW_NS)
231            .unwrap();
232        let second = NonceManager::new()
233            .next_nonce_at(WALLET_A, 3, NOW_NS)
234            .unwrap();
235
236        assert_eq!(first, NONCE_START);
237        assert_eq!(second, NONCE_START + 1);
238    }
239
240    #[rstest]
241    #[expect(
242        clippy::needless_collect,
243        reason = "all threads must start before any can pass the barrier"
244    )]
245    fn test_simultaneous_managers_allocate_unique_ordered_range() {
246        const THREADS: u64 = 8;
247        const ALLOCATIONS: u64 = 128;
248
249        let barrier = StdArc::new(Barrier::new(THREADS as usize));
250        let handles: Vec<_> = (0..THREADS)
251            .map(|_| {
252                let barrier = StdArc::clone(&barrier);
253
254                thread::spawn(move || {
255                    let mgr = NonceManager::new();
256                    barrier.wait();
257                    (0..ALLOCATIONS)
258                        .map(|_| mgr.next_nonce_at(WALLET_A, 4, NOW_NS).unwrap())
259                        .collect::<Vec<_>>()
260                })
261            })
262            .collect();
263
264        let mut nonces: Vec<_> = handles
265            .into_iter()
266            .flat_map(|handle| handle.join().unwrap())
267            .collect();
268        nonces.sort_unstable();
269
270        let expected: Vec<_> = (0..THREADS * ALLOCATIONS)
271            .map(|offset| NONCE_START + offset)
272            .collect();
273        assert_eq!(nonces, expected);
274    }
275
276    #[rstest]
277    fn test_advancing_clock_uses_nanosecond_timestamp() {
278        let mgr = NonceManager::new();
279        let first = mgr.next_nonce_at(WALLET_A, 5, NOW_NS).unwrap();
280        let second = mgr.next_nonce_at(WALLET_A, 5, NOW_NS + 1).unwrap();
281
282        assert_eq!(first, NONCE_START);
283        assert_eq!(second, NONCE_START + 1);
284    }
285
286    #[rstest]
287    fn test_clock_rollback_advances_last_nanosecond() {
288        let first = NonceManager::new()
289            .next_nonce_at(WALLET_A, 6, NOW_NS + 10)
290            .unwrap();
291        let second = NonceManager::new()
292            .next_nonce_at(WALLET_A, 6, NOW_NS)
293            .unwrap();
294
295        assert_eq!(first, NOW_NS + 10);
296        assert_eq!(second, first + 1);
297    }
298
299    #[rstest]
300    fn test_distinct_wallets_track_independent_state() {
301        let mgr = NonceManager::new();
302        let first_a = mgr.next_nonce_at(WALLET_A, 7, NOW_NS).unwrap();
303        let first_b = mgr.next_nonce_at(WALLET_B, 7, NOW_NS).unwrap();
304        let second_a = mgr.next_nonce_at(WALLET_A, 7, NOW_NS).unwrap();
305
306        assert_eq!(first_a, NONCE_START);
307        assert_eq!(first_b, NONCE_START);
308        assert_eq!(second_a, NONCE_START + 1);
309        assert_eq!(mgr.last_issued(WALLET_A, 7), Some(second_a));
310        assert_eq!(mgr.last_issued(WALLET_B, 7), Some(first_b));
311    }
312
313    #[rstest]
314    fn test_distinct_subaccounts_track_independent_state() {
315        let mgr = NonceManager::new();
316        let first = mgr.next_nonce_at(WALLET_A, 8, NOW_NS).unwrap();
317        let second = mgr.next_nonce_at(WALLET_A, 9, NOW_NS).unwrap();
318
319        assert_eq!(first, NONCE_START);
320        assert_eq!(second, NONCE_START);
321    }
322
323    #[rstest]
324    fn test_checksum_and_lowercase_wallet_share_state() {
325        let lowercase = "0x000000000000000000000000000000000000abcd";
326        let checksum = "0x000000000000000000000000000000000000ABCD";
327        let first = NonceManager::new()
328            .next_nonce_at(lowercase, 10, NOW_NS)
329            .unwrap();
330        let second = NonceManager::new()
331            .next_nonce_at(checksum, 10, NOW_NS)
332            .unwrap();
333
334        assert_eq!(first, NONCE_START);
335        assert_eq!(second, NONCE_START + 1);
336        assert_eq!(NonceManager::new().last_issued(lowercase, 10), Some(second),);
337        assert_eq!(NonceManager::new().last_issued(checksum, 10), Some(second),);
338    }
339
340    #[rstest]
341    fn test_last_issued_reports_latest_value() {
342        let mgr = NonceManager::new();
343        assert_eq!(mgr.last_issued(WALLET_A, 11), None);
344
345        let nonce = mgr.next_nonce_at(WALLET_A, 11, NOW_NS).unwrap();
346
347        assert_eq!(nonce, NONCE_START);
348        assert_eq!(mgr.last_issued(WALLET_A, 11), Some(NONCE_START));
349    }
350
351    #[rstest]
352    fn test_contention_has_no_millisecond_suffix_limit() {
353        let mgr = NonceManager::new();
354
355        for offset in 0..2_000 {
356            assert_eq!(
357                mgr.next_nonce_at(WALLET_A, 12, NOW_NS),
358                Ok(NONCE_START + offset)
359            );
360        }
361
362        assert_eq!(mgr.last_issued(WALLET_A, 12), Some(NONCE_START + 1_999));
363    }
364
365    #[rstest]
366    #[case(NOW_NS - NONCE_PAST_NS, true)]
367    #[case(NOW_NS - NONCE_PAST_NS - 1, false)]
368    #[case(NOW_NS + NONCE_FUTURE_NS, true)]
369    #[case(NOW_NS + NONCE_FUTURE_NS + 1, false)]
370    fn test_nonce_window_is_inclusive(#[case] nonce: u64, #[case] accepted: bool) {
371        let expected = if accepted {
372            Ok(())
373        } else {
374            Err(NonceError::OutsideWindow {
375                nonce,
376                now_ns: NOW_NS,
377            })
378        };
379
380        assert_eq!(validate_nonce_at(nonce, NOW_NS), expected);
381    }
382
383    #[rstest]
384    fn test_rollback_past_future_window_preserves_state() {
385        let mgr = NonceManager::new();
386        assert_eq!(mgr.next_nonce_at(WALLET_A, 13, NOW_NS), Ok(NOW_NS));
387        let now_ns = NOW_NS - NONCE_FUTURE_NS;
388        assert_eq!(
389            mgr.next_nonce_at(WALLET_A, 13, now_ns),
390            Err(NonceError::OutsideWindow {
391                nonce: NOW_NS + 1,
392                now_ns
393            })
394        );
395        assert_eq!(mgr.last_issued(WALLET_A, 13), Some(NOW_NS));
396        assert_eq!(mgr.next_nonce_at(WALLET_A, 13, now_ns + 1), Ok(NOW_NS + 1));
397    }
398
399    #[rstest]
400    fn test_window_bounds_do_not_overflow() {
401        assert_eq!(validate_nonce_at(0, 0), Ok(()));
402        assert_eq!(validate_nonce_at(u64::MAX - 1, u64::MAX), Ok(()));
403    }
404
405    #[rstest]
406    fn test_epoch_first_call_uses_zero_nonce() {
407        let mgr = NonceManager::new();
408        let first = mgr.next_nonce_at(WALLET_A, 17, 0).unwrap();
409        let second = mgr.next_nonce_at(WALLET_A, 17, 0).unwrap();
410
411        assert_eq!(first, 0);
412        assert_eq!(second, 1);
413        assert_eq!(mgr.last_issued(WALLET_A, 17), Some(1));
414    }
415
416    #[rstest]
417    fn test_nonce_overflow_does_not_emit_uninitialized_sentinel() {
418        let mgr = NonceManager::new();
419        assert_eq!(
420            mgr.next_nonce_at(WALLET_A, 15, u64::MAX - 1),
421            Ok(u64::MAX - 1)
422        );
423        assert_eq!(
424            mgr.next_nonce_at(WALLET_A, 15, u64::MAX - 1),
425            Err(NonceError::NonceOverflow)
426        );
427        assert_eq!(mgr.last_issued(WALLET_A, 15), Some(u64::MAX - 1));
428        assert_eq!(
429            mgr.next_nonce_at(WALLET_A, 14, u64::MAX),
430            Err(NonceError::NonceOverflow)
431        );
432        assert_eq!(mgr.last_issued(WALLET_A, 14), None);
433    }
434
435    #[rstest]
436    fn test_next_nonce_uses_system_clock_when_called_without_injection() {
437        let mgr = NonceManager::new();
438        let nonce = mgr.next_nonce(WALLET_A, 16).unwrap();
439
440        assert!(nonce > NONCE_START);
441        assert_eq!(mgr.last_issued(WALLET_A, 16), Some(nonce));
442    }
443}