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 an error if [`NonceManager::refresh`] has not run for this key.
175    pub fn sync_from_venue(
176        &self,
177        account_index: i64,
178        api_key_index: u8,
179        venue_next_nonce: i64,
180    ) -> Result<(), NonceError> {
181        let state = self.state_for(account_index, api_key_index)?;
182        let applied = venue_next_nonce - 1;
183        state.last_issued.fetch_max(applied, Ordering::AcqRel);
184        state.baseline.fetch_max(applied, Ordering::AcqRel);
185        Ok(())
186    }
187
188    /// Allocate the next nonce for `(account_index, api_key_index)`.
189    ///
190    /// Returns the issued integer on success. Errors with
191    /// [`NonceError::NotInitialized`] if [`NonceManager::refresh`] has not run
192    /// for this key, and with [`NonceError::SkipWindowExhausted`] if the
193    /// number of nonces already issued past the last baseline exceeds
194    /// [`NonceManager::skip_window`]. The CAS loop guarantees monotonic,
195    /// gap-free issuance under contention; concurrent callers serialize
196    /// through the atomic compare-exchange.
197    pub fn next_nonce(&self, account_index: i64, api_key_index: u8) -> Result<i64, NonceError> {
198        let state = self.state_for(account_index, api_key_index)?;
199
200        loop {
201            let last = state.last_issued.load(Ordering::Acquire);
202            let baseline = state.baseline.load(Ordering::Acquire);
203            let next = last.wrapping_add(1);
204            let outstanding = next.saturating_sub(baseline);
205
206            if outstanding > i64::from(self.skip_window) {
207                return Err(NonceError::SkipWindowExhausted {
208                    account_index,
209                    api_key_index,
210                    outstanding: u32::try_from(outstanding).unwrap_or(u32::MAX),
211                    skip_window: self.skip_window,
212                });
213            }
214
215            if state
216                .last_issued
217                .compare_exchange_weak(last, next, Ordering::AcqRel, Ordering::Acquire)
218                .is_ok()
219            {
220                return Ok(next);
221            }
222        }
223    }
224
225    /// Record a venue success ack for `nonce`, monotonically advancing the
226    /// baseline to `max(baseline, nonce)`.
227    ///
228    /// The venue has applied the acked transaction, so every nonce up to and
229    /// including `nonce` no longer counts against the skip window. The
230    /// advance is a monotonic max: a misattributed ack (one that pops the
231    /// wrong pending entry) can only open the window early, never shrink it
232    /// or cause a nonce to be reissued, because acked nonces are always ones
233    /// this manager issued.
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if [`NonceManager::refresh`] has not run for this key.
238    pub fn ack_success(
239        &self,
240        account_index: i64,
241        api_key_index: u8,
242        nonce: i64,
243    ) -> Result<(), NonceError> {
244        let state = self.state_for(account_index, api_key_index)?;
245        state.baseline.fetch_max(nonce, Ordering::AcqRel);
246        Ok(())
247    }
248
249    /// Roll back the most recently issued nonce for the given key.
250    ///
251    /// Mirrors `acknowledge_failure` in the Python reference: when the venue
252    /// rejects a tx outside the "stale nonce" path, the manager decrements
253    /// `last_issued` so the next [`NonceManager::next_nonce`] reuses the freed
254    /// integer. Errors with [`NonceError::NothingToRollBack`] when called
255    /// while `last_issued == baseline`.
256    ///
257    /// Caller contract: only the most recent issuance may be rolled back, and
258    /// only before any newer nonce reaches the wire. Callers that cannot
259    /// guarantee this (any path with multiple in-flight txs) must use
260    /// [`NonceManager::ack_failure_if_latest`] instead.
261    pub fn ack_failure(&self, account_index: i64, api_key_index: u8) -> Result<i64, NonceError> {
262        let state = self.state_for(account_index, api_key_index)?;
263
264        loop {
265            let last = state.last_issued.load(Ordering::Acquire);
266            let baseline = state.baseline.load(Ordering::Acquire);
267
268            if last == baseline {
269                return Err(NonceError::NothingToRollBack {
270                    account_index,
271                    api_key_index,
272                });
273            }
274
275            let prev = last - 1;
276
277            if state
278                .last_issued
279                .compare_exchange_weak(last, prev, Ordering::AcqRel, Ordering::Acquire)
280                .is_ok()
281            {
282                return Ok(last);
283            }
284        }
285    }
286
287    /// Roll back `nonce` only when it is still the most recent issuance.
288    ///
289    /// Returns `Ok(true)` when `last_issued` was decremented from `nonce` to
290    /// `nonce - 1`, and `Ok(false)` when the rollback was skipped: either a
291    /// newer nonce has been issued (so decrementing would free an integer
292    /// already signed into an in-flight tx, and the next allocation would
293    /// duplicate it on the wire), or the baseline has already advanced to
294    /// `nonce` (the venue applied it, so the failure signal is stale). A
295    /// skipped rollback leaves a gap that heals through
296    /// [`NonceManager::ack_success`] or [`NonceManager::sync_from_venue`].
297    ///
298    /// # Errors
299    ///
300    /// Returns an error if [`NonceManager::refresh`] has not run for this key.
301    pub fn ack_failure_if_latest(
302        &self,
303        account_index: i64,
304        api_key_index: u8,
305        nonce: i64,
306    ) -> Result<bool, NonceError> {
307        let state = self.state_for(account_index, api_key_index)?;
308
309        loop {
310            let last = state.last_issued.load(Ordering::Acquire);
311            let baseline = state.baseline.load(Ordering::Acquire);
312
313            if last != nonce || last <= baseline {
314                return Ok(false);
315            }
316
317            if state
318                .last_issued
319                .compare_exchange_weak(last, last - 1, Ordering::AcqRel, Ordering::Acquire)
320                .is_ok()
321            {
322                return Ok(true);
323            }
324        }
325    }
326
327    /// Snapshot the last issued nonce for diagnostic and test purposes.
328    #[must_use]
329    pub fn last_issued(&self, account_index: i64, api_key_index: u8) -> Option<i64> {
330        self.states
331            .get(&(account_index, api_key_index))
332            .map(|s| s.last_issued.load(Ordering::Acquire))
333    }
334
335    /// Snapshot the configured baseline for diagnostic and test purposes.
336    #[must_use]
337    pub fn baseline(&self, account_index: i64, api_key_index: u8) -> Option<i64> {
338        self.states
339            .get(&(account_index, api_key_index))
340            .map(|s| s.baseline.load(Ordering::Acquire))
341    }
342
343    /// Look up the per-key state, dropping the [`DashMap`] guard before
344    /// returning so callers can spin in CAS loops without holding a shard
345    /// lock.
346    fn state_for(
347        &self,
348        account_index: i64,
349        api_key_index: u8,
350    ) -> Result<Arc<AccountNonce>, NonceError> {
351        let entry =
352            self.states
353                .get(&(account_index, api_key_index))
354                .ok_or(NonceError::NotInitialized {
355                    account_index,
356                    api_key_index,
357                })?;
358        let state = entry.value().clone();
359        drop(entry);
360        Ok(state)
361    }
362}
363
364impl Default for NonceManager {
365    fn default() -> Self {
366        Self::new(DEFAULT_SKIP_WINDOW)
367    }
368}
369
370#[derive(Debug)]
371struct AccountNonce {
372    last_issued: AtomicI64,
373    baseline: AtomicI64,
374}
375
376impl AccountNonce {
377    fn new(initial: i64) -> Self {
378        Self {
379            last_issued: AtomicI64::new(initial),
380            baseline: AtomicI64::new(initial),
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use std::{sync::Arc as StdArc, thread};
388
389    use proptest::prelude::*;
390    use rstest::rstest;
391
392    use super::*;
393
394    const ACCOUNT: i64 = 12345;
395    const API_KEY: u8 = 5;
396
397    #[rstest]
398    fn next_nonce_uninitialized_errors() {
399        let mgr = NonceManager::new(8);
400        let err = mgr.next_nonce(ACCOUNT, API_KEY).expect_err("must error");
401        assert_eq!(
402            err,
403            NonceError::NotInitialized {
404                account_index: ACCOUNT,
405                api_key_index: API_KEY
406            },
407        );
408    }
409
410    #[rstest]
411    fn ack_failure_uninitialized_errors() {
412        let mgr = NonceManager::new(8);
413        let err = mgr.ack_failure(ACCOUNT, API_KEY).expect_err("must error");
414        assert_eq!(
415            err,
416            NonceError::NotInitialized {
417                account_index: ACCOUNT,
418                api_key_index: API_KEY
419            },
420        );
421    }
422
423    #[rstest]
424    fn default_uses_default_skip_window() {
425        let mgr = NonceManager::default();
426        assert_eq!(
427            mgr.skip_window(),
428            DEFAULT_SKIP_WINDOW,
429            "Default impl must use DEFAULT_SKIP_WINDOW, was {}",
430            mgr.skip_window(),
431        );
432    }
433
434    #[rstest]
435    fn last_issued_and_baseline_return_none_for_absent_key() {
436        let mgr = NonceManager::new(8);
437        assert_eq!(
438            mgr.last_issued(ACCOUNT, API_KEY),
439            None,
440            "absent key must report no last_issued",
441        );
442        assert_eq!(
443            mgr.baseline(ACCOUNT, API_KEY),
444            None,
445            "absent key must report no baseline",
446        );
447    }
448
449    #[rstest]
450    fn baseline_pins_to_refresh_value_through_allocations() {
451        let mgr = NonceManager::new(8);
452        mgr.refresh(ACCOUNT, API_KEY, 42);
453        assert_eq!(
454            mgr.baseline(ACCOUNT, API_KEY),
455            Some(41),
456            "baseline must equal venue_next_nonce - 1 after refresh",
457        );
458
459        for _ in 0..3 {
460            mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
461        }
462        assert_eq!(
463            mgr.baseline(ACCOUNT, API_KEY),
464            Some(41),
465            "baseline must not move when next_nonce advances last_issued",
466        );
467
468        mgr.refresh(ACCOUNT, API_KEY, 100);
469        assert_eq!(
470            mgr.baseline(ACCOUNT, API_KEY),
471            Some(99),
472            "subsequent refresh must reset baseline to new venue value - 1",
473        );
474    }
475
476    #[rstest]
477    fn refresh_then_next_nonce_starts_at_venue_value() {
478        let mgr = NonceManager::new(8);
479        mgr.refresh(ACCOUNT, API_KEY, 42);
480        let n = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
481        assert_eq!(n, 42, "first nonce must equal venue baseline, was {n}");
482    }
483
484    #[rstest]
485    fn next_nonce_is_monotonic_and_gap_free() {
486        let mgr = NonceManager::new(64);
487        mgr.refresh(ACCOUNT, API_KEY, 0);
488        let issued: Vec<i64> = (0..32)
489            .map(|_| mgr.next_nonce(ACCOUNT, API_KEY).unwrap())
490            .collect();
491        let expected: Vec<i64> = (0..32).collect();
492        assert_eq!(
493            issued, expected,
494            "nonces must be monotonic and gap-free, was {issued:?}",
495        );
496    }
497
498    #[rstest]
499    fn skip_window_caps_outstanding_allocations() {
500        let mgr = NonceManager::new(4);
501        mgr.refresh(ACCOUNT, API_KEY, 100);
502        for _ in 0..4 {
503            mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
504        }
505        let err = mgr.next_nonce(ACCOUNT, API_KEY).expect_err("must error");
506        match err {
507            NonceError::SkipWindowExhausted {
508                outstanding,
509                skip_window,
510                ..
511            } => {
512                assert_eq!(skip_window, 4, "skip_window mismatch, was {skip_window}");
513                assert_eq!(outstanding, 5, "outstanding mismatch, was {outstanding}");
514            }
515            other => panic!("expected SkipWindowExhausted, was {other:?}"),
516        }
517    }
518
519    #[rstest]
520    fn ack_failure_rolls_back_most_recent_issuance() {
521        let mgr = NonceManager::new(8);
522        mgr.refresh(ACCOUNT, API_KEY, 0);
523        let issued = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
524        let rolled = mgr.ack_failure(ACCOUNT, API_KEY).unwrap();
525        assert_eq!(
526            rolled, issued,
527            "ack_failure must report rolled-back nonce, was {rolled}",
528        );
529        let reused = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
530        assert_eq!(
531            reused, issued,
532            "rolled-back nonce must be reissued, was {reused}"
533        );
534    }
535
536    #[rstest]
537    fn ack_failure_at_baseline_errors() {
538        let mgr = NonceManager::new(8);
539        mgr.refresh(ACCOUNT, API_KEY, 7);
540        let err = mgr.ack_failure(ACCOUNT, API_KEY).expect_err("must error");
541        assert_eq!(
542            err,
543            NonceError::NothingToRollBack {
544                account_index: ACCOUNT,
545                api_key_index: API_KEY
546            },
547        );
548    }
549
550    #[rstest]
551    fn ack_success_uninitialized_errors() {
552        let mgr = NonceManager::new(8);
553        let err = mgr
554            .ack_success(ACCOUNT, API_KEY, 5)
555            .expect_err("must error");
556        assert_eq!(
557            err,
558            NonceError::NotInitialized {
559                account_index: ACCOUNT,
560                api_key_index: API_KEY
561            },
562        );
563    }
564
565    #[rstest]
566    fn ack_success_advances_baseline_monotonically() {
567        let mgr = NonceManager::new(8);
568        mgr.refresh(ACCOUNT, API_KEY, 0);
569        for _ in 0..5 {
570            mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
571        }
572
573        mgr.ack_success(ACCOUNT, API_KEY, 2).unwrap();
574        assert_eq!(
575            mgr.baseline(ACCOUNT, API_KEY),
576            Some(2),
577            "ack must advance baseline to the acked nonce",
578        );
579
580        mgr.ack_success(ACCOUNT, API_KEY, 0).unwrap();
581        assert_eq!(
582            mgr.baseline(ACCOUNT, API_KEY),
583            Some(2),
584            "lower ack must not retreat the baseline",
585        );
586
587        mgr.ack_success(ACCOUNT, API_KEY, 4).unwrap();
588        assert_eq!(mgr.baseline(ACCOUNT, API_KEY), Some(4));
589        assert_eq!(
590            mgr.last_issued(ACCOUNT, API_KEY),
591            Some(4),
592            "ack must not touch last_issued",
593        );
594    }
595
596    #[rstest]
597    fn ack_success_recovers_window_across_more_than_window_txs() {
598        let window = 16_u32;
599        let total = 40_i64;
600        let mgr = NonceManager::new(window);
601        mgr.refresh(ACCOUNT, API_KEY, 0);
602
603        let mut issued = Vec::with_capacity(total as usize);
604        for i in 0..total {
605            if i >= i64::from(window) {
606                // Ack the oldest outstanding tx; pre-fix the 17th allocation failed
607                mgr.ack_success(ACCOUNT, API_KEY, i - i64::from(window))
608                    .unwrap();
609            }
610            issued.push(mgr.next_nonce(ACCOUNT, API_KEY).unwrap());
611        }
612
613        let expected: Vec<i64> = (0..total).collect();
614        assert_eq!(
615            issued, expected,
616            "interleaved acks must keep issuance contiguous past the window",
617        );
618    }
619
620    #[rstest]
621    fn sync_from_venue_uninitialized_errors() {
622        let mgr = NonceManager::new(8);
623        let err = mgr
624            .sync_from_venue(ACCOUNT, API_KEY, 5)
625            .expect_err("must error");
626        assert_eq!(
627            err,
628            NonceError::NotInitialized {
629                account_index: ACCOUNT,
630                api_key_index: API_KEY
631            },
632        );
633    }
634
635    #[rstest]
636    fn sync_from_venue_lifts_baseline_and_last_issued() {
637        let mgr = NonceManager::new(2);
638        mgr.refresh(ACCOUNT, API_KEY, 0);
639        for _ in 0..2 {
640            mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
641        }
642        assert!(
643            mgr.next_nonce(ACCOUNT, API_KEY).is_err(),
644            "window must trip"
645        );
646
647        // Venue applied both txs (next expected nonce is 2)
648        mgr.sync_from_venue(ACCOUNT, API_KEY, 2).unwrap();
649        assert_eq!(mgr.baseline(ACCOUNT, API_KEY), Some(1));
650        assert_eq!(
651            mgr.last_issued(ACCOUNT, API_KEY),
652            Some(1),
653            "venue sync must not retreat last_issued below issued nonces",
654        );
655        let n = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
656        assert_eq!(n, 2, "venue sync must re-arm allocation, was {n}");
657
658        // Venue jumped ahead; both values lift so allocation resumes there
659        mgr.sync_from_venue(ACCOUNT, API_KEY, 10).unwrap();
660        assert_eq!(mgr.baseline(ACCOUNT, API_KEY), Some(9));
661        assert_eq!(mgr.last_issued(ACCOUNT, API_KEY), Some(9));
662        let n = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
663        assert_eq!(n, 10, "allocation must resume at venue nonce, was {n}");
664    }
665
666    #[rstest]
667    fn sync_from_venue_never_moves_backwards() {
668        let mgr = NonceManager::new(8);
669        mgr.refresh(ACCOUNT, API_KEY, 100);
670        for _ in 0..2 {
671            mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
672        }
673
674        // A stale venue read must not free nonces signed into in-flight txs
675        mgr.sync_from_venue(ACCOUNT, API_KEY, 50).unwrap();
676        assert_eq!(mgr.baseline(ACCOUNT, API_KEY), Some(99));
677        assert_eq!(mgr.last_issued(ACCOUNT, API_KEY), Some(101));
678        let n = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
679        assert_eq!(n, 102, "stale venue read must not cause reissue, was {n}");
680    }
681
682    #[rstest]
683    fn ack_failure_if_latest_uninitialized_errors() {
684        let mgr = NonceManager::new(8);
685        let err = mgr
686            .ack_failure_if_latest(ACCOUNT, API_KEY, 5)
687            .expect_err("must error");
688        assert_eq!(
689            err,
690            NonceError::NotInitialized {
691                account_index: ACCOUNT,
692                api_key_index: API_KEY
693            },
694        );
695    }
696
697    #[rstest]
698    fn ack_failure_if_latest_rolls_back_latest_issuance() {
699        let mgr = NonceManager::new(8);
700        mgr.refresh(ACCOUNT, API_KEY, 0);
701        mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
702        let latest = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
703
704        let rolled = mgr.ack_failure_if_latest(ACCOUNT, API_KEY, latest).unwrap();
705        assert!(rolled, "latest issuance must roll back");
706        let reused = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
707        assert_eq!(
708            reused, latest,
709            "rolled-back nonce must be reissued, was {reused}",
710        );
711    }
712
713    #[rstest]
714    fn ack_failure_if_latest_skips_with_newer_issuance() {
715        let mgr = NonceManager::new(8);
716        mgr.refresh(ACCOUNT, API_KEY, 0);
717        let older = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
718        let newer = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
719
720        let rolled = mgr.ack_failure_if_latest(ACCOUNT, API_KEY, older).unwrap();
721        assert!(!rolled, "non-latest nonce must not roll back");
722        assert_eq!(
723            mgr.last_issued(ACCOUNT, API_KEY),
724            Some(newer),
725            "skipped rollback must leave last_issued alone",
726        );
727        let next = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
728        assert_eq!(
729            next,
730            newer + 1,
731            "no nonce signed into an in-flight tx may be reissued, was {next}",
732        );
733    }
734
735    #[rstest]
736    fn ack_failure_if_latest_skips_when_baseline_caught_up() {
737        let mgr = NonceManager::new(8);
738        mgr.refresh(ACCOUNT, API_KEY, 0);
739        let nonce = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
740        mgr.ack_success(ACCOUNT, API_KEY, nonce).unwrap();
741
742        let rolled = mgr.ack_failure_if_latest(ACCOUNT, API_KEY, nonce).unwrap();
743        assert!(
744            !rolled,
745            "a nonce the venue already applied must not roll back",
746        );
747        assert_eq!(mgr.last_issued(ACCOUNT, API_KEY), Some(nonce));
748    }
749
750    #[rstest]
751    fn refresh_resets_after_skip_window_exhausted() {
752        let mgr = NonceManager::new(2);
753        mgr.refresh(ACCOUNT, API_KEY, 0);
754        for _ in 0..2 {
755            mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
756        }
757        assert!(
758            mgr.next_nonce(ACCOUNT, API_KEY).is_err(),
759            "window must trip"
760        );
761        // Venue confirms our view caught up; refresh re-anchors the baseline.
762        mgr.refresh(ACCOUNT, API_KEY, 5);
763        let n = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
764        assert_eq!(n, 5, "refresh must re-arm allocation, was {n}");
765    }
766
767    #[rstest]
768    fn distinct_keys_track_independent_state() {
769        let mgr = NonceManager::new(8);
770        mgr.refresh(ACCOUNT, 0, 0);
771        mgr.refresh(ACCOUNT, 1, 100);
772        let a = mgr.next_nonce(ACCOUNT, 0).unwrap();
773        let b = mgr.next_nonce(ACCOUNT, 1).unwrap();
774        assert_eq!(a, 0, "key 0 must start at 0, was {a}");
775        assert_eq!(b, 100, "key 1 must start at 100, was {b}");
776    }
777
778    #[rstest]
779    fn concurrent_callers_see_no_duplicate_or_gap() {
780        let mgr = StdArc::new(NonceManager::new(10_000));
781        mgr.refresh(ACCOUNT, API_KEY, 0);
782        let threads = 8;
783        let per_thread = 250;
784        let handles: Vec<_> = (0..threads)
785            .map(|_| {
786                let mgr = StdArc::clone(&mgr);
787
788                thread::spawn(move || -> Vec<i64> {
789                    (0..per_thread)
790                        .map(|_| mgr.next_nonce(ACCOUNT, API_KEY).unwrap())
791                        .collect()
792                })
793            })
794            .collect();
795        let mut all = Vec::with_capacity(threads * per_thread);
796        for h in handles {
797            all.extend(h.join().unwrap());
798        }
799        all.sort_unstable();
800        let expected: Vec<i64> = (0..(threads as i64) * (per_thread as i64)).collect();
801        assert_eq!(
802            all, expected,
803            "concurrent issuance must cover [0, N) without gaps or duplicates",
804        );
805    }
806
807    #[rstest]
808    fn concurrent_allocation_with_interleaved_acks_is_gap_free() {
809        let threads = 4;
810        let per_thread = 200;
811        // Window must absorb at most `threads` unacked allocations at a time
812        let mgr = StdArc::new(NonceManager::new(64));
813        mgr.refresh(ACCOUNT, API_KEY, 0);
814        let handles: Vec<_> = (0..threads)
815            .map(|_| {
816                let mgr = StdArc::clone(&mgr);
817
818                thread::spawn(move || -> Vec<i64> {
819                    (0..per_thread)
820                        .map(|_| {
821                            let nonce = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
822                            mgr.ack_success(ACCOUNT, API_KEY, nonce).unwrap();
823                            nonce
824                        })
825                        .collect()
826                })
827            })
828            .collect();
829        let mut all = Vec::with_capacity(threads * per_thread);
830        for h in handles {
831            all.extend(h.join().unwrap());
832        }
833        all.sort_unstable();
834        let expected: Vec<i64> = (0..(threads as i64) * (per_thread as i64)).collect();
835        assert_eq!(
836            all, expected,
837            "concurrent issuance with acks must cover [0, N) without gaps or duplicates",
838        );
839    }
840
841    proptest! {
842        /// Sequential `next_nonce` calls produce a strictly monotonic, contiguous
843        /// run starting at the refreshed baseline.
844        #[rstest]
845        fn prop_sequential_issuance_is_contiguous(
846            baseline in 0i64..1_000_000,
847            count in 1usize..256,
848        ) {
849            let mgr = NonceManager::new(u32::MAX);
850            mgr.refresh(ACCOUNT, API_KEY, baseline);
851            let issued: Vec<i64> = (0..count)
852                .map(|_| mgr.next_nonce(ACCOUNT, API_KEY).unwrap())
853                .collect();
854
855            for (i, &n) in issued.iter().enumerate() {
856                prop_assert_eq!(n, baseline + i as i64);
857            }
858            prop_assert_eq!(
859                mgr.last_issued(ACCOUNT, API_KEY),
860                Some(baseline + count as i64 - 1),
861            );
862        }
863
864        /// Issue then roll back, and the next allocation reuses the rolled-back
865        /// nonce: the round-trip is identity-preserving on `last_issued`.
866        #[rstest]
867        fn prop_ack_failure_is_idempotent_round_trip(
868            baseline in 0i64..1_000_000,
869            advance in 1usize..32,
870        ) {
871            let mgr = NonceManager::new(u32::MAX);
872            mgr.refresh(ACCOUNT, API_KEY, baseline);
873            for _ in 0..advance - 1 {
874                mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
875            }
876            let issued = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
877            let rolled = mgr.ack_failure(ACCOUNT, API_KEY).unwrap();
878            prop_assert_eq!(rolled, issued);
879            let reused = mgr.next_nonce(ACCOUNT, API_KEY).unwrap();
880            prop_assert_eq!(reused, issued);
881        }
882    }
883}