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