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//! Per-`(wallet, subaccount)` nonce manager for Derive self-custodial requests.
17//!
18//! Nonce format (matching `derive_action_signing/utils.py::get_action_nonce`):
19//! `int(str(utc_ms) || str(suffix))`. The suffix is a non-negative integer
20//! whose decimal representation is concatenated to the millisecond timestamp.
21//!
22//! Manager guarantees:
23//!
24//! 1. Monotonically increasing per `(wallet, subaccount)`. The venue rejects
25//!    a nonce equal to or below the last accepted nonce for the signer, so
26//!    the manager always issues `max(now_ms_with_suffix0, last_issued + 1)`.
27//! 2. Lock-free per key under contention. A `DashMap` shards the per-key
28//!    state and a `compare_exchange` loop on a single `AtomicU64` serialises
29//!    concurrent allocators.
30//!
31//! Cross-instance ordering (multiple processes signing for the same key) is
32//! the caller's responsibility; the venue's `nextNonce`-style endpoint should
33//! seed the manager via [`NonceManager::refresh`] at startup.
34
35use std::sync::{
36    Arc,
37    atomic::{AtomicU64, Ordering},
38};
39
40use dashmap::DashMap;
41use thiserror::Error;
42
43use crate::signing::encoding::utc_now_ms;
44
45/// Errors raised by [`NonceManager`].
46#[derive(Debug, Error, PartialEq, Eq)]
47pub enum NonceError {
48    /// The system clock is before the UNIX epoch.
49    #[error("system clock is before UNIX epoch")]
50    ClockBeforeEpoch,
51}
52
53/// Thread-safe sequential nonce allocator keyed by `(wallet, subaccount_id)`.
54#[derive(Debug, Default)]
55pub struct NonceManager {
56    states: DashMap<(String, u64), Arc<AtomicU64>>,
57}
58
59impl NonceManager {
60    /// Constructs an empty manager.
61    #[must_use]
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Allocates the next nonce for `(wallet, subaccount_id)` using the
67    /// system clock as the millisecond reference.
68    ///
69    /// # Errors
70    ///
71    /// Returns [`NonceError::ClockBeforeEpoch`] when the system clock is
72    /// invalid.
73    pub fn next_nonce(&self, wallet: &str, subaccount_id: u64) -> Result<u64, NonceError> {
74        let now_ms = utc_now_ms().map_err(|_| NonceError::ClockBeforeEpoch)?;
75        Ok(self.next_nonce_at(wallet, subaccount_id, now_ms))
76    }
77
78    /// Allocates the next nonce for `(wallet, subaccount_id)` with an
79    /// injected `now_ms`, suitable for deterministic testing.
80    pub fn next_nonce_at(&self, wallet: &str, subaccount_id: u64, now_ms: u64) -> u64 {
81        let state = self.state_for(wallet, subaccount_id);
82        // Suffix "0" -> multiply ms by 10 and append 0 (which is just *10).
83        let initial = now_ms.saturating_mul(10);
84
85        loop {
86            let last = state.load(Ordering::Acquire);
87            let candidate = if initial > last { initial } else { last + 1 };
88            if state
89                .compare_exchange_weak(last, candidate, Ordering::AcqRel, Ordering::Acquire)
90                .is_ok()
91            {
92                return candidate;
93            }
94        }
95    }
96
97    /// Aligns the local last-issued state with a venue-reported `nextNonce`,
98    /// monotonically. The stored value advances to `last_seen_nonce` only
99    /// when it is strictly greater than the current state, otherwise the
100    /// call is a no-op.
101    ///
102    /// Monotonicity matters because rewinding would re-issue nonces already
103    /// dispatched on the wire, which the venue rejects as replays. A stale
104    /// or out-of-date venue snapshot must never clobber more recent local
105    /// allocations.
106    pub fn refresh(&self, wallet: &str, subaccount_id: u64, last_seen_nonce: u64) {
107        let state = self.state_for(wallet, subaccount_id);
108        loop {
109            let current = state.load(Ordering::Acquire);
110            if last_seen_nonce <= current {
111                return;
112            }
113
114            if state
115                .compare_exchange_weak(
116                    current,
117                    last_seen_nonce,
118                    Ordering::AcqRel,
119                    Ordering::Acquire,
120                )
121                .is_ok()
122            {
123                return;
124            }
125        }
126    }
127
128    /// Returns the most recently issued nonce for a key, if any.
129    #[must_use]
130    pub fn last_issued(&self, wallet: &str, subaccount_id: u64) -> Option<u64> {
131        self.states
132            .get(&Self::normalize_key(wallet, subaccount_id))
133            .map(|s| s.load(Ordering::Acquire))
134            .filter(|n| *n != 0)
135    }
136
137    fn state_for(&self, wallet: &str, subaccount_id: u64) -> Arc<AtomicU64> {
138        let entry = self
139            .states
140            .entry(Self::normalize_key(wallet, subaccount_id))
141            .or_insert_with(|| Arc::new(AtomicU64::new(0)));
142        entry.value().clone()
143    }
144
145    // Lowercase the wallet hex so checksum and lowercase forms of the same
146    // EVM address share a single nonce stream; mixing them would otherwise
147    // issue duplicate nonces for the same on-chain account. All read and
148    // write paths must route through here to stay symmetrical.
149    fn normalize_key(wallet: &str, subaccount_id: u64) -> (String, u64) {
150        (wallet.to_ascii_lowercase(), subaccount_id)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use std::{sync::Arc as StdArc, thread};
157
158    use rstest::rstest;
159
160    use super::*;
161
162    const WALLET_A: &str = "0x000000000000000000000000000000000000aaaa";
163    const WALLET_B: &str = "0x000000000000000000000000000000000000bbbb";
164
165    #[rstest]
166    fn test_next_nonce_at_first_call_concatenates_zero_suffix() {
167        let mgr = NonceManager::new();
168        let nonce = mgr.next_nonce_at(WALLET_A, 1, 1_700_000_000_000);
169        // ms appended with "0" -> ms * 10
170        assert_eq!(nonce, 17_000_000_000_000);
171    }
172
173    #[rstest]
174    fn test_sequential_calls_within_same_ms_are_monotonic() {
175        let mgr = NonceManager::new();
176        let n1 = mgr.next_nonce_at(WALLET_A, 1, 1_700_000_000_000);
177        let n2 = mgr.next_nonce_at(WALLET_A, 1, 1_700_000_000_000);
178        let n3 = mgr.next_nonce_at(WALLET_A, 1, 1_700_000_000_000);
179        assert!(
180            n1 < n2 && n2 < n3,
181            "nonces must be monotonic, was {n1}, {n2}, {n3}"
182        );
183        assert_eq!(n2 - n1, 1);
184        assert_eq!(n3 - n2, 1);
185    }
186
187    #[rstest]
188    fn test_advancing_clock_jumps_to_new_prefix() {
189        let mgr = NonceManager::new();
190        let n1 = mgr.next_nonce_at(WALLET_A, 1, 1_700_000_000_000);
191        let n2 = mgr.next_nonce_at(WALLET_A, 1, 1_700_000_000_001);
192        // n1 = ms * 10 = 17000000000000
193        // n2 should restart at ms2 * 10 = 17000000000010, larger by 10.
194        assert_eq!(n1, 17_000_000_000_000);
195        assert_eq!(n2, 17_000_000_000_010);
196        assert!(n2 > n1);
197    }
198
199    #[rstest]
200    fn test_distinct_keys_track_independent_state() {
201        let mgr = NonceManager::new();
202        let a = mgr.next_nonce_at(WALLET_A, 1, 1_700_000_000_000);
203        let b = mgr.next_nonce_at(WALLET_B, 1, 1_700_000_000_000);
204        // Same ms reference, but each wallet starts from scratch
205        assert_eq!(a, b);
206        // And subsequent calls advance independently
207        let a2 = mgr.next_nonce_at(WALLET_A, 1, 1_700_000_000_000);
208        assert_eq!(a2, a + 1);
209        assert_eq!(mgr.last_issued(WALLET_B, 1), Some(b));
210    }
211
212    #[rstest]
213    fn test_distinct_subaccounts_track_independent_state() {
214        let mgr = NonceManager::new();
215        let a1 = mgr.next_nonce_at(WALLET_A, 1, 1_700_000_000_000);
216        let a2 = mgr.next_nonce_at(WALLET_A, 2, 1_700_000_000_000);
217        assert_eq!(a1, a2);
218    }
219
220    #[rstest]
221    fn test_refresh_advances_last_issued() {
222        let mgr = NonceManager::new();
223        mgr.next_nonce_at(WALLET_A, 1, 1_700_000_000_000);
224        mgr.refresh(WALLET_A, 1, 99_999_999_999_999);
225        let n = mgr.next_nonce_at(WALLET_A, 1, 1_700_000_000_000);
226        assert_eq!(n, 99_999_999_999_999 + 1);
227    }
228
229    #[rstest]
230    fn test_refresh_never_rewinds_below_local_state() {
231        let mgr = NonceManager::new();
232        // Issue a high local nonce, then accept a stale (lower) venue
233        // snapshot. The stored state must not rewind, otherwise the next
234        // allocation would re-issue an already-dispatched nonce.
235        let high = mgr.next_nonce_at(WALLET_A, 1, 9_000_000_000_000);
236        mgr.refresh(WALLET_A, 1, 1_000);
237        let next = mgr.next_nonce_at(WALLET_A, 1, 1_000);
238        assert!(
239            next > high,
240            "refresh below local state must not rewind, last={high}, next={next}",
241        );
242    }
243
244    #[rstest]
245    fn test_checksum_and_lowercase_wallet_share_state() {
246        let mgr = NonceManager::new();
247        let lowercase = "0x000000000000000000000000000000000000abcd";
248        let checksum = "0x000000000000000000000000000000000000ABCD";
249        let n1 = mgr.next_nonce_at(lowercase, 1, 1_700_000_000_000);
250        let n2 = mgr.next_nonce_at(checksum, 1, 1_700_000_000_000);
251        assert_eq!(
252            n2,
253            n1 + 1,
254            "checksum and lowercase forms of the same address must share one nonce stream",
255        );
256    }
257
258    #[rstest]
259    fn test_last_issued_finds_state_regardless_of_address_case() {
260        let mgr = NonceManager::new();
261        let lowercase = "0x000000000000000000000000000000000000abcd";
262        let checksum = "0x000000000000000000000000000000000000ABCD";
263        let issued = mgr.next_nonce_at(lowercase, 1, 1_700_000_000_000);
264        assert_eq!(mgr.last_issued(lowercase, 1), Some(issued));
265        assert_eq!(
266            mgr.last_issued(checksum, 1),
267            Some(issued),
268            "lookup must normalize the same way as next_nonce/refresh",
269        );
270    }
271
272    #[rstest]
273    fn test_last_issued_reports_latest_value() {
274        let mgr = NonceManager::new();
275        assert_eq!(mgr.last_issued(WALLET_A, 1), None);
276        let n = mgr.next_nonce_at(WALLET_A, 1, 1_700_000_000_000);
277        assert_eq!(mgr.last_issued(WALLET_A, 1), Some(n));
278    }
279
280    #[rstest]
281    fn test_concurrent_callers_see_no_duplicates_or_gaps() {
282        let mgr = StdArc::new(NonceManager::new());
283        let threads = 8;
284        let per_thread = 250;
285        let now_ms = 1_700_000_000_000;
286        let handles: Vec<_> = (0..threads)
287            .map(|_| {
288                let mgr = StdArc::clone(&mgr);
289
290                thread::spawn(move || -> Vec<u64> {
291                    (0..per_thread)
292                        .map(|_| mgr.next_nonce_at(WALLET_A, 1, now_ms))
293                        .collect()
294                })
295            })
296            .collect();
297        let mut all = Vec::with_capacity(threads * per_thread);
298        for h in handles {
299            all.extend(h.join().unwrap());
300        }
301        all.sort_unstable();
302        let total = (threads * per_thread) as u64;
303        let expected: Vec<u64> = (0..total).map(|i| now_ms * 10 + i).collect();
304        assert_eq!(
305            all, expected,
306            "concurrent issuance must be contiguous from ms*10",
307        );
308    }
309
310    #[rstest]
311    fn test_next_nonce_uses_system_clock_when_called_without_injection() {
312        let mgr = NonceManager::new();
313        let n = mgr.next_nonce(WALLET_A, 1).unwrap();
314        // System clock must be past Jan 2026 (1.7e12 ms), and the nonce is
315        // ms * 10 so it must exceed 1.7e13.
316        assert!(n > 17_000_000_000_000, "nonce too small: {n}");
317    }
318}