Skip to main content

nautilus_lighter/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-`(account_index, api_key_index)` sequential nonce manager.
17//!
18//! Mirrors the optimistic strategy `lighter-python`'s `OptimisticNonceManager`
19//! uses: each call to [`NonceManager::next_nonce`] hands out the next monotonic
20//! integer for the requested key without waiting for the venue to confirm the
21//! prior one. The caller bounds the number of unconfirmed allocations through
22//! the [`NonceManager::skip_window`] argument; once the window is exhausted,
23//! [`NonceManager::next_nonce`] errors so the caller can drain or refresh
24//! before issuing further transactions.
25//!
26//! The window is the L2's tolerance for out-of-order submission: the sequencer
27//! accepts a tx whose nonce is up to `skip_window` ahead of the last applied
28//! nonce, which is what makes the optimistic allocation correct in the first
29//! place. Lower the window if the venue rejects rates of out-of-order accepts;
30//! raise it when burst-trading multiple keys.
31//!
32//! The module is lock-free per key: a [`DashMap`] keys an [`Arc`] holding two
33//! [`AtomicI64`]s (`last_issued` and `baseline`). [`NonceManager::next_nonce`]
34//! is a CAS loop bounded by `skip_window`; [`NonceManager::ack_success`]
35//! monotonically advances the baseline when the venue confirms a tx;
36//! [`NonceManager::ack_failure_if_latest`] rolls back the most recent
37//! allocation when the failed nonce is still the latest issuance.
38
39use std::sync::{
40    Arc,
41    atomic::{AtomicI64, Ordering},
42};
43
44use dashmap::DashMap;
45use thiserror::Error;
46
47/// Default skip-window used when the caller does not specify one.
48///
49/// The venue accepts up to 16 out-of-order nonces per `(account, api_key)`;
50/// matching that bound on the client keeps optimistic submissions inside the
51/// sequencer's tolerance.
52pub const DEFAULT_SKIP_WINDOW: u32 = 16;
53
54/// Errors raised by [`NonceManager`].
55#[derive(Debug, Error, PartialEq, Eq)]
56pub enum NonceError {
57    /// `next_nonce` was called before [`NonceManager::refresh`] seeded the
58    /// `(account_index, api_key_index)` pair.
59    #[error("nonce manager not initialized for account={account_index}, api_key={api_key_index}")]
60    NotInitialized {
61        /// Lighter L2 account index.
62        account_index: i64,
63        /// Per-account API key slot.
64        api_key_index: u8,
65    },
66    /// More than `skip_window` nonces are outstanding for this key.
67    #[error(
68        "skip-window exhausted for account={account_index}, api_key={api_key_index}: outstanding={outstanding}, window={skip_window}"
69    )]
70    SkipWindowExhausted {
71        /// Lighter L2 account index.
72        account_index: i64,
73        /// Per-account API key slot.
74        api_key_index: u8,
75        /// Number of nonces already issued past the last `refresh` baseline.
76        outstanding: u32,
77        /// Configured tolerance.
78        skip_window: u32,
79    },
80    /// `ack_failure` was called while no nonce had been issued past the
81    /// baseline; nothing to roll back.
82    #[error(
83        "no outstanding nonce to roll back for account={account_index}, api_key={api_key_index}"
84    )]
85    NothingToRollBack {
86        /// Lighter L2 account index.
87        account_index: i64,
88        /// Per-account API key slot.
89        api_key_index: u8,
90    },
91}
92
93/// Thread-safe sequential nonce allocator keyed by `(account_index, api_key_index)`.
94///
95/// Construct with [`NonceManager::new`] (or [`NonceManager::default`] for
96/// [`DEFAULT_SKIP_WINDOW`]) and seed each key via [`NonceManager::refresh`]
97/// from the `nextNonce` REST endpoint before calling [`NonceManager::next_nonce`].
98#[derive(Debug)]
99pub struct NonceManager {
100    skip_window: u32,
101    states: DashMap<(i64, u8), Arc<AccountNonce>>,
102}
103
104impl NonceManager {
105    /// Construct a manager with an explicit `skip_window` tolerance.
106    #[must_use]
107    pub fn new(skip_window: u32) -> Self {
108        Self {
109            skip_window,
110            states: DashMap::new(),
111        }
112    }
113
114    /// Configured outstanding-allocation bound.
115    #[must_use]
116    pub fn skip_window(&self) -> u32 {
117        self.skip_window
118    }
119
120    /// Seed (or hard-reset) the nonce baseline for a key.
121    ///
122    /// `venue_next_nonce` is what the venue's `nextNonce` endpoint reports as
123    /// the next expected nonce for `(account_index, api_key_index)`. After
124    /// this call, the very next [`NonceManager::next_nonce`] for the same key
125    /// returns `venue_next_nonce`.
126    ///
127    /// Call this once at startup, and again after a venue rejection signals
128    /// the local view is stale (mirrors `hard_refresh_nonce` in the Python
129    /// reference).
130    ///
131    /// Caller contract: `refresh` is a control-plane operation and is not
132    /// mutually exclusive with concurrent [`NonceManager::next_nonce`] calls
133    /// on the same key. A `next_nonce` that started before `refresh` may
134    /// still complete with a pre-refresh value. The Python reference makes
135    /// the same trade-off; callers that need exclusive semantics must
136    /// serialize `refresh` against in-flight allocations themselves
137    /// (typically by quiescing submission before issuing a hard refresh).
138    pub fn refresh(&self, account_index: i64, api_key_index: u8, venue_next_nonce: i64) {
139        let entry = self
140            .states
141            .entry((account_index, api_key_index))
142            .or_insert_with(|| Arc::new(AccountNonce::new(venue_next_nonce - 1)));
143
144        // Per the caller contract above, `refresh` is not mutually exclusive
145        // with concurrent `next_nonce` on the same key: a racing allocator
146        // may load a mixed pre/post-refresh pair. The CAS in `next_nonce`
147        // detects only the `last_issued` mutation, so the documented
148        // contract is what makes refresh-vs-allocate safe — not these
149        // stores. Release ordering carries `baseline` to subsequent Acquire
150        // loads after the caller serializes refresh quiescently.
151        entry
152            .baseline
153            .store(venue_next_nonce - 1, Ordering::Release);
154        entry
155            .last_issued
156            .store(venue_next_nonce - 1, Ordering::Release);
157    }
158
159    /// Monotonically advance the baseline toward the venue's reported
160    /// `nextNonce` without ever moving state backwards.
161    ///
162    /// Unlike [`NonceManager::refresh`], which hard-resets both `baseline`
163    /// and `last_issued` and can therefore reissue nonces already signed
164    /// into in-flight transactions, this method only lifts values: it is
165    /// safe to call while submissions are in flight. Use it to recover from
166    /// [`NonceError::SkipWindowExhausted`] when the venue may have applied
167    /// transactions whose acks never reached the manager (for example HTTP
168    /// `sendTxBatch` submissions).
169    ///
170    /// `last_issued` is lifted before `baseline` so a concurrent
171    /// [`NonceManager::next_nonce`] never observes a baseline ahead of
172    /// `last_issued`, which would make it hand out nonces the venue has
173    /// already consumed.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`NonceError::NotInitialized`] if [`NonceManager::refresh`]
178    /// has not run for this key.
179    pub fn sync_from_venue(
180        &self,
181        account_index: i64,
182        api_key_index: u8,
183        venue_next_nonce: i64,
184    ) -> Result<(), NonceError> {
185        let state = self.state_for(account_index, api_key_index)?;
186        let applied = venue_next_nonce - 1;
187        state.last_issued.fetch_max(applied, Ordering::AcqRel);
188        state.baseline.fetch_max(applied, Ordering::AcqRel);
189        Ok(())
190    }
191
192    /// Allocate the next nonce for `(account_index, api_key_index)`.
193    ///
194    /// Returns the issued integer on success. Errors with
195    /// [`NonceError::NotInitialized`] if [`NonceManager::refresh`] has not run
196    /// for this key, and with [`NonceError::SkipWindowExhausted`] if the
197    /// number of nonces already issued past the last baseline exceeds
198    /// [`NonceManager::skip_window`]. The CAS loop guarantees monotonic,
199    /// gap-free issuance under contention; concurrent callers serialize
200    /// through the atomic compare-exchange.
201    pub fn next_nonce(&self, account_index: i64, api_key_index: u8) -> Result<i64, NonceError> {
202        let state = self.state_for(account_index, api_key_index)?;
203
204        loop {
205            let last = state.last_issued.load(Ordering::Acquire);
206            let baseline = state.baseline.load(Ordering::Acquire);
207            let next = last.wrapping_add(1);
208            let outstanding = next.saturating_sub(baseline);
209
210            if outstanding > i64::from(self.skip_window) {
211                return Err(NonceError::SkipWindowExhausted {
212                    account_index,
213                    api_key_index,
214                    outstanding: u32::try_from(outstanding).unwrap_or(u32::MAX),
215                    skip_window: self.skip_window,
216                });
217            }
218
219            if state
220                .last_issued
221                .compare_exchange_weak(last, next, Ordering::AcqRel, Ordering::Acquire)
222                .is_ok()
223            {
224                return Ok(next);
225            }
226        }
227    }
228
229    /// Record a venue success ack for `nonce`, monotonically advancing the
230    /// baseline to `max(baseline, nonce)`.
231    ///
232    /// The venue has applied the acked transaction, so every nonce up to and
233    /// including `nonce` no longer counts against the skip window. The
234    /// advance is a monotonic max: a misattributed ack (one that pops the
235    /// wrong pending entry) can only open the window early, never shrink it
236    /// or cause a nonce to be reissued, because acked nonces are always ones
237    /// this manager issued.
238    ///
239    /// # Errors
240    ///
241    /// Returns [`NonceError::NotInitialized`] if [`NonceManager::refresh`]
242    /// has not run for this key.
243    pub fn ack_success(
244        &self,
245        account_index: i64,
246        api_key_index: u8,
247        nonce: i64,
248    ) -> Result<(), NonceError> {
249        let state = self.state_for(account_index, api_key_index)?;
250        state.baseline.fetch_max(nonce, Ordering::AcqRel);
251        Ok(())
252    }
253
254    /// Roll back the most recently issued nonce for the given key.
255    ///
256    /// Mirrors `acknowledge_failure` in the Python reference: when the venue
257    /// rejects a tx outside the "stale nonce" path, the manager decrements
258    /// `last_issued` so the next [`NonceManager::next_nonce`] reuses the freed
259    /// integer. Errors with [`NonceError::NothingToRollBack`] when called
260    /// while `last_issued == baseline`.
261    ///
262    /// Caller contract: only the most recent issuance may be rolled back, and
263    /// only before any newer nonce reaches the wire. Callers that cannot
264    /// guarantee this (any path with multiple in-flight txs) must use
265    /// [`NonceManager::ack_failure_if_latest`] instead.
266    pub fn ack_failure(&self, account_index: i64, api_key_index: u8) -> Result<i64, NonceError> {
267        let state = self.state_for(account_index, api_key_index)?;
268
269        loop {
270            let last = state.last_issued.load(Ordering::Acquire);
271            let baseline = state.baseline.load(Ordering::Acquire);
272
273            if last == baseline {
274                return Err(NonceError::NothingToRollBack {
275                    account_index,
276                    api_key_index,
277                });
278            }
279
280            let prev = last - 1;
281
282            if state
283                .last_issued
284                .compare_exchange_weak(last, prev, Ordering::AcqRel, Ordering::Acquire)
285                .is_ok()
286            {
287                return Ok(last);
288            }
289        }
290    }
291
292    /// Roll back `nonce` only when it is still the most recent issuance.
293    ///
294    /// Returns `Ok(true)` when `last_issued` was decremented from `nonce` to
295    /// `nonce - 1`, and `Ok(false)` when the rollback was skipped: either a
296    /// newer nonce has been issued (so decrementing would free an integer
297    /// already signed into an in-flight tx, and the next allocation would
298    /// duplicate it on the wire), or the baseline has already advanced to
299    /// `nonce` (the venue applied it, so the failure signal is stale). A
300    /// skipped rollback leaves a gap that heals through
301    /// [`NonceManager::ack_success`] or [`NonceManager::sync_from_venue`].
302    ///
303    /// # Errors
304    ///
305    /// Returns [`NonceError::NotInitialized`] if [`NonceManager::refresh`]
306    /// has not run for this key.
307    pub fn ack_failure_if_latest(
308        &self,
309        account_index: i64,
310        api_key_index: u8,
311        nonce: i64,
312    ) -> Result<bool, NonceError> {
313        let state = self.state_for(account_index, api_key_index)?;
314
315        loop {
316            let last = state.last_issued.load(Ordering::Acquire);
317            let baseline = state.baseline.load(Ordering::Acquire);
318
319            if last != nonce || last <= baseline {
320                return Ok(false);
321            }
322
323            if state
324                .last_issued
325                .compare_exchange_weak(last, last - 1, Ordering::AcqRel, Ordering::Acquire)
326                .is_ok()
327            {
328                return Ok(true);
329            }
330        }
331    }
332
333    /// Snapshot the last issued nonce for diagnostic and test purposes.
334    #[must_use]
335    pub fn last_issued(&self, account_index: i64, api_key_index: u8) -> Option<i64> {
336        self.states
337            .get(&(account_index, api_key_index))
338            .map(|s| s.last_issued.load(Ordering::Acquire))
339    }
340
341    /// Snapshot the configured baseline for diagnostic and test purposes.
342    #[must_use]
343    pub fn baseline(&self, account_index: i64, api_key_index: u8) -> Option<i64> {
344        self.states
345            .get(&(account_index, api_key_index))
346            .map(|s| s.baseline.load(Ordering::Acquire))
347    }
348
349    /// Look up the per-key state, dropping the [`DashMap`] guard before
350    /// returning so callers can spin in CAS loops without holding a shard
351    /// lock.
352    fn state_for(
353        &self,
354        account_index: i64,
355        api_key_index: u8,
356    ) -> Result<Arc<AccountNonce>, NonceError> {
357        let entry =
358            self.states
359                .get(&(account_index, api_key_index))
360                .ok_or(NonceError::NotInitialized {
361                    account_index,
362                    api_key_index,
363                })?;
364        let state = entry.value().clone();
365        drop(entry);
366        Ok(state)
367    }
368}
369
370impl Default for NonceManager {
371    fn default() -> Self {
372        Self::new(DEFAULT_SKIP_WINDOW)
373    }
374}
375
376#[derive(Debug)]
377struct AccountNonce {
378    last_issued: AtomicI64,
379    baseline: AtomicI64,
380}
381
382impl AccountNonce {
383    fn new(initial: i64) -> Self {
384        Self {
385            last_issued: AtomicI64::new(initial),
386            baseline: AtomicI64::new(initial),
387        }
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use std::{sync::Arc as StdArc, thread};
394
395    use proptest::prelude::*;
396    use rstest::rstest;
397
398    use super::*;
399
400    const ACCOUNT: i64 = 12345;
401    const API_KEY: u8 = 5;
402
403    #[rstest]
404    fn next_nonce_uninitialized_errors() {
405        let mgr = NonceManager::new(8);
406        let err = mgr.next_nonce(ACCOUNT, API_KEY).expect_err("must error");
407        assert_eq!(
408            err,
409            NonceError::NotInitialized {
410                account_index: ACCOUNT,
411                api_key_index: API_KEY
412            },
413        );
414    }
415
416    #[rstest]
417    fn ack_failure_uninitialized_errors() {
418        let mgr = NonceManager::new(8);
419        let err = mgr.ack_failure(ACCOUNT, API_KEY).expect_err("must error");
420        assert_eq!(
421            err,
422            NonceError::NotInitialized {
423                account_index: ACCOUNT,
424                api_key_index: API_KEY
425            },
426        );
427    }
428
429    #[rstest]
430    fn default_uses_default_skip_window() {
431        let mgr = NonceManager::default();
432        assert_eq!(
433            mgr.skip_window(),
434            DEFAULT_SKIP_WINDOW,
435            "Default impl must use DEFAULT_SKIP_WINDOW, was {}",
436            mgr.skip_window(),
437        );
438    }
439
440    #[rstest]
441    fn last_issued_and_baseline_return_none_for_absent_key() {
442        let mgr = NonceManager::new(8);
443        assert_eq!(
444            mgr.last_issued(ACCOUNT, API_KEY),
445            None,
446            "absent key must report no last_issued",
447        );
448        assert_eq!(
449            mgr.baseline(ACCOUNT, API_KEY),
450            None,
451            "absent key must report no baseline",
452        );
453    }
454
455    #[rstest]
456    fn baseline_pins_to_refresh_value_through_allocations() {
457        let mgr = NonceManager::new(8);
458        mgr.refresh(ACCOUNT, API_KEY, 42);
459        assert_eq!(
460            mgr.baseline(ACCOUNT, API_KEY),
461            Some(41),
462            "baseline must equal venue_next_nonce - 1 after refresh",
463        );
464
465        for _ in 0..3 {
466            mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
467        }
468        assert_eq!(
469            mgr.baseline(ACCOUNT, API_KEY),
470            Some(41),
471            "baseline must not move when next_nonce advances last_issued",
472        );
473
474        mgr.refresh(ACCOUNT, API_KEY, 100);
475        assert_eq!(
476            mgr.baseline(ACCOUNT, API_KEY),
477            Some(99),
478            "subsequent refresh must reset baseline to new venue value - 1",
479        );
480    }
481
482    #[rstest]
483    fn refresh_then_next_nonce_starts_at_venue_value() {
484        let mgr = NonceManager::new(8);
485        mgr.refresh(ACCOUNT, API_KEY, 42);
486        let n = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
487        assert_eq!(n, 42, "first nonce must equal venue baseline, was {n}");
488    }
489
490    #[rstest]
491    fn next_nonce_is_monotonic_and_gap_free() {
492        let mgr = NonceManager::new(64);
493        mgr.refresh(ACCOUNT, API_KEY, 0);
494        let issued: Vec<i64> = (0..32)
495            .map(|_| mgr.next_nonce(ACCOUNT, API_KEY).unwrap())
496            .collect();
497        let expected: Vec<i64> = (0..32).collect();
498        assert_eq!(
499            issued, expected,
500            "nonces must be monotonic and gap-free, was {issued:?}",
501        );
502    }
503
504    #[rstest]
505    fn skip_window_caps_outstanding_allocations() {
506        let mgr = NonceManager::new(4);
507        mgr.refresh(ACCOUNT, API_KEY, 100);
508        for _ in 0..4 {
509            mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
510        }
511        let err = mgr.next_nonce(ACCOUNT, API_KEY).expect_err("must error");
512        match err {
513            NonceError::SkipWindowExhausted {
514                outstanding,
515                skip_window,
516                ..
517            } => {
518                assert_eq!(skip_window, 4, "skip_window mismatch, was {skip_window}");
519                assert_eq!(outstanding, 5, "outstanding mismatch, was {outstanding}");
520            }
521            other => panic!("expected SkipWindowExhausted, was {other:?}"),
522        }
523    }
524
525    #[rstest]
526    fn ack_failure_rolls_back_most_recent_issuance() {
527        let mgr = NonceManager::new(8);
528        mgr.refresh(ACCOUNT, API_KEY, 0);
529        let issued = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
530        let rolled = mgr.ack_failure(ACCOUNT, API_KEY).unwrap();
531        assert_eq!(
532            rolled, issued,
533            "ack_failure must report rolled-back nonce, was {rolled}",
534        );
535        let reused = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
536        assert_eq!(
537            reused, issued,
538            "rolled-back nonce must be reissued, was {reused}"
539        );
540    }
541
542    #[rstest]
543    fn ack_failure_at_baseline_errors() {
544        let mgr = NonceManager::new(8);
545        mgr.refresh(ACCOUNT, API_KEY, 7);
546        let err = mgr.ack_failure(ACCOUNT, API_KEY).expect_err("must error");
547        assert_eq!(
548            err,
549            NonceError::NothingToRollBack {
550                account_index: ACCOUNT,
551                api_key_index: API_KEY
552            },
553        );
554    }
555
556    #[rstest]
557    fn ack_success_uninitialized_errors() {
558        let mgr = NonceManager::new(8);
559        let err = mgr
560            .ack_success(ACCOUNT, API_KEY, 5)
561            .expect_err("must error");
562        assert_eq!(
563            err,
564            NonceError::NotInitialized {
565                account_index: ACCOUNT,
566                api_key_index: API_KEY
567            },
568        );
569    }
570
571    #[rstest]
572    fn ack_success_advances_baseline_monotonically() {
573        let mgr = NonceManager::new(8);
574        mgr.refresh(ACCOUNT, API_KEY, 0);
575        for _ in 0..5 {
576            mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
577        }
578
579        mgr.ack_success(ACCOUNT, API_KEY, 2).unwrap();
580        assert_eq!(
581            mgr.baseline(ACCOUNT, API_KEY),
582            Some(2),
583            "ack must advance baseline to the acked nonce",
584        );
585
586        mgr.ack_success(ACCOUNT, API_KEY, 0).unwrap();
587        assert_eq!(
588            mgr.baseline(ACCOUNT, API_KEY),
589            Some(2),
590            "lower ack must not retreat the baseline",
591        );
592
593        mgr.ack_success(ACCOUNT, API_KEY, 4).unwrap();
594        assert_eq!(mgr.baseline(ACCOUNT, API_KEY), Some(4));
595        assert_eq!(
596            mgr.last_issued(ACCOUNT, API_KEY),
597            Some(4),
598            "ack must not touch last_issued",
599        );
600    }
601
602    #[rstest]
603    fn ack_success_recovers_window_across_more_than_window_txs() {
604        let window = 16_u32;
605        let total = 40_i64;
606        let mgr = NonceManager::new(window);
607        mgr.refresh(ACCOUNT, API_KEY, 0);
608
609        let mut issued = Vec::with_capacity(total as usize);
610        for i in 0..total {
611            if i >= i64::from(window) {
612                // Ack the oldest outstanding tx; pre-fix the 17th allocation failed
613                mgr.ack_success(ACCOUNT, API_KEY, i - i64::from(window))
614                    .unwrap();
615            }
616            issued.push(mgr.next_nonce(ACCOUNT, API_KEY).unwrap());
617        }
618
619        let expected: Vec<i64> = (0..total).collect();
620        assert_eq!(
621            issued, expected,
622            "interleaved acks must keep issuance contiguous past the window",
623        );
624    }
625
626    #[rstest]
627    fn sync_from_venue_uninitialized_errors() {
628        let mgr = NonceManager::new(8);
629        let err = mgr
630            .sync_from_venue(ACCOUNT, API_KEY, 5)
631            .expect_err("must error");
632        assert_eq!(
633            err,
634            NonceError::NotInitialized {
635                account_index: ACCOUNT,
636                api_key_index: API_KEY
637            },
638        );
639    }
640
641    #[rstest]
642    fn sync_from_venue_lifts_baseline_and_last_issued() {
643        let mgr = NonceManager::new(2);
644        mgr.refresh(ACCOUNT, API_KEY, 0);
645        for _ in 0..2 {
646            mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
647        }
648        assert!(
649            mgr.next_nonce(ACCOUNT, API_KEY).is_err(),
650            "window must trip"
651        );
652
653        // Venue applied both txs (next expected nonce is 2)
654        mgr.sync_from_venue(ACCOUNT, API_KEY, 2).unwrap();
655        assert_eq!(mgr.baseline(ACCOUNT, API_KEY), Some(1));
656        assert_eq!(
657            mgr.last_issued(ACCOUNT, API_KEY),
658            Some(1),
659            "venue sync must not retreat last_issued below issued nonces",
660        );
661        let n = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
662        assert_eq!(n, 2, "venue sync must re-arm allocation, was {n}");
663
664        // Venue jumped ahead; both values lift so allocation resumes there
665        mgr.sync_from_venue(ACCOUNT, API_KEY, 10).unwrap();
666        assert_eq!(mgr.baseline(ACCOUNT, API_KEY), Some(9));
667        assert_eq!(mgr.last_issued(ACCOUNT, API_KEY), Some(9));
668        let n = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
669        assert_eq!(n, 10, "allocation must resume at venue nonce, was {n}");
670    }
671
672    #[rstest]
673    fn sync_from_venue_never_moves_backwards() {
674        let mgr = NonceManager::new(8);
675        mgr.refresh(ACCOUNT, API_KEY, 100);
676        for _ in 0..2 {
677            mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
678        }
679
680        // A stale venue read must not free nonces signed into in-flight txs
681        mgr.sync_from_venue(ACCOUNT, API_KEY, 50).unwrap();
682        assert_eq!(mgr.baseline(ACCOUNT, API_KEY), Some(99));
683        assert_eq!(mgr.last_issued(ACCOUNT, API_KEY), Some(101));
684        let n = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
685        assert_eq!(n, 102, "stale venue read must not cause reissue, was {n}");
686    }
687
688    #[rstest]
689    fn ack_failure_if_latest_uninitialized_errors() {
690        let mgr = NonceManager::new(8);
691        let err = mgr
692            .ack_failure_if_latest(ACCOUNT, API_KEY, 5)
693            .expect_err("must error");
694        assert_eq!(
695            err,
696            NonceError::NotInitialized {
697                account_index: ACCOUNT,
698                api_key_index: API_KEY
699            },
700        );
701    }
702
703    #[rstest]
704    fn ack_failure_if_latest_rolls_back_latest_issuance() {
705        let mgr = NonceManager::new(8);
706        mgr.refresh(ACCOUNT, API_KEY, 0);
707        mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
708        let latest = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
709
710        let rolled = mgr.ack_failure_if_latest(ACCOUNT, API_KEY, latest).unwrap();
711        assert!(rolled, "latest issuance must roll back");
712        let reused = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
713        assert_eq!(
714            reused, latest,
715            "rolled-back nonce must be reissued, was {reused}",
716        );
717    }
718
719    #[rstest]
720    fn ack_failure_if_latest_skips_with_newer_issuance() {
721        let mgr = NonceManager::new(8);
722        mgr.refresh(ACCOUNT, API_KEY, 0);
723        let older = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
724        let newer = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
725
726        let rolled = mgr.ack_failure_if_latest(ACCOUNT, API_KEY, older).unwrap();
727        assert!(!rolled, "non-latest nonce must not roll back");
728        assert_eq!(
729            mgr.last_issued(ACCOUNT, API_KEY),
730            Some(newer),
731            "skipped rollback must leave last_issued alone",
732        );
733        let next = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
734        assert_eq!(
735            next,
736            newer + 1,
737            "no nonce signed into an in-flight tx may be reissued, was {next}",
738        );
739    }
740
741    #[rstest]
742    fn ack_failure_if_latest_skips_when_baseline_caught_up() {
743        let mgr = NonceManager::new(8);
744        mgr.refresh(ACCOUNT, API_KEY, 0);
745        let nonce = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
746        mgr.ack_success(ACCOUNT, API_KEY, nonce).unwrap();
747
748        let rolled = mgr.ack_failure_if_latest(ACCOUNT, API_KEY, nonce).unwrap();
749        assert!(
750            !rolled,
751            "a nonce the venue already applied must not roll back",
752        );
753        assert_eq!(mgr.last_issued(ACCOUNT, API_KEY), Some(nonce));
754    }
755
756    #[rstest]
757    fn refresh_resets_after_skip_window_exhausted() {
758        let mgr = NonceManager::new(2);
759        mgr.refresh(ACCOUNT, API_KEY, 0);
760        for _ in 0..2 {
761            mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
762        }
763        assert!(
764            mgr.next_nonce(ACCOUNT, API_KEY).is_err(),
765            "window must trip"
766        );
767        // Venue confirms our view caught up; refresh re-anchors the baseline.
768        mgr.refresh(ACCOUNT, API_KEY, 5);
769        let n = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
770        assert_eq!(n, 5, "refresh must re-arm allocation, was {n}");
771    }
772
773    #[rstest]
774    fn distinct_keys_track_independent_state() {
775        let mgr = NonceManager::new(8);
776        mgr.refresh(ACCOUNT, 0, 0);
777        mgr.refresh(ACCOUNT, 1, 100);
778        let a = mgr.next_nonce(ACCOUNT, 0).unwrap();
779        let b = mgr.next_nonce(ACCOUNT, 1).unwrap();
780        assert_eq!(a, 0, "key 0 must start at 0, was {a}");
781        assert_eq!(b, 100, "key 1 must start at 100, was {b}");
782    }
783
784    #[rstest]
785    fn concurrent_callers_see_no_duplicate_or_gap() {
786        let mgr = StdArc::new(NonceManager::new(10_000));
787        mgr.refresh(ACCOUNT, API_KEY, 0);
788        let threads = 8;
789        let per_thread = 250;
790        let handles: Vec<_> = (0..threads)
791            .map(|_| {
792                let mgr = StdArc::clone(&mgr);
793
794                thread::spawn(move || -> Vec<i64> {
795                    (0..per_thread)
796                        .map(|_| mgr.next_nonce(ACCOUNT, API_KEY).unwrap())
797                        .collect()
798                })
799            })
800            .collect();
801        let mut all = Vec::with_capacity(threads * per_thread);
802        for h in handles {
803            all.extend(h.join().unwrap());
804        }
805        all.sort_unstable();
806        let expected: Vec<i64> = (0..(threads as i64) * (per_thread as i64)).collect();
807        assert_eq!(
808            all, expected,
809            "concurrent issuance must cover [0, N) without gaps or duplicates",
810        );
811    }
812
813    #[rstest]
814    fn concurrent_allocation_with_interleaved_acks_is_gap_free() {
815        let threads = 4;
816        let per_thread = 200;
817        // Window must absorb at most `threads` unacked allocations at a time
818        let mgr = StdArc::new(NonceManager::new(64));
819        mgr.refresh(ACCOUNT, API_KEY, 0);
820        let handles: Vec<_> = (0..threads)
821            .map(|_| {
822                let mgr = StdArc::clone(&mgr);
823
824                thread::spawn(move || -> Vec<i64> {
825                    (0..per_thread)
826                        .map(|_| {
827                            let nonce = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
828                            mgr.ack_success(ACCOUNT, API_KEY, nonce).unwrap();
829                            nonce
830                        })
831                        .collect()
832                })
833            })
834            .collect();
835        let mut all = Vec::with_capacity(threads * per_thread);
836        for h in handles {
837            all.extend(h.join().unwrap());
838        }
839        all.sort_unstable();
840        let expected: Vec<i64> = (0..(threads as i64) * (per_thread as i64)).collect();
841        assert_eq!(
842            all, expected,
843            "concurrent issuance with acks must cover [0, N) without gaps or duplicates",
844        );
845    }
846
847    proptest! {
848        /// Sequential `next_nonce` calls produce a strictly monotonic, contiguous
849        /// run starting at the refreshed baseline.
850        #[rstest]
851        fn prop_sequential_issuance_is_contiguous(
852            baseline in 0i64..1_000_000,
853            count in 1usize..256,
854        ) {
855            let mgr = NonceManager::new(u32::MAX);
856            mgr.refresh(ACCOUNT, API_KEY, baseline);
857            let issued: Vec<i64> = (0..count)
858                .map(|_| mgr.next_nonce(ACCOUNT, API_KEY).unwrap())
859                .collect();
860
861            for (i, &n) in issued.iter().enumerate() {
862                prop_assert_eq!(n, baseline + i as i64);
863            }
864            prop_assert_eq!(
865                mgr.last_issued(ACCOUNT, API_KEY),
866                Some(baseline + count as i64 - 1),
867            );
868        }
869
870        /// Issue then roll back, and the next allocation reuses the rolled-back
871        /// nonce: the round-trip is identity-preserving on `last_issued`.
872        #[rstest]
873        fn prop_ack_failure_is_idempotent_round_trip(
874            baseline in 0i64..1_000_000,
875            advance in 1usize..32,
876        ) {
877            let mgr = NonceManager::new(u32::MAX);
878            mgr.refresh(ACCOUNT, API_KEY, baseline);
879            for _ in 0..advance - 1 {
880                mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
881            }
882            let issued = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
883            let rolled = mgr.ack_failure(ACCOUNT, API_KEY).unwrap();
884            prop_assert_eq!(rolled, issued);
885            let reused = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
886            prop_assert_eq!(reused, issued);
887        }
888    }
889}