Skip to main content

hopr_protocol_pix/reconstructor/
mod.rs

1mod utils;
2
3use std::collections::{HashMap, HashSet};
4
5use hopr_types::{
6    crypto::{
7        crypto_traits::elliptic_curve::Field,
8        prelude::{HalfKey, HalfKeyChallenge, OffchainPublicKey},
9    },
10    internal::prelude::Acknowledgement,
11};
12use utils::{AddShareOutcome, RecoveredSsaTail, RecoveredTailCredit, SsaCommitmentBuilder, SsaCycle};
13use validator::Validate;
14
15use crate::{
16    CoefficientIndex, CompletedShare, ExitAcknowledgementShareProcessor, Group, MAX_POLYS_PER_SSA, PixGroup,
17    PixGroupRepr, PixParams, PixScalar, PixSpec, PolynomialIndex, RecoveredSsa, ShareResolution, SsaCommitmentProof,
18    SsaCommitmentState, SsaIndex, SsaPolynomialId, SsaRecoveryProgress, TaggedEncryptedPartialSsaShare,
19    errors::PixError, types::SsaId,
20};
21
22type ReadyResolutionNotifier = Box<dyn Fn() + Send + Sync + 'static>;
23
24/// Compact retirement state for one Session.
25///
26/// `highest_retired` alone is not enough: a later member of a batch may retire while an earlier
27/// member is still live. The exception sets retain only indices that still own a registration or
28/// were released before going live and may be retried. Live registrations are bounded by the
29/// Session layer's generation limit; a failed pre-live request closes a production Session, whose
30/// scope releases its retriable entries. The number of completed cycles is represented by one
31/// watermark rather than one allocation each.
32#[derive(Default)]
33struct RetirementFrontier {
34    highest_retired: Option<SsaIndex>,
35    registered: HashSet<SsaIndex>,
36    retriable: HashSet<SsaIndex>,
37    /// The most recently recovered cycle's bounded liveness tail.
38    ///
39    /// One slot is sufficient and security-critical: the Entry emits cycles in index order, so
40    /// only the immediate recovered predecessor can legitimately remain in the Exit's FIFO. A
41    /// newer recovered cycle replaces it, preventing old shares from buying service indefinitely
42    /// while keeping retirement memory constant over the Session's lifetime.
43    recovered_tail: Option<RecoveredSsaTail>,
44}
45
46impl RetirementFrontier {
47    fn is_retired(&self, index: SsaIndex) -> bool {
48        self.highest_retired.is_some_and(|highest| index <= highest)
49            && !self.registered.contains(&index)
50            && !self.retriable.contains(&index)
51    }
52
53    fn register(&mut self, index: SsaIndex) -> bool {
54        if self.is_retired(index) {
55            return false;
56        }
57        self.retriable.remove(&index);
58        self.registered.insert(index);
59        true
60    }
61
62    /// Releases a registration that never went live while leaving its index reusable.
63    fn abandon(&mut self, index: SsaIndex) {
64        if self.registered.remove(&index) {
65            self.retriable.insert(index);
66        }
67    }
68
69    /// Retires a known registration and raises the compact watermark.
70    ///
71    /// Returning `false` for an unknown index is load-bearing: allowing an arbitrary `retire_ssa`
72    /// call to raise the watermark would condemn legitimate lower indices that are registered
73    /// later.
74    fn retire(&mut self, index: SsaIndex) -> bool {
75        let was_registered = self.registered.remove(&index);
76        let was_retriable = self.retriable.remove(&index);
77        if !was_registered && !was_retriable {
78            return false;
79        }
80
81        self.highest_retired = Some(match self.highest_retired {
82            Some(highest) if highest >= index => highest,
83            _ => index,
84        });
85        true
86    }
87
88    fn retire_recovered(&mut self, index: SsaIndex, tail: RecoveredSsaTail) -> bool {
89        if !self.retire(index) {
90            return false;
91        }
92        debug_assert_eq!(index, tail.ssa_index());
93        self.recovered_tail = Some(tail);
94        true
95    }
96}
97
98type SharedRetirementFrontier = std::sync::Arc<parking_lot::Mutex<RetirementFrontier>>;
99type RegisteredExitCommitment<S> = (PixGroup<S>, std::sync::Arc<RegisteredCommitment<S>>);
100
101/// A commitment registration paired with the Session retirement state that governs it.
102struct RegisteredCommitment<S: PixSpec> {
103    builder: parking_lot::Mutex<SsaCommitmentBuilder<S>>,
104    retirement: SharedRetirementFrontier,
105}
106
107impl<S: PixSpec> std::ops::Deref for RegisteredCommitment<S> {
108    type Target = parking_lot::Mutex<SsaCommitmentBuilder<S>>;
109
110    fn deref(&self) -> &Self::Target {
111        &self.builder
112    }
113}
114
115/// A published cycle paired with the same retirement state as its commitment registration.
116struct RegisteredCycle<S: PixSpec> {
117    cycle: SsaCycle<S>,
118    registration: std::sync::Arc<RegisteredCommitment<S>>,
119}
120
121impl<S: PixSpec> std::ops::Deref for RegisteredCycle<S> {
122    type Target = SsaCycle<S>;
123
124    fn deref(&self) -> &Self::Target {
125        &self.cycle
126    }
127}
128
129/// Owns one Session's retirement-frontier registration in an [`SsaReconstructor`].
130///
131/// The Session action driver holds this for its whole lifetime. Dropping it removes the pseudonym
132/// from the reconstructor immediately; commitment guards and in-flight workers retain their own
133/// `Arc` to the frontier until the publication/retirement race has settled.
134#[must_use = "dropping the scope stops tracking retirement state for its Session"]
135pub struct SsaRetirementScope<S: PixSpec> {
136    reconstructor: std::sync::Weak<SsaReconstructor<S>>,
137    pseudonym: S::Pseudonym,
138    retirement: SharedRetirementFrontier,
139}
140
141/// Rejects an early-recovery fraction that is not a finite value in `0.0..=1.0`.
142///
143/// A `range` validator does not cover this. Every IEEE comparison against `NaN` is false, so `NaN`
144/// satisfies `min = 0.0, max = 1.0` untouched — and it does not stay inert downstream:
145/// [`SsaBuilder::check_early_threshold`](utils::SsaBuilder::check_early_threshold) computes
146/// `(threshold * num_polys).ceil() as usize`,
147/// and casting a `NaN` float to an integer saturates to zero in Rust. The early-recovery signal then
148/// fires on the very first reconstructed polynomial rather than at 85 % of them, which is the
149/// earliest instant the Exit can possibly ask for its next batch — and the Entry's successor gate,
150/// which exists to refuse exactly that, drops the request and leaves the Session to die on its
151/// commitment deadline.
152fn validate_early_recovery_threshold(threshold: f64) -> Result<(), validator::ValidationError> {
153    if threshold.is_finite() && (0.0..=1.0).contains(&threshold) {
154        Ok(())
155    } else {
156        Err(validator::ValidationError::new(
157            "early_recovery_threshold must be a finite fraction between 0.0 and 1.0",
158        ))
159    }
160}
161
162/// Configuration for the SSA reconstructor.
163#[derive(Debug, Clone, Copy, PartialEq, smart_default::SmartDefault, validator::Validate)]
164pub struct SsaReconstructorConfig {
165    /// Time until the complete commitment to an SSA must be received.
166    ///
167    /// Default is 2 minutes.
168    #[default(Self::DEFAULT_INCOMPLETE_COMMITMENT_LIFETIME)]
169    pub incomplete_commitment_lifetime: std::time::Duration,
170    /// Maximum time an SSA cycle can go without progress before it is discarded.
171    ///
172    /// Measured from the last acknowledged share *anywhere* in the cycle, not per polynomial — see
173    /// `SsaCycle` for why that distinction is load-bearing. A cycle that is still being served
174    /// therefore never expires, whatever the line rate.
175    ///
176    /// Default is 30 minutes.
177    #[default(Self::DEFAULT_UNUSED_VERIFIER_LIFETIME)]
178    pub unused_verifier_lifetime: std::time::Duration,
179    /// Maximum number of peers that can be tracked simultaneously with unacknowledged shares.
180    ///
181    /// Default is 2000, minimum is 10.
182    ///
183    /// This is a per-*peer* fan-out bound, and it guards the opposite concentration to
184    /// [`max_awaiting_acks`](Self::max_awaiting_acks): traffic spread thinly across many
185    /// first-relayers. The two cannot both be saturated at once, which is why their product is not
186    /// the reconstructor's memory bound — see `PixReconstructorConfig` in `hopr-transport` for the
187    /// bound that is.
188    #[validate(range(min = 10))]
189    #[default(Self::DEFAULT_MAX_TRACKED_PEERS)]
190    pub max_tracked_peers: usize,
191    /// Maximum number of awaited acknowledgements to extract a single share.
192    ///
193    /// This corresponds to the maximum number of unacknowledged HOPR packets awaiting acknowledgement.
194    ///
195    /// Default is 1 000 000, must be at least 10 000.
196    ///
197    /// Sizes one inner cache **per peer**, so it must cover the concentrated case: every Session on
198    /// the node returning through a single first-relayer. At the operating point
199    /// `tests/memory_profile.rs` models that is ~542 000 entries, which is what makes a cap of this
200    /// order the right one rather than an oversight.
201    #[default(Self::DEFAULT_MAX_AWAITING_ACKS)]
202    #[validate(range(min = 10000))]
203    pub max_awaiting_acks: usize,
204    /// Maximum time an acknowledgement can be awaited before it is discarded.
205    ///
206    /// Default is 30 seconds.
207    ///
208    /// Multiplies the whole awaiting-ack buffer: the reachable state is the Exit's share-emission
209    /// rate times this window, so it — not either cap above — is the dial that actually sizes the
210    /// buffer.
211    #[default(Self::DEFAULT_MAX_ACK_AWAIT_TIME)]
212    pub max_ack_await_time: std::time::Duration,
213    /// Indicates whether to use batch verification algorithm for acknowledgements.
214    ///
215    /// Default is false.
216    ///
217    /// Batching only covers the acknowledgement *signature* check. While each share also cost a
218    /// `threshold`-term multi-scalar multiplication, that MSM dominated and the choice was
219    /// immaterial — measured 2.46 MiB/s batched against 2.50 MiB/s unbatched. Committing to the
220    /// constant term alone removed the MSM, and the batching overhead is no longer hidden by it:
221    /// the same benchmark then measures 50.2 MiB/s batched against 92.8 MiB/s unbatched, so
222    /// batching costs 46 % of the sustained rate.
223    ///
224    /// **Settled against the concurrent pipeline shape**, which is what the Exit actually runs and
225    /// what the sequential figures above could not speak to. `concurrent_quota_rate` on 48 cores at
226    /// production width, aggregate MiB/s of Session quota:
227    ///
228    /// | callers | unbatched | batched  |                              |
229    /// | ------- | --------- | -------- | ---------------------------- |
230    /// | 1       | **90.9**  | 46.4     | unbatched 1.96×              |
231    /// | 10      | 130.2     | 126.2    | tie — confidence intervals overlap |
232    /// | 48      | 139.8     | **152.6**| batched 1.09×                |
233    ///
234    /// So batching does eventually pay, but only *above* the concurrency the pipeline is
235    /// configured for: `DEFAULT_ACK_INPUT_CONCURRENCY` is 10, which is precisely the row where the
236    /// two are indistinguishable. `false` stays the default because it is far better at low
237    /// concurrency and no worse at the configured one, making it the safer choice across the range
238    /// an operator can set — not because batching is slower everywhere.
239    ///
240    /// Kept configurable for the operator who raises `ack_input_concurrency` well past its default,
241    /// where the last row says the choice flips.
242    ///
243    /// **Headroom is narrower than it looks.** A deployed Exit serves 10–30 clients at 16–20 Mbps,
244    /// so it absorbs 19–72 MiB/s; against the 130 MiB/s above that is **1.8× at the top of the
245    /// range**, not the 7.3× an earlier reading of these figures claimed. That claim came from
246    /// comparing against the benchmark suite's own model of production — 100 Sessions at 1.5 Mbps,
247    /// 18.75 MiB/s — which understates real per-Session rate by 13×. The rates here are also
248    /// measured at 4096 polynomials rather than the 512 used previously, which costs about 5 %.
249    ///
250    /// All of these were measured at 1038 B of quota per share. A share is now priced at the 1452 B
251    /// Session MTU, so the same work is ≈ 1.4× more quota: 130.2 MiB/s becomes ≈ 182 MiB/s, and the
252    /// headroom at the top of the range ≈ 2.5×.
253    #[default(Self::DEFAULT_USE_BATCH_VERIFICATION)]
254    pub use_batch_verification: bool,
255    /// Fraction of reconstructed polynomials at which to emit an early recovery
256    /// notification, triggering pipelined SSA request preparation.
257    ///
258    /// Range: 0.0..1.0. Default: 0.85.
259    ///
260    /// A reconstructor driving a real Session is additionally held to
261    /// [`MIN_EARLY_RECOVERY_THRESHOLD`](crate::MIN_EARLY_RECOVERY_THRESHOLD), the floor an Entry
262    /// assumes when it decides whether a successor `SsaRequest` has been earned. Anything below it is
263    /// unusable rather than merely aggressive — the request goes out early and the Entry refuses it.
264    /// The bound is not in this validator because it belongs to the pairing rather than to the
265    /// reconstructor: `hopr-transport-session` checks it where both halves are known.
266    #[default(Self::DEFAULT_EARLY_RECOVERY_THRESHOLD)]
267    #[validate(custom(function = "validate_early_recovery_threshold"))]
268    pub early_recovery_threshold: f64,
269    /// Ceiling on the total live state held in the awaiting-acknowledgement buffer, across every
270    /// peer, in bytes.
271    ///
272    /// This is the *global* bound; [`max_tracked_peers`](Self::max_tracked_peers) and
273    /// [`max_awaiting_acks`](Self::max_awaiting_acks) are per-dimension backstops and their product
274    /// is not one. See [`SsaReconstructor::insert_encrypted_share`] for why the product overstates
275    /// by roughly three thousandfold and why bounding it instead would lose shares.
276    ///
277    /// Enforced at insertion time rather than by validating a workload model. A model has to assume
278    /// a Session count and a packet rate; the node enforces neither — `maximum_managed_sessions`
279    /// validates to 100 000 and `SessionCapability::NoRateControl` removes the rate limiter
280    /// entirely — so a configuration can be perfectly valid and still exceed any modelled budget.
281    /// Counting what is actually held is indifferent to all of that.
282    ///
283    /// Default is 1 GiB, which at [`AWAITING_ACK_ENTRY_BYTES`] is ~2.68 M shares in flight.
284    ///
285    /// The minimum is 25 600 B — 64 entries. That is a sanity floor, not a sizing recommendation:
286    /// its only job is to stop the budget rounding down to a handful of shares. Whether a given
287    /// value is *adequate* depends on the node's traffic, which is exactly the thing this design
288    /// stopped trying to predict, so the floor deliberately does not pretend to encode it. A node
289    /// configured near it will drop shares and say so.
290    #[default(Self::DEFAULT_MAX_ACK_BUFFER_BYTES)]
291    #[validate(range(min = 25_600))]
292    pub max_ack_buffer_bytes: usize,
293}
294
295/// Live heap one entry in the awaiting-acknowledgement buffer costs, in bytes.
296///
297/// **Measured, not derived.** `size_of` accounts for only 145 B of it — a 33 B `HalfKeyChallenge`
298/// key and a 112 B [`TaggedEncryptedPartialSsaShare`] value, both inline arrays — and moka's
299/// per-entry bookkeeping (hash map entry, LRU node, TTL timer-wheel node, the `Arc` around the
300/// value) is the other 244 B. Run `awaiting_ack_entry_cost` in `tests/memory_profile.rs` to
301/// re-derive it; at the time of writing it reports 383 B/entry at 20 000 entries and 389 B/entry at
302/// 100 000. Rounded up, because understating it would let
303/// [`max_ack_buffer_bytes`](SsaReconstructorConfig::max_ack_buffer_bytes) be exceeded.
304///
305/// Every entry is this size — the payload is fixed-width inline arrays with no indirection — which
306/// is what lets the runtime bound count entries rather than weigh each one.
307pub const AWAITING_ACK_ENTRY_BYTES: usize = 400;
308
309/// Per-polynomial live heap a cycle holds that `size_of` cannot account for, in bytes.
310///
311/// [`peak_cycle_bytes`] reads the structural per-polynomial terms — the part builder, the shared
312/// surplus counter, and the commitment map entry retained behind them — straight from the types,
313/// so they track the curve and the structs without anyone maintaining a number. This covers what
314/// is left:
315///
316/// * `SsaBuilder`'s received-index set, sized at `num_polys` on construction;
317/// * the `Box<[_]>` header the part builders live in, and allocator rounding on both collections;
318/// * the acknowledgement-path residue a cycle drags along while it fills. `awaiting_acks` is bounded globally by
319///   [`max_ack_buffer_bytes`](SsaReconstructorConfig::max_ack_buffer_bytes) rather than per cycle, but `moka` applies
320///   removals on a later maintenance pass, so a cycle being fed at line rate carries a backlog of redeemed-but-not-yet
321///   -reclaimed entries. Charging a slice of it twice is deliberate: it is the difference between a model that is a
322///   ceiling and one that is merely a good estimate.
323///
324/// **Measured, not derived**, like [`AWAITING_ACK_ENTRY_BYTES`]:
325/// `exit_reconstructor_worst_case_share_order` in `tests/memory_profile.rs` prints the modelled and
326/// measured per-polynomial figures side by side with the share buffers subtracted, which is exactly
327/// this number. Run it to re-derive every figure below.
328///
329/// The arithmetic, at the time of writing and at the deployed dimensions:
330///
331/// * measured per polynomial, share buffers excluded: **~880–905 B**. A range because it is a range: the
332///   acknowledgement residue is a scheduling artefact, and two runs of the same test on the same machine gave 876 B and
333///   904 B. That variance is the reason this constant is set with headroom rather than at the measurement;
334/// * of which `size_of` accounts for **458 B**, leaving a **~420–450 B** residue the types cannot see;
335/// * this constant is **704 B**, ~1.6× that residue, so the model charges 458 + 704 = **1162 B** per polynomial — ~30 %
336///   above the measurement;
337/// * across a whole cycle that dilutes to ~6 %, because the share buffers are modelled exactly and dominate: **41.1
338///   MiB** modelled against **38.8 MiB** measured, of which 32.0 MiB is share buffers.
339///
340/// Both headroom figures are worth keeping straight, because they answer different questions: ~30 %
341/// is the margin on the term this constant actually sets, and ~6 % is the margin on the number the
342/// Session layer's live-cycle budget is denominated in.
343///
344/// Headroom rather than the measured figure for the variance above, and because understating this
345/// would let that budget be exceeded.
346pub const PART_BUILDER_OVERHEAD_BYTES: usize = 704;
347
348/// The per-polynomial share buffer [`peak_cycle_bytes`] models, in bytes.
349///
350/// `Vec` doubles from a minimum of four elements, so a buffer holding `threshold - 1` shares has a
351/// capacity of `threshold.next_power_of_two()` — up to twice what is stored. Modelling the
352/// allocation rather than the occupancy is what makes [`peak_cycle_bytes`] a ceiling.
353///
354/// Public because it is the one term of that model a consumer has reason to subtract back out:
355/// `tests/memory_profile.rs` reports the per-polynomial figure both with and without it, since the
356/// buffers dominate at the deployed dimensions and the residue is what
357/// [`PART_BUILDER_OVERHEAD_BYTES`] is set against. Restating the expression at the call site is what
358/// this exists to prevent — an external caller cannot reproduce it even in principle, because
359/// `CompletedShare` is crate-private and they would have to assume it is two scalars. True of the
360/// shipped specs, and not what the model reads.
361pub fn peak_share_buffer_bytes<S: PixSpec>(params: &PixParams) -> u64 {
362    (params.shares_per_poly() as u64)
363        .next_power_of_two()
364        .max(4)
365        .saturating_mul(size_of::<CompletedShare<S>>() as u64)
366}
367
368/// Worst-case live heap one Exit-side SSA cycle can hold, in bytes.
369///
370/// This is the figure the Session layer's live-cycle budget is denominated in, and it is
371/// deliberately the **adversarial** peak rather than the conforming one. Nothing constrains the
372/// order in which an Entry emits shares within a cycle: the shipped generator walks polynomials in
373/// blocks of [`SHARE_EMISSION_WINDOW`](crate::SHARE_EMISSION_WINDOW), so only that window holds
374/// share buffers at once and a conforming cycle peaks five times below this — 7.8 MiB against
375/// 39.1 MiB measured at the deployed dimensions — but a peer running anything else can drive *every*
376/// polynomial to one share short of its threshold and hold the lot.
377/// `SsaPartBuilder::release_verification_state` frees a buffer when its polynomial reconstructs; a
378/// polynomial one share short never reconstructs.
379///
380/// Not free to the peer, which is why this is a capacity bound and not a security one: a share only
381/// reaches the Exit on a return SURB the Exit itself spends, so reaching this peak costs a full
382/// quota deposit per cycle. What it bounds is an Exit selling more service than its memory can hold.
383///
384/// Excludes the awaiting-acknowledgement buffer, which is bounded separately and globally by
385/// [`max_ack_buffer_bytes`](SsaReconstructorConfig::max_ack_buffer_bytes) — counting it here would
386/// charge the same bytes twice.
387pub fn peak_cycle_bytes<S: PixSpec>(params: &PixParams) -> u64 {
388    let share_buffer = peak_share_buffer_bytes::<S>(params);
389
390    // Read from the types rather than restated as a constant, so the model follows the curve this
391    // node was built for and cannot drift when a field is added to either struct.
392    //
393    // The commitment map term is the one that surprises: `SsaCommitmentBuilder` is *drained* when
394    // the part builders are handed out, but `drain` does not return the allocation and the builder
395    // lives until `remove_cycle` — so a completed cycle still holds a bucket per polynomial. The
396    // factor of two is hashbrown's power-of-two bucket count at its 87.5 % load factor, and the
397    // extra byte is the control byte beside each one.
398    let per_poly = size_of::<parking_lot::Mutex<utils::SsaPartBuilder<S>>>() as u64
399        // This allocation is shared with the compact recovered tail. It used to be the
400        // `surplus_seen` field inside each part builder; keeping it explicit here preserves the
401        // admission bound when the counter moves behind an Arc to span recovery.
402        + size_of::<std::sync::atomic::AtomicUsize>() as u64
403        + 2 * (size_of::<(PolynomialIndex, PixGroup<S>)>() as u64 + 1)
404        + PART_BUILDER_OVERHEAD_BYTES as u64;
405
406    (params.polys_per_ssa() as u64).saturating_mul(per_poly + share_buffer)
407}
408
409/// The defaults, named so that a mirror can share them instead of restating them.
410///
411/// `hopr-transport`'s `PixReconstructorConfig` is the operator-facing shape of this struct, and a
412/// second copy of these literals over there would be a second thing to keep true. Referencing them
413/// from both `#[default(…)]` sites means the two cannot disagree by construction, which is a
414/// stronger guarantee than any test comparing them after the fact.
415impl SsaReconstructorConfig {
416    /// Default [`early_recovery_threshold`](Self::early_recovery_threshold).
417    pub const DEFAULT_EARLY_RECOVERY_THRESHOLD: f64 = 0.85;
418    /// Default [`incomplete_commitment_lifetime`](Self::incomplete_commitment_lifetime).
419    pub const DEFAULT_INCOMPLETE_COMMITMENT_LIFETIME: std::time::Duration = std::time::Duration::from_secs(120);
420    /// Default [`max_ack_await_time`](Self::max_ack_await_time).
421    pub const DEFAULT_MAX_ACK_AWAIT_TIME: std::time::Duration = std::time::Duration::from_secs(30);
422    /// Default [`max_ack_buffer_bytes`](Self::max_ack_buffer_bytes).
423    pub const DEFAULT_MAX_ACK_BUFFER_BYTES: usize = 1024 * 1024 * 1024;
424    /// Default [`max_awaiting_acks`](Self::max_awaiting_acks).
425    pub const DEFAULT_MAX_AWAITING_ACKS: usize = 1_000_000;
426    /// Default [`max_tracked_peers`](Self::max_tracked_peers).
427    pub const DEFAULT_MAX_TRACKED_PEERS: usize = 2000;
428    /// Default [`unused_verifier_lifetime`](Self::unused_verifier_lifetime).
429    pub const DEFAULT_UNUSED_VERIFIER_LIFETIME: std::time::Duration = std::time::Duration::from_secs(1800);
430    /// Default [`use_batch_verification`](Self::use_batch_verification).
431    pub const DEFAULT_USE_BATCH_VERIFICATION: bool = false;
432}
433
434type EncryptedShareCache<S> =
435    moka::sync::Cache<HalfKeyChallenge, TaggedEncryptedPartialSsaShare<S, <S as PixSpec>::Pseudonym, PixScalar<S>>>;
436
437/// An acknowledgement that arrived before its polynomial's verifier was installed.
438///
439/// The peer is carried per entry because a polynomial's shares are spread across return paths, and
440/// therefore across first-relayers: one bucket can hold deferred acks from several peers.
441type DeferredAck = (OffchainPublicKey, HalfKeyChallenge, HalfKey);
442
443/// Deferred acknowledgements for one cycle, drained in one shot when its part builders install.
444///
445/// Plain `Vec`s under one mutex rather than nested caches: a bucket is only ever appended to and
446/// then drained whole, so per-entry cache bookkeeping (and its ~200 B overhead per entry) buys
447/// nothing. The mutex serialises deferrals *within one cycle* only, and deferral is O(1) work off
448/// the steady-state path.
449#[derive(Default)]
450struct DeferredAcks {
451    by_poly: std::collections::HashMap<PolynomialIndex, Vec<DeferredAck>>,
452    /// Running sum of the `by_poly` lengths, maintained so the per-cycle cap is an O(1) check.
453    ///
454    /// Recomputing it would walk every sub-bucket inside the mutex on every deferral, and there
455    /// can be one sub-bucket per entry: filling a bucket to
456    /// [`MAX_DEFERRED_ACKS_PER_CYCLE`] would then cost ~33M map-entry visits, on the path every
457    /// acknowledgement takes during the commitment window. The invariant
458    /// `total == by_poly.values().map(Vec::len).sum()` has one increment site and one reset site,
459    /// both under this mutex.
460    total: usize,
461    /// Set by the one drain this bucket will ever get, in the same critical section as the take.
462    ///
463    /// A bucket is reachable through two routes: the `pending_acks` key, and an `Arc` a
464    /// [`defer_ack`](SsaReconstructor::defer_ack) already holds. The drain removes the first but
465    /// cannot revoke the second, so an append that lands after it would sit in a bucket nothing
466    /// will ever read again. This flag is how such an append notices — see
467    /// [`Deferral::Orphaned`].
468    drained: bool,
469}
470
471type DeferredAckBucket = std::sync::Arc<parking_lot::Mutex<DeferredAcks>>;
472
473/// What became of an acknowledgement handed to [`defer_ack`](SsaReconstructor::defer_ack).
474///
475/// Fieldless — the caller still owns the acknowledgement, since [`DeferredAck`] is `Copy` — so this
476/// stays a discriminant rather than carrying 264 bytes back out of the critical section.
477#[derive(Clone, Copy)]
478enum Deferral {
479    /// Appended to a live bucket. The drain that installs the cycle will redeem it.
480    Buffered,
481    /// The bucket had already been drained, so no drain will come for this one. The caller must
482    /// redeem it inline; parking it would be silent loss.
483    Orphaned,
484    /// Over one of the two caps. Already warned about, and deliberately discarded.
485    Dropped,
486}
487
488/// Cap on deferred acknowledgements held for a single polynomial.
489///
490/// A conforming Entry emits `threshold + surplus` shares per polynomial — 96 at the default
491/// dimensions — across all return paths combined, so at the defaults this cannot be reached without
492/// the peer exceeding its own share budget. Anything above the cap is dropped rather than buffered.
493///
494/// It is *not* unreachable in general: both halves are a byte wide, so a conforming Entry may
495/// legitimately announce up to `255 + 255` and have its excess deferrals silently discarded. Both
496/// values now travel in [`PixParams`], so an Exit that cares *can* compare
497/// `shares_per_poly + surplus_shares` against this cap when it accepts a Session.
498///
499/// Deliberately not refused on that comparison, though, and the distinction matters. Only shares
500/// arriving *before* the cycle's commitments install are deferred at all — a prefix, not the whole
501/// emission — so `shares_per_poly + surplus_shares <= MAX_DEFERRED_ACKS_PER_POLYNOMIAL` is a
502/// sufficient condition for "no acknowledgement is ever dropped for want of room", not a necessary
503/// one for the dimensions to work. Enforcing it would reject legitimate re-splits: `4096 x 128` holds
504/// the profiled product exactly and sums to 160. The shipping defaults sum to 96 and are asserted to
505/// stay under the cap in `hopr-transport`'s config tests; wider dimensions lose the guarantee and
506/// fall back on the deferral window being short.
507///
508/// Public because it is observable behaviour, not an implementation detail: past the cap an
509/// acknowledgement is discarded, so anything measuring or exercising the deferral path has to stay
510/// underneath it or it silently measures the discard instead.
511pub const MAX_DEFERRED_ACKS_PER_POLYNOMIAL: usize = 128;
512
513/// Cap on deferred acknowledgements held across all polynomials of one cycle.
514///
515/// The per-polynomial cap alone leaves the cycle total at `num_polys × 128` — a million entries at
516/// production dimensions, which is no bound at all. This one is derived rather than chosen: the
517/// drain discards any acknowledgement whose share has already left `awaiting_acks`, and that cache
518/// expires entries after
519/// `max_ack_await_time` (30 s by default). An older deferral is therefore provably dead, so the
520/// ceiling only has to cover the shares one cycle can receive inside that window: ~181 shares/s,
521/// the per-Session rate this was sized at, so ~5 400. 8192 covers up to ~273 shares/s and costs at most
522/// ~786 KB per cycle.
523///
524/// Public for the same reason as [`MAX_DEFERRED_ACKS_PER_POLYNOMIAL`].
525pub const MAX_DEFERRED_ACKS_PER_CYCLE: usize = 8192;
526
527/// Allows server-side reconstruction of SSAs.
528///
529/// There are 3 inputs that reconstructor is dependent on (in order):
530/// 1. SSA commitments from the Client (delivered via
531///    [`insert_coefficient_commitments`](ExitAcknowledgementShareProcessor::insert_coefficient_commitments))
532/// 2. Extraction of pending encrypted shares (added via
533///    [`insert_encrypted_share`](ExitAcknowledgementShareProcessor::insert_encrypted_share)
534/// 3. Decryption of pending encrypted shares via [`Acknowledgement`]s (via
535///    [`acknowledge_shares`](ExitAcknowledgementShareProcessor::acknowledge_shares))
536///
537/// It is able to track SSA for multiple different pseudonyms (Sessions).
538pub struct SsaReconstructor<S: PixSpec> {
539    commitment_builder: moka::sync::Cache<SsaId<S::Pseudonym>, std::sync::Arc<RegisteredCommitment<S>>>,
540    /// Post-commitment state of every live cycle: the part accumulator and all part builders,
541    /// published and reclaimed as one unit. See [`SsaCycle`].
542    ssa_cycles: moka::sync::Cache<SsaId<S::Pseudonym>, std::sync::Arc<RegisteredCycle<S>>>,
543    /// One compact retirement frontier per active Session pseudonym.
544    ///
545    /// A plain `HashMap` under a short synchronous lock is deliberate. Session scopes remove their
546    /// entries immediately on teardown; a cache would defer physical removal and put the same
547    /// turnover-rate assumption back into the memory bound this state replaces.
548    retirement_frontiers: parking_lot::Mutex<HashMap<S::Pseudonym, SharedRetirementFrontier>>,
549    awaiting_acks: moka::sync::Cache<OffchainPublicKey, EncryptedShareCache<S>>,
550    /// Acknowledgements that arrived before their cycle's part builders were installed, bucketed by
551    /// cycle and then by polynomial.
552    ///
553    /// ## Why bucketed at all
554    ///
555    /// The bucket key is exactly the thing whose arrival unblocks the entries inside it, so a
556    /// bucket is drained once, by the installation of its own cycle, and never scanned
557    /// speculatively. That is what keeps [`Self::acknowledge_shares`] free of retry work: it only
558    /// ever *appends* to a bucket.
559    ///
560    /// The original per-peer stash had to be re-scanned in full on every `acknowledge_shares` call,
561    /// because a per-peer key says nothing about which entries have become viable. That is
562    /// quadratic in the number of acks received while a cycle's commitments are in flight, and the
563    /// per-peer key aggregates across every Session sharing a first-relayer.
564    ///
565    /// ## Why keyed by cycle, sub-bucketed by polynomial
566    ///
567    /// Part builders are installed for a whole cycle at once, so a per-polynomial *key* no longer
568    /// buys selective draining — the drain would just walk every polynomial of the cycle. Keying by
569    /// cycle makes it one lookup. The per-polynomial sub-bucket is kept because its cap is what
570    /// bounds a misbehaving peer (see [`MAX_DEFERRED_ACKS_PER_POLYNOMIAL`]).
571    ///
572    /// The capacity unit is cycles, not polynomials. Keyed per polynomial it was `2 *
573    /// MAX_POLYS_PER_SSA` entries — which one cycle can exhaust on its own, so past roughly four
574    /// concurrent cycles node-wide, moka began LRU-evicting buckets and silently dropping real
575    /// shares. A size eviction here is share loss, so the headroom is deliberate and the
576    /// `max_ack_await_time` TTL is the operative bound.
577    pending_acks: moka::sync::Cache<SsaId<S::Pseudonym>, DeferredAckBucket>,
578    /// Resolutions produced by draining deferred-ack buckets at verifier-installation time, waiting
579    /// for either the transport notification stream or an acknowledgement batch to collect them.
580    ///
581    /// Draining happens on the commitment path (`insert_coefficient_commitments`), which is where
582    /// the verifier that unblocks the acks is installed. That deliberately keeps the share
583    /// verification off the acknowledgement hot path, but it also means the resolutions surface
584    /// on a synchronous worker. The buffer bridges that worker to the transport's asynchronous
585    /// resolution stream without blocking it or making recovered deposit keys depend on later
586    /// acknowledgement traffic.
587    ready_resolutions: parking_lot::Mutex<Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>>>,
588    /// Length of [`ready_resolutions`](Self::ready_resolutions), so the common case (nothing to pick
589    /// up) costs one relaxed load instead of a mutex acquisition on every ack batch.
590    ready_resolutions_len: std::sync::atomic::AtomicUsize,
591    /// Non-blocking wake-up for the asynchronous consumer of [`ready_resolutions`](Self::ready_resolutions).
592    ///
593    /// The callback carries no data: resolutions remain owned by `ready_resolutions` until a
594    /// consumer atomically takes them, so wake-ups may safely coalesce and channel backpressure
595    /// cannot drop a recovered deposit key. `OnceLock` prevents two transport streams from racing
596    /// as competing designated consumers; `acknowledge_shares` remains an opportunistic fallback.
597    ready_resolution_notifier: std::sync::OnceLock<ReadyResolutionNotifier>,
598    /// Running estimate of the entries live in [`awaiting_acks`](Self::awaiting_acks), summed over
599    /// every peer, so the global budget costs one relaxed load per insertion.
600    ///
601    /// An *estimate*, and knowingly so — see [`Self::resync_ack_buffer`] for the one drift source
602    /// that cannot be listened for and what keeps it from accumulating.
603    ///
604    /// Behind an `Arc` because each peer's inner cache decrements it from an eviction listener, and
605    /// moka requires those to be `'static` — they cannot borrow the reconstructor that owns them.
606    ack_buffer_entries: std::sync::Arc<std::sync::atomic::AtomicUsize>,
607    /// [`max_ack_buffer_bytes`](SsaReconstructorConfig::max_ack_buffer_bytes) in entries, divided
608    /// once here rather than on every insertion.
609    max_ack_buffer_entries: usize,
610    /// When [`resync_ack_buffer`](Self::resync_ack_buffer) last ran, and the lock that keeps two
611    /// from running at once.
612    ///
613    /// `None` until the first run, so a buffer that saturates immediately is not made to wait out
614    /// an interval before its first ground-truth reading.
615    ack_buffer_resync: parking_lot::Mutex<Option<std::time::Instant>>,
616    cfg: SsaReconstructorConfig,
617}
618
619impl<S: PixSpec> Drop for SsaRetirementScope<S> {
620    fn drop(&mut self) {
621        let Some(reconstructor) = self.reconstructor.upgrade() else {
622            return;
623        };
624
625        let mut frontiers = reconstructor.retirement_frontiers.lock();
626        let owns_entry = frontiers
627            .get(&self.pseudonym)
628            .is_some_and(|current| std::sync::Arc::ptr_eq(current, &self.retirement));
629        if owns_entry {
630            frontiers.remove(&self.pseudonym);
631        }
632    }
633}
634
635/// Result of processing a single verified acknowledgement in the SSA reconstructor.
636///
637/// The counters behind [`SsaRecoveryProgress`] are updated by `process_verified_ack` itself, on the
638/// cycle it already holds, so these variants only have to say *whether* a snapshot is worth emitting
639/// — not what changed. That is why a duplicate, a surplus share and an unmatched acknowledgement all
640/// collapse into [`NoProgress`](Self::NoProgress): none of them moves a counter, so none of them can
641/// make a snapshot differ from the last one sent.
642enum ProcessedAckResult<S: PixSpec> {
643    /// Nothing to report: the acknowledgement matched no pending share, or the share was a duplicate
644    /// or was absorbed by an already-failed polynomial.
645    ///
646    /// A *surplus* share is not in this set. It advances nothing, but it is evidence the Entry is
647    /// still serving, and a consumer needs to be able to tell that from silence — see
648    /// [`Progressed`](Self::Progressed).
649    NoProgress,
650    /// The share is valid but its polynomial's verifier is not installed yet, so it cannot be
651    /// checked. Deferral, not failure: the ack is bucketed under this
652    /// [`SsaPolynomialId`] and retried once the verifier arrives.
653    VerifierNotReady(SsaPolynomialId<<S as PixSpec>::Pseudonym>),
654    /// A share landed on this cycle without finishing it. The snapshot says whether it advanced
655    /// recovery: `useful_shares` moves for a useful share, only `shares_seen` for a surplus one.
656    Progressed(SsaRecoveryProgress<<S as PixSpec>::Pseudonym>),
657    /// The share failed verification. Carries the SSA's aggregate fault total across all peers.
658    InvalidShare(SsaId<<S as PixSpec>::Pseudonym>, u64),
659    /// The early recovery threshold was crossed.
660    EarlyRecovery(SsaRecoveryProgress<<S as PixSpec>::Pseudonym>),
661    /// Full SSA was recovered. Carries the progress at the recovery instant; later buffered shares
662    /// may advance only the compact tail's liveness counter.
663    FullRecovery(
664        RecoveredSsa<<S as PixSpec>::Pseudonym, <S as PixSpec>::AddressPrivateKey>,
665        SsaRecoveryProgress<<S as PixSpec>::Pseudonym>,
666    ),
667}
668
669/// Merges a snapshot into the batch's pending set, keeping the furthest-along one per SSA.
670///
671/// Concurrent batches share a cycle's counters, so snapshots taken microseconds apart can be
672/// unordered. Keeping the maximum means one batch never reports its own SSA going backwards.
673///
674/// Merged **componentwise** rather than by choosing one snapshot whole, because the counters are two
675/// independent axes and a batch can advance either alone. A surplus share moves only `shares_seen`,
676/// so selecting on `useful_shares` discarded exactly the snapshots that say an Entry is still
677/// serving during a surplus run — the run this crate emits `Progressed` for in the first place, and
678/// the axis the Session layer's egress gate and idle deadline both read. Selecting on `shares_seen`
679/// instead would only move the same defect onto the other axis, since the two orderings disagree
680/// precisely when a snapshot is worth keeping.
681///
682/// Every field merged here is monotone over a cycle's life, so the componentwise maximum is itself a
683/// reachable state rather than an artefact. `target_useful_shares` is fixed at construction and
684/// `ssa_id` is the key, so neither is merged.
685fn record_progress<P: PartialEq>(acc: &mut Vec<SsaRecoveryProgress<P>>, snapshot: SsaRecoveryProgress<P>) {
686    match acc.iter_mut().find(|p| p.ssa_id == snapshot.ssa_id) {
687        Some(existing) => {
688            existing.useful_shares = existing.useful_shares.max(snapshot.useful_shares);
689            existing.shares_seen = existing.shares_seen.max(snapshot.shares_seen);
690            existing.recovered_polynomials = existing.recovered_polynomials.max(snapshot.recovered_polynomials);
691        }
692        None => acc.push(snapshot),
693    }
694}
695
696/// One SSA's fault observation, with the relayer that carried the offending share.
697type FaultObservation<P> = (Box<OffchainPublicKey>, SsaId<P>, u64);
698
699/// Merges a fault observation into the batch's pending set, keeping the highest total per SSA.
700///
701/// The relayer travels with the total rather than being filled in at emission time: a batch can also
702/// carry faults redeemed from deferral, and those were relayed by whoever held the share at the time
703/// — not by the peer whose acknowledgements are being processed now.
704fn record_fault<P: PartialEq>(acc: &mut Vec<FaultObservation<P>>, observation: FaultObservation<P>) {
705    match acc.iter_mut().find(|(_, id, _)| *id == observation.1) {
706        Some(existing) if existing.2 >= observation.2 => {}
707        Some(existing) => *existing = observation,
708        None => acc.push(observation),
709    }
710}
711
712/// Appends a resolution unless an equal one is already present.
713fn push_unique<P: PartialEq, A>(acc: &mut Vec<ShareResolution<P, A>>, resolution: ShareResolution<P, A>) {
714    if !acc.contains(&resolution) {
715        acc.push(resolution);
716    }
717}
718
719impl<S: PixSpec + Clone> Default for SsaReconstructor<S> {
720    fn default() -> Self {
721        Self::new(Default::default())
722    }
723}
724
725impl<S: PixSpec + Clone> SsaReconstructor<S> {
726    fn retirement_frontier(&self, pseudonym: S::Pseudonym) -> SharedRetirementFrontier {
727        self.retirement_frontiers
728            .lock()
729            .entry(pseudonym)
730            .or_insert_with(|| std::sync::Arc::new(parking_lot::Mutex::new(RetirementFrontier::default())))
731            .clone()
732    }
733
734    fn existing_retirement_frontier(&self, pseudonym: &S::Pseudonym) -> Option<SharedRetirementFrontier> {
735        self.retirement_frontiers.lock().get(pseudonym).cloned()
736    }
737
738    fn retirement_frontier_for(&self, ssa_id: &SsaId<S::Pseudonym>) -> Option<SharedRetirementFrontier> {
739        self.commitment_builder
740            .get(ssa_id)
741            .map(|registration| registration.retirement.clone())
742            .or_else(|| {
743                self.ssa_cycles
744                    .get(ssa_id)
745                    .map(|cycle| cycle.registration.retirement.clone())
746            })
747            .or_else(|| self.existing_retirement_frontier(ssa_id.pseudonym()))
748    }
749
750    /// Registers a Session pseudonym for compact retirement tracking.
751    ///
752    /// The returned scope belongs in the same owner as that Session's
753    /// [`SsaCommitmentGuard`]s. Dropping it removes the map entry immediately, while any guards,
754    /// published cycles or commitment workers still in flight retain the shared frontier until
755    /// their own retirement race has settled.
756    pub fn begin_retirement_scope(self: &std::sync::Arc<Self>, pseudonym: S::Pseudonym) -> SsaRetirementScope<S> {
757        let retirement = self.retirement_frontier(pseudonym);
758        SsaRetirementScope {
759            reconstructor: std::sync::Arc::downgrade(self),
760            pseudonym,
761            retirement,
762        }
763    }
764
765    /// Creates a new SSA reconstructor from the given configuration.
766    ///
767    /// Fails if the configuration does not validate. Prefer this over [`Self::new`] anywhere the
768    /// configuration is assembled at runtime — a config built programmatically or read from a file
769    /// is input, not a constant, and turning it into a panic makes it un-handleable by the caller.
770    pub fn try_new(cfg: SsaReconstructorConfig) -> Result<Self, PixError<S::Pseudonym>> {
771        cfg.validate()?;
772        Ok(Self {
773            commitment_builder: moka::sync::Cache::builder()
774                .time_to_idle(cfg.incomplete_commitment_lifetime)
775                .build(),
776            // Indispensable per-cycle state: never size-evicted. Built without a `max_capacity`,
777            // so only `time_to_idle` reclaims it. Active removal happens via `remove_cycle` on
778            // full recovery and `retire_ssa` on session teardown; the TTL is the backstop.
779            // A hard capacity would silently strand a live cycle.
780            //
781            // The idle timer is refreshed by an acknowledgement for *any* polynomial of the cycle,
782            // because the whole cycle is one entry. That is what makes reclamation correct at any
783            // line rate — see `SsaCycle`.
784            ssa_cycles: moka::sync::Cache::builder()
785                .time_to_idle(cfg.unused_verifier_lifetime)
786                .build(),
787            retirement_frontiers: parking_lot::Mutex::new(HashMap::new()),
788            awaiting_acks: moka::sync::CacheBuilder::new(cfg.max_tracked_peers as u64)
789                .time_to_idle(cfg.max_ack_await_time)
790                // Dropping a peer entry drops its whole inner cache, and dropping a moka handle
791                // does not run that cache's eviction listener — so without this, every entry the
792                // peer still held would stay counted against the global budget forever. Invalidating
793                // routes them through the inner listener instead.
794                //
795                // `run_pending_tasks()` is deliberately not called here: it is unbounded work on
796                // whichever thread happened to trigger maintenance. That leaves the invalidation
797                // best-effort, which is precisely why `resync_ack_buffer` exists — this narrows the
798                // drift, it does not close it.
799                .eviction_listener(|_, shares: EncryptedShareCache<S>, _| shares.invalidate_all())
800                .build(),
801            // One bucket per cycle, expiring on the same clock as the shares it belongs to: an ack
802            // whose share has left `awaiting_acks` can never be used again, so there is nothing to
803            // keep. `time_to_live`, not idle — appending to a bucket must not extend the life of
804            // entries already in it.
805            //
806            // The capacity is in cycles. `MAX_POLYS_PER_SSA` is reused only as a generous count of
807            // concurrently deferring cycles; a size eviction here is share loss, so it is
808            // deliberately far above the pipelining factor and the TTL is the operative bound.
809            pending_acks: moka::sync::CacheBuilder::new(MAX_POLYS_PER_SSA as u64)
810                .time_to_live(cfg.max_ack_await_time)
811                .build(),
812            ready_resolutions: parking_lot::Mutex::new(Vec::new()),
813            ready_resolutions_len: std::sync::atomic::AtomicUsize::new(0),
814            ready_resolution_notifier: std::sync::OnceLock::new(),
815            ack_buffer_entries: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
816            // At least one, so a budget rounded below one entry refuses everything rather than
817            // dividing to zero and admitting everything.
818            max_ack_buffer_entries: (cfg.max_ack_buffer_bytes / AWAITING_ACK_ENTRY_BYTES).max(1),
819            ack_buffer_resync: parking_lot::Mutex::new(None),
820            cfg,
821        })
822    }
823
824    /// Creates a new SSA reconstructor from the given configuration.
825    ///
826    /// # Panics
827    /// Panics if the configuration fails validation. Use [`Self::try_new`] to handle that case
828    /// instead.
829    pub fn new(cfg: SsaReconstructorConfig) -> Self {
830        Self::try_new(cfg).expect("invalid SsaReconstructorConfig")
831    }
832
833    /// Returns the configuration of the reconstructor.
834    #[inline]
835    pub fn config(&self) -> &SsaReconstructorConfig {
836        &self.cfg
837    }
838
839    /// Installs the non-blocking wake-up used when commitment installation resolves deferred
840    /// acknowledgements.
841    ///
842    /// Returns `false` when a notifier was already installed. The callback is invoked only after
843    /// the resolution-buffer mutex has been released, so it may immediately call
844    /// [`take_ready_resolutions`](Self::take_ready_resolutions). If resolutions were queued before
845    /// registration, registration wakes the consumer once before returning.
846    pub fn set_ready_resolution_notifier(&self, notifier: impl Fn() + Send + Sync + 'static) -> bool {
847        if self.ready_resolution_notifier.set(Box::new(notifier)).is_err() {
848            return false;
849        }
850
851        if self.ready_resolutions_len.load(std::sync::atomic::Ordering::Acquire) > 0 {
852            self.notify_ready_resolutions();
853        }
854        true
855    }
856
857    #[inline]
858    fn notify_ready_resolutions(&self) {
859        if let Some(notifier) = self.ready_resolution_notifier.get() {
860            notifier();
861        }
862    }
863
864    /// Returns `true` if the reconstructor still holds a builder (SSA-part
865    /// builder or commitment builder) for the given cycle.  Used by tests to
866    /// verify that [`retire_ssa`](ExitAcknowledgementShareProcessor::retire_ssa)
867    /// cleaned up the expected state.
868    pub fn contains_builder(&self, ssa_id: &SsaId<S::Pseudonym>) -> bool {
869        self.ssa_cycles.contains_key(ssa_id) || self.commitment_builder.contains_key(ssa_id)
870    }
871
872    /// Polynomial-index runs of `ssa_id` whose constant-term commitment never arrived, so the caller
873    /// can ask the peer to re-send exactly those.
874    ///
875    /// A cycle's commitment ships as hundreds of packets and completes only when every one of them
876    /// has landed, so without this a single lost packet is a lost Session — nothing else in the
877    /// protocol can repair it, since the peer has no way to learn which piece went missing.
878    ///
879    /// * `None` — no registration for `ssa_id`. It was retired, or it went unanswered for longer than
880    ///   [`incomplete_commitment_lifetime`](SsaReconstructorConfig::incomplete_commitment_lifetime), and either way
881    ///   there is nothing left for a retransmission to complete.
882    /// * `Some([])` — the commitment is complete; there is nothing to ask for. This is the *only* thing an empty vector
883    ///   means: a `max_runs` of zero is read as one rather than honoured.
884    /// * `Some(runs)` — at most `max_runs` runs, lowest first. A caller that gets exactly `max_runs` back has not seen
885    ///   the whole shortfall and should ask again once these are answered.
886    ///
887    /// `max_runs` is the asking message's capacity, which this crate cannot know, so the caller
888    /// supplies it; `SsaCommitmentBuilder::missing_runs` documents how the runs are chosen.
889    // That name is deliberately unlinked: `SsaCommitmentBuilder` is crate-private, so an intra-doc
890    // link from this public method trips `rustdoc::private_intra_doc_links`, which the
891    // `nix build .#docs` job builds as an error.
892    pub fn missing_commitment_runs(
893        &self,
894        ssa_id: &SsaId<S::Pseudonym>,
895        max_runs: usize,
896    ) -> Option<Vec<(PolynomialIndex, PolynomialIndex)>> {
897        self.commitment_builder
898            .get(ssa_id)
899            .map(|registration| registration.lock().missing_runs(max_runs))
900    }
901
902    /// Removes the heavyweight reconstructor state for a single SSA cycle.
903    ///
904    /// A successfully recovered cycle's compact accounting tail is owned by the Session retirement
905    /// frontier, not by these caches, and intentionally survives this operation while paid SURBs
906    /// drain.
907    ///
908    /// Idempotent: invalidating an absent key is a no-op.
909    fn remove_cycle(&self, ssa_id: SsaId<S::Pseudonym>) {
910        self.ssa_cycles.invalidate(&ssa_id);
911        // Deferred acks for a retired cycle can never be redeemed — their part builders will not
912        // come back and their shares are about to expire.
913        self.pending_acks.invalidate(&ssa_id);
914        self.commitment_builder.invalidate(&ssa_id);
915    }
916
917    /// Removes a cycle that has reached a terminal state and advances its Session frontier.
918    ///
919    /// Retirement is recorded *before* the removal, so a commitment completing concurrently sees
920    /// the retirement and undoes its own publication rather than resurrecting the cycle.
921    ///
922    /// This is the operation for a cycle that went **live** and failed terminally. Successful
923    /// recovery uses [`retire_recovered_cycle`](Self::retire_recovered_cycle), which performs the
924    /// same heavyweight teardown but installs a bounded liveness tail. It is deliberately *not* the
925    /// operation for a commitment released before going live: that index is retried at the same
926    /// value by design, and retirement would block the retry permanently.
927    /// [`release_abandoned_commitment`](Self::release_abandoned_commitment) draws exactly that line,
928    /// and escalates to this for the live case.
929    fn retire_cycle(&self, ssa_id: SsaId<S::Pseudonym>, retirement: &SharedRetirementFrontier) {
930        // Keep the frontier lock through cache invalidation. Registration takes the same lock, so a
931        // same-index retry cannot install itself between the retirement marker and the removal and
932        // then be mistaken for the state this call intended to tear down.
933        let mut frontier = retirement.lock();
934        frontier.retire(ssa_id.ssa_index());
935        self.remove_cycle(ssa_id);
936    }
937
938    /// Retires the heavyweight recovery state while retaining only the bounded allowance needed by
939    /// SURBs that were minted before recovery and are still queued in the Exit's FIFO.
940    fn retire_recovered_cycle(
941        &self,
942        ssa_id: SsaId<S::Pseudonym>,
943        retirement: &SharedRetirementFrontier,
944        tail: RecoveredSsaTail,
945    ) {
946        // Publication and retirement use this same lock. Installing the tail before invalidating
947        // the live cache leaves no miss window in which a legitimate trailing acknowledgement is
948        // mistaken for a not-yet-published cycle and deferred.
949        let mut frontier = retirement.lock();
950        frontier.retire_recovered(ssa_id.ssa_index(), tail);
951        self.remove_cycle(ssa_id);
952    }
953
954    fn process_verified_ack(
955        &self,
956        ack: HalfKey,
957        ack_challenge: HalfKeyChallenge,
958        awaiting_ack_from_peer: &moka::sync::Cache<HalfKeyChallenge, TaggedEncryptedPartialSsaShare<S>>,
959    ) -> Result<ProcessedAckResult<S>, PixError<S::Pseudonym>> {
960        let Some(share) = awaiting_ack_from_peer.get(&ack_challenge) else {
961            tracing::trace!(?ack_challenge, "received ack for unknown share");
962            return Ok(ProcessedAckResult::NoProgress);
963        };
964
965        let spi = share.ssa_polynomial_id().ok_or(PixError::ShareIsEmpty)?;
966
967        // One lookup for the whole cycle: the part builders and the accumulator are published and
968        // reclaimed together, so there is no state in which one is reachable and the other is not.
969        // The lookup also refreshes the cycle's idle timer, which is what keeps a cycle that is
970        // still being served from being reclaimed underneath itself.
971        let Some(cycle) = self.ssa_cycles.get(spi.as_ref()) else {
972            // Two states share this miss and only one of them is worth deferring for. A cycle that
973            // has not been published yet will arrive, so the share stays put and the caller buckets
974            // the ack against it. A cycle that has been retired — recovered, or failed terminally —
975            // never will, and deferring holds both the bucket and the share for `max_ack_await_time`
976            // to no end.
977            //
978            // Not a corner case at the deployed dimensions: a conforming Entry ends every emission
979            // window in a run of shares that advance nothing, so *every* recovered cycle is followed
980            // by acks landing here. Deferring them would hold up to `MAX_DEFERRED_ACKS_PER_CYCLE`
981            // dead entries per cycle, and — the part that actually costs something — keep their
982            // shares in `awaiting_acks`, whose budget is global. There they crowd out live cycles
983            // rather than merely wasting space.
984            if let Some(frontier) = self.existing_retirement_frontier(spi.pseudonym())
985                && frontier.lock().is_retired(spi.ssa_index())
986            {
987                // Frees the buffer entry: the per-peer cache's eviction listener decrements the
988                // global count on an explicit removal, which is what the redeeming path below
989                // relies on too.
990                awaiting_ack_from_peer.remove(&ack_challenge);
991
992                // Decrypt before taking the short Session frontier lock below. The tail itself is
993                // immutable apart from atomics, but matching and crediting happen under that lock
994                // so a newer recovered cycle cannot replace the one slot between those operations.
995                let partial_share = share.partial_share.decrypt(spi.pseudonym(), &ack)?;
996                let ssa_id = *spi.as_ref();
997                let frontier = frontier.lock();
998                let Some(tail) = frontier
999                    .recovered_tail
1000                    .as_ref()
1001                    .filter(|tail| tail.ssa_index() == spi.ssa_index())
1002                else {
1003                    tracing::trace!(%spi, "ack for a retired cycle outside the active recovered tail — dropped");
1004                    return Ok(ProcessedAckResult::NoProgress);
1005                };
1006
1007                return match tail.credit_share::<S>(ssa_id, spi.poly_index(), share.nonce, &partial_share) {
1008                    Ok(RecoveredTailCredit::Credited(progress)) => {
1009                        tracing::trace!(%spi, "credited buffered surplus against recovered cycle tail");
1010                        Ok(ProcessedAckResult::Progressed(progress))
1011                    }
1012                    Ok(RecoveredTailCredit::OverBudget) => {
1013                        tracing::debug!(%spi, "buffered share is past the recovered polynomial's negotiated surplus");
1014                        Ok(ProcessedAckResult::NoProgress)
1015                    }
1016                    Ok(RecoveredTailCredit::InvalidPolynomial) => {
1017                        tracing::error!(%spi, "buffered share names a polynomial outside its recovered cycle");
1018                        Err(PixError::InvalidInput)
1019                    }
1020                    Ok(RecoveredTailCredit::InvalidShare(observed_total)) => {
1021                        tracing::error!(%spi, observed_total, "invalid buffered share addressed to a recovered polynomial");
1022                        Ok(ProcessedAckResult::InvalidShare(ssa_id, observed_total))
1023                    }
1024                    Err(error) => Err(error),
1025                };
1026            }
1027            // Not an error: the constant-term set is still incomplete, so no part builder exists
1028            // yet. Leave the share in `awaiting_acks` and hand the caller the key it needs to
1029            // bucket the ack.
1030            //
1031            // A third state also lands here and is deliberately left to the TTL: a cycle that idled
1032            // out of `ssa_cycles` through `unused_verifier_lifetime` is still registered, not
1033            // retired, so its late acks are bucketed as though the cycle were still coming. Both
1034            // the bucket and retained share expire on `max_ack_await_time`; retirement paths above
1035            // are classified exactly without allocating one historical entry per cycle.
1036            return Ok(ProcessedAckResult::VerifierNotReady(spi));
1037        };
1038
1039        // The polynomial index comes from the peer's own share, so it is untrusted. Once the cycle
1040        // is known its dimensions are too, which makes an out-of-range index definitively invalid
1041        // rather than merely early — there is no later state in which it becomes meaningful.
1042        let Some(part) = cycle.part(spi.poly_index()) else {
1043            tracing::error!(%spi, num_polys = cycle.num_polys(), "share names a polynomial outside the cycle");
1044            return Err(PixError::InvalidInput);
1045        };
1046
1047        // Cycle confirmed — safe to consume the share.
1048        awaiting_ack_from_peer.remove(&ack_challenge);
1049
1050        // The share cannot be empty at this point because we prevent empty share insertions
1051        let partial_share = share.partial_share.decrypt(spi.pseudonym(), &ack)?;
1052
1053        let ssa_id = *spi.as_ref();
1054
1055        // The part lock is released before the accumulator is taken below. That order is the one
1056        // callers must keep, and neither lock is ever held across the other.
1057        let ssa_part = match part.lock().add_share(share.nonce, partial_share) {
1058            Ok(AddShareOutcome::Completed(share)) => {
1059                tracing::trace!(%spi, "ssa part complete");
1060                cycle.record_useful_share();
1061                cycle.record_completed_part();
1062                share
1063            }
1064            Ok(AddShareOutcome::Useful) => {
1065                tracing::trace!(%spi, "ssa part not yet complete, waiting for more shares");
1066                cycle.record_useful_share();
1067                // Snapshot here rather than making the caller look the cycle up again: this is the
1068                // steady-state outcome for all but one share in `threshold`, so a second cache get
1069                // would double the lookups on the hot path.
1070                return Ok(ProcessedAckResult::Progressed(cycle.progress()));
1071            }
1072            // Expected traffic, not a fault: a conforming Entry emits `threshold + surplus` shares
1073            // per polynomial, so every polynomial ends its life absorbing surplus.
1074            //
1075            // Reported rather than swallowed, even though it advances nothing. Polynomials in an
1076            // emission window run in lockstep, so they reach `threshold` together and then take their
1077            // surplus together — `surplus × window` consecutive shares, 4096 at the deployed
1078            // dimensions, none of them useful. A consumer that only ever hears about useful shares
1079            // reads that as the Entry having gone quiet. The snapshot says which it is: `shares_seen`
1080            // moves, `useful_shares` does not.
1081            Ok(AddShareOutcome::Surplus) => {
1082                match cycle.credit_surplus(spi.poly_index()) {
1083                    Some(true) => {
1084                        tracing::trace!(%spi, "share arrived after its polynomial was reconstructed");
1085                        cycle.record_surplus_share();
1086                        return Ok(ProcessedAckResult::Progressed(cycle.progress()));
1087                    }
1088                    Some(false) => {
1089                        // Past the negotiated surplus the peer is emitting more for this polynomial
1090                        // than it said it would, and reconstruction has already released the share
1091                        // set, so these cannot even be told apart as duplicates. Withholding the
1092                        // snapshot prevents one completed polynomial from refreshing the Exit's
1093                        // gate indefinitely while the rest of the cycle is untouched.
1094                        tracing::debug!(%spi, "share arrived past the peer's negotiated surplus for this polynomial");
1095                        return Ok(ProcessedAckResult::NoProgress);
1096                    }
1097                    None => {
1098                        // `cycle.part` performed the same checked lookup above, so this can only be
1099                        // an internal disagreement between the part and budget dimensions.
1100                        tracing::error!(%spi, "surplus budget does not contain the cycle's polynomial");
1101                        return Err(PixError::InvalidInput);
1102                    }
1103                }
1104            }
1105            Ok(AddShareOutcome::Duplicate) => {
1106                tracing::trace!(%spi, "duplicate evaluation identifier");
1107                return Ok(ProcessedAckResult::NoProgress);
1108            }
1109            Ok(AddShareOutcome::Absorbed) => {
1110                tracing::trace!(%spi, "share for a polynomial that already failed its commitment");
1111                return Ok(ProcessedAckResult::NoProgress);
1112            }
1113            Err(PixError::VsssError(vsss_rs::Error::InvalidShare)) => {
1114                // Counted rather than raised: the caller reports it as a resolution, and the count
1115                // has to be taken here because the cycle that holds it is in hand.
1116                //
1117                // Almost always this means the polynomial's reconstructed constant term did not
1118                // open its commitment, in which case the offending share is one of the `threshold`
1119                // that went into it and cannot be singled out. The whole cycle is lost either way,
1120                // since the SSA needs every polynomial.
1121                let observed_total = cycle.record_invalid_share();
1122                tracing::error!(%spi, observed_total, "ssa part failed to open its commitment");
1123                return Ok(ProcessedAckResult::InvalidShare(ssa_id, observed_total));
1124            }
1125            Err(e) => return Err(e),
1126        };
1127
1128        let mut builder_guard = cycle.builder().lock();
1129        let ssa = match builder_guard.add_recovered_ssa_part(spi.poly_index(), ssa_part) {
1130            Ok(ssa) => ssa,
1131            Err(error) => {
1132                // As terminal as `scalar_to_private_key` returning `None` below, and torn down the
1133                // same way. Propagating alone would leave the accumulator and every part builder in
1134                // place, and each further share for the cycle would refresh the idle timer that is
1135                // supposed to reclaim them — so a Session that keeps sending holds a cycle that can
1136                // never reconstruct for as long as it likes.
1137                //
1138                // The lock goes first: `retire_cycle` drops the last `Arc` to this very cycle.
1139                drop(builder_guard);
1140                tracing::error!(%spi, %error, "ssa part could not be added to its accumulator");
1141                self.retire_cycle(ssa_id, &cycle.registration.retirement);
1142                return Err(error);
1143            }
1144        };
1145        match ssa {
1146            Some(scalar) => {
1147                // Capture the recovery instant before retirement. The counters survive in the
1148                // compact tail, but any later movement is buffered liveness and must not be folded
1149                // backwards into the terminal recovery event.
1150                let progress = cycle.progress();
1151                // Release the accumulator lock before retiring, so `retire_cycle` — which drops
1152                // the last `Arc` to this very cycle — does not run while it is held.
1153                drop(builder_guard);
1154                let Some(ssa) = S::scalar_to_private_key(scalar) else {
1155                    tracing::error!(%spi, "ssa reconstruction failed");
1156                    self.retire_cycle(ssa_id, &cycle.registration.retirement);
1157                    return Err(PixError::InvalidSsa);
1158                };
1159                // Full recovery releases every heavyweight builder immediately. Only the shared
1160                // counters and per-polynomial remainder survive, so SURBs already queued with this
1161                // cycle's shares can drain as bounded liveness without retaining the recovered key
1162                // or giving the Entry a replayable cycle-wide allowance.
1163                let tail = cycle.recovered_tail();
1164                self.retire_recovered_cycle(ssa_id, &cycle.registration.retirement, tail);
1165                tracing::info!(%ssa_id, "ssa recovered");
1166                Ok(ProcessedAckResult::FullRecovery(RecoveredSsa { ssa_id, ssa }, progress))
1167            }
1168            None => {
1169                tracing::trace!(%spi, "ssa not yet complete, waiting for more ssa parts");
1170                // Check early threshold while we hold the lock
1171                let early = builder_guard.check_early_threshold(self.cfg.early_recovery_threshold);
1172                drop(builder_guard);
1173                let progress = cycle.progress();
1174                if early {
1175                    tracing::info!(%ssa_id, "early recovery threshold reached");
1176                    Ok(ProcessedAckResult::EarlyRecovery(progress))
1177                } else {
1178                    Ok(ProcessedAckResult::Progressed(progress))
1179                }
1180            }
1181        }
1182    }
1183
1184    /// Buckets an acknowledgement whose cycle's part builders have not been installed yet.
1185    ///
1186    /// O(1) — this is the entire cost the acknowledgement path pays for a deferral.
1187    fn defer_ack(&self, spi: SsaPolynomialId<S::Pseudonym>, deferred: DeferredAck) {
1188        let bucket = self.pending_acks.get_with(*spi.as_ref(), || {
1189            std::sync::Arc::new(parking_lot::Mutex::new(Default::default()))
1190        });
1191        self.defer_ack_into(&bucket, spi, deferred);
1192    }
1193
1194    /// The bucket half of [`defer_ack`](Self::defer_ack), taking the bucket rather than looking it
1195    /// up.
1196    ///
1197    /// Split out so a test can hold a handle across the drain that invalidates the cache key,
1198    /// which is the interleaving this guards against and the one thing a single thread cannot
1199    /// otherwise produce — after the invalidate, `get_with` hands out a *fresh* bucket.
1200    fn defer_ack_into(&self, bucket: &DeferredAckBucket, spi: SsaPolynomialId<S::Pseudonym>, deferred: DeferredAck) {
1201        let ssa_id = *spi.as_ref();
1202        let outcome = {
1203            let mut bucket = bucket.lock();
1204            if bucket.drained {
1205                Deferral::Orphaned
1206            } else if bucket.total >= MAX_DEFERRED_ACKS_PER_CYCLE {
1207                // The cycle as a whole is holding more than the shares it could plausibly have
1208                // received inside `max_ack_await_time`, so the excess cannot be redeemable.
1209                tracing::warn!(
1210                    %ssa_id,
1211                    cap = MAX_DEFERRED_ACKS_PER_CYCLE,
1212                    "dropping deferred acknowledgement: cycle bucket is full"
1213                );
1214                Deferral::Dropped
1215            } else {
1216                // Reborrowed off the guard so `by_poly` and `total` are disjoint field borrows.
1217                let bucket = &mut *bucket;
1218                let per_poly = bucket.by_poly.entry(spi.poly_index()).or_default();
1219                if per_poly.len() >= MAX_DEFERRED_ACKS_PER_POLYNOMIAL {
1220                    // Only reachable if the peer emits more shares for one polynomial than its own
1221                    // `threshold + surplus` budget allows, so the excess is almost certainly
1222                    // duplicate.
1223                    tracing::warn!(
1224                        %spi,
1225                        cap = MAX_DEFERRED_ACKS_PER_POLYNOMIAL,
1226                        "dropping deferred acknowledgement: polynomial bucket is full"
1227                    );
1228                    Deferral::Dropped
1229                } else {
1230                    per_poly.push(deferred);
1231                    bucket.total += 1;
1232                    Deferral::Buffered
1233                }
1234            }
1235        };
1236
1237        match outcome {
1238            // The drain took this bucket while we were on our way into it. Redeeming here is what
1239            // makes the mutex the whole synchronisation point: the drain's take and this append
1240            // are serialised by it, so exactly one of them owns the ack.
1241            Deferral::Orphaned => {
1242                tracing::trace!(%spi, "redeeming an acknowledgement deferred into a drained bucket");
1243                self.redeem_deferred_acks(&ssa_id, std::iter::once(deferred));
1244            }
1245            // Close the race against a concurrent installation. The decision to defer was made on
1246            // a cycle lookup that missed; if the cycle has appeared since, the drain that would
1247            // have redeemed this ack may already have run against a bucket we never saw.
1248            Deferral::Buffered => {
1249                if self.ssa_cycles.contains_key(&ssa_id) {
1250                    self.drain_deferred_acks(&ssa_id);
1251                }
1252            }
1253            Deferral::Dropped => {}
1254        }
1255    }
1256
1257    /// Redeems the acknowledgements that were waiting for this cycle's part builders.
1258    ///
1259    /// Called from the commitment path immediately after the cycle is installed, so each bucket is
1260    /// processed exactly once and never speculatively re-scanned. Resolutions are parked in
1261    /// [`ready_resolutions`](Self::ready_resolutions) for the next `acknowledge_shares` call, since
1262    /// the commitment path has no route to the upper layer.
1263    fn drain_deferred_acks(&self, ssa_id: &SsaId<S::Pseudonym>) {
1264        let Some(bucket) = self.pending_acks.get(ssa_id) else {
1265            return;
1266        };
1267        self.pending_acks.invalidate(ssa_id);
1268
1269        // Take and tombstone in one critical section. A `defer_ack_into` that looked this bucket
1270        // up before the invalidate still holds an `Arc` to it; the flag is what stops its append
1271        // from disappearing into a bucket nothing will read again.
1272        let deferred = {
1273            let mut bucket = bucket.lock();
1274            bucket.drained = true;
1275            bucket.total = 0;
1276            std::mem::take(&mut bucket.by_poly)
1277        };
1278        if deferred.is_empty() {
1279            return;
1280        }
1281
1282        self.redeem_deferred_acks(ssa_id, deferred.into_values().flatten());
1283    }
1284
1285    /// Processes acknowledgements whose verifier has since been installed, parking whatever they
1286    /// resolve to.
1287    ///
1288    /// Shared by the two routes that can redeem a deferral — the drain on the commitment path and
1289    /// an [`orphaned`](Deferral::Orphaned) append — so both produce the same resolutions in the
1290    /// same order.
1291    fn redeem_deferred_acks(&self, ssa_id: &SsaId<S::Pseudonym>, deferred: impl IntoIterator<Item = DeferredAck>) {
1292        let mut resolved = Vec::new();
1293        // The furthest-along snapshot any redeemed ack produced. Shares recovered here would
1294        // otherwise be invisible to the consumer until some later batch happened to touch this same
1295        // SSA, since only `acknowledge_shares` emits snapshots.
1296        let mut progress = Vec::new();
1297        for (peer, challenge, ack) in deferred {
1298            // The share lives in the peer's own awaiting-acks cache; if the peer entry is gone the
1299            // share has expired with it and the ack is dead.
1300            let Some(awaiting) = self.awaiting_acks.get(&peer) else {
1301                continue;
1302            };
1303            match self.process_verified_ack(ack, challenge, &awaiting) {
1304                Ok(ProcessedAckResult::FullRecovery(ssa, snapshot)) => {
1305                    record_progress(&mut progress, snapshot);
1306                    resolved.push(ShareResolution::RecoveredSsa(ssa));
1307                }
1308                Ok(ProcessedAckResult::EarlyRecovery(snapshot)) => {
1309                    let id = snapshot.ssa_id;
1310                    record_progress(&mut progress, snapshot);
1311                    resolved.push(ShareResolution::AlmostRecoveredSsa(id));
1312                }
1313                Ok(ProcessedAckResult::Progressed(snapshot)) => record_progress(&mut progress, snapshot),
1314                Ok(ProcessedAckResult::InvalidShare(id, observed_total)) => {
1315                    tracing::error!(%id, observed_total, "deferred share could not be verified");
1316                    resolved.push(ShareResolution::InvalidShares {
1317                        peer: peer.into(),
1318                        ssa_id: id,
1319                        observed_total,
1320                    });
1321                }
1322                Ok(ProcessedAckResult::NoProgress) => {}
1323                Ok(ProcessedAckResult::VerifierNotReady(_)) => {
1324                    // The cycle was installed and then immediately withdrawn, which only the
1325                    // retirement path does. Re-bucketing would leak, so drop.
1326                    tracing::trace!(%ssa_id, "cycle withdrawn while draining deferred acknowledgements");
1327                }
1328                Err(error) => tracing::debug!(%ssa_id, %error, "failed to process deferred acknowledgement"),
1329            }
1330        }
1331
1332        // Snapshots go in ahead of the terminal events they belong to, matching what
1333        // `acknowledge_shares` emits — the consumer sees one order regardless of which path resolved
1334        // a share.
1335        if !progress.is_empty() {
1336            let mut ordered = progress.into_iter().map(ShareResolution::Progress).collect::<Vec<_>>();
1337            ordered.append(&mut resolved);
1338            resolved = ordered;
1339        }
1340
1341        if !resolved.is_empty() {
1342            tracing::debug!(%ssa_id, num = resolved.len(), "redeemed deferred acknowledgements");
1343            let mut ready = self.ready_resolutions.lock();
1344            ready.extend(resolved);
1345            self.ready_resolutions_len
1346                .store(ready.len(), std::sync::atomic::Ordering::Release);
1347            drop(ready);
1348            self.notify_ready_resolutions();
1349        }
1350    }
1351
1352    /// Takes any resolutions parked by `drain_deferred_acks`.
1353    ///
1354    /// The take is atomic with respect to producers and other consumers. This lets the transport's
1355    /// notification stream and the acknowledgement fallback race safely: exactly one receives each
1356    /// queued resolution.
1357    ///
1358    /// One atomic load is the entire common-case cost — the buckets are empty whenever the Entry
1359    /// finishes the constant-term pass before the shares that reference it arrive.
1360    pub fn take_ready_resolutions(&self) -> Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>> {
1361        if self.ready_resolutions_len.load(std::sync::atomic::Ordering::Acquire) == 0 {
1362            return Vec::new();
1363        }
1364        let mut ready = self.ready_resolutions.lock();
1365        self.ready_resolutions_len
1366            .store(0, std::sync::atomic::Ordering::Release);
1367        std::mem::take(&mut *ready)
1368    }
1369
1370    /// Minimum wall time between two [`resync_ack_buffer`](Self::resync_ack_buffer) passes.
1371    ///
1372    /// A saturated buffer would otherwise turn every rejected insertion into an
1373    /// `O(max_tracked_peers)` scan — the overload path amplifying its own cost, which is the shape
1374    /// of bug this budget exists to prevent.
1375    ///
1376    /// Derived from [`max_ack_await_time`](SsaReconstructorConfig::max_ack_await_time) rather than
1377    /// fixed, because what a resync reclaims is entries that have aged out of *that* window. A
1378    /// constant would be wrong at both ends: too slow for a short window, so a drained buffer keeps
1379    /// refusing shares long after it emptied, and needlessly eager for a long one.
1380    ///
1381    /// The resulting staleness — up to ~1.9 s at the 30 s default — only bites when the counter has
1382    /// drifted high *and* nothing is touching the caches, since redemption and any cache access
1383    /// drive moka's expiry maintenance and fire the listener directly. That is the traffic-stopped
1384    /// case, where refusing a share costs nothing.
1385    fn ack_buffer_resync_interval(&self) -> std::time::Duration {
1386        (self.cfg.max_ack_await_time / 16).max(std::time::Duration::from_millis(1))
1387    }
1388
1389    /// Recomputes [`ack_buffer_entries`](Self::ack_buffer_entries) from what the caches actually
1390    /// hold.
1391    ///
1392    /// # Why a counter needs a backstop at all
1393    ///
1394    /// Entries leave the buffer four ways: redeemed by their acknowledgement, expired, size-evicted
1395    /// from their peer's cache, or dropped wholesale when the peer's entry leaves `awaiting_acks`.
1396    /// The inner eviction listener catches the first three exactly. The fourth cannot be caught:
1397    /// dropping a moka handle does not run its eviction listener, so the outer listener falls back
1398    /// to `invalidate_all`, which is best-effort and races an insertion landing on the very cache
1399    /// being discarded.
1400    ///
1401    /// Left alone, that residue only ever accumulates *upward*, and an over-count is far worse than
1402    /// an under-count: it would eventually refuse every share while the buffer sat empty, turning a
1403    /// memory ceiling into a permanent outage of the acknowledgement path. (The sibling
1404    /// `HoprUnacknowledgedTicketProcessor` in `hopr-protocol-hopr` has the same nesting and the same
1405    /// residue; there it only skews metrics.)
1406    ///
1407    /// So the counter is treated as a hint that is allowed to be wrong, and ground truth is
1408    /// consulted at the one moment being wrong would cost something — when it says the buffer is
1409    /// full. `try_lock` rather than `lock`: a caller that finds a resync already running should
1410    /// proceed on the current estimate, not queue up behind it.
1411    fn resync_ack_buffer(&self) {
1412        let Some(mut last_run) = self.ack_buffer_resync.try_lock() else {
1413            return;
1414        };
1415        if last_run.is_some_and(|at| at.elapsed() < self.ack_buffer_resync_interval()) {
1416            return;
1417        }
1418
1419        let held = self.count_ack_buffer_entries();
1420        let previous = self.ack_buffer_entries.swap(held, std::sync::atomic::Ordering::Relaxed);
1421        *last_run = Some(std::time::Instant::now());
1422
1423        if previous != held {
1424            tracing::debug!(
1425                previous,
1426                held,
1427                "resynchronised the awaiting-acknowledgement buffer count"
1428            );
1429        }
1430    }
1431
1432    /// Ground truth: the entries actually held across every peer.
1433    ///
1434    /// Never reads [`ack_buffer_entries`](Self::ack_buffer_entries), so a test asserting the two
1435    /// agree is testing the counter rather than agreeing with it — the same reason
1436    /// `deferred_ack_count` recomputes from `by_poly` instead of reading `DeferredAcks::total`.
1437    ///
1438    /// `O(max_tracked_peers)`, and each `run_pending_tasks` is bounded by that cache's pending write
1439    /// queue rather than its size. Both callers keep it off the steady-state path.
1440    fn count_ack_buffer_entries(&self) -> usize {
1441        self.awaiting_acks.run_pending_tasks();
1442        self.awaiting_acks
1443            .iter()
1444            .map(|(_, shares)| {
1445                shares.run_pending_tasks();
1446                shares.entry_count() as usize
1447            })
1448            .sum()
1449    }
1450
1451    /// The published cycle, if it is still live.
1452    #[cfg(test)]
1453    fn cycle(&self, ssa_id: &SsaId<S::Pseudonym>) -> Option<std::sync::Arc<RegisteredCycle<S>>> {
1454        self.ssa_cycles.get(ssa_id)
1455    }
1456
1457    /// Number of live cycles across all Sessions.
1458    #[cfg(test)]
1459    fn live_cycles(&self) -> u64 {
1460        self.ssa_cycles.run_pending_tasks();
1461        self.ssa_cycles.entry_count()
1462    }
1463
1464    /// Number of part builders installed for a cycle, or `0` if the cycle is not live.
1465    ///
1466    /// The per-polynomial cache entry count used to express this. It has to be asked of the cycle
1467    /// now, because the cache holds one entry per cycle rather than one per polynomial — so
1468    /// `entry_count()` alone can no longer tell "every part installed" from "one part installed".
1469    #[cfg(test)]
1470    fn installed_parts(&self, ssa_id: &SsaId<S::Pseudonym>) -> usize {
1471        self.ssa_cycles.get(ssa_id).map(|c| c.num_polys()).unwrap_or(0)
1472    }
1473
1474    /// Total deferred acknowledgements bucketed for a cycle.
1475    ///
1476    /// Recomputed from `by_poly` rather than read off `DeferredAcks::total` deliberately: a
1477    /// counter that has drifted from the map is exactly what this should catch, and an accessor
1478    /// reading the counter would agree with it whatever it said.
1479    #[cfg(test)]
1480    fn deferred_ack_count(&self, ssa_id: &SsaId<S::Pseudonym>) -> usize {
1481        self.pending_acks
1482            .get(ssa_id)
1483            .map(|b| b.lock().by_poly.values().map(Vec::len).sum())
1484            .unwrap_or(0)
1485    }
1486}
1487
1488/// Ownership of an Exit SSA commitment, released when dropped.
1489///
1490/// Registering an Exit commitment is the first fallible step of many: the request still has to be
1491/// encoded, sent, and answered. Every early return between here and the point where a permanent
1492/// owner takes over would otherwise strand the commitment in the reconstructor until its own
1493/// lifetime expired, and a stranded commitment is not inert — its `SsaId` is occupied, so a retry at
1494/// the same index is rejected as a duplicate.
1495///
1496/// Move-only by design: no `Clone`, no `Copy`, so there is exactly one release point. A success path
1497/// hands ownership on with [`disarm`](Self::disarm) rather than letting the guard fall out of scope.
1498///
1499/// Dropping releases the registration **without** retiring the SSA, so the same index can be
1500/// requested again — see `SsaReconstructor::release_abandoned_commitment` for why that
1501/// distinction is load-bearing.
1502#[must_use = "dropping the guard immediately releases the SSA it owns"]
1503pub struct SsaCommitmentGuard<S: PixSpec + Clone> {
1504    /// `None` once disarmed, which is the only state in which `Drop` does nothing.
1505    owned: Option<OwnedCommitment<S>>,
1506}
1507
1508/// What an [`SsaCommitmentGuard`] needs to release its SSA: where it is registered, which one, and
1509/// the Session frontier that must outlive a concurrent commitment worker.
1510type OwnedCommitment<S> = (
1511    std::sync::Arc<SsaReconstructor<S>>,
1512    SsaId<<S as PixSpec>::Pseudonym>,
1513    std::sync::Arc<RegisteredCommitment<S>>,
1514);
1515
1516/// A registered Exit commitment, paired with ownership of its lifetime.
1517type GuardedExitCommitment<S> = (PixGroup<S>, SsaCommitmentGuard<S>);
1518
1519impl<S: PixSpec + Clone> std::fmt::Debug for SsaCommitmentGuard<S> {
1520    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1521        f.debug_struct("SsaCommitmentGuard")
1522            .field("ssa_id", &self.owned.as_ref().map(|(_, id, _)| id))
1523            .finish()
1524    }
1525}
1526
1527impl<S: PixSpec + Clone> SsaCommitmentGuard<S> {
1528    /// The SSA this guard owns, or `None` if it has been disarmed.
1529    pub fn ssa_id(&self) -> Option<&SsaId<S::Pseudonym>> {
1530        self.owned.as_ref().map(|(_, id, _)| id)
1531    }
1532
1533    /// Gives up ownership without retiring, returning the SSA that is now the caller's to release.
1534    ///
1535    /// Returns `None` if the guard was already disarmed.
1536    pub fn disarm(mut self) -> Option<SsaId<S::Pseudonym>> {
1537        self.owned.take().map(|(_, ssa_id, _)| ssa_id)
1538    }
1539}
1540
1541impl<S: PixSpec + Clone> Drop for SsaCommitmentGuard<S> {
1542    fn drop(&mut self) {
1543        if let Some((reconstructor, ssa_id, registration)) = self.owned.take() {
1544            reconstructor.release_abandoned_commitment(ssa_id, &registration);
1545        }
1546    }
1547}
1548
1549impl<S: PixSpec + Clone> SsaReconstructor<S> {
1550    fn register_exit_commitment(
1551        &self,
1552        id: SsaId<S::Pseudonym>,
1553        params: PixParams,
1554    ) -> Result<RegisteredExitCommitment<S>, PixError<S::Pseudonym>> {
1555        let exit_commitment_secret = PixScalar::<S>::random(&mut hopr_types::crypto_random::rng());
1556        let exit_commitment_public = PixGroup::<S>::mul_by_generator(&exit_commitment_secret);
1557        let retirement = self.retirement_frontier(*id.pseudonym());
1558        let registration = std::sync::Arc::new(RegisteredCommitment {
1559            builder: parking_lot::Mutex::new(SsaCommitmentBuilder::new(
1560                id,
1561                params,
1562                exit_commitment_secret,
1563                exit_commitment_public,
1564            )),
1565            retirement: retirement.clone(),
1566        });
1567
1568        // Registration and retirement serialize on this Session-local lock. The cache insertion is
1569        // inside the critical section so a retiring call cannot remove the old registration and
1570        // accidentally remove a same-index retry installed between its marker and invalidation.
1571        let mut frontier = retirement.lock();
1572        if frontier.is_retired(id.ssa_index()) || self.ssa_cycles.contains_key(&id) {
1573            return Err(PixError::DuplicateCommitment);
1574        }
1575
1576        self.commitment_builder
1577            .entry(id)
1578            .and_try_compute_with(|entry| match entry {
1579                Some(_) => Err(PixError::DuplicateCommitment),
1580                None => Ok(moka::ops::compute::Op::Put(registration.clone())),
1581            })?;
1582
1583        // The retired check above and this mutation share the same lock, so this cannot fail unless
1584        // the frontier's own invariants were broken locally.
1585        let newly_registered = frontier.register(id.ssa_index());
1586        debug_assert!(newly_registered, "registration raced the frontier under its own lock");
1587        drop(frontier);
1588
1589        Ok((exit_commitment_public, registration))
1590    }
1591
1592    /// Releases a commitment that was registered but never taken over by an owner.
1593    ///
1594    /// Deliberately **not** [`retire_ssa`](ExitAcknowledgementShareProcessor::retire_ssa), which
1595    /// permanently moves the index behind the Session's retirement frontier. A pre-live release
1596    /// instead marks the index retriable, so a replacement registration can use the same value.
1597    ///
1598    /// Same-index retry is not a corner case: the SSA index is advanced only after every fallible
1599    /// step of a request has succeeded, so a request that failed keeps its index by design and the
1600    /// next attempt reuses it.
1601    ///
1602    /// Escalates to a full retirement if a cycle did go live, which means the peer was asked and
1603    /// answered — and therefore that ownership should already have been transferred with
1604    /// [`disarm`](SsaCommitmentGuard::disarm). That branch is a caller error, and retiring is the
1605    /// safe response to it, because a live cycle is exactly what the retirement frontier protects.
1606    fn release_abandoned_commitment(
1607        &self,
1608        ssa_id: SsaId<S::Pseudonym>,
1609        registration: &std::sync::Arc<RegisteredCommitment<S>>,
1610    ) {
1611        // Hold the frontier lock through both classification and removal. A completion that already
1612        // published is retired and removed here; one that publishes later sees either retirement or
1613        // an abandoned registration and withdraws its own state.
1614        let mut frontier = registration.retirement.lock();
1615        let owns_live_cycle = self
1616            .ssa_cycles
1617            .get(&ssa_id)
1618            .is_some_and(|cycle| std::sync::Arc::ptr_eq(&cycle.registration, registration));
1619        let owns_pending_commitment = self
1620            .commitment_builder
1621            .get(&ssa_id)
1622            .is_some_and(|current| std::sync::Arc::ptr_eq(&current, registration));
1623
1624        if owns_live_cycle {
1625            tracing::warn!(%ssa_id, "abandoned ssa commitment was already live — retiring it");
1626            frontier.retire(ssa_id.ssa_index());
1627        } else if owns_pending_commitment {
1628            tracing::debug!(%ssa_id, "releasing ssa commitment abandoned by its owner");
1629            frontier.abandon(ssa_id.ssa_index());
1630        } else {
1631            // This guard belonged to an expired registration that has since been retried. Pointer
1632            // identity is what prevents the old owner from tearing down the replacement.
1633            return;
1634        }
1635        self.remove_cycle(ssa_id);
1636    }
1637
1638    /// [`new_exit_commitment`](ExitAcknowledgementShareProcessor::new_exit_commitment), with the
1639    /// registration owned by an [`SsaCommitmentGuard`].
1640    ///
1641    /// No guard is produced on failure, so a rejected duplicate never retires the registration that
1642    /// caused the rejection.
1643    pub fn new_guarded_exit_commitment(
1644        self: &std::sync::Arc<Self>,
1645        id: SsaId<S::Pseudonym>,
1646        params: PixParams,
1647    ) -> Result<GuardedExitCommitment<S>, PixError<S::Pseudonym>> {
1648        let (exit_commitment, registration) = self.register_exit_commitment(id, params)?;
1649        Ok((
1650            exit_commitment,
1651            SsaCommitmentGuard {
1652                owned: Some((self.clone(), id, registration)),
1653            },
1654        ))
1655    }
1656}
1657
1658impl<S: PixSpec> Drop for SsaReconstructor<S> {
1659    /// Reports terminal resolutions that were never collected.
1660    ///
1661    /// The transport normally drains this buffer as soon as the commitment path wakes it, with an
1662    /// acknowledgement batch retained as a fallback consumer. Reaching `Drop` with anything left
1663    /// therefore means every delivery route has gone away. A `RecoveredSsa` reported here is a
1664    /// deposit key the Exit held and never handed on.
1665    fn drop(&mut self) {
1666        if self.ready_resolutions_len.load(std::sync::atomic::Ordering::Acquire) == 0 {
1667            return;
1668        }
1669        for resolution in self.ready_resolutions.lock().drain(..) {
1670            tracing::error!(
1671                ?resolution,
1672                "pix resolution was never collected and is lost with the reconstructor"
1673            );
1674        }
1675    }
1676}
1677
1678impl<S: PixSpec + Clone> ExitAcknowledgementShareProcessor<S> for SsaReconstructor<S> {
1679    type Error = PixError<S::Pseudonym>;
1680
1681    fn has_pending_shares(&self, peer: &OffchainPublicKey) -> bool {
1682        // The parked-resolution check is not redundant with the per-peer one. Callers use this to
1683        // skip `acknowledge_shares` entirely. The transport notification stream is the primary
1684        // consumer of `ready_resolutions`, but an acknowledgement batch remains a useful fallback
1685        // if it wins the race to this global buffer. Its entries name their own `SsaId`, so any
1686        // batch can correctly carry them out; gating collection behind the producing peer's
1687        // `awaiting_acks` entry, which expires on its own timer, would strand them for no reason.
1688        self.ready_resolutions_len.load(std::sync::atomic::Ordering::Acquire) > 0
1689            || self.awaiting_acks.contains_key(peer)
1690    }
1691
1692    fn is_expected_error(&self, error: &Self::Error) -> bool {
1693        matches!(error, PixError::UnexpectedShare)
1694    }
1695
1696    fn retire_ssa(&self, ssa_id: SsaId<S::Pseudonym>) {
1697        // Every heavyweight key is the SsaId itself, so there is nothing to enumerate and no way for
1698        // one builder to survive the removal of the rest. The frontier-before-removal ordering the
1699        // commitment completion path depends on lives in `retire_cycle`, which the terminal
1700        // acknowledgement paths share with this one.
1701        //
1702        // A successfully recovered id is already retired, so `RetirementFrontier::retire` returns
1703        // false and deliberately leaves its compact tail intact. The Session tombstone is a short
1704        // diagnostic record; the FIFO can take longer to drain. The one-slot tail is replaced by
1705        // the next successful recovery and finally released with the Session retirement scope. An
1706        // entirely unknown id has no frontier to advance and remains the documented idempotent no-op.
1707        if let Some(retirement) = self.retirement_frontier_for(&ssa_id) {
1708            self.retire_cycle(ssa_id, &retirement);
1709        } else {
1710            self.remove_cycle(ssa_id);
1711        }
1712    }
1713
1714    /// No range check on the dimensions: holding a [`PixParams`] is what proves them in range, since
1715    /// [`PixParams::try_new`] is the only way to make one and the two bounded fields are as wide as
1716    /// their types. The check this replaced tested exactly those bounds.
1717    ///
1718    /// "The two bounded fields" is `polys_per_ssa` and `shares_per_poly`, and the omission of
1719    /// `surplus_shares` is deliberate rather than an oversight — worth stating, because the surplus
1720    /// does reach `SsaPartBuilder::max_credited_surplus` and so does size how much post-reconstruction
1721    /// traffic one completed polynomial may credit as liveness. `try_new` accepts the whole `u8`
1722    /// range for it on purpose: the surplus is a *wire* value, `surplus_must_not_exceed_threshold`
1723    /// bounds only what this node will locally configure, and an extravagant surplus is caught where
1724    /// it costs the peer something — the Exit's `quota_range`, which prices `polys × (threshold +
1725    /// surplus)` and so rises with it. A peer declaring a surplus of 255 against a threshold of 2 is
1726    /// asking to be charged 128× and is refused unless the operator configured a range that admits
1727    /// it, in which case the traffic it credits is traffic it paid for.
1728    fn new_exit_commitment(&self, id: SsaId<S::Pseudonym>, params: PixParams) -> Result<PixGroup<S>, Self::Error> {
1729        self.register_exit_commitment(id, params)
1730            .map(|(commitment, _)| commitment)
1731    }
1732
1733    fn insert_coefficient_commitments(
1734        &self,
1735        ssa_id: SsaId<S::Pseudonym>,
1736        index: CoefficientIndex,
1737        proof: Option<SsaCommitmentProof<S>>,
1738        commitments: impl Iterator<Item = (PolynomialIndex, PixGroupRepr<S>)>,
1739    ) -> Result<SsaCommitmentState<S::Pseudonym, S::DepositAddress>, Self::Error> {
1740        let mut res = SsaCommitmentState::new(ssa_id);
1741
1742        // The Server commitment must be present first
1743        let Some(builder) = self.commitment_builder.get(&ssa_id) else {
1744            return Err(PixError::MissingSsaCommitment);
1745        };
1746        let retirement = builder.retirement.clone();
1747
1748        let progress = {
1749            let mut builder = builder.lock();
1750            res.is_first_encountered = builder.is_empty();
1751            res.ssa_deposit_address = builder.get_deposit_address().copied();
1752            builder.add_transposed(index, proof, commitments)?
1753        };
1754
1755        // `ssa_deposit_address` was read *before* the insertion, so it being absent here means the
1756        // address (if any) was discovered by this very call.
1757        res.deposit_address_first_encountered = res.ssa_deposit_address.is_none();
1758
1759        let Some(full_ssa_commitment) = progress.full_commitment else {
1760            res.deposit_address_first_encountered = false; // Not yet encountered
1761            tracing::trace!(%ssa_id, "ssa commitment not yet complete, waiting for more constant terms");
1762            return Ok(res);
1763        };
1764        res.ssa_deposit_address = Some(S::group_to_deposit_address(full_ssa_commitment).ok_or(PixError::InvalidSsa)?);
1765
1766        // The accumulator and every part builder go in as one entry. There is deliberately no
1767        // ordering to get right here: a share can never observe a cycle in which one is reachable
1768        // and the other is not, which used to be a permanent-drop hazard.
1769        let mut publication_rejected = false;
1770        let installed = if let Some(ssa_builder) = progress.ssa_builder {
1771            let num_polys = ssa_builder.num_polys();
1772            let cycle = SsaCycle::new(ssa_id, ssa_builder, progress.new_verifiers)?;
1773            let registered_cycle = std::sync::Arc::new(RegisteredCycle {
1774                cycle,
1775                registration: builder.clone(),
1776            });
1777
1778            // Registration, abandonment and retirement all take this same Session-local lock. A
1779            // stale worker therefore cannot overwrite a newer retry: it verifies pointer identity
1780            // and publishes while every state transition for this index is excluded.
1781            let frontier = retirement.lock();
1782            let registration_is_current = self
1783                .commitment_builder
1784                .get(&ssa_id)
1785                .is_some_and(|current| std::sync::Arc::ptr_eq(&current, &builder));
1786            let was_retired = frontier.is_retired(ssa_id.ssa_index());
1787            if !registration_is_current || was_retired {
1788                publication_rejected = true;
1789                false
1790            } else {
1791                self.ssa_cycles.insert(ssa_id, registered_cycle);
1792
1793                // Keep the check after publication as well. Cache expiry is independent of the
1794                // frontier lock; if it withdraws this registration during insertion, undo the cycle
1795                // before releasing the lock and allowing a retry.
1796                let registration_is_current = self
1797                    .commitment_builder
1798                    .get(&ssa_id)
1799                    .is_some_and(|current| std::sync::Arc::ptr_eq(&current, &builder));
1800                if !registration_is_current {
1801                    self.remove_cycle(ssa_id);
1802                    publication_rejected = true;
1803                    false
1804                } else {
1805                    tracing::debug!(%ssa_id, num_polys, "ssa commitment known — cycle is live");
1806                    true
1807                }
1808            }
1809        } else {
1810            false
1811        };
1812
1813        if publication_rejected {
1814            tracing::trace!(%ssa_id, "ssa commitment progressed after its registration ended — dropped published state");
1815            res.deposit_address_first_encountered = false;
1816            return Ok(res);
1817        }
1818
1819        // Installing the cycle unblocks every acknowledgement bucketed under it. Doing this here
1820        // rather than on the acknowledgement path is what keeps `acknowledge_shares` free of retry
1821        // scanning.
1822        if installed {
1823            self.drain_deferred_acks(&ssa_id);
1824        }
1825
1826        res.is_verifiable = progress.fully_committed;
1827        if progress.fully_committed {
1828            tracing::trace!(%ssa_id, "ssa commitment completed");
1829        }
1830
1831        Ok(res)
1832    }
1833
1834    /// Buffers an encrypted share until its acknowledgement arrives, subject to the global byte
1835    /// budget.
1836    ///
1837    /// # The bound is global, and it is not the product of the two caps
1838    ///
1839    /// [`max_tracked_peers`](SsaReconstructorConfig::max_tracked_peers) and
1840    /// [`max_awaiting_acks`](SsaReconstructorConfig::max_awaiting_acks) bound one dimension each,
1841    /// and their product — 2 000 × 1 000 000 by default, some 800 GB — is neither reachable nor the
1842    /// right thing to bound. Not reachable, because an entry exists only for a share this node has
1843    /// already *sent*, so filling it would take 66 M packets/s of egress inside the default 30 s
1844    /// window. Not right, because the two guard **mutually exclusive** concentrations:
1845    /// `max_awaiting_acks` sizes one cache per peer and has to cover every Session returning through
1846    /// a single first-relayer, while `max_tracked_peers` covers traffic spread thin. Squeezing the
1847    /// product would push one of them below what its own case needs, and a `max_awaiting_acks` set
1848    /// too low does not save memory — it size-evicts shares before their acknowledgements arrive.
1849    ///
1850    /// So the real bound is [`max_ack_buffer_bytes`](SsaReconstructorConfig::max_ack_buffer_bytes),
1851    /// counted here across all peers at once. Validating a workload model instead would not do:
1852    /// a model has to assume a Session count and a packet rate, and this node enforces neither.
1853    ///
1854    /// # Behaviour at the ceiling
1855    ///
1856    /// The newest share is refused, rather than the oldest evicted: there is no cheap global
1857    /// "oldest" across per-peer caches, and the oldest is nearest its TTL anyway. Either way a full
1858    /// buffer means share loss — the packet is already on the wire and its acknowledgement will find
1859    /// nothing — which is the honest cost of a hard ceiling. [`PixError::AckBufferFull`] is
1860    /// deliberately not an expected error so the caller logs it.
1861    ///
1862    /// The check and the insertion are not atomic. Concurrent inserters can overshoot the ceiling by
1863    /// their own number, which is the right trade: a lock on this path would cost more than the few
1864    /// hundred kilobytes of overshoot it would prevent.
1865    fn insert_encrypted_share(
1866        &self,
1867        peer: &OffchainPublicKey,
1868        challenge: HalfKeyChallenge,
1869        tagged_enc_share: TaggedEncryptedPartialSsaShare<S>,
1870    ) -> Result<(), Self::Error> {
1871        if tagged_enc_share.partial_share.is_empty() {
1872            return Err(PixError::ShareIsEmpty);
1873        }
1874
1875        // One relaxed load in the steady state. Only a buffer that has actually reached its ceiling
1876        // pays for the ground-truth pass, and even then at most one caller at a time does.
1877        if self.ack_buffer_entries.load(std::sync::atomic::Ordering::Relaxed) >= self.max_ack_buffer_entries {
1878            self.resync_ack_buffer();
1879            if self.ack_buffer_entries.load(std::sync::atomic::Ordering::Relaxed) >= self.max_ack_buffer_entries {
1880                tracing::error!(
1881                    %peer,
1882                    budget_bytes = self.cfg.max_ack_buffer_bytes,
1883                    "awaiting-acknowledgement buffer is full — dropping share"
1884                );
1885                return Err(PixError::AckBufferFull);
1886            }
1887        }
1888
1889        // Incremented unconditionally, including when this replaces an entry under the same
1890        // challenge. The inner listener fires for `Replaced` too, so the pair nets to zero.
1891        self.ack_buffer_entries
1892            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1893        self.awaiting_acks
1894            .get_with_by_ref(peer, || {
1895                // Inner cache keyed by HalfKeyChallenge — each entry gets its own TTL
1896                // so a late-arriving share gets the full max_ack_await_time window.
1897                let released = self.ack_buffer_entries.clone();
1898                moka::sync::CacheBuilder::new(self.cfg.max_awaiting_acks as u64)
1899                    .time_to_live(self.cfg.max_ack_await_time)
1900                    // Every way an entry can leave this cache — redeemed by its acknowledgement,
1901                    // expired, or size-evicted — arrives here, which is what keeps the global count
1902                    // falling without the removal sites having to know about it.
1903                    .eviction_listener(move |_, _, _| {
1904                        released.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
1905                    })
1906                    .build()
1907            })
1908            .insert(challenge, tagged_enc_share);
1909
1910        Ok(())
1911    }
1912
1913    fn acknowledge_shares(
1914        &self,
1915        peer: OffchainPublicKey,
1916        acks: Vec<Acknowledgement>,
1917    ) -> Result<Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>>, Self::Error> {
1918        let Some((awaiting_ack_from_peer, half_keys_challenges)) = crate::ack_verify::verify_expected_acknowledgements(
1919            peer,
1920            acks,
1921            &self.awaiting_acks,
1922            self.cfg.use_batch_verification,
1923        ) else {
1924            return Err(PixError::UnexpectedShare);
1925        };
1926
1927        // Three accumulators rather than one deduplicating set, because the emission contract is an
1928        // ordering as well as a multiplicity: every SSA's counters precede its terminal event.
1929        //
1930        // All three are `Vec`s deduplicated by linear scan. A per-peer batch resolves shares for the
1931        // cycle that peer is relaying for — one, or a small handful while a Session pipelines the
1932        // next — so the scan is over a one- or two-element list and a hash set would cost more than
1933        // it saves.
1934        let mut progress: Vec<SsaRecoveryProgress<S::Pseudonym>> = Vec::new();
1935        let mut faults: Vec<FaultObservation<S::Pseudonym>> = Vec::new();
1936        let mut terminal: Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>> = Vec::new();
1937
1938        // Collect anything redeemed while verifiers were being installed. No retry scanning happens
1939        // here: a deferred ack is retried exactly once, by the installation of the verifier it was
1940        // waiting for (see `drain_deferred_acks`).
1941        for resolution in self.take_ready_resolutions() {
1942            match resolution {
1943                ShareResolution::Progress(snapshot) => record_progress(&mut progress, snapshot),
1944                ShareResolution::InvalidShares {
1945                    peer,
1946                    ssa_id,
1947                    observed_total,
1948                } => record_fault(&mut faults, (peer, ssa_id, observed_total)),
1949                other => push_unique(&mut terminal, other),
1950            }
1951        }
1952
1953        for (ack, ack_challenge) in half_keys_challenges {
1954            match self.process_verified_ack(ack, ack_challenge, &awaiting_ack_from_peer) {
1955                Ok(ProcessedAckResult::FullRecovery(ssa, snapshot)) => {
1956                    record_progress(&mut progress, snapshot);
1957                    push_unique(&mut terminal, ShareResolution::RecoveredSsa(ssa));
1958                }
1959                Ok(ProcessedAckResult::EarlyRecovery(snapshot)) => {
1960                    let ssa_id = snapshot.ssa_id;
1961                    record_progress(&mut progress, snapshot);
1962                    push_unique(&mut terminal, ShareResolution::AlmostRecoveredSsa(ssa_id));
1963                }
1964                Ok(ProcessedAckResult::Progressed(snapshot)) => record_progress(&mut progress, snapshot),
1965                Ok(ProcessedAckResult::InvalidShare(ssa_id, observed_total)) => {
1966                    tracing::error!(%ssa_id, observed_total, "encountered share that could not be verified");
1967                    record_fault(&mut faults, (Box::new(peer), ssa_id, observed_total));
1968                }
1969                Ok(ProcessedAckResult::NoProgress) => {}
1970                Ok(ProcessedAckResult::VerifierNotReady(spi)) => {
1971                    // The share stays in `awaiting_acks`; bucket the ack under the polynomial whose
1972                    // verifier it needs, so installing that verifier redeems it.
1973                    tracing::trace!(%peer, %spi, "verifier not yet installed, deferring acknowledgement");
1974                    self.defer_ack(spi, (peer, ack_challenge, ack));
1975                }
1976                Err(PixError::ShareIsEmpty) => tracing::trace!(%peer, "received empty share"),
1977                Err(error) => {
1978                    tracing::error!(%error, "failed to process acknowledgement");
1979                }
1980            }
1981        }
1982
1983        let mut res = Vec::with_capacity(progress.len() + faults.len() + terminal.len());
1984        res.extend(progress.into_iter().map(ShareResolution::Progress));
1985        res.extend(
1986            faults
1987                .into_iter()
1988                .map(|(peer, ssa_id, observed_total)| ShareResolution::InvalidShares {
1989                    peer,
1990                    ssa_id,
1991                    observed_total,
1992                }),
1993        );
1994        res.append(&mut terminal);
1995        Ok(res)
1996    }
1997}
1998
1999#[cfg(test)]
2000mod tests {
2001    use std::collections::HashMap;
2002
2003    use anyhow::Context;
2004    use hopr_types::{
2005        crypto::{crypto_traits, prelude::*},
2006        crypto_random::Randomizable,
2007        internal::prelude::VerifiedAcknowledgement,
2008    };
2009    use vsss_rs::elliptic_curve::Field;
2010
2011    use super::{utils::SsaBuilder, *};
2012    use crate::{
2013        DEFAULT_POLY_THRESHOLD, DEFAULT_POLYS_PER_SSA, DEFAULT_SURPLUS_SHARES, GroupEncoding, PartialSsaShare,
2014        SsaGeneratorConfig, SsaIndex, SsaShareGenerator,
2015        tests::TestSpec,
2016        traits::{EntryShareGenerator, ExitAcknowledgementShareProcessor},
2017    };
2018
2019    /// The two structural claims [`peak_cycle_bytes`] rests on, pinned where CI can see them.
2020    ///
2021    /// The empirical check is `exit_reconstructor_worst_case_share_order` in
2022    /// `tests/memory_profile.rs`, which needs a tracking allocator and a production-width cycle and
2023    /// is therefore `#[ignore]`d. This catches the two ways the model can silently stop being a
2024    /// ceiling without anyone running that: a part builder growing past the per-polynomial constant,
2025    /// and `Vec`'s growth policy allocating more than the next power of two for a buffer that stops
2026    /// one share short of its threshold.
2027    #[test]
2028    fn peak_cycle_bytes_rests_on_the_sizes_it_claims() {
2029        // `Vec` doubles from a minimum of four, which is what makes `next_power_of_two` the
2030        // allocation for a buffer holding `threshold - 1` shares. Modelled with a same-sized array
2031        // so the check does not need a live cycle; the equality below keeps the proxy honest.
2032        assert_eq!(
2033            64,
2034            size_of::<CompletedShare<TestSpec>>(),
2035            "the growth model below assumes a 64 B share"
2036        );
2037        for threshold in [2usize, 3, 4, 5, 64, 65, 128, 255] {
2038            let mut shares: Vec<[u8; 64]> = Vec::new();
2039            for _ in 0..threshold - 1 {
2040                shares.push([0; 64]);
2041            }
2042            assert!(
2043                shares.capacity() as u64 <= (threshold as u64).next_power_of_two().max(4),
2044                "a buffer of {} shares allocated {} slots, past the modelled {}",
2045                threshold - 1,
2046                shares.capacity(),
2047                (threshold as u64).next_power_of_two().max(4)
2048            );
2049        }
2050
2051        // The share buffers are what the model is *for*, so they must dominate it at the shipped
2052        // dimensions. If the per-polynomial terms ever caught up, the whole "worst share order is
2053        // the worst case" argument would stop being the thing worth bounding.
2054        let params = PixParams::try_from_config::<TestSpec>(&SsaGeneratorConfig {
2055            polynomials_per_ssa: DEFAULT_POLYS_PER_SSA,
2056            threshold: DEFAULT_POLY_THRESHOLD,
2057            surplus_shares: DEFAULT_SURPLUS_SHARES,
2058        })
2059        .expect("the shipped dimensions must be valid");
2060        let total = peak_cycle_bytes::<TestSpec>(&params);
2061        let buffers = DEFAULT_POLYS_PER_SSA as u64
2062            * (DEFAULT_POLY_THRESHOLD as u64).next_power_of_two()
2063            * size_of::<CompletedShare<TestSpec>>() as u64;
2064        assert!(
2065            buffers * 2 > total,
2066            "share buffers ({buffers} B) must dominate the modelled cycle ({total} B)"
2067        );
2068    }
2069
2070    #[test]
2071    fn retirement_frontier_keeps_out_of_order_and_retriable_indices_live() -> anyhow::Result<()> {
2072        let first = SsaIndex::new(1).context("one is a non-zero SSA index")?;
2073        let second = SsaIndex::new(2).context("two is a non-zero SSA index")?;
2074        let unknown = SsaIndex::new(3).context("three is a non-zero SSA index")?;
2075        let mut frontier = RetirementFrontier::default();
2076
2077        assert!(!frontier.retire(unknown), "retiring an unknown index must be a no-op");
2078        assert!(frontier.register(first));
2079        assert!(frontier.register(second));
2080        assert!(frontier.retire(second));
2081        assert!(frontier.is_retired(second));
2082        assert!(
2083            !frontier.is_retired(first),
2084            "a registered lower batch member must survive a later member's retirement"
2085        );
2086
2087        frontier.abandon(first);
2088        assert!(
2089            !frontier.is_retired(first),
2090            "an abandoned pre-live index remains reusable below the watermark"
2091        );
2092        assert!(frontier.register(first), "same-index retry must be admitted");
2093        assert!(frontier.retire(first));
2094        assert!(frontier.is_retired(first));
2095
2096        Ok(())
2097    }
2098
2099    #[test]
2100    fn sequential_retirements_use_one_compact_frontier() -> anyhow::Result<()> {
2101        const CYCLES: u32 = 100_000;
2102        let mut frontier = RetirementFrontier::default();
2103
2104        for raw in 1..=CYCLES {
2105            let index = SsaIndex::new(raw).context("the test range contains no zero index")?;
2106            assert!(frontier.register(index));
2107            assert!(frontier.retire(index));
2108        }
2109
2110        assert_eq!(SsaIndex::new(CYCLES), frontier.highest_retired);
2111        assert!(frontier.registered.is_empty());
2112        assert!(frontier.retriable.is_empty());
2113
2114        Ok(())
2115    }
2116
2117    #[test]
2118    fn dropping_a_retirement_scope_removes_its_session_entry() {
2119        let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
2120        let pseudonym = SimplePseudonym::random();
2121
2122        let scope = reconstructor.begin_retirement_scope(pseudonym);
2123        assert!(reconstructor.retirement_frontiers.lock().contains_key(&pseudonym));
2124
2125        drop(scope);
2126        assert!(
2127            !reconstructor.retirement_frontiers.lock().contains_key(&pseudonym),
2128            "Session teardown must release its frontier immediately"
2129        );
2130    }
2131
2132    #[test]
2133    fn ssa_reconstructor_try_new_should_reject_an_invalid_config_without_panicking() {
2134        let cfg = SsaReconstructorConfig {
2135            // Outside the validated 0.0..=1.0 range.
2136            early_recovery_threshold: 1.5,
2137            ..Default::default()
2138        };
2139
2140        assert!(matches!(
2141            SsaReconstructor::<TestSpec>::try_new(cfg),
2142            Err(PixError::InvalidConfiguration(_))
2143        ));
2144    }
2145
2146    #[test]
2147    #[should_panic(expected = "invalid SsaReconstructorConfig")]
2148    fn ssa_reconstructor_new_should_still_panic_on_an_invalid_config() {
2149        let _ = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
2150            early_recovery_threshold: 1.5,
2151            ..Default::default()
2152        });
2153    }
2154
2155    /// A distinct, insertable share and the acknowledgement that redeems it.
2156    ///
2157    /// The contents do not matter to the budget — only that each entry has its own key — so this
2158    /// skips the generator entirely and builds a `PartialSsaShare::default()` under a fresh
2159    /// acknowledgement key.
2160    fn budget_share(
2161        spi: &SsaPolynomialId<SimplePseudonym>,
2162    ) -> anyhow::Result<(HalfKey, HalfKeyChallenge, TaggedEncryptedPartialSsaShare<TestSpec>)> {
2163        let ack_key = HalfKey::random();
2164        let challenge = ack_key.to_challenge()?;
2165        Ok((
2166            ack_key,
2167            challenge,
2168            TaggedEncryptedPartialSsaShare {
2169                pseudonym: *spi.pseudonym(),
2170                nonce: crypto_traits::elliptic_curve::Scalar::<Secp256k1>::random(&mut hopr_types::crypto_random::rng()),
2171                partial_share: PartialSsaShare::default().encrypt(spi, &ack_key)?,
2172            },
2173        ))
2174    }
2175
2176    /// A reconstructor whose acknowledgement buffer holds exactly `entries`.
2177    fn reconstructor_with_ack_budget(entries: usize) -> SsaReconstructor<TestSpec> {
2178        SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
2179            max_ack_buffer_bytes: entries * AWAITING_ACK_ENTRY_BYTES,
2180            ..Default::default()
2181        })
2182    }
2183
2184    #[test]
2185    fn ack_buffer_refuses_shares_past_its_byte_budget() -> anyhow::Result<()> {
2186        // The validated minimum, so the budget is the smallest one a real config can ask for.
2187        const BUDGET: usize = 64;
2188
2189        let reconstructor = reconstructor_with_ack_budget(BUDGET);
2190        let spi = SsaPolynomialId::new(SsaId::new(SimplePseudonym::random(), 1.try_into()?), 0);
2191        let peer = OffchainKeypair::random();
2192
2193        for i in 0..BUDGET {
2194            let (_, challenge, share) = budget_share(&spi)?;
2195            reconstructor
2196                .insert_encrypted_share(peer.public(), challenge, share)
2197                .map_err(|error| anyhow::anyhow!("insertion {i} must fit the budget: {error}"))?;
2198        }
2199
2200        let (_, challenge, share) = budget_share(&spi)?;
2201        assert!(
2202            matches!(
2203                reconstructor.insert_encrypted_share(peer.public(), challenge, share),
2204                Err(PixError::AckBufferFull)
2205            ),
2206            "the share past the budget must be refused rather than buffered"
2207        );
2208        assert_eq!(
2209            BUDGET,
2210            reconstructor.count_ack_buffer_entries(),
2211            "the buffer must hold exactly its budget — no more, and the refusal must not have dropped any"
2212        );
2213
2214        Ok(())
2215    }
2216
2217    /// Redeeming an acknowledgement must give its budget back.
2218    ///
2219    /// This is the release path the inner eviction listener exists for: `process_verified_ack`
2220    /// calls `remove`, which moka reports as an `Explicit` removal. A listener that only handled
2221    /// expiry and size eviction would leak here, and the buffer would fill permanently on a node
2222    /// that was working perfectly.
2223    #[test]
2224    fn redeeming_an_acknowledgement_returns_its_budget() -> anyhow::Result<()> {
2225        const BUDGET: usize = 64;
2226
2227        let reconstructor = reconstructor_with_ack_budget(BUDGET);
2228        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
2229        let spi = SsaPolynomialId::new(ssa_id, 0);
2230        let peer = OffchainKeypair::random();
2231        reconstructor.new_exit_commitment(ssa_id, test_params(DEFAULT_POLYS_PER_SSA, DEFAULT_POLY_THRESHOLD))?;
2232
2233        let mut redeemable = None;
2234        for _ in 0..BUDGET {
2235            let (ack_key, challenge, share) = budget_share(&spi)?;
2236            reconstructor.insert_encrypted_share(peer.public(), challenge, share)?;
2237            redeemable = Some((ack_key, challenge));
2238        }
2239
2240        let (ack_key, challenge) = redeemable.expect("the loop ran at least once");
2241        let (_, refused, share) = budget_share(&spi)?;
2242        assert!(
2243            matches!(
2244                reconstructor.insert_encrypted_share(peer.public(), refused, share),
2245                Err(PixError::AckBufferFull)
2246            ),
2247            "precondition: the buffer is full"
2248        );
2249
2250        // The cycle is not published, so this defers rather than redeeming — and deliberately
2251        // leaves the share in place. Budget must therefore *not* be returned yet.
2252        let peer_cache = reconstructor
2253            .awaiting_acks
2254            .get(peer.public())
2255            .expect("the peer holds shares");
2256        assert!(matches!(
2257            reconstructor.process_verified_ack(ack_key, challenge, &peer_cache),
2258            Ok(ProcessedAckResult::VerifierNotReady(_))
2259        ));
2260        assert_eq!(
2261            BUDGET,
2262            reconstructor.count_ack_buffer_entries(),
2263            "a deferral retains the share, so it must still be charged for"
2264        );
2265
2266        // An outright removal is the redemption path's effect on the buffer.
2267        peer_cache.remove(&challenge);
2268        assert_eq!(
2269            BUDGET - 1,
2270            reconstructor.count_ack_buffer_entries(),
2271            "redeeming must free the entry"
2272        );
2273        reconstructor
2274            .insert_encrypted_share(peer.public(), refused, share)
2275            .map_err(|error| anyhow::anyhow!("the freed slot must be reusable: {error}"))?;
2276
2277        Ok(())
2278    }
2279
2280    /// Expiry must give budget back too — the release path that runs constantly in production.
2281    ///
2282    /// Most shares are never acknowledged in the window that matters; they age out. If the TTL did
2283    /// not release budget, a busy Exit would fill its buffer once and never accept another share.
2284    #[test]
2285    fn expiring_shares_return_their_budget() -> anyhow::Result<()> {
2286        const BUDGET: usize = 64;
2287        const WINDOW: std::time::Duration = std::time::Duration::from_millis(100);
2288
2289        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
2290            max_ack_buffer_bytes: BUDGET * AWAITING_ACK_ENTRY_BYTES,
2291            max_ack_await_time: WINDOW,
2292            ..Default::default()
2293        });
2294        let spi = SsaPolynomialId::new(SsaId::new(SimplePseudonym::random(), 1.try_into()?), 0);
2295        let peer = OffchainKeypair::random();
2296
2297        for _ in 0..BUDGET {
2298            let (_, challenge, share) = budget_share(&spi)?;
2299            reconstructor.insert_encrypted_share(peer.public(), challenge, share)?;
2300        }
2301        let (_, challenge, share) = budget_share(&spi)?;
2302        assert!(
2303            matches!(
2304                reconstructor.insert_encrypted_share(peer.public(), challenge, share),
2305                Err(PixError::AckBufferFull)
2306            ),
2307            "precondition: the buffer is full"
2308        );
2309
2310        std::thread::sleep(WINDOW * 2);
2311
2312        // moka expires lazily, so the entries are still charged for until maintenance runs. The
2313        // insertion path reaches that through `resync_ack_buffer`, which is what this asserts: a
2314        // buffer full of expired shares must let the next one in without any caller intervening.
2315        let (_, challenge, share) = budget_share(&spi)?;
2316        reconstructor
2317            .insert_encrypted_share(peer.public(), challenge, share)
2318            .map_err(|error| anyhow::anyhow!("expired entries must free their budget: {error}"))?;
2319
2320        assert_eq!(
2321            1,
2322            reconstructor.count_ack_buffer_entries(),
2323            "only the share inserted after the window should remain"
2324        );
2325
2326        Ok(())
2327    }
2328
2329    /// The counter is a hint; this asserts it does not become a lying one.
2330    ///
2331    /// The churn below deliberately includes the drift source no listener can catch — evicting a
2332    /// peer's whole entry from `awaiting_acks`, which drops its inner cache rather than draining
2333    /// it. Over-counting is the dangerous direction: it would eventually refuse every share while
2334    /// the buffer sat empty. `count_ack_buffer_entries` recomputes from the caches instead of
2335    /// reading the counter, so this compares the counter against the truth rather than against
2336    /// itself.
2337    #[test]
2338    fn the_ack_buffer_counter_does_not_inflate_as_peers_churn() -> anyhow::Result<()> {
2339        const PEERS: usize = 40;
2340        const TRACKED: usize = 10;
2341        const PER_PEER: usize = 5;
2342
2343        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
2344            // Well above anything inserted here: the point is peer churn, not the ceiling.
2345            max_ack_buffer_bytes: 4096 * AWAITING_ACK_ENTRY_BYTES,
2346            // Forces the outer cache to evict peers — and with them, whole inner caches — four
2347            // times over.
2348            max_tracked_peers: TRACKED,
2349            ..Default::default()
2350        });
2351        let spi = SsaPolynomialId::new(SsaId::new(SimplePseudonym::random(), 1.try_into()?), 0);
2352
2353        for _ in 0..PEERS {
2354            let peer = OffchainKeypair::random();
2355            for _ in 0..PER_PEER {
2356                let (_, challenge, share) = budget_share(&spi)?;
2357                reconstructor.insert_encrypted_share(peer.public(), challenge, share)?;
2358            }
2359        }
2360
2361        let held = reconstructor.count_ack_buffer_entries();
2362        assert!(
2363            held <= TRACKED * PER_PEER,
2364            "the outer cache bounds what can be held: {held} entries across at most {TRACKED} peers"
2365        );
2366
2367        // A resync is what makes the counter authoritative again; without the backstop the residue
2368        // from dropped inner caches would sit on it permanently.
2369        reconstructor.resync_ack_buffer();
2370        assert_eq!(
2371            held,
2372            reconstructor
2373                .ack_buffer_entries
2374                .load(std::sync::atomic::Ordering::Relaxed),
2375            "the counter must agree with what the caches actually hold after churn"
2376        );
2377
2378        Ok(())
2379    }
2380
2381    /// The bound no longer depends on a workload model — which is the whole of M2.
2382    ///
2383    /// Both caps are set so their product is orders of magnitude past the budget, exactly the
2384    /// configuration the old modelled validation would have waved through. What is held is decided
2385    /// by the byte budget and nothing else: no Session count, no assumed packet rate, no
2386    /// acknowledgement window.
2387    #[test]
2388    fn the_ack_buffer_budget_binds_regardless_of_the_configured_caps() -> anyhow::Result<()> {
2389        const BUDGET: usize = 64;
2390        /// Enough peers that neither cap is anywhere near binding, few enough that the test is not
2391        /// dominated by keypair generation.
2392        const PEERS: usize = 16;
2393
2394        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
2395            max_ack_buffer_bytes: BUDGET * AWAITING_ACK_ENTRY_BYTES,
2396            max_tracked_peers: 2000,
2397            max_awaiting_acks: 1_000_000,
2398            // An hour, against a 30 s default: the dial the modelled check keyed on, turned to a
2399            // value it would have rejected outright.
2400            max_ack_await_time: std::time::Duration::from_secs(3600),
2401            ..Default::default()
2402        });
2403        let spi = SsaPolynomialId::new(SsaId::new(SimplePseudonym::random(), 1.try_into()?), 0);
2404
2405        // Spread across peers so neither per-peer nor per-dimension cap is anywhere near binding.
2406        let peers = (0..PEERS).map(|_| OffchainKeypair::random()).collect::<Vec<_>>();
2407        let mut accepted = 0;
2408        for i in 0..(BUDGET * 4) {
2409            let (_, challenge, share) = budget_share(&spi)?;
2410            if reconstructor
2411                .insert_encrypted_share(peers[i % PEERS].public(), challenge, share)
2412                .is_ok()
2413            {
2414                accepted += 1;
2415            }
2416        }
2417
2418        assert_eq!(
2419            BUDGET, accepted,
2420            "the byte budget must be what binds, not the caps whose product is 2e9 entries"
2421        );
2422        Ok(())
2423    }
2424
2425    /// Pulls from the generator until it yields a share for `poly_index`, discarding the rest.
2426    ///
2427    /// Shares are emitted round-robin across [`crate::SHARE_EMISSION_WINDOW`] polynomials, so a test
2428    /// that needs to drive one polynomial to its threshold cannot assume consecutive calls stay on
2429    /// it. Discarding the others is sound here: these tests assert on one polynomial's behaviour,
2430    /// and a polynomial that never receives its shares simply stays incomplete.
2431    fn next_share_for_poly(
2432        generator: &SsaShareGenerator<TestSpec>,
2433        pseudonym: &SimplePseudonym,
2434        poly_index: PolynomialIndex,
2435    ) -> anyhow::Result<([u8; 20], crate::GeneratedShare<TestSpec>)> {
2436        loop {
2437            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
2438            let share = generator
2439                .next_share(pseudonym, &msg)?
2440                .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
2441            if share.id.poly_index() == poly_index {
2442                return Ok((msg, share));
2443            }
2444        }
2445    }
2446
2447    /// The proof a generated commitment carries, attached only to the constant-term batch — the
2448    /// shape the wire uses, since that batch is what determines the commitment being opened.
2449    fn proof_of(
2450        commitment: &crate::SsaCommitment<TestSpec>,
2451        coeff_index: CoefficientIndex,
2452    ) -> Option<SsaCommitmentProof<TestSpec>> {
2453        (coeff_index == 0).then_some(commitment.commitment_proof)
2454    }
2455
2456    /// Proof matching the all-identity commitment sets some fixtures use as filler: their sum is the
2457    /// identity, whose discrete logarithm is zero, so the proof is honest rather than a bypass.
2458    fn identity_proof(ssa_id: &SsaId<SimplePseudonym>) -> SsaCommitmentProof<TestSpec> {
2459        let zero = <PixScalar<TestSpec> as Field>::ZERO;
2460        SsaCommitmentProof::prove(ssa_id, &zero, &PixGroup::<TestSpec>::mul_by_generator(&zero))
2461            .expect("identity proof must be constructible")
2462    }
2463
2464    /// Negotiated dimensions for a test cycle.
2465    ///
2466    /// The surplus is [`DEFAULT_SURPLUS_SHARES`] rather than zero so that no test trips the
2467    /// per-polynomial liveness credit cap by accident — the tests that mean to exercise that cap
2468    /// build their own [`PixParams`] with a surplus small enough to reach it.
2469    fn test_params(polys: u16, threshold: u8) -> PixParams {
2470        PixParams::try_new_for::<TestSpec>(polys, threshold, DEFAULT_SURPLUS_SHARES)
2471            .expect("test dimensions must be in range")
2472    }
2473
2474    /// The dimension bounds live in [`PixParams::try_new`] now, so what used to be five refusals
2475    /// from `new_exit_commitment` is five values that cannot be built in the first place.
2476    ///
2477    /// Kept rather than deleted, and pointed at the constructor: the property is unchanged — these
2478    /// dimensions must never reach a commitment builder — and only its enforcement point moved. A
2479    /// future signature that took the fields loose again would drop it silently.
2480    #[test]
2481    fn out_of_range_exit_commitment_dimensions_must_be_unrepresentable() -> anyhow::Result<()> {
2482        for (polys, threshold) in [
2483            (0, 2),                     // polys_per_ssa == 0
2484            (MAX_POLYS_PER_SSA + 1, 2), // polys_per_ssa exceeds MAX
2485            (2, 0),                     // shares_per_poly == 0
2486            (2, 1),                     // shares_per_poly below the minimum of 2
2487        ] {
2488            assert!(
2489                PixParams::try_new_for::<TestSpec>(polys, threshold, 0).is_err(),
2490                "{polys} x {threshold} must not be constructible"
2491            );
2492        }
2493
2494        // The fifth case — a threshold above the maximum — has no representation at all now that
2495        // the field is as wide as its bound.
2496        assert_eq!(u8::MAX, crate::MAX_POLY_THRESHOLD);
2497
2498        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
2499        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into().unwrap());
2500        assert!(reconstructor.new_exit_commitment(ssa_id, test_params(2, 2)).is_ok());
2501
2502        Ok(())
2503    }
2504
2505    #[test]
2506    fn reconstructor_invalid_commitment_inputs() -> anyhow::Result<()> {
2507        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
2508
2509        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
2510
2511        reconstructor.new_exit_commitment(ssa_id, test_params(2, 2))?;
2512
2513        // 1. Any non-constant coefficient index is ignored rather than rejected, whether or not it is within the
2514        //    threshold — PIX commits to nothing but the constant term, and a peer that still sends a full Feldman
2515        //    matrix must not be treated as hostile.
2516        for coeff_index in [1, 2, CoefficientIndex::MAX] {
2517            let ignored =
2518                reconstructor.insert_coefficient_commitments(ssa_id, coeff_index, None, HashMap::new().into_iter())?;
2519            assert!(ignored.ssa_deposit_address.is_none());
2520            assert!(!ignored.is_verifiable);
2521        }
2522        assert!(
2523            reconstructor
2524                .commitment_builder
2525                .get(&ssa_id)
2526                .ok_or_else(|| anyhow::anyhow!("missing builder"))?
2527                .lock()
2528                .is_empty(),
2529            "ignored commitments must not enter the builder's state"
2530        );
2531
2532        // 2. Invalid polynomial index (>= polys_per_ssa)
2533        let mut invalid_poly_map = HashMap::new();
2534        invalid_poly_map.insert(2 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
2535        let result = reconstructor.insert_coefficient_commitments(ssa_id, 0, None, invalid_poly_map.into_iter());
2536        assert!(matches!(result, Err(PixError::InvalidInput)));
2537
2538        Ok(())
2539    }
2540
2541    #[test]
2542    fn reconstructor_should_not_accept_client_commitments_without_priod_exit_commitment() -> anyhow::Result<()> {
2543        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
2544
2545        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
2546
2547        let mut poly_map = HashMap::new();
2548        for poly in 0..2 {
2549            poly_map.insert(poly as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
2550        }
2551
2552        let res = reconstructor.insert_coefficient_commitments(ssa_id, 0, None, poly_map.into_iter());
2553
2554        assert!(matches!(res, Err(PixError::MissingSsaCommitment)));
2555
2556        Ok(())
2557    }
2558
2559    /// Commitments arriving after the set is complete are absorbed, and still report the cycle as
2560    /// complete.
2561    ///
2562    /// This is what makes retransmission safe. The peer re-sends parts of the set on request, and a
2563    /// re-sent copy travels the same mixed path as the original it repairs — so it can arrive after
2564    /// the gap it was asked for was filled by the original showing up late. Refusing those as
2565    /// duplicates would turn every successful repair into an error on the next packet. Nothing can
2566    /// change at this point anyway: every slot is filled, and the per-slot check cannot even speak
2567    /// for these, because the map is drained when the part builders are handed out.
2568    #[test]
2569    fn commitments_arriving_after_completion_are_absorbed() -> anyhow::Result<()> {
2570        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
2571
2572        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
2573
2574        reconstructor.new_exit_commitment(ssa_id, test_params(2, 2))?;
2575
2576        // Fill every constant term, which is the whole commitment
2577        let mut poly_map = HashMap::new();
2578        for poly in 0..2 {
2579            poly_map.insert(poly as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
2580        }
2581        reconstructor.insert_coefficient_commitments(ssa_id, 0, Some(identity_proof(&ssa_id)), poly_map.into_iter())?;
2582
2583        // A re-delivery of a slot that is already filled, which is what a repair racing its own
2584        // original looks like.
2585        let mut resent = HashMap::new();
2586        resent.insert(0 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
2587        let absorbed = reconstructor.insert_coefficient_commitments(ssa_id, 0, None, resent.into_iter())?;
2588        assert!(
2589            absorbed.is_verifiable,
2590            "absorbing a re-delivery must not make a completed cycle look incomplete"
2591        );
2592
2593        // A trailing non-constant coefficient is ignored for its own reason: a peer that still emits
2594        // the full Feldman matrix sends the bulk of it *after* the constant-term pass has completed.
2595        let mut trailing = HashMap::new();
2596        trailing.insert(0 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
2597        let ignored = reconstructor.insert_coefficient_commitments(ssa_id, 1, None, trailing.into_iter())?;
2598        assert!(
2599            ignored.is_verifiable,
2600            "ignoring a message must not make a completed cycle look incomplete"
2601        );
2602
2603        Ok(())
2604    }
2605
2606    /// A slot re-offered the commitment it holds is a no-op; re-offered a *different* one, it is
2607    /// still refused.
2608    ///
2609    /// The single-assignment invariant is the one that matters: a slot that has been decoded and
2610    /// accepted can never be rebound, or a peer could displace a commitment the deposit address was
2611    /// derived from. Re-delivering the *same* bytes cannot do that, and has to be tolerated because
2612    /// retransmission produces exactly that case.
2613    #[test]
2614    fn a_slot_tolerates_its_own_commitment_and_refuses_a_different_one() -> anyhow::Result<()> {
2615        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
2616
2617        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
2618
2619        reconstructor.new_exit_commitment(ssa_id, test_params(2, 2))?;
2620
2621        // Insert the constant term of poly 0
2622        let mut poly_map_1 = HashMap::new();
2623        poly_map_1.insert(0 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
2624        reconstructor.insert_coefficient_commitments(ssa_id, 0, None, poly_map_1.into_iter())?;
2625
2626        // The same commitment for the same slot: absorbed, and it must not consume the slot twice —
2627        // proven below by the set still completing on poly 1 alone.
2628        let mut resent = HashMap::new();
2629        resent.insert(0 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
2630        let absorbed = reconstructor.insert_coefficient_commitments(ssa_id, 0, None, resent.into_iter())?;
2631        assert!(!absorbed.is_verifiable, "poly 1 has still not been committed");
2632
2633        // A *different* commitment for that slot is a contradiction and must be refused.
2634        let mut conflicting = HashMap::new();
2635        conflicting.insert(0 as PolynomialIndex, PixGroup::<TestSpec>::generator().to_bytes());
2636        let result = reconstructor.insert_coefficient_commitments(ssa_id, 0, None, conflicting.into_iter());
2637        assert!(matches!(result, Err(PixError::DuplicateCommitment)));
2638
2639        // Poly 1's constant term is a different slot and must still complete the set.
2640        let mut poly_map_3 = HashMap::new();
2641        poly_map_3.insert(1 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
2642        let completed = reconstructor.insert_coefficient_commitments(
2643            ssa_id,
2644            0,
2645            Some(identity_proof(&ssa_id)),
2646            poly_map_3.into_iter(),
2647        )?;
2648        assert!(
2649            completed.is_verifiable,
2650            "the absorbed re-delivery must not have consumed poly 1's slot"
2651        );
2652
2653        Ok(())
2654    }
2655
2656    /// The missing-commitment scope is what the Exit puts on the wire to repair a lost packet, so it
2657    /// has to name the gaps exactly, compactly, and within one message.
2658    #[test]
2659    fn missing_commitment_runs_report_the_gaps_and_nothing_else() -> anyhow::Result<()> {
2660        const POLYS: u16 = 8;
2661        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
2662        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
2663
2664        // An unregistered cycle is distinguishable from a complete one: there is nothing left for a
2665        // retransmission to complete, which the caller has to treat differently from "ask again".
2666        assert_eq!(None, reconstructor.missing_commitment_runs(&ssa_id, 8));
2667
2668        reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, 2))?;
2669        assert_eq!(
2670            Some(vec![(0, POLYS as PolynomialIndex - 1)]),
2671            reconstructor.missing_commitment_runs(&ssa_id, 8),
2672            "before anything arrives the whole set is one run"
2673        );
2674
2675        // Two chunk-shaped holes: 2..=3 and 6..=6. Each is what one lost packet leaves behind, since
2676        // the sender slices the set into consecutive polynomial indices.
2677        let arrived: HashMap<PolynomialIndex, PixGroupRepr<TestSpec>> = [0, 1, 4, 5, 7]
2678            .into_iter()
2679            .map(|poly| (poly as PolynomialIndex, PixGroupRepr::<TestSpec>::default()))
2680            .collect();
2681        reconstructor.insert_coefficient_commitments(ssa_id, 0, None, arrived.into_iter())?;
2682        assert_eq!(
2683            Some(vec![(2, 3), (6, 6)]),
2684            reconstructor.missing_commitment_runs(&ssa_id, 8),
2685            "consecutive gaps must coalesce into one run each"
2686        );
2687
2688        // A scope larger than the asking message can carry is truncated lowest-first, so successive
2689        // asks converge on a prefix instead of interleaving.
2690        assert_eq!(
2691            Some(vec![(2, 3)]),
2692            reconstructor.missing_commitment_runs(&ssa_id, 1),
2693            "the run budget must bound the reported scope"
2694        );
2695
2696        // A budget of zero is read as one rather than honoured, and this is the branch that makes an
2697        // empty result mean "complete" and nothing else. Honouring it would report an incomplete
2698        // commitment as having nothing to ask for, and the caller — which branches on exactly that —
2699        // would stop repairing without a word.
2700        assert_eq!(
2701            Some(vec![(2, 3)]),
2702            reconstructor.missing_commitment_runs(&ssa_id, 0),
2703            "a zero run budget must not be mistaken for a complete commitment"
2704        );
2705
2706        // Filling the gaps completes the set, and a complete set has nothing to ask for. Note the map
2707        // is *drained* at completion, so reporting emptiness here is not incidental.
2708        let repaired: HashMap<PolynomialIndex, PixGroupRepr<TestSpec>> = [2, 3, 6]
2709            .into_iter()
2710            .map(|poly| (poly as PolynomialIndex, PixGroupRepr::<TestSpec>::default()))
2711            .collect();
2712        let state = reconstructor.insert_coefficient_commitments(
2713            ssa_id,
2714            0,
2715            Some(identity_proof(&ssa_id)),
2716            repaired.into_iter(),
2717        )?;
2718        assert!(state.is_verifiable);
2719        assert_eq!(Some(Vec::new()), reconstructor.missing_commitment_runs(&ssa_id, 8));
2720
2721        Ok(())
2722    }
2723
2724    /// The whole point of the feature: a commitment burst that lost a slice still completes, because
2725    /// the Exit can name the slice and the Entry can re-send exactly it.
2726    ///
2727    /// Without this a single dropped packet costs the cycle — and by then the Entry has usually
2728    /// already been told to fund its deposit address.
2729    #[test]
2730    fn a_lost_slice_of_a_commitment_is_repaired_by_a_scoped_retransmission() -> anyhow::Result<()> {
2731        const POLYS: u16 = 16;
2732        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2733            polynomials_per_ssa: POLYS,
2734            threshold: 2,
2735            surplus_shares: 0,
2736        });
2737        let pseudonym = SimplePseudonym::random();
2738        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2739
2740        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2741        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
2742        reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, 2))?;
2743
2744        // Deliver every constant term except one packet's worth, the middle quarter of the set.
2745        let full = coefficient_of(&commitment, 0, None)?;
2746        let lost: std::collections::HashSet<PolynomialIndex> = (4..8).collect();
2747        let delivered: Vec<_> = full
2748            .iter()
2749            .copied()
2750            .filter(|(poly_index, _)| !lost.contains(poly_index))
2751            .collect();
2752        let partial =
2753            reconstructor.insert_coefficient_commitments(ssa_id, 0, proof_of(&commitment, 0), delivered.into_iter())?;
2754        assert!(
2755            !partial.is_verifiable && partial.ssa_deposit_address.is_none(),
2756            "a set one packet short yields no deposit address — this is the failure being repaired"
2757        );
2758
2759        // The Exit names the gap; the Entry answers it from the same bytes it sent the first time.
2760        let runs = reconstructor
2761            .missing_commitment_runs(&ssa_id, 64)
2762            .expect("the registration must still be live");
2763        assert_eq!(vec![(4, 7)], runs);
2764
2765        let repair = generator.recommit(&pseudonym, SsaIndex::MIN, runs)?;
2766        let repaired = coefficient_of(&repair, 0, None)?;
2767        assert_eq!(
2768            lost,
2769            repaired.iter().map(|(poly_index, _)| *poly_index).collect(),
2770            "the retransmission must carry exactly the missing polynomials"
2771        );
2772        for (poly_index, resent) in &repaired {
2773            assert_eq!(
2774                Some(resent),
2775                full.iter()
2776                    .find(|(index, _)| index == poly_index)
2777                    .map(|(_, original)| original),
2778                "the retransmission must repeat the original bytes rather than derive new ones"
2779            );
2780        }
2781
2782        let completed =
2783            reconstructor.insert_coefficient_commitments(ssa_id, 0, proof_of(&repair, 0), repaired.into_iter())?;
2784        assert!(
2785            completed.is_verifiable && completed.ssa_deposit_address.is_some(),
2786            "the repaired set must complete the commitment and yield the deposit address"
2787        );
2788        assert!(completed.deposit_address_first_encountered);
2789
2790        Ok(())
2791    }
2792
2793    #[test]
2794    fn reconstructor_missing_verifier_retains_share() -> anyhow::Result<()> {
2795        // Regression test for the share-loss race:
2796        // When the polynomial's verifier is not installed yet, the share must NOT be removed
2797        // from the awaiting_acks cache — it must remain available for the retry that happens
2798        // when the verifier arrives.
2799        //
2800        // The implementation guarantees this: `process_verified_ack` looks the share up with
2801        // `.get()` and only `.remove()`s it after the verifier lookup succeeds, so the
2802        // `VerifierNotReady` deferral leaves the share in place. This test asserts that retention.
2803        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig { ..Default::default() });
2804
2805        let ack_key = HalfKey::random();
2806        let challenge = ack_key.to_challenge()?;
2807
2808        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
2809        let spi = SsaPolynomialId::new(ssa_id, 0);
2810
2811        let partial_share = PartialSsaShare::default().encrypt(&spi, &ack_key)?;
2812        let peer = OffchainKeypair::random();
2813        let nonce = crypto_traits::elliptic_curve::Scalar::<Secp256k1>::random(&mut hopr_types::crypto_random::rng());
2814
2815        reconstructor.new_exit_commitment(ssa_id, test_params(DEFAULT_POLYS_PER_SSA, DEFAULT_POLY_THRESHOLD))?;
2816
2817        reconstructor.insert_encrypted_share(
2818            peer.public(),
2819            challenge,
2820            TaggedEncryptedPartialSsaShare {
2821                pseudonym: *spi.pseudonym(),
2822                nonce,
2823                partial_share,
2824            },
2825        )?;
2826
2827        // Verify the share exists before processing
2828        let peer_cache = reconstructor.awaiting_acks.get(peer.public());
2829        assert!(peer_cache.is_some(), "share must be inserted before processing");
2830        assert!(
2831            peer_cache.as_ref().unwrap().contains_key(&challenge),
2832            "share must be present in the peer cache before processing"
2833        );
2834
2835        // Process the ack — the verifier is not installed, so this defers rather than failing,
2836        // and reports the polynomial the ack must be bucketed under.
2837        let peer_cache_ref = reconstructor.awaiting_acks.get(peer.public()).unwrap();
2838        let result = reconstructor.process_verified_ack(ack_key, challenge, &peer_cache_ref);
2839        assert!(
2840            matches!(result, Ok(ProcessedAckResult::VerifierNotReady(reported)) if reported == spi),
2841            "expected deferral naming polynomial {spi:?}"
2842        );
2843
2844        // The share MUST NOT be destroyed by the deferral: the implementation only removes it
2845        // after the verifier lookup succeeds, so it stays available for the retry.
2846        let peer_cache_after = reconstructor.awaiting_acks.get(peer.public());
2847        assert!(peer_cache_after.is_some(), "share must be retained when deferred");
2848        assert!(
2849            peer_cache_after.as_ref().unwrap().contains_key(&challenge),
2850            "share must be retained when deferred"
2851        );
2852
2853        Ok(())
2854    }
2855
2856    #[test]
2857    fn reconstructor_defers_ack_when_verifier_is_not_installed() -> anyhow::Result<()> {
2858        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig { ..Default::default() });
2859
2860        let ack_key = HalfKey::random();
2861        let challenge = ack_key.to_challenge()?;
2862
2863        // Add a pending share but NO commitment (so no verifier is created)
2864        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
2865        let spi = SsaPolynomialId::new(ssa_id, 0);
2866
2867        // We need a valid-looking encrypted share even if it's junk.
2868        // EncryptedPartialSsaShare is basically a wrapper around bytes.
2869        let partial_share = PartialSsaShare::default().encrypt(&spi, &ack_key)?;
2870
2871        let peer = OffchainKeypair::random();
2872        let nonce = crypto_traits::elliptic_curve::Scalar::<Secp256k1>::random(&mut hopr_types::crypto_random::rng());
2873
2874        reconstructor.new_exit_commitment(ssa_id, test_params(DEFAULT_POLYS_PER_SSA, DEFAULT_POLY_THRESHOLD))?;
2875
2876        reconstructor.insert_encrypted_share(
2877            peer.public(),
2878            challenge,
2879            TaggedEncryptedPartialSsaShare {
2880                pseudonym: *spi.pseudonym(),
2881                nonce,
2882                partial_share,
2883            },
2884        )?;
2885
2886        let result = reconstructor.process_verified_ack(
2887            ack_key,
2888            challenge,
2889            reconstructor
2890                .awaiting_acks
2891                .get(peer.public())
2892                .as_ref()
2893                .ok_or(anyhow::anyhow!("missing peer"))?,
2894        );
2895        assert!(
2896            matches!(result, Ok(ProcessedAckResult::VerifierNotReady(reported)) if reported == spi),
2897            "an ack with no installed verifier must be deferred, not failed"
2898        );
2899
2900        Ok(())
2901    }
2902
2903    #[test]
2904    fn reconstructor_rejects_duplicate_share_via_different_challenges() -> anyhow::Result<()> {
2905        // 1 poly, threshold=2 → need 2 shares per polynomial to reconstruct.
2906        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2907            polynomials_per_ssa: 1,
2908            threshold: 2,
2909            surplus_shares: 0,
2910        });
2911
2912        let pseudonym = SimplePseudonym::random();
2913        let peer = OffchainKeypair::random();
2914        let ssa_id = SsaId::new(pseudonym, 1.try_into()?);
2915
2916        let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2917
2918        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
2919        let _server_commitment = reconstructor.new_exit_commitment(ssa_id, test_params(1, 2))?;
2920        commitment_msg.process_into_reconstructor(&reconstructor)?;
2921
2922        // --- Step 1: Generate the first share ---
2923        let msg1: [u8; 20] = hopr_types::crypto_random::random_bytes();
2924        let Some(first) = generator.next_share(&pseudonym, &msg1)? else {
2925            anyhow::bail!("expected first share");
2926        };
2927        // Clone the PartialSsaShare so we can re-encrypt it as a duplicate later
2928        let first_share = first.share.clone();
2929        let ack1 = HalfKey::random();
2930        let challenge1 = ack1.to_challenge()?;
2931        let enc1 = first.share.encrypt(&first.id, &ack1)?;
2932        reconstructor.insert_encrypted_share(
2933            peer.public(),
2934            challenge1,
2935            TaggedEncryptedPartialSsaShare::new(pseudonym, &msg1, enc1)?,
2936        )?;
2937
2938        // --- Step 2: Re-encrypt the SAME share under a different challenge (true duplicate) ---
2939        // The PartialSsaShare retains the same scalar value and derives the same identifier
2940        // (X-coordinate) from msg1, so it will be recognised as a duplicate at share-insertion time.
2941        let dup_ack = HalfKey::random();
2942        let dup_challenge = dup_ack.to_challenge()?;
2943        let enc_dup = first_share.encrypt(&first.id, &dup_ack)?;
2944        reconstructor.insert_encrypted_share(
2945            peer.public(),
2946            dup_challenge,
2947            TaggedEncryptedPartialSsaShare::new(pseudonym, &msg1, enc_dup)?,
2948        )?;
2949
2950        // --- Step 3: Process the first ack — share accepted, not yet complete ---
2951        let resolution1 = reconstructor.process_verified_ack(
2952            ack1,
2953            challenge1,
2954            reconstructor
2955                .awaiting_acks
2956                .get(peer.public())
2957                .as_ref()
2958                .ok_or(anyhow::anyhow!("missing peer"))?,
2959        )?;
2960        assert!(
2961            matches!(resolution1, ProcessedAckResult::Progressed(p) if p.useful_shares == 1),
2962            "first share should count as progress without completing the SSA"
2963        );
2964
2965        // --- Step 4: Process the duplicate ---
2966        // The SsaPartBuilder has 1/2 shares. The duplicate share has the same identifier
2967        // (same X-coordinate from msg1), so it hits the
2968        // `any(|s| s.identifier == share.identifier)` check in SsaPartBuilder::add_share.
2969        //
2970        // The point of the assertion is the contrast with step 3: a duplicate is not merely
2971        // "incomplete", it is *not progress*, so it must leave the counters where they were.
2972        let resolution_dup = reconstructor.process_verified_ack(
2973            dup_ack,
2974            dup_challenge,
2975            reconstructor
2976                .awaiting_acks
2977                .get(peer.public())
2978                .as_ref()
2979                .ok_or(anyhow::anyhow!("missing peer"))?,
2980        )?;
2981        assert!(
2982            matches!(resolution_dup, ProcessedAckResult::NoProgress),
2983            "duplicate share must not register as progress"
2984        );
2985        assert_eq!(
2986            1,
2987            reconstructor
2988                .cycle(&ssa_id)
2989                .ok_or(anyhow::anyhow!("cycle went away"))?
2990                .progress()
2991                .useful_shares,
2992            "the duplicate must not have advanced the useful-share count"
2993        );
2994
2995        // --- Step 5: Generate and process the second distinct share ---
2996        let msg2: [u8; 20] = hopr_types::crypto_random::random_bytes();
2997        let Some(second) = generator.next_share(&pseudonym, &msg2)? else {
2998            anyhow::bail!("expected second share");
2999        };
3000        let ack2 = HalfKey::random();
3001        let challenge2 = ack2.to_challenge()?;
3002        let enc2 = second.share.encrypt(&second.id, &ack2)?;
3003        reconstructor.insert_encrypted_share(
3004            peer.public(),
3005            challenge2,
3006            TaggedEncryptedPartialSsaShare::new(pseudonym, &msg2, enc2)?,
3007        )?;
3008
3009        let resolution2 = reconstructor.process_verified_ack(
3010            ack2,
3011            challenge2,
3012            reconstructor
3013                .awaiting_acks
3014                .get(peer.public())
3015                .as_ref()
3016                .ok_or(anyhow::anyhow!("missing peer"))?,
3017        )?;
3018        assert!(
3019            matches!(resolution2, ProcessedAckResult::FullRecovery(ref r, _) if r.ssa_id == ssa_id),
3020            "second unique share should complete SSA reconstruction"
3021        );
3022
3023        Ok(())
3024    }
3025
3026    #[test]
3027    fn reconstructor_must_not_accept_empty_encrypted_share() -> anyhow::Result<()> {
3028        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig { ..Default::default() });
3029
3030        let ack_key = HalfKey::random();
3031        let challenge = ack_key.to_challenge()?;
3032
3033        let peer = OffchainKeypair::random();
3034
3035        assert!(
3036            reconstructor
3037                .insert_encrypted_share(
3038                    peer.public(),
3039                    challenge,
3040                    TaggedEncryptedPartialSsaShare {
3041                        pseudonym: SimplePseudonym::random(),
3042                        nonce: Default::default(),
3043                        partial_share: Default::default(),
3044                    }
3045                )
3046                .is_err()
3047        );
3048
3049        Ok(())
3050    }
3051
3052    // -----------------------------------------------------------------------
3053    // early_recovery_threshold tests
3054    // -----------------------------------------------------------------------
3055
3056    /// A non-finite early-recovery threshold must not survive configuration validation.
3057    ///
3058    /// The `range(min = 0.0, max = 1.0)` this replaced could not reject `NaN`: IEEE comparison against
3059    /// it is false in both directions, so it satisfied both bounds. And it did not stay harmless —
3060    /// `check_early_threshold` multiplies by `num_polys`, calls `ceil`, and casts to `usize`, and a
3061    /// `NaN` survives all three to saturate at zero. The Exit would then announce early recovery on
3062    /// its first reconstructed polynomial, which is the earliest instant it can ask for the next batch
3063    /// and precisely what the Entry's successor gate refuses.
3064    ///
3065    /// Asserted through `validate` rather than through `SsaReconstructor::new`, whose `expect` would
3066    /// make the failure a panic and the passing cases the only observable ones.
3067    #[test]
3068    fn a_non_finite_early_recovery_threshold_is_not_a_valid_configuration() {
3069        use validator::Validate;
3070
3071        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1.5, -0.1] {
3072            let cfg = SsaReconstructorConfig {
3073                early_recovery_threshold: bad,
3074                ..Default::default()
3075            };
3076            assert!(cfg.validate().is_err(), "{bad} is not a fraction and must be rejected");
3077        }
3078
3079        for good in [0.0, 0.85, 1.0] {
3080            let cfg = SsaReconstructorConfig {
3081                early_recovery_threshold: good,
3082                ..Default::default()
3083            };
3084            assert!(cfg.validate().is_ok(), "{good} is a valid fraction");
3085        }
3086    }
3087
3088    /// Helper: create an SsaBuilder that accepts zero-valued sub-secrets.
3089    ///
3090    /// These tests exercise part accumulation rather than progress reporting, so the recovery target
3091    /// only has to be self-consistent: one useful share per polynomial is the smallest value that
3092    /// keeps `SsaCycle::new`'s cross-check satisfiable for callers that go on to build a cycle.
3093    fn make_builder(num_polys: usize) -> SsaBuilder<TestSpec> {
3094        let exit_secret = PixScalar::<TestSpec>::default();
3095        let full_commitment = PixGroup::<TestSpec>::default();
3096        SsaBuilder::new(full_commitment, exit_secret, num_polys, num_polys as u64)
3097    }
3098
3099    /// Helper: add `n` zero-valued polynomial parts to `builder`, returning
3100    /// the result of each call.
3101    fn add_parts(
3102        builder: &mut SsaBuilder<TestSpec>,
3103        n: usize,
3104    ) -> crate::errors::Result<Vec<Option<PixScalar<TestSpec>>>, <TestSpec as PixSpec>::Pseudonym> {
3105        let mut results = Vec::with_capacity(n);
3106        for i in 0..n {
3107            let sub = PixScalar::<TestSpec>::default();
3108            results.push(builder.add_recovered_ssa_part(i as PolynomialIndex, sub)?);
3109        }
3110        Ok(results)
3111    }
3112
3113    /// A cycle with no polynomials can never recover, so the constructor refuses to build one.
3114    ///
3115    /// Unreachable through the commitment path — `PixParams::try_new` rejects `polys_per_ssa == 0` —
3116    /// but `SsaCycle::new` and `SsaBuilder::new` are both public, so this is a property of the type
3117    /// rather than of its current callers. It is also the case that must behave identically in debug
3118    /// and release: the guard predates the cross-check below it precisely because
3119    /// `debug_assert_eq!` compiles away and anything fallible inside it would not.
3120    #[test]
3121    fn a_cycle_with_no_polynomials_is_refused() {
3122        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3123        assert!(
3124            matches!(
3125                SsaCycle::new(ssa_id, make_builder(0), Vec::new()),
3126                Err(PixError::InvalidInput)
3127            ),
3128            "a zero-part cycle is unusable and must not be constructible"
3129        );
3130    }
3131
3132    #[test]
3133    fn ssa_builder_early_threshold_below() -> anyhow::Result<()> {
3134        // num_polys=10, threshold=0.85 → ceil(0.85×10)=9.
3135        // Adding 8 parts should NOT reach the threshold.
3136        let mut builder = make_builder(10);
3137        add_parts(&mut builder, 8)?;
3138        assert!(!builder.check_early_threshold(0.85));
3139        Ok(())
3140    }
3141
3142    #[test]
3143    fn ssa_builder_early_threshold_hits_ceil_at_9() -> anyhow::Result<()> {
3144        // num_polys=10, threshold=0.85 → ceil(0.85×10)=9.
3145        // Adding 9 parts SHOULD fire on the first check.
3146        let mut builder = make_builder(10);
3147        add_parts(&mut builder, 9)?;
3148        assert!(builder.check_early_threshold(0.85));
3149        // Second call must return false (idempotent guard).
3150        assert!(!builder.check_early_threshold(0.85));
3151        Ok(())
3152    }
3153
3154    #[test]
3155    fn ssa_builder_threshold_1_dot_0_fires_at_full_recovery() -> anyhow::Result<()> {
3156        // num_polys=10, threshold=1.0 → ceil(1.0×10)=10.
3157        // Only fires when ALL 10 polynomial parts are received.
3158        let mut builder = make_builder(10);
3159        add_parts(&mut builder, 9)?;
3160        assert!(!builder.check_early_threshold(1.0));
3161        add_parts(&mut builder, 1)?; // 10th part → completes SSA
3162        // After full recovery, early_notified is set by add_recovered_ssa_part.
3163        // check_early_threshold should still report false.
3164        assert!(!builder.check_early_threshold(1.0));
3165        Ok(())
3166    }
3167
3168    #[test]
3169    fn process_verified_ack_emits_early_and_full_recovery() -> anyhow::Result<()> {
3170        // Use a small SSA config where we can observe both events.
3171        // 4 polynomials, threshold=4, surplus=0 → 16 shares total.
3172        // early_recovery_threshold=0.5 → ceil(0.5×4)=2.
3173        // After 2 polynomial parts → EarlyRecovery.
3174        // After all 4         → FullRecovery.
3175        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3176            polynomials_per_ssa: 4,
3177            threshold: 4,
3178            surplus_shares: 0,
3179        });
3180
3181        let pseudonym = SimplePseudonym::random();
3182        let peer = OffchainKeypair::random();
3183        let ssa_id = SsaId::new(pseudonym, 1.try_into()?);
3184
3185        let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3186
3187        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
3188            early_recovery_threshold: 0.5,
3189            ..Default::default()
3190        });
3191
3192        let _server_commitment = reconstructor.new_exit_commitment(ssa_id, test_params(4, 4))?;
3193
3194        commitment_msg.process_into_reconstructor(&reconstructor)?;
3195
3196        // No shares inserted yet — has_pending_shares must be false.
3197        assert!(
3198            !reconstructor.has_pending_shares(peer.public()),
3199            "no shares inserted yet"
3200        );
3201
3202        // Generate and insert all 16 encrypted shares
3203        let mut acks = Vec::new();
3204        while let Some((msg, share)) = {
3205            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3206            generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
3207        }? {
3208            let ack = HalfKey::random();
3209            let ack_challenge = ack.to_challenge()?;
3210            let enc_share = share.share.encrypt(&share.id, &ack)?;
3211
3212            reconstructor.insert_encrypted_share(
3213                peer.public(),
3214                ack_challenge,
3215                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
3216            )?;
3217            acks.push(VerifiedAcknowledgement::new(ack, &peer).leak());
3218        }
3219
3220        // After inserting encrypted shares, the peer must have pending shares.
3221        assert!(
3222            reconstructor.has_pending_shares(peer.public()),
3223            "shares were just inserted"
3224        );
3225
3226        // Process all acks in one batch
3227        let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
3228
3229        // Both events MUST be present
3230        let has_early = resolutions
3231            .iter()
3232            .any(|r| matches!(r, ShareResolution::AlmostRecoveredSsa(id) if *id == ssa_id));
3233        let has_full = resolutions
3234            .iter()
3235            .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id));
3236
3237        assert!(has_early, "expected AlmostRecoveredSsa event");
3238        assert!(has_full, "expected RecoveredSsa event");
3239
3240        Ok(())
3241    }
3242
3243    /// Sets up one fully committed cycle and returns every share's acknowledgement, unprocessed.
3244    ///
3245    /// Shares are inserted but not acknowledged, so the caller can feed acks in whatever grouping
3246    /// the test needs and read the cycle's counters in between.
3247    #[allow(clippy::type_complexity)]
3248    fn cycle_with_pending_acks(
3249        polys: u16,
3250        threshold: u8,
3251        surplus: u8,
3252        peer: &OffchainKeypair,
3253    ) -> anyhow::Result<(SsaReconstructor<TestSpec>, SsaId<SimplePseudonym>, Vec<Acknowledgement>)> {
3254        cycle_with_declared_surplus(polys, threshold, surplus, surplus, peer)
3255    }
3256
3257    /// As [`cycle_with_pending_acks`], but lets the Entry emit a different surplus from the one it
3258    /// negotiated.
3259    ///
3260    /// The two are equal for a conforming peer, which is why every other caller uses the wrapper
3261    /// above. Separating them is what makes an over-emitting Entry expressible: the Exit sizes its
3262    /// per-polynomial liveness credit on `declared_surplus`, and nothing but that bound stops the
3263    /// extra shares from counting.
3264    fn cycle_with_declared_surplus(
3265        polys: u16,
3266        threshold: u8,
3267        emitted_surplus: u8,
3268        declared_surplus: u8,
3269        peer: &OffchainKeypair,
3270    ) -> anyhow::Result<(SsaReconstructor<TestSpec>, SsaId<SimplePseudonym>, Vec<Acknowledgement>)> {
3271        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3272            polynomials_per_ssa: polys,
3273            threshold,
3274            surplus_shares: emitted_surplus,
3275        });
3276        let pseudonym = SimplePseudonym::random();
3277        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3278
3279        let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3280        let reconstructor = SsaReconstructor::<TestSpec>::default();
3281        reconstructor.new_exit_commitment(
3282            ssa_id,
3283            PixParams::try_new_for::<TestSpec>(polys, threshold, declared_surplus)?,
3284        )?;
3285        commitment_msg.process_into_reconstructor(&reconstructor)?;
3286
3287        let mut acks = Vec::new();
3288        while let Some((msg, share)) = {
3289            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3290            generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
3291        }? {
3292            let ack = HalfKey::random();
3293            let ack_challenge = ack.to_challenge()?;
3294            let enc_share = share.share.encrypt(&share.id, &ack)?;
3295            reconstructor.insert_encrypted_share(
3296                peer.public(),
3297                ack_challenge,
3298                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
3299            )?;
3300            acks.push(VerifiedAcknowledgement::new(ack, peer).leak());
3301        }
3302
3303        Ok((reconstructor, ssa_id, acks))
3304    }
3305
3306    #[test]
3307    fn progress_counts_only_the_shares_that_advance_reconstruction() -> anyhow::Result<()> {
3308        // 2 polynomials × threshold 2, plus 1 surplus share each: 6 shares on the wire, of which
3309        // only 4 can ever be useful. The gap between those two numbers is the whole point — a
3310        // consumer sizing a progress ratio against packets received would read 6/4.
3311        const POLYS: u16 = 2;
3312        const THRESHOLD: u8 = 2;
3313        let peer = OffchainKeypair::random();
3314        let (reconstructor, ssa_id, acks) = cycle_with_pending_acks(POLYS, THRESHOLD, 1, &peer)?;
3315        assert_eq!(
3316            6,
3317            acks.len(),
3318            "generator should emit (threshold + surplus) per polynomial"
3319        );
3320
3321        let mut snapshots = Vec::new();
3322        let mut recovered = false;
3323        // One ack at a time, so each share's individual contribution is observable.
3324        for ack in acks {
3325            for resolution in reconstructor.acknowledge_shares(*peer.public(), vec![ack])? {
3326                match resolution {
3327                    ShareResolution::Progress(p) => snapshots.push(p),
3328                    ShareResolution::RecoveredSsa(r) => {
3329                        assert_eq!(ssa_id, r.ssa_id);
3330                        recovered = true;
3331                    }
3332                    _ => {}
3333                }
3334            }
3335        }
3336
3337        assert!(recovered, "the cycle must reconstruct from its own shares");
3338
3339        // Shares are emitted polynomial-major, so the useful ones are shares 1,2 and 4,5 — the
3340        // third share of each polynomial arrives after that polynomial is already reconstructed.
3341        assert_eq!(
3342            vec![1, 2, 3, 4, 4, 4],
3343            snapshots.iter().map(|p| p.useful_shares).collect::<Vec<_>>(),
3344            "the first four snapshots advance reconstruction; the buffered tail reports liveness only"
3345        );
3346        let last = snapshots.last().ok_or(anyhow::anyhow!("no progress emitted"))?;
3347        assert_eq!(
3348            (POLYS as u64 * THRESHOLD as u64),
3349            last.target_useful_shares,
3350            "target must be polynomials × threshold, matching the negotiated dimensions"
3351        );
3352        assert_eq!(
3353            last.useful_shares, last.target_useful_shares,
3354            "a completed cycle must report itself as complete"
3355        );
3356        assert_eq!(
3357            POLYS, last.recovered_polynomials,
3358            "every polynomial must be accounted for"
3359        );
3360        assert_eq!(
3361            POLYS as u64 * (THRESHOLD as u64 + 1),
3362            last.shares_seen,
3363            "every negotiated share, including the post-recovery tail, must be visible as liveness"
3364        );
3365
3366        Ok(())
3367    }
3368
3369    /// A surplus share must report itself as liveness without reporting itself as progress.
3370    ///
3371    /// This is the distinction the Exit's egress gate and recovery-idle deadline turn on. A conforming
3372    /// Entry emits `threshold + surplus` shares per polynomial, and a whole emission window reaches its
3373    /// threshold in lockstep, so `surplus × window` consecutive shares advance nothing — 4096 packets
3374    /// at the deployed dimensions. A consumer told only about *useful* shares reads that run as the
3375    /// Entry having gone quiet, and if it answers by withholding service it removes the only thing that
3376    /// could have produced the next useful share.
3377    ///
3378    /// Acks are fed polynomial-major rather than in emission order, which is what puts a surplus share
3379    /// in front of a still-incomplete cycle. In emission order both surplus shares of a
3380    /// two-polynomial cycle arrive after full recovery has already removed it, which is why
3381    /// [`progress_counts_only_the_shares_that_advance_reconstruction`] never reaches this path.
3382    #[test]
3383    fn a_surplus_share_reports_liveness_but_not_progress() -> anyhow::Result<()> {
3384        const POLYS: u16 = 2;
3385        const THRESHOLD: u8 = 2;
3386        let peer = OffchainKeypair::random();
3387        let (reconstructor, ssa_id, acks) = cycle_with_pending_acks(POLYS, THRESHOLD, 1, &peer)?;
3388        assert_eq!(6, acks.len(), "(threshold + surplus) per polynomial");
3389
3390        // Emission alternates between the two polynomials, so the even positions are one polynomial's
3391        // three shares and the odd positions the other's. Draining the evens first completes that
3392        // polynomial and then hands its third share to a cycle that is still short.
3393        let (evens, odds): (Vec<_>, Vec<_>) = acks.into_iter().enumerate().partition(|(i, _)| i % 2 == 0);
3394        let ordered = evens.into_iter().chain(odds).map(|(_, ack)| ack);
3395
3396        let mut snapshots = Vec::new();
3397        let mut recovered = false;
3398        for ack in ordered {
3399            for resolution in reconstructor.acknowledge_shares(*peer.public(), vec![ack])? {
3400                match resolution {
3401                    ShareResolution::Progress(p) => snapshots.push((p.useful_shares, p.shares_seen)),
3402                    ShareResolution::RecoveredSsa(r) => {
3403                        assert_eq!(ssa_id, r.ssa_id);
3404                        recovered = true;
3405                    }
3406                    _ => {}
3407                }
3408            }
3409        }
3410
3411        assert!(recovered, "the cycle must still reconstruct from its own shares");
3412        assert_eq!(
3413            vec![(1, 1), (2, 2), (2, 3), (3, 4), (4, 5), (4, 6)],
3414            snapshots,
3415            "(useful, seen): the third entry is the surplus share — seen advances, useful holds. The sixth share \
3416             arrives after full recovery removed the heavy cycle, and its compact tail still reports liveness."
3417        );
3418
3419        Ok(())
3420    }
3421
3422    /// A batch that ends on a surplus share reports that share's liveness, not the batch's last
3423    /// *useful* one.
3424    ///
3425    /// Every other test in this module feeds acknowledgements one at a time, so each batch carries a
3426    /// single snapshot and `record_progress` never has to merge anything. Batching is what makes the
3427    /// merge observable, and the merge is where a surplus run can be lost: the two counters are
3428    /// independent axes, and a selector keyed on `useful_shares` alone keeps the older snapshot for
3429    /// every share that advances only `shares_seen`.
3430    ///
3431    /// That is not a cosmetic loss. `shares_seen` is the axis the Session layer's egress gate and
3432    /// recovery-idle deadline both read, precisely so that the `surplus × window` run at the end of
3433    /// every emission window is not mistaken for the Entry falling silent. Dropping the surplus
3434    /// snapshot inside a batch reinstates exactly the reading those consumers were built to avoid.
3435    #[test]
3436    fn a_batch_ending_in_surplus_reports_the_surplus_share() -> anyhow::Result<()> {
3437        let peer = OffchainKeypair::random();
3438        let (reconstructor, _ssa_id, acks) = cycle_with_pending_acks(2, 2, 1, &peer)?;
3439
3440        // Emission alternates polynomials, so the even positions are polynomial zero's three shares:
3441        // two useful, then one surplus. Handed over as a single batch, they collapse to one snapshot.
3442        let batch: Vec<_> = acks.into_iter().step_by(2).collect();
3443        assert_eq!(3, batch.len(), "threshold 2 plus 1 surplus for polynomial zero");
3444
3445        let snapshots: Vec<_> = reconstructor
3446            .acknowledge_shares(*peer.public(), batch)?
3447            .into_iter()
3448            .filter_map(|r| match r {
3449                ShareResolution::Progress(p) => Some((p.useful_shares, p.shares_seen)),
3450                _ => None,
3451            })
3452            .collect();
3453
3454        assert_eq!(
3455            vec![(2, 3)],
3456            snapshots,
3457            "(useful, seen): the batch's last share is surplus, so `seen` must reach 3 while `useful` holds at 2. \
3458             Reporting (2, 2) means the surplus snapshot was discarded for having an equal `useful_shares`, and the \
3459             Exit sees a batch it was served as one it was not."
3460        );
3461
3462        Ok(())
3463    }
3464
3465    /// A polynomial credits at most the negotiated surplus, however many shares arrive for it.
3466    ///
3467    /// Reconstruction releases the collected share set, so from that moment the builder cannot tell
3468    /// one post-completion share from another — every replay of a single share looks exactly like a
3469    /// fresh one. Without a bound, an Entry completes one polynomial of a large cycle, replays its
3470    /// shares forever, and every replay refreshes the Exit's egress gate and recovery-idle deadline
3471    /// while the remaining polynomials are never touched: unbounded service, no recovery, and so no
3472    /// payment. The bound is what makes that ride finite.
3473    ///
3474    /// The Entry here emits four surplus shares per polynomial having negotiated one, which is the
3475    /// only way to express over-emission — a conforming generator cannot exceed its own budget.
3476    #[test]
3477    fn a_polynomial_credits_no_more_surplus_than_was_negotiated() -> anyhow::Result<()> {
3478        const POLYS: u16 = 2;
3479        // Threshold and emitted surplus are equal because that is the largest over-emission
3480        // `SsaGeneratorConfig` will construct: `surplus_must_not_exceed_threshold` bounds the
3481        // surplus by the threshold, so expressing "emits four where it negotiated one" needs a
3482        // threshold of at least four.
3483        const THRESHOLD: u8 = 4;
3484        const DECLARED_SURPLUS: u8 = 1;
3485        const EMITTED_SURPLUS: u8 = 4;
3486
3487        let peer = OffchainKeypair::random();
3488        let (reconstructor, _ssa_id, acks) =
3489            cycle_with_declared_surplus(POLYS, THRESHOLD, EMITTED_SURPLUS, DECLARED_SURPLUS, &peer)?;
3490        assert_eq!(16, acks.len(), "(threshold + emitted surplus) per polynomial");
3491
3492        // Drain one polynomial's six shares before touching the other, so the cycle stays live while
3493        // that polynomial takes every surplus share the Entry has for it. In emission order the
3494        // second polynomial would complete the cycle first and retire it.
3495        let (evens, odds): (Vec<_>, Vec<_>) = acks.into_iter().enumerate().partition(|(i, _)| i % 2 == 0);
3496
3497        let mut seen = Vec::new();
3498        for (_, ack) in evens {
3499            for resolution in reconstructor.acknowledge_shares(*peer.public(), vec![ack])? {
3500                if let ShareResolution::Progress(p) = resolution {
3501                    seen.push((p.useful_shares, p.shares_seen));
3502                }
3503            }
3504        }
3505
3506        assert_eq!(
3507            vec![(1, 1), (2, 2), (3, 3), (4, 4), (4, 5)],
3508            seen,
3509            "(useful, seen): four useful shares complete the polynomial, then exactly one surplus share is credited. \
3510             The remaining three are over the negotiated budget and must report nothing at all — had they been \
3511             credited, `seen` would have run to 8."
3512        );
3513
3514        // The other polynomial is untouched, so the cycle is still short of recovery — which is the
3515        // state the attack wants to hold the Exit in.
3516        let (_, odds_first) = odds.into_iter().next().expect("second polynomial has shares");
3517        assert!(
3518            reconstructor
3519                .acknowledge_shares(*peer.public(), vec![odds_first])?
3520                .iter()
3521                .any(|r| matches!(r, ShareResolution::Progress(_))),
3522            "the cycle must still be live and still accepting useful shares elsewhere"
3523        );
3524
3525        Ok(())
3526    }
3527
3528    /// Once a polynomial is reconstructed, the surplus path must not let malformed shares become
3529    /// liveness signals. Otherwise an Entry can keep the funded-service gate open indefinitely by
3530    /// naming a completed polynomial: `SsaPartBuilder::add_share` returns `Surplus` before even the
3531    /// inexpensive zero-share validation that applies while the polynomial is incomplete.
3532    #[test]
3533    fn an_invalid_share_for_a_completed_polynomial_is_not_liveness() -> anyhow::Result<()> {
3534        let peer = OffchainKeypair::random();
3535        // The negotiated surplus must be non-zero, and that is the whole reason this test can fail.
3536        // At a surplus of zero `max_credited_surplus` is zero too, so a share arriving for a
3537        // completed polynomial is refused because no per-polynomial credit remains, whichever side
3538        // of the `reconstructed` check the zero-coordinate validation sits on — and the assertion
3539        // below would hold even with the laundering channel wide open.
3540        let (reconstructor, ssa_id, acks) = cycle_with_pending_acks(2, 2, 1, &peer)?;
3541
3542        // Emission alternates polynomials, so the even positions are polynomial zero's shares. Only
3543        // `threshold` of them are needed to complete it; taking exactly two leaves its surplus
3544        // credit unspent, which is the state an Entry would want to launder a malformed share
3545        // through. Polynomial one is never touched, so the cycle stays incomplete.
3546        for ack in acks.into_iter().step_by(2).take(2) {
3547            reconstructor.acknowledge_shares(*peer.public(), vec![ack])?;
3548        }
3549
3550        let spi = SsaPolynomialId::new(ssa_id, 0);
3551        let ack = HalfKey::random();
3552        let challenge = ack.to_challenge()?;
3553        let encrypted = PartialSsaShare::<TestSpec>::default().encrypt(&spi, &ack)?;
3554        let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3555        reconstructor.insert_encrypted_share(
3556            peer.public(),
3557            challenge,
3558            TaggedEncryptedPartialSsaShare::new(*spi.pseudonym(), &msg, encrypted)?,
3559        )?;
3560
3561        let resolutions =
3562            reconstructor.acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?;
3563        assert!(
3564            resolutions.iter().all(|r| !matches!(r, ShareResolution::Progress(_))),
3565            "a provably invalid zero share must not reset the service gate; got {resolutions:?}"
3566        );
3567        // Stated positively as well, so the test cannot pass by the share being dropped somewhere
3568        // upstream of the validation it exists to pin.
3569        assert!(
3570            resolutions
3571                .iter()
3572                .any(|r| matches!(r, ShareResolution::InvalidShares { ssa_id: id, .. } if *id == ssa_id)),
3573            "the share must be reported as a fault, not merely absorbed; got {resolutions:?}"
3574        );
3575
3576        Ok(())
3577    }
3578
3579    #[test]
3580    fn progress_precedes_the_terminal_event_it_belongs_to() -> anyhow::Result<()> {
3581        // The emission order is a contract: a consumer that acts on RecoveredSsa must already have
3582        // been told the counters that justify it, including for the batch that completes the cycle —
3583        // the one whose snapshot is taken from a cycle that no longer exists by the time the batch
3584        // returns.
3585        let peer = OffchainKeypair::random();
3586        let (reconstructor, ssa_id, acks) = cycle_with_pending_acks(2, 2, 0, &peer)?;
3587
3588        let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
3589
3590        let first_terminal = resolutions
3591            .iter()
3592            .position(|r| {
3593                matches!(
3594                    r,
3595                    ShareResolution::RecoveredSsa(_) | ShareResolution::AlmostRecoveredSsa(_)
3596                )
3597            })
3598            .ok_or(anyhow::anyhow!("expected a terminal resolution"))?;
3599        let last_progress = resolutions
3600            .iter()
3601            .rposition(|r| matches!(r, ShareResolution::Progress(_)))
3602            .ok_or(anyhow::anyhow!("expected a progress resolution"))?;
3603        assert!(
3604            last_progress < first_terminal,
3605            "every Progress must precede every terminal event, got {resolutions:?}"
3606        );
3607
3608        // Exactly one snapshot per SSA per batch, even though four shares moved the counters.
3609        let progress = resolutions
3610            .iter()
3611            .filter_map(|r| match r {
3612                ShareResolution::Progress(p) => Some(p),
3613                _ => None,
3614            })
3615            .collect::<Vec<_>>();
3616        assert_eq!(1, progress.len(), "one snapshot per SSA per batch, got {progress:?}");
3617        assert_eq!(ssa_id, progress[0].ssa_id);
3618        assert_eq!(
3619            4, progress[0].useful_shares,
3620            "the surviving snapshot must be the furthest-along one"
3621        );
3622
3623        Ok(())
3624    }
3625
3626    #[test]
3627    fn an_abandoned_commitment_guard_releases_its_registration() -> anyhow::Result<()> {
3628        let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
3629        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3630
3631        let (_commitment, guard) = reconstructor.new_guarded_exit_commitment(ssa_id, test_params(2, 2))?;
3632        assert_eq!(Some(&ssa_id), guard.ssa_id());
3633        assert!(
3634            reconstructor.contains_builder(&ssa_id),
3635            "the commitment must be registered while the guard holds it"
3636        );
3637
3638        drop(guard);
3639        assert!(
3640            !reconstructor.contains_builder(&ssa_id),
3641            "dropping the guard must release the registration it owned"
3642        );
3643
3644        Ok(())
3645    }
3646
3647    /// Abandoning a guard whose cycle already went live escalates to a full retirement.
3648    ///
3649    /// Reaching this is a caller error — a live cycle means the peer was asked and answered, so
3650    /// ownership should already have been handed on with `disarm()`. Retiring is the safe response
3651    /// rather than the merely tidy one: the frontier is what stops a commitment completion racing
3652    /// the teardown from republishing the cycle that was just dismantled.
3653    #[test]
3654    fn abandoning_a_live_cycle_retires_it_rather_than_just_releasing_it() -> anyhow::Result<()> {
3655        const POLYS: u16 = 2;
3656        const THRESHOLD: u8 = 2;
3657        let pseudonym = SimplePseudonym::random();
3658        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3659        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3660            polynomials_per_ssa: POLYS,
3661            threshold: THRESHOLD,
3662            surplus_shares: 0,
3663        });
3664
3665        let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
3666        let (_commitment, guard) = reconstructor.new_guarded_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
3667
3668        // Take the cycle live under the guard, which is the state a correct caller would have
3669        // disarmed out of.
3670        generator
3671            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
3672            .process_into_reconstructor(reconstructor.as_ref())?;
3673        assert!(reconstructor.cycle(&ssa_id).is_some(), "the cycle must be live");
3674
3675        drop(guard);
3676        assert!(
3677            reconstructor.cycle(&ssa_id).is_none(),
3678            "abandoning a live cycle must tear it down"
3679        );
3680
3681        assert!(
3682            matches!(
3683                reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD)),
3684                Err(PixError::DuplicateCommitment)
3685            ),
3686            "the Session frontier must reject a retired SsaId before accepting replacement commitments"
3687        );
3688
3689        Ok(())
3690    }
3691
3692    #[test]
3693    fn a_disarmed_commitment_guard_leaves_its_ssa_alone() -> anyhow::Result<()> {
3694        let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
3695        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3696
3697        let (_commitment, guard) = reconstructor.new_guarded_exit_commitment(ssa_id, test_params(2, 2))?;
3698        assert_eq!(Some(ssa_id), guard.disarm());
3699        assert!(
3700            reconstructor.contains_builder(&ssa_id),
3701            "a disarmed guard must not retire the commitment it handed on"
3702        );
3703
3704        Ok(())
3705    }
3706
3707    /// Abandoning a request must leave its SSA index usable, because that index is what the next
3708    /// attempt will use — it is advanced only once every fallible step has succeeded.
3709    ///
3710    /// Releasing through the full retirement path instead advances the Session frontier and makes
3711    /// the retry fail as a duplicate before the peer can be asked to fund it.
3712    #[test]
3713    fn an_ssa_index_stays_usable_after_its_request_is_abandoned() -> anyhow::Result<()> {
3714        const POLYS: u16 = 2;
3715        const THRESHOLD: u8 = 2;
3716        let pseudonym = SimplePseudonym::random();
3717        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3718
3719        let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
3720
3721        // First attempt is abandoned before the peer is ever asked for commitments.
3722        let (_commitment, guard) = reconstructor.new_guarded_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
3723        drop(guard);
3724
3725        // Retry at the same index, which is what a failed request leaves behind.
3726        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3727            polynomials_per_ssa: POLYS,
3728            threshold: THRESHOLD,
3729            surplus_shares: 0,
3730        });
3731        reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
3732        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3733        let mut final_state = None;
3734        for (coeff_idx, coeffs) in commitment.verifiers.clone() {
3735            let proof = (coeff_idx == crate::CONSTANT_TERM_COEFFICIENT).then_some(commitment.commitment_proof);
3736            final_state =
3737                Some(reconstructor.insert_coefficient_commitments(ssa_id, coeff_idx, proof, coeffs.into_iter())?);
3738        }
3739        let state = final_state.ok_or(anyhow::anyhow!("commitment carried no batches"))?;
3740
3741        assert!(
3742            state.is_verifiable,
3743            "the retry must become verifiable — otherwise the peer is asked to fund a cycle that cannot reconstruct"
3744        );
3745        assert!(state.ssa_deposit_address.is_some(), "a verifiable cycle has an address");
3746        assert!(
3747            reconstructor.cycle(&ssa_id).is_some(),
3748            "the retry's cycle must be live, not published and then withdrawn"
3749        );
3750
3751        Ok(())
3752    }
3753
3754    #[test]
3755    fn a_rejected_duplicate_registration_does_not_retire_the_original() -> anyhow::Result<()> {
3756        // The failure path must not produce a guard: if it did, dropping the error's guard would
3757        // retire the very registration whose presence caused the rejection — turning a harmless
3758        // duplicate request into the loss of a live cycle.
3759        let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
3760        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3761
3762        let (_commitment, guard) = reconstructor.new_guarded_exit_commitment(ssa_id, test_params(2, 2))?;
3763
3764        assert!(
3765            matches!(
3766                reconstructor.new_guarded_exit_commitment(ssa_id, test_params(2, 2)),
3767                Err(PixError::DuplicateCommitment)
3768            ),
3769            "a second registration at the same index must be rejected"
3770        );
3771        assert!(
3772            reconstructor.contains_builder(&ssa_id),
3773            "the rejected duplicate must leave the original registration intact"
3774        );
3775
3776        drop(guard);
3777        Ok(())
3778    }
3779
3780    #[test]
3781    fn an_expired_registration_guard_does_not_release_its_same_index_retry() -> anyhow::Result<()> {
3782        let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
3783        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3784
3785        let (_commitment, stale_guard) = reconstructor.new_guarded_exit_commitment(ssa_id, test_params(2, 2))?;
3786        // Model the commitment cache's independent TTL expiry while its owner is still alive.
3787        reconstructor.commitment_builder.invalidate(&ssa_id);
3788
3789        let (_commitment, retry_guard) = reconstructor.new_guarded_exit_commitment(ssa_id, test_params(2, 2))?;
3790        drop(stale_guard);
3791
3792        assert!(
3793            reconstructor.contains_builder(&ssa_id),
3794            "a stale guard must not remove the replacement registration at the same index"
3795        );
3796
3797        drop(retry_guard);
3798        Ok(())
3799    }
3800
3801    #[test]
3802    fn a_live_cycle_stays_duplicate_after_its_commitment_registration_expires() -> anyhow::Result<()> {
3803        let pseudonym = SimplePseudonym::random();
3804        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3805        let params = test_params(2, 2);
3806        let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
3807        let (_commitment, guard) = reconstructor.new_guarded_exit_commitment(ssa_id, params)?;
3808
3809        SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3810            polynomials_per_ssa: 2,
3811            threshold: 2,
3812            surplus_shares: 0,
3813        })
3814        .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
3815        .process_into_reconstructor(reconstructor.as_ref())?;
3816        reconstructor.commitment_builder.invalidate(&ssa_id);
3817
3818        assert!(
3819            matches!(
3820                reconstructor.new_guarded_exit_commitment(ssa_id, params),
3821                Err(PixError::DuplicateCommitment)
3822            ),
3823            "a live cycle must continue occupying its index after the commitment cache entry expires"
3824        );
3825
3826        drop(guard);
3827        Ok(())
3828    }
3829
3830    /// **L2 regression.** A polynomial index repeated inside one batch must not consume its slot
3831    /// twice, and a repeat carrying a *different* commitment must be refused.
3832    ///
3833    /// The two-phase check tested each entry against `committed_polynomials`, which holds only what
3834    /// earlier calls inserted, so two entries sharing an index both found the slot vacant. The
3835    /// second insert then rebound the first — the single-assignment invariant the two phases exist
3836    /// to enforce — and `total_committed` counted two occupants of one slot. The practical bite:
3837    /// a batch carrying every polynomial with a repeat among them could never complete the set, and
3838    /// the peer had no way to supply the one it displaced.
3839    ///
3840    /// Both halves are checked here because they now take different paths: an identical repeat is
3841    /// *absorbed*, since retransmission legitimately produces one, while a conflicting one is still
3842    /// an error. Either way the slot is occupied exactly once, which is the invariant.
3843    ///
3844    /// The wire decoder rejects intra-message duplicates today. This builder is not meant to depend
3845    /// on that, which is why the check is here.
3846    #[test]
3847    fn a_polynomial_repeated_within_one_batch_occupies_its_slot_once() -> anyhow::Result<()> {
3848        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3849            polynomials_per_ssa: 2,
3850            threshold: 2,
3851            surplus_shares: 0,
3852        });
3853        let pseudonym = SimplePseudonym::random();
3854        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3855
3856        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3857        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3858        reconstructor.new_exit_commitment(ssa_id, test_params(2, 2))?;
3859
3860        // Polynomial 0 twice with conflicting commitments: a contradiction, refused outright.
3861        let mut conflicting = coefficient_of(&commitment, 0, Some(0))?;
3862        conflicting.push((conflicting[0].0, PixGroup::<TestSpec>::generator().to_bytes()));
3863        let result =
3864            reconstructor.insert_coefficient_commitments(ssa_id, 0, proof_of(&commitment, 0), conflicting.into_iter());
3865        assert!(
3866            matches!(&result, Err(crate::errors::PixError::DuplicateCommitment)),
3867            "a conflicting repeat inside the batch must be rejected, got {result:?}"
3868        );
3869
3870        // Rejected transactionally: neither entry was written, so polynomial 0 is still vacant. Now
3871        // the same index twice with the *same* commitment — accepted, occupying one slot, which is
3872        // the shape that would otherwise leave the set permanently one short while reporting two
3873        // commitments received.
3874        let mut repeated = coefficient_of(&commitment, 0, Some(0))?;
3875        repeated.push(repeated[0]);
3876        let partial =
3877            reconstructor.insert_coefficient_commitments(ssa_id, 0, proof_of(&commitment, 0), repeated.into_iter())?;
3878        assert!(
3879            !partial.is_verifiable,
3880            "two entries for one polynomial must not stand in for the other one"
3881        );
3882
3883        // ...and the set completes on the polynomial the repeat did not cover.
3884        let retry = reconstructor.insert_coefficient_commitments(
3885            ssa_id,
3886            0,
3887            proof_of(&commitment, 0),
3888            coefficient_of(&commitment, 0, Some(1))?.into_iter(),
3889        )?;
3890        assert!(
3891            retry.is_verifiable && retry.ssa_deposit_address.is_some(),
3892            "the remaining polynomial must complete the commitment"
3893        );
3894
3895        Ok(())
3896    }
3897
3898    #[test]
3899    fn malformed_commitment_does_not_poison_corrected_retransmission() -> anyhow::Result<()> {
3900        // Regression test for M2: a malformed coefficient that fails EC point
3901        // decoding must NOT leave the commitment builder permanently poisoned.
3902        // After submitting malformed bytes, a retry with correct bytes must
3903        // succeed and complete the SSA.
3904        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3905            polynomials_per_ssa: 2,
3906            threshold: 2,
3907            surplus_shares: 0,
3908        });
3909        let pseudonym = SimplePseudonym::random();
3910        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3911
3912        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3913
3914        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3915        reconstructor.new_exit_commitment(ssa_id, test_params(2, 2))?;
3916
3917        // Step 1: Submit polynomial 0's constant term — must succeed, but the SSA commitment needs
3918        // every constant term, so no deposit address yet.
3919        let partial = reconstructor.insert_coefficient_commitments(
3920            ssa_id,
3921            0,
3922            proof_of(&commitment, 0),
3923            coefficient_of(&commitment, 0, Some(0))?.into_iter(),
3924        )?;
3925        assert!(
3926            partial.ssa_deposit_address.is_none(),
3927            "the deposit address needs every constant term"
3928        );
3929        assert!(!partial.is_verifiable, "not yet complete");
3930
3931        // Step 2: Submit a malformed constant term for polynomial 1 (bytes with an invalid EC
3932        // compressed-point prefix of 0xff) — must return InvalidInput.
3933        let mut malformed = PixGroupRepr::<TestSpec>::default(); // zero-filled
3934        AsMut::<[u8]>::as_mut(&mut malformed).fill(0xff);
3935        let malformed_result = reconstructor.insert_coefficient_commitments(
3936            ssa_id,
3937            0,
3938            proof_of(&commitment, 0),
3939            [(1, malformed)].into_iter(),
3940        );
3941        assert!(
3942            matches!(&malformed_result, Err(crate::errors::PixError::InvalidInput)),
3943            "malformed commitment must be rejected, got {malformed_result:?}"
3944        );
3945
3946        // Step 3: Retry with the correct bytes — must succeed and complete the SSA commitment.
3947        let retry = reconstructor.insert_coefficient_commitments(
3948            ssa_id,
3949            0,
3950            proof_of(&commitment, 0),
3951            coefficient_of(&commitment, 0, Some(1))?.into_iter(),
3952        );
3953        assert!(
3954            matches!(&retry, Ok(state) if state.is_verifiable && state.ssa_deposit_address.is_some()),
3955            "corrected retransmission must complete the SSA, got {retry:?}"
3956        );
3957
3958        Ok(())
3959    }
3960
3961    /// Regression test for M13: the validation applied when a commitment *arrives* is the only
3962    /// validation it ever gets, so it must include the prime-order-subgroup test.
3963    ///
3964    /// A commitment that passes occupies its polynomial's slot permanently — re-insertion is
3965    /// rejected as a duplicate. A decodable-but-small-order point admitted here would take the
3966    /// slot and then make every reconstruction of that polynomial fail, with no way to retransmit
3967    /// a correction. This matters in practice: the default build uses BabyJubJub, whose cofactor is
3968    /// 8, so small-order points do exist and do pass a plain on-curve check.
3969    ///
3970    /// What this test covers is the *arrival point*, not the subgroup filter itself: `TestSpec` is
3971    /// secp256k1, cofactor 1, so no small-order point exists to feed it. The subgroup case is
3972    /// `pix_group_element_rejects_a_small_order_point` in `hopr-crypto-packet`, which also records
3973    /// why the filter cannot be isolated by any test — the Baby JubJub backend's own `from_bytes`
3974    /// already rejects, so `is_torsion_free` is defence in depth rather than the acting check.
3975    #[test]
3976    fn decode_commitment_is_the_single_validation_point() {
3977        use vsss_rs::elliptic_curve::group::GroupEncoding;
3978
3979        use crate::SsaPartCommitment;
3980
3981        // A well-formed commitment (the generator) decodes.
3982        let generator_repr = PixGroup::<TestSpec>::generator().to_bytes();
3983        assert!(
3984            SsaPartCommitment::<TestSpec>::decode_commitment(&generator_repr).is_ok(),
3985            "the generator is a valid constant-term commitment (scalar coefficient 1)"
3986        );
3987
3988        // Undecodable bytes are rejected.
3989        let mut malformed = PixGroupRepr::<TestSpec>::default();
3990        AsMut::<[u8]>::as_mut(&mut malformed).fill(0xff);
3991        assert!(
3992            matches!(
3993                SsaPartCommitment::<TestSpec>::decode_commitment(&malformed),
3994                Err(PixError::InvalidInput)
3995            ),
3996            "undecodable bytes must be rejected"
3997        );
3998    }
3999
4000    /// A reconstructed polynomial part must release its collected shares, while **keeping its
4001    /// cache entry**.
4002    ///
4003    /// That buffer dominates reconstructor memory — `threshold` shares held for every one of
4004    /// `polys` polynomials — and it cannot be read again once the part is reconstructed.
4005    ///
4006    /// Retaining the (now-stripped) cache entry is equally deliberate: evicting it would make every
4007    /// late or surplus share for that polynomial look like a not-yet-installed verifier, so the ack
4008    /// would be deferred into a bucket that nothing will ever drain. The stripped builder keeps the
4009    /// cheap already-reconstructed path instead.
4010    #[test]
4011    fn reconstructed_polynomial_releases_verification_state_but_keeps_its_entry() -> anyhow::Result<()> {
4012        // 2 polynomials, threshold 2, no surplus: finishing polynomial 0 does *not* complete the
4013        // SSA, so the cycle is still live and its per-polynomial state is observable.
4014        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4015            polynomials_per_ssa: 2,
4016            threshold: 2,
4017            surplus_shares: 0,
4018        });
4019        let pseudonym = SimplePseudonym::random();
4020        let peer = OffchainKeypair::random();
4021        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4022        let poly_0 = SsaPolynomialId::new(ssa_id, 0);
4023
4024        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4025        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
4026        reconstructor.new_exit_commitment(ssa_id, test_params(2, 2))?;
4027        commitment.process_into_reconstructor(&reconstructor)?;
4028
4029        // Freshly installed: no shares collected yet.
4030        let cycle = reconstructor
4031            .cycle(&ssa_id)
4032            .ok_or_else(|| anyhow::anyhow!("cycle must be live"))?;
4033        assert_eq!(
4034            0,
4035            cycle
4036                .part(poly_0.poly_index())
4037                .ok_or_else(|| anyhow::anyhow!("part builder for polynomial 0 must be installed"))?
4038                .lock()
4039                .verification_state_len()
4040        );
4041        drop(cycle);
4042
4043        // Feed exactly enough shares to reconstruct polynomial 0, skipping the ones the round-robin
4044        // emission hands out for polynomial 1 in between.
4045        for _ in 0..2 {
4046            let (msg, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
4047
4048            let ack = HalfKey::random();
4049            let challenge = ack.to_challenge()?;
4050            let enc = share.share.encrypt(&share.id, &ack)?;
4051            reconstructor.insert_encrypted_share(
4052                peer.public(),
4053                challenge,
4054                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
4055            )?;
4056            reconstructor.acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?;
4057        }
4058
4059        // The SSA as a whole is not recovered (polynomial 1 is untouched), so the cycle — and
4060        // polynomial 0's cache entry — must still be there.
4061        assert!(
4062            reconstructor.contains_builder(&ssa_id),
4063            "the cycle must still be live while polynomial 1 is outstanding"
4064        );
4065        let cycle = reconstructor
4066            .cycle(&ssa_id)
4067            .ok_or_else(|| anyhow::anyhow!("a reconstructed polynomial must keep its slot"))?;
4068        assert_eq!(
4069            0,
4070            cycle
4071                .part(poly_0.poly_index())
4072                .ok_or_else(|| anyhow::anyhow!("polynomial 0 must keep its slot"))?
4073                .lock()
4074                .verification_state_len(),
4075            "a reconstructed polynomial must hold no shares"
4076        );
4077
4078        // Polynomial 1 is untouched and must still be installed, awaiting its own shares.
4079        assert!(
4080            cycle.part(1).is_some(),
4081            "part builder for polynomial 1 must be installed"
4082        );
4083
4084        Ok(())
4085    }
4086
4087    /// A failed interpolation must leave the part terminal, exactly as a failed commitment opening
4088    /// does.
4089    ///
4090    /// The two paths were asymmetric: the commitment mismatch released the share buffer and set
4091    /// `failed`, while a `combine()` error propagated with `?` and did neither. That left the part
4092    /// holding a full share set with no terminal flag, so neither early return in `add_share`
4093    /// fired and every remaining share for the polynomial was pushed and re-ran the interpolation
4094    /// over a larger set — `O(threshold²)` per share, against a buffer that should already have
4095    /// been released, re-reporting the same fault each time.
4096    ///
4097    /// The interpolation is forced to fail here by giving the builder a one-share threshold, which
4098    /// `vsss_rs` rejects outright. How the failure arises is not the property under test; that the
4099    /// part is left terminal either way is.
4100    #[test]
4101    fn a_failed_interpolation_leaves_the_part_terminal() -> anyhow::Result<()> {
4102        use utils::{AddShareOutcome, SsaPartBuilder};
4103
4104        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4105            polynomials_per_ssa: 1,
4106            threshold: 2,
4107            surplus_shares: 2,
4108        });
4109        let pseudonym = SimplePseudonym::random();
4110        let spi = SsaPolynomialId::new(SsaId::new(pseudonym, SsaIndex::MIN), 0);
4111        generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4112
4113        // The commitment is never reached — `verify_reconstructed` runs only on a value that
4114        // interpolated — so the generator is a stand-in for any well-formed constant term.
4115        let mut part = SsaPartBuilder::<TestSpec>::new(
4116            crate::SsaPartCommitment::from_decoded_commitment(spi, PixGroup::<TestSpec>::generator()),
4117            1,
4118            0,
4119        );
4120
4121        let mut rng = hopr_types::crypto_random::rng();
4122        let (_, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
4123        assert!(
4124            part.add_share(PixScalar::<TestSpec>::random(&mut rng), share.share)
4125                .is_err(),
4126            "one share is below what `combine` accepts, so the interpolation must fail"
4127        );
4128        assert_eq!(
4129            0,
4130            part.verification_state_len(),
4131            "a part that can never reconstruct must release its share buffer"
4132        );
4133
4134        // Every later share is absorbed instead of re-running the interpolation and re-reporting.
4135        let (_, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
4136        assert!(
4137            matches!(
4138                part.add_share(PixScalar::<TestSpec>::random(&mut rng), share.share)?,
4139                AddShareOutcome::Absorbed
4140            ),
4141            "a failed part must absorb later shares silently"
4142        );
4143        assert_eq!(0, part.verification_state_len());
4144
4145        Ok(())
4146    }
4147
4148    #[test]
4149    fn full_recovery_retires_all_reconstructor_state() -> anyhow::Result<()> {
4150        // 4 polynomials, threshold 4, no surplus → 16 shares, fully recoverable.
4151        // Multiple polynomials on purpose: this exercises the whole `0..num_polys`
4152        // cleanup loop, so a wrong-key or off-by-one in `remove_cycle` would surface.
4153        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4154            polynomials_per_ssa: 4,
4155            threshold: 4,
4156            surplus_shares: 0,
4157        });
4158        let pseudonym = SimplePseudonym::random();
4159        let peer = OffchainKeypair::random();
4160        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4161
4162        let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4163        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
4164        reconstructor.new_exit_commitment(ssa_id, test_params(4, 4))?;
4165        commitment_msg.process_into_reconstructor(&reconstructor)?;
4166
4167        // Precondition: the completed cycle holds every part builder and its accumulator.
4168        assert_eq!(
4169            4,
4170            reconstructor.installed_parts(&ssa_id),
4171            "4 part builders present after completion"
4172        );
4173        assert_eq!(1, reconstructor.live_cycles(), "the cycle is live after completion");
4174
4175        // Drive full recovery: generate every share, insert it encrypted, acknowledge.
4176        let mut acks = Vec::new();
4177        while let Some((msg, share)) = {
4178            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
4179            generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
4180        }? {
4181            let ack = HalfKey::random();
4182            let ack_challenge = ack.to_challenge()?;
4183            let enc_share = share.share.encrypt(&share.id, &ack)?;
4184            reconstructor.insert_encrypted_share(
4185                peer.public(),
4186                ack_challenge,
4187                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
4188            )?;
4189            acks.push(VerifiedAcknowledgement::new(ack, &peer).leak());
4190        }
4191        let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
4192        assert!(
4193            resolutions
4194                .iter()
4195                .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id)),
4196            "cycle must fully recover"
4197        );
4198
4199        // Behaviour under test: full recovery must retire ALL of the cycle's
4200        // reconstructor state, rather than leave it to linger until the idle TTL.
4201        reconstructor.commitment_builder.run_pending_tasks();
4202        assert_eq!(
4203            0,
4204            reconstructor.live_cycles(),
4205            "the cycle must be retired on full recovery"
4206        );
4207        assert!(
4208            !reconstructor.commitment_builder.contains_key(&ssa_id),
4209            "commitment builder must be retired on full recovery"
4210        );
4211
4212        Ok(())
4213    }
4214
4215    #[test]
4216    fn retire_ssa_removes_cycle_state_and_is_idempotent() -> anyhow::Result<()> {
4217        // 3 polynomials so the cleanup loop is again exercised over several keys.
4218        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4219            polynomials_per_ssa: 3,
4220            threshold: 4,
4221            surplus_shares: 0,
4222        });
4223        let pseudonym = SimplePseudonym::random();
4224        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4225
4226        let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4227        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
4228        reconstructor.new_exit_commitment(ssa_id, test_params(3, 4))?;
4229        commitment_msg.process_into_reconstructor(&reconstructor)?;
4230
4231        assert_eq!(
4232            3,
4233            reconstructor.installed_parts(&ssa_id),
4234            "3 part builders present after completion"
4235        );
4236
4237        // Explicit retirement (as invoked on session teardown) drops everything.
4238        reconstructor.retire_ssa(ssa_id);
4239        reconstructor.commitment_builder.run_pending_tasks();
4240        assert_eq!(
4241            0,
4242            reconstructor.live_cycles(),
4243            "the cycle must be removed by retire_ssa"
4244        );
4245        assert!(!reconstructor.commitment_builder.contains_key(&ssa_id));
4246
4247        // Idempotent: retiring the same (now-empty) cycle again is a harmless no-op.
4248        reconstructor.retire_ssa(ssa_id);
4249
4250        // Retiring a cycle that was never created must not panic and must leave the caches
4251        // untouched.
4252        let never_seen = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
4253        reconstructor.retire_ssa(never_seen);
4254        assert_eq!(0, reconstructor.live_cycles());
4255
4256        Ok(())
4257    }
4258
4259    #[test]
4260    fn retiring_a_later_cycle_does_not_block_an_earlier_registered_cycle() -> anyhow::Result<()> {
4261        let pseudonym = SimplePseudonym::random();
4262        let first_index = SsaIndex::new(1).context("one is a non-zero SSA index")?;
4263        let second_index = SsaIndex::new(2).context("two is a non-zero SSA index")?;
4264        let first = SsaId::new(pseudonym, first_index);
4265        let second = SsaId::new(pseudonym, second_index);
4266        let params = test_params(2, 2);
4267        let reconstructor = SsaReconstructor::<TestSpec>::default();
4268
4269        reconstructor.new_exit_commitment(first, params)?;
4270        reconstructor.new_exit_commitment(second, params)?;
4271        reconstructor.retire_ssa(second);
4272
4273        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4274            polynomials_per_ssa: 2,
4275            threshold: 2,
4276            surplus_shares: 0,
4277        });
4278        generator
4279            .new_ssa_commitment(&pseudonym, first_index)?
4280            .process_into_reconstructor(&reconstructor)?;
4281
4282        assert!(
4283            reconstructor.cycle(&first).is_some(),
4284            "the registered lower index must win over the higher retirement watermark"
4285        );
4286        assert!(
4287            matches!(
4288                reconstructor.new_exit_commitment(second, params),
4289                Err(PixError::DuplicateCommitment)
4290            ),
4291            "the exact retired index must remain spent"
4292        );
4293
4294        Ok(())
4295    }
4296
4297    /// Deferred acknowledgements are bucketed by the cycle they are waiting for, sub-bucketed by
4298    /// polynomial, and never by peer.
4299    ///
4300    /// All three halves matter. Keying by cycle is what lets a bucket be drained by exactly one
4301    /// event (its own cycle installing) instead of being rescanned speculatively. The
4302    /// per-polynomial sub-bucket is what the cap is expressed against. And a sub-bucket
4303    /// deliberately holding several peers' acks is not an accident: one polynomial's shares are
4304    /// spread across return paths, hence across first-relayers, so the peer has to be carried per
4305    /// entry rather than being the key.
4306    #[test]
4307    fn deferred_acks_are_bucketed_by_cycle_and_polynomial_across_peers() -> anyhow::Result<()> {
4308        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4309
4310        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
4311        let other_ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
4312        let spi_0 = SsaPolynomialId::new(ssa_id, 0);
4313        let spi_1 = SsaPolynomialId::new(ssa_id, 1);
4314        let spi_other = SsaPolynomialId::new(other_ssa_id, 0);
4315
4316        let peer_a = OffchainKeypair::random();
4317        let peer_b = OffchainKeypair::random();
4318        let ack_a = HalfKey::random();
4319        let ack_b = HalfKey::random();
4320        let ack_other_poly = HalfKey::random();
4321        let ack_other_cycle = HalfKey::random();
4322
4323        // Two peers defer for the same polynomial; a third ack belongs to another polynomial of the
4324        // same cycle; a fourth to a different cycle entirely.
4325        reconstructor.defer_ack(spi_0, (*peer_a.public(), ack_a.to_challenge()?, ack_a));
4326        reconstructor.defer_ack(spi_0, (*peer_b.public(), ack_b.to_challenge()?, ack_b));
4327        reconstructor.defer_ack(
4328            spi_1,
4329            (*peer_a.public(), ack_other_poly.to_challenge()?, ack_other_poly),
4330        );
4331        reconstructor.defer_ack(
4332            spi_other,
4333            (*peer_a.public(), ack_other_cycle.to_challenge()?, ack_other_cycle),
4334        );
4335
4336        let bucket = reconstructor
4337            .pending_acks
4338            .get(&ssa_id)
4339            .ok_or_else(|| anyhow::anyhow!("missing bucket for the cycle"))?;
4340        {
4341            let bucket = bucket.lock();
4342            assert_eq!(
4343                2,
4344                bucket.by_poly.get(&0).map(Vec::len).unwrap_or(0),
4345                "one sub-bucket holds both peers' acks"
4346            );
4347            assert_eq!(
4348                1,
4349                bucket.by_poly.get(&1).map(Vec::len).unwrap_or(0),
4350                "a different polynomial keeps its own sub-bucket"
4351            );
4352        }
4353        assert_eq!(3, reconstructor.deferred_ack_count(&ssa_id));
4354        assert_eq!(1, reconstructor.deferred_ack_count(&other_ssa_id));
4355
4356        // Draining one cycle's bucket must not touch another's. No share exists in `awaiting_acks`,
4357        // so nothing is redeemed — the point is the bucket bookkeeping.
4358        reconstructor.drain_deferred_acks(&ssa_id);
4359        assert!(
4360            !reconstructor.pending_acks.contains_key(&ssa_id),
4361            "a drained bucket is removed, with all of its polynomials"
4362        );
4363        assert!(
4364            reconstructor.pending_acks.contains_key(&other_ssa_id),
4365            "draining one cycle must not disturb another"
4366        );
4367
4368        Ok(())
4369    }
4370
4371    /// Every polynomial becomes reconstructible on the call that completes the constant-term set,
4372    /// and none before it.
4373    ///
4374    /// A polynomial's whole commitment *is* its constant term, so there is no partially committed
4375    /// row to wait on; but a part builder is still useless until the [`SsaBuilder`] exists to
4376    /// receive what it reconstructs, and that needs the constant terms of *all* polynomials.
4377    /// The two therefore coincide, and this pins that they do.
4378    #[test]
4379    fn verifiers_are_installed_when_the_constant_term_set_completes() -> anyhow::Result<()> {
4380        const POLYS: u16 = 6;
4381        const THRESHOLD: u8 = 4;
4382
4383        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4384            polynomials_per_ssa: POLYS,
4385            threshold: THRESHOLD,
4386            surplus_shares: 0,
4387        });
4388        let pseudonym = SimplePseudonym::random();
4389        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4390        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4391
4392        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4393        reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
4394
4395        // One polynomial's constant term per call. Nothing is published until the last one.
4396        for poly in 0..POLYS as PolynomialIndex {
4397            let state = reconstructor.insert_coefficient_commitments(
4398                ssa_id,
4399                0,
4400                proof_of(&commitment, 0),
4401                coefficient_of(&commitment, 0, Some(poly))?.into_iter(),
4402            )?;
4403
4404            let last = poly == POLYS as PolynomialIndex - 1;
4405            assert_eq!(
4406                state.ssa_deposit_address.is_some(),
4407                last,
4408                "the deposit address is the sum of every constant term (poly {poly})"
4409            );
4410            assert_eq!(
4411                state.is_verifiable, last,
4412                "the cycle becomes verifiable exactly when the constant-term set closes (poly {poly})"
4413            );
4414
4415            let expected = if last { POLYS as usize } else { 0 };
4416            assert_eq!(
4417                expected,
4418                reconstructor.installed_parts(&ssa_id),
4419                "after polynomial {poly} there must be {expected} part builders installed"
4420            );
4421        }
4422
4423        Ok(())
4424    }
4425
4426    /// An Entry that still emits the full Feldman matrix must not break the cycle: the Exit ignores
4427    /// every non-constant coefficient, whether it arrives before, during or after the constant-term
4428    /// pass, and recovery proceeds unaffected.
4429    #[test]
4430    fn non_constant_coefficients_are_ignored_wherever_they_arrive() -> anyhow::Result<()> {
4431        const POLYS: u16 = 2;
4432        const THRESHOLD: u8 = 2;
4433
4434        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4435            polynomials_per_ssa: POLYS,
4436            threshold: THRESHOLD,
4437            surplus_shares: 0,
4438        });
4439        let pseudonym = SimplePseudonym::random();
4440        let peer = OffchainKeypair::random();
4441        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4442        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4443
4444        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
4445            early_recovery_threshold: 1.0,
4446            ..Default::default()
4447        });
4448        reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
4449
4450        // Stand-in for what a Feldman-emitting Entry would send: a well-formed commitment under a
4451        // non-constant coefficient index, for every polynomial.
4452        let higher: Vec<(PolynomialIndex, PixGroupRepr<TestSpec>)> = (0..POLYS as PolynomialIndex)
4453            .map(|poly| {
4454                (
4455                    poly,
4456                    PixGroup::<TestSpec>::mul_by_generator(&PixScalar::<TestSpec>::random(
4457                        &mut hopr_types::crypto_random::rng(),
4458                    ))
4459                    .to_bytes(),
4460                )
4461            })
4462            .collect();
4463
4464        // Before the constant-term pass.
4465        reconstructor.insert_coefficient_commitments(ssa_id, 1, None, higher.clone().into_iter())?;
4466
4467        // Interleaved with it.
4468        reconstructor.insert_coefficient_commitments(
4469            ssa_id,
4470            0,
4471            proof_of(&commitment, 0),
4472            coefficient_of(&commitment, 0, Some(0))?.into_iter(),
4473        )?;
4474        reconstructor.insert_coefficient_commitments(ssa_id, 1, None, higher.clone().into_iter())?;
4475        let state = reconstructor.insert_coefficient_commitments(
4476            ssa_id,
4477            0,
4478            proof_of(&commitment, 0),
4479            coefficient_of(&commitment, 0, Some(1))?.into_iter(),
4480        )?;
4481        assert!(state.is_verifiable, "the constant-term pass alone completes the cycle");
4482
4483        // And after it — the bulk of what such an Entry sends. Must not read as a duplicate.
4484        reconstructor.insert_coefficient_commitments(ssa_id, 1, None, higher.into_iter())?;
4485
4486        // Recovery is unaffected.
4487        let mut acks = Vec::new();
4488        while let Some((msg, share)) = {
4489            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
4490            generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
4491        }? {
4492            let ack = HalfKey::random();
4493            let enc_share = share.share.encrypt(&share.id, &ack)?;
4494            reconstructor.insert_encrypted_share(
4495                peer.public(),
4496                ack.to_challenge()?,
4497                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
4498            )?;
4499            acks.push(VerifiedAcknowledgement::new(ack, &peer).leak());
4500        }
4501
4502        let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
4503        assert!(
4504            resolutions
4505                .iter()
4506                .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id)),
4507            "the SSA must recover from the constant terms alone, got {resolutions:?}"
4508        );
4509
4510        Ok(())
4511    }
4512
4513    /// A single corrupted share is invisible until the polynomial is interpolated — that is the
4514    /// price of dropping the per-coefficient commitments — and it must surface exactly then, once,
4515    /// with no further noise from the shares that follow it.
4516    #[test]
4517    fn a_corrupted_share_is_reported_once_at_the_threshold_th_share() -> anyhow::Result<()> {
4518        const POLYS: u16 = 2;
4519        const THRESHOLD: u8 = 4;
4520        const SURPLUS: u8 = 2;
4521
4522        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4523            polynomials_per_ssa: POLYS,
4524            threshold: THRESHOLD,
4525            surplus_shares: SURPLUS,
4526        });
4527        let pseudonym = SimplePseudonym::random();
4528        let peer = OffchainKeypair::random();
4529        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4530
4531        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4532        reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
4533        generator
4534            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
4535            .process_into_reconstructor(&reconstructor)?;
4536
4537        // Corrupt the very first share of polynomial 0, then feed its whole budget one at a time.
4538        let mut invalid_reports: Vec<u64> = Vec::new();
4539        for i in 0..THRESHOLD as usize + SURPLUS as usize {
4540            let (msg, mut share) = next_share_for_poly(&generator, &pseudonym, 0)?;
4541
4542            if i == 0 {
4543                // Flip a low bit of the share value. It stays a valid field element, so nothing
4544                // rejects it on arrival — only the interpolation notices.
4545                AsMut::<[u8]>::as_mut(&mut share.share.0)[31] ^= 1;
4546            }
4547
4548            let ack = HalfKey::random();
4549            let enc = share.share.clone().encrypt(&share.id, &ack)?;
4550            reconstructor.insert_encrypted_share(
4551                peer.public(),
4552                ack.to_challenge()?,
4553                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
4554            )?;
4555            let resolutions = reconstructor
4556                .acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?;
4557
4558            let reported = resolutions
4559                .iter()
4560                .filter_map(|r| match r {
4561                    ShareResolution::InvalidShares {
4562                        ssa_id: id,
4563                        observed_total,
4564                        ..
4565                    } if *id == ssa_id => Some(*observed_total),
4566                    _ => None,
4567                })
4568                .collect::<Vec<_>>();
4569            if i + 1 < THRESHOLD as usize {
4570                assert!(
4571                    reported.is_empty(),
4572                    "share {i} must pass unremarked — nothing checks it yet"
4573                );
4574            }
4575            invalid_reports.extend(reported);
4576        }
4577
4578        assert_eq!(
4579            vec![1],
4580            invalid_reports,
4581            "the corrupted set must be reported exactly once, at the threshold-th share, as the cycle's first fault"
4582        );
4583
4584        Ok(())
4585    }
4586
4587    /// The fault total is a property of the cycle, not of whoever relayed the offending share.
4588    ///
4589    /// A Session's shares reach the Exit through whichever relayer the return path happens to use,
4590    /// so a per-peer total would let an Entry stay under any limit by spreading its bad shares —
4591    /// and would make the number the consumer enforces on depend on routing rather than on conduct.
4592    #[test]
4593    fn fault_totals_aggregate_over_the_cycle_not_the_peer() -> anyhow::Result<()> {
4594        const POLYS: u16 = 2;
4595        const THRESHOLD: u8 = 2;
4596
4597        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4598            polynomials_per_ssa: POLYS,
4599            threshold: THRESHOLD,
4600            surplus_shares: 0,
4601        });
4602        let pseudonym = SimplePseudonym::random();
4603        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4604        // One relayer per polynomial, which is what shares arriving over different return paths
4605        // looks like from here.
4606        let relayers = [OffchainKeypair::random(), OffchainKeypair::random()];
4607
4608        let reconstructor = SsaReconstructor::<TestSpec>::default();
4609        reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
4610        generator
4611            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
4612            .process_into_reconstructor(&reconstructor)?;
4613
4614        // Emission is round-robin across the window, and with no surplus every share matters — so
4615        // the cycle's whole budget is drawn first and grouped, rather than filtered as it comes.
4616        let mut by_poly: std::collections::BTreeMap<PolynomialIndex, Vec<_>> = Default::default();
4617        for _ in 0..POLYS as usize * THRESHOLD as usize {
4618            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
4619            let share = generator
4620                .next_share(&pseudonym, &msg)?
4621                .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
4622            by_poly.entry(share.id.poly_index()).or_default().push((msg, share));
4623        }
4624
4625        let mut totals = Vec::new();
4626        for poly in 0..POLYS {
4627            let relayer = &relayers[poly as usize];
4628            let shares = by_poly
4629                .remove(&poly)
4630                .ok_or_else(|| anyhow::anyhow!("no shares for polynomial {poly}"))?;
4631            assert_eq!(THRESHOLD as usize, shares.len());
4632
4633            for (i, (msg, mut share)) in shares.into_iter().enumerate() {
4634                // Corrupt one share of each polynomial, so each one fails on its own threshold-th.
4635                if i == 0 {
4636                    AsMut::<[u8]>::as_mut(&mut share.share.0)[31] ^= 1;
4637                }
4638
4639                let ack = HalfKey::random();
4640                let enc = share.share.clone().encrypt(&share.id, &ack)?;
4641                reconstructor.insert_encrypted_share(
4642                    relayer.public(),
4643                    ack.to_challenge()?,
4644                    TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
4645                )?;
4646                totals.extend(
4647                    reconstructor
4648                        .acknowledge_shares(
4649                            *relayer.public(),
4650                            vec![VerifiedAcknowledgement::new(ack, relayer).leak()],
4651                        )?
4652                        .into_iter()
4653                        .filter_map(|r| match r {
4654                            ShareResolution::InvalidShares { observed_total, .. } => Some(observed_total),
4655                            _ => None,
4656                        }),
4657                );
4658            }
4659        }
4660
4661        assert_eq!(
4662            vec![1, 2],
4663            totals,
4664            "the second relayer's fault must be charged to the cycle's running total, not restart it"
4665        );
4666        assert_eq!(
4667            2,
4668            reconstructor
4669                .cycle(&ssa_id)
4670                .ok_or(anyhow::anyhow!("cycle went away"))?
4671                .invalid_shares(),
4672            "the cycle must hold the aggregate"
4673        );
4674
4675        Ok(())
4676    }
4677
4678    /// End-to-end deferral: shares that arrive before their polynomial's row is committed must be
4679    /// redeemed when the verifier installs, and the resulting resolutions must reach the caller.
4680    ///
4681    /// This is the path that used to be a full stash re-scan on every acknowledgement batch.
4682    #[test]
4683    fn shares_arriving_before_their_verifier_are_redeemed_on_installation() -> anyhow::Result<()> {
4684        const POLYS: u16 = 2;
4685        const THRESHOLD: u8 = 2;
4686
4687        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4688            polynomials_per_ssa: POLYS,
4689            threshold: THRESHOLD,
4690            surplus_shares: 0,
4691        });
4692        let pseudonym = SimplePseudonym::random();
4693        let peer = OffchainKeypair::random();
4694        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4695        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4696
4697        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4698        reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
4699
4700        // Only part of the constant-term pass so far, so the SSA commitment is still unknown and no
4701        // part builder can be installed — a recovered part would have nowhere to go.
4702        reconstructor.insert_coefficient_commitments(
4703            ssa_id,
4704            0,
4705            proof_of(&commitment, 0),
4706            coefficient_of(&commitment, 0, Some(0))?.into_iter(),
4707        )?;
4708
4709        // All shares for the whole cycle arrive now — every one of them ahead of its verifier.
4710        let mut acks = Vec::new();
4711        while let Some((msg, share)) = {
4712            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
4713            generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
4714        }? {
4715            let ack = HalfKey::random();
4716            let ack_challenge = ack.to_challenge()?;
4717            let enc_share = share.share.encrypt(&share.id, &ack)?;
4718            reconstructor.insert_encrypted_share(
4719                peer.public(),
4720                ack_challenge,
4721                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
4722            )?;
4723            acks.push(VerifiedAcknowledgement::new(ack, &peer).leak());
4724        }
4725
4726        // Nothing can resolve yet, and every ack must have been bucketed rather than dropped.
4727        let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
4728        assert!(
4729            resolutions.is_empty(),
4730            "no share can resolve before its polynomial is committed"
4731        );
4732        assert_eq!(
4733            (POLYS as usize * THRESHOLD as usize),
4734            reconstructor.deferred_ack_count(&ssa_id),
4735            "every early ack must be bucketed under its own cycle"
4736        );
4737
4738        // The last constant term closes the set, which installs every part builder and redeems the
4739        // deferred acknowledgements.
4740        let state = reconstructor.insert_coefficient_commitments(
4741            ssa_id,
4742            0,
4743            proof_of(&commitment, 0),
4744            coefficient_of(&commitment, 0, Some(1))?.into_iter(),
4745        )?;
4746        assert!(state.is_verifiable);
4747
4748        // The bucket is consumed by the drain, not left to be re-scanned.
4749        assert!(
4750            !reconstructor.pending_acks.contains_key(&ssa_id),
4751            "the cycle's bucket must be consumed by the drain"
4752        );
4753
4754        // The redeemed resolutions surface on the next acknowledgement batch. An empty ack batch is
4755        // enough — the work is already done, only the hand-off remains.
4756        let resolutions = reconstructor.acknowledge_shares(*peer.public(), Vec::new())?;
4757        assert!(
4758            resolutions
4759                .iter()
4760                .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id)),
4761            "deferred shares must recover the SSA once their verifiers install, got {resolutions:?}"
4762        );
4763
4764        Ok(())
4765    }
4766
4767    /// Acknowledgements trailing a fully recovered cycle must use the compact tail, not be deferred.
4768    ///
4769    /// A missing cycle means one of two opposite things, and the acknowledgement path cannot tell
4770    /// them apart from the cache miss alone: a cycle not yet published, whose acks are worth
4771    /// bucketing, or one already retired. Advancing the Session frontier at recovery separates
4772    /// them, and the one recovered-tail slot preserves only the latter cycle's paid remainder.
4773    ///
4774    /// This is the common case rather than an edge: a conforming Entry emits `threshold + surplus`
4775    /// per polynomial, so the run of shares that advance nothing lands *after* the last polynomial
4776    /// completes — every recovered cycle is followed by acks in this state. Dropping their liveness
4777    /// makes the Session supervisor charge the FIFO drain to the successor; deferring them costs a
4778    /// bucket that can never be redeemed and holds the global acknowledgement budget.
4779    #[test]
4780    fn acks_trailing_a_recovered_cycle_report_liveness_without_deferral() -> anyhow::Result<()> {
4781        // A single polynomial, so its recovery *is* the SSA's and the surplus shares are
4782        // unambiguously post-recovery. With more, emission interleaves and which shares land after
4783        // the last completion stops being obvious from the dimensions.
4784        const POLYS: u16 = 1;
4785        const THRESHOLD: u8 = 2;
4786        const SURPLUS: u8 = 2;
4787
4788        let peer = OffchainKeypair::random();
4789        let (reconstructor, ssa_id, acks) = cycle_with_pending_acks(POLYS, THRESHOLD, SURPLUS, &peer)?;
4790        assert_eq!(
4791            (THRESHOLD + SURPLUS) as usize,
4792            acks.len(),
4793            "the generator emits threshold + surplus for the single polynomial"
4794        );
4795        assert_eq!(
4796            acks.len(),
4797            reconstructor.count_ack_buffer_entries(),
4798            "every emitted share is awaiting its acknowledgement"
4799        );
4800
4801        // One at a time: the two shares after the recovering one are the subject, and feeding the
4802        // batch whole would let a single dropped resolution hide which of them did what.
4803        let mut recovered_after = None;
4804        let mut tail_progress = Vec::new();
4805        for (i, ack) in acks.into_iter().enumerate() {
4806            let already_recovered = recovered_after.is_some();
4807            let mut recovered_now = false;
4808            for resolution in reconstructor.acknowledge_shares(*peer.public(), vec![ack])? {
4809                match resolution {
4810                    ShareResolution::Progress(progress) if already_recovered => {
4811                        tail_progress.push((progress.useful_shares, progress.shares_seen));
4812                    }
4813                    ShareResolution::RecoveredSsa(r) => {
4814                        assert_eq!(ssa_id, r.ssa_id);
4815                        recovered_after = Some(i + 1);
4816                        recovered_now = true;
4817                    }
4818                    _ => {}
4819                }
4820            }
4821            if recovered_now {
4822                // Mirrors supervisor tombstone cleanup. The compact Session tail deliberately
4823                // outlives that short diagnostic record; otherwise a buffer taking longer than the
4824                // retention window to drain would recreate the same liveness hole later.
4825                reconstructor.retire_ssa(ssa_id);
4826            }
4827        }
4828        assert_eq!(
4829            Some(THRESHOLD as usize),
4830            recovered_after,
4831            "the SSA must recover on the threshold-th share, leaving the surplus to trail it"
4832        );
4833        assert_eq!(
4834            vec![
4835                (THRESHOLD as u64, THRESHOLD as u64 + 1),
4836                (THRESHOLD as u64, THRESHOLD as u64 + 2)
4837            ],
4838            tail_progress,
4839            "each negotiated post-recovery share must advance only the liveness counter"
4840        );
4841
4842        assert_eq!(
4843            0,
4844            reconstructor.deferred_ack_count(&ssa_id),
4845            "a retired cycle must not accumulate deferred acknowledgements"
4846        );
4847        assert!(
4848            !reconstructor.pending_acks.contains_key(&ssa_id),
4849            "no bucket may be created for a cycle that cannot come back"
4850        );
4851        // The load-bearing memory property: the tail consumes the encrypted entries immediately;
4852        // it does not retain the heavyweight cycle or park acknowledgements until their TTL.
4853        assert_eq!(
4854            0,
4855            reconstructor.count_ack_buffer_entries(),
4856            "the trailing shares must leave the acknowledgement buffer after bounded accounting"
4857        );
4858
4859        Ok(())
4860    }
4861
4862    /// A recovered tail must retain the live path's *per-polynomial* security boundary.
4863    ///
4864    /// A cycle-wide remainder is not equivalent. Once reconstruction releases duplicate state, an
4865    /// Entry can replay one completed polynomial; with an aggregate allowance, that one polynomial
4866    /// could spend the paid surplus belonging to every other polynomial and keep the Exit serving
4867    /// without ever presenting their buffered SURBs. Each polynomial therefore owns exactly its
4868    /// negotiated remainder before and after retirement.
4869    #[test]
4870    fn a_recovered_polynomial_cannot_borrow_another_polynomials_surplus_budget() -> anyhow::Result<()> {
4871        const POLYS: u16 = 2;
4872        const THRESHOLD: u8 = 4;
4873        const DECLARED_SURPLUS: u8 = 1;
4874        const EMITTED_SURPLUS: u8 = 4;
4875
4876        let peer = OffchainKeypair::random();
4877        let (reconstructor, ssa_id, mut acks) =
4878            cycle_with_declared_surplus(POLYS, THRESHOLD, EMITTED_SURPLUS, DECLARED_SURPLUS, &peer)?;
4879
4880        // Round-robin emission puts the first `polys × threshold` acknowledgements entirely in the
4881        // useful prefix. Its last acknowledgement recovers the cycle; every remaining one exercises
4882        // the compact tail rather than an `SsaPartBuilder`.
4883        let tail = acks.split_off(POLYS as usize * THRESHOLD as usize);
4884        let prefix = reconstructor.acknowledge_shares(*peer.public(), acks)?;
4885        assert!(
4886            prefix
4887                .iter()
4888                .any(|resolution| matches!(resolution, ShareResolution::RecoveredSsa(recovered) if recovered.ssa_id == ssa_id)),
4889            "the useful prefix must recover the cycle before the attack starts"
4890        );
4891
4892        let (polynomial_zero, polynomial_one): (Vec<_>, Vec<_>) =
4893            tail.into_iter().enumerate().partition(|(index, _)| index % 2 == 0);
4894        let mut zero_snapshots = Vec::new();
4895        for (_, ack) in polynomial_zero {
4896            zero_snapshots.extend(
4897                reconstructor
4898                    .acknowledge_shares(*peer.public(), vec![ack])?
4899                    .into_iter()
4900                    .filter_map(|resolution| match resolution {
4901                        ShareResolution::Progress(progress) => Some(progress.shares_seen),
4902                        _ => None,
4903                    }),
4904            );
4905        }
4906        assert_eq!(
4907            vec![POLYS as u64 * THRESHOLD as u64 + 1],
4908            zero_snapshots,
4909            "replaying one recovered polynomial may consume only its own single negotiated slot"
4910        );
4911
4912        let (_, other_ack) = polynomial_one.into_iter().next().expect("second polynomial has a tail");
4913        let other_progress = reconstructor
4914            .acknowledge_shares(*peer.public(), vec![other_ack])?
4915            .into_iter()
4916            .find_map(|resolution| match resolution {
4917                ShareResolution::Progress(progress) => Some(progress.shares_seen),
4918                _ => None,
4919            });
4920        assert_eq!(
4921            Some(POLYS as u64 * THRESHOLD as u64 + 2),
4922            other_progress,
4923            "the attack must not consume the independent allowance of the untouched polynomial"
4924        );
4925
4926        Ok(())
4927    }
4928
4929    /// Malformed shares cannot be laundered through a recovered polynomial as paid liveness.
4930    #[test]
4931    fn an_invalid_recovered_tail_share_is_neither_liveness_nor_budget_spend() -> anyhow::Result<()> {
4932        let peer = OffchainKeypair::random();
4933        let (reconstructor, ssa_id, mut acks) = cycle_with_pending_acks(1, 2, 1, &peer)?;
4934        let legitimate_tail = acks.pop().expect("one negotiated surplus acknowledgement");
4935
4936        let prefix = reconstructor.acknowledge_shares(*peer.public(), acks)?;
4937        assert!(
4938            prefix
4939                .iter()
4940                .any(|resolution| matches!(resolution, ShareResolution::RecoveredSsa(recovered) if recovered.ssa_id == ssa_id)),
4941            "the threshold prefix must recover the cycle"
4942        );
4943
4944        let spi = SsaPolynomialId::new(ssa_id, 0);
4945        let ack = HalfKey::random();
4946        let challenge = ack.to_challenge()?;
4947        let encrypted = PartialSsaShare::<TestSpec>::default().encrypt(&spi, &ack)?;
4948        let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
4949        reconstructor.insert_encrypted_share(
4950            peer.public(),
4951            challenge,
4952            TaggedEncryptedPartialSsaShare::new(*spi.pseudonym(), &msg, encrypted)?,
4953        )?;
4954
4955        let invalid =
4956            reconstructor.acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?;
4957        assert!(
4958            invalid
4959                .iter()
4960                .all(|resolution| !matches!(resolution, ShareResolution::Progress(_))),
4961            "a zero share addressed to the recovered tail must not reopen service: {invalid:?}"
4962        );
4963        assert!(
4964            invalid.iter().any(
4965                |resolution| matches!(resolution, ShareResolution::InvalidShares { ssa_id: id, .. } if *id == ssa_id)
4966            ),
4967            "the malformed share must reach validation and be reported: {invalid:?}"
4968        );
4969
4970        let legitimate = reconstructor.acknowledge_shares(*peer.public(), vec![legitimate_tail])?;
4971        assert!(
4972            legitimate.iter().any(
4973                |resolution| matches!(resolution, ShareResolution::Progress(progress) if progress.shares_seen == 3)
4974            ),
4975            "rejecting malformed traffic must not consume the paid slot of a legitimate buffered SURB: {legitimate:?}"
4976        );
4977
4978        Ok(())
4979    }
4980
4981    /// A fault found while draining deferred acknowledgements must stay attributed to the relayer
4982    /// that carried the offending share.
4983    ///
4984    /// The drain runs on the commitment path, so its findings are handed to whichever
4985    /// `acknowledge_shares` call happens to collect them next — routinely a different peer's. Filling
4986    /// the relayer in at emission time would name that unrelated peer, which is worse than naming
4987    /// none: the field exists to attribute misbehaviour.
4988    #[test]
4989    fn a_fault_redeemed_from_deferral_keeps_its_own_relayer() -> anyhow::Result<()> {
4990        const POLYS: u16 = 1;
4991        const THRESHOLD: u8 = 2;
4992
4993        // One surplus share, kept back to give `collector` an `awaiting_acks` entry.
4994        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4995            polynomials_per_ssa: POLYS,
4996            threshold: THRESHOLD,
4997            surplus_shares: 1,
4998        });
4999        let pseudonym = SimplePseudonym::random();
5000        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
5001        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
5002
5003        // `carrier` relays the shares; `collector` is an unrelated peer whose later batch picks up
5004        // the parked resolutions.
5005        let carrier = OffchainKeypair::random();
5006        let collector = OffchainKeypair::random();
5007
5008        let reconstructor = SsaReconstructor::<TestSpec>::default();
5009        reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
5010
5011        // Shares arrive over `carrier` before any commitment, so they defer. One is corrupted, so the
5012        // drain is what discovers the fault.
5013        let mut acks = Vec::new();
5014        for i in 0..THRESHOLD {
5015            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
5016            let mut share = generator
5017                .next_share(&pseudonym, &msg)?
5018                .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
5019            if i == 0 {
5020                AsMut::<[u8]>::as_mut(&mut share.share.0)[31] ^= 1;
5021            }
5022            let ack = HalfKey::random();
5023            let enc = share.share.clone().encrypt(&share.id, &ack)?;
5024            reconstructor.insert_encrypted_share(
5025                carrier.public(),
5026                ack.to_challenge()?,
5027                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
5028            )?;
5029            acks.push(VerifiedAcknowledgement::new(ack, &carrier).leak());
5030        }
5031        assert!(
5032            reconstructor.acknowledge_shares(*carrier.public(), acks)?.is_empty(),
5033            "shares must defer while the commitment is unknown"
5034        );
5035
5036        // Completing the commitment installs the part builder and drains the bucket, which is where
5037        // the corrupted set is detected.
5038        reconstructor.insert_coefficient_commitments(
5039            ssa_id,
5040            0,
5041            proof_of(&commitment, 0),
5042            coefficient_of(&commitment, 0, None)?.into_iter(),
5043        )?;
5044
5045        // The surplus share gives `collector` an entry in `awaiting_acks`, which is what makes its
5046        // batch acceptable. It is never acknowledged — the batch below is empty, so the only thing
5047        // `collector` contributes is being the wrong answer for the `peer` field.
5048        let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
5049        let surplus = generator
5050            .next_share(&pseudonym, &msg)?
5051            .ok_or_else(|| anyhow::anyhow!("generator must yield the surplus share"))?;
5052        let filler = HalfKey::random();
5053        reconstructor.insert_encrypted_share(
5054            collector.public(),
5055            filler.to_challenge()?,
5056            TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, surplus.share.encrypt(&surplus.id, &filler)?)?,
5057        )?;
5058
5059        let faults = reconstructor
5060            .acknowledge_shares(*collector.public(), Vec::new())?
5061            .into_iter()
5062            .filter_map(|r| match r {
5063                ShareResolution::InvalidShares { peer, ssa_id, .. } => Some((peer, ssa_id)),
5064                _ => None,
5065            })
5066            .collect::<Vec<_>>();
5067
5068        assert_eq!(1, faults.len(), "the drained fault must surface exactly once");
5069        assert_eq!(ssa_id, faults[0].1);
5070        assert_eq!(
5071            carrier.public(),
5072            faults[0].0.as_ref(),
5073            "the fault must name the relayer that carried the share, not the peer that collected it"
5074        );
5075
5076        Ok(())
5077    }
5078
5079    /// Deferring is decided on a cycle lookup that missed, so a cycle installing concurrently would
5080    /// leave the ack in a bucket whose one and only drain has already run — a share silently lost
5081    /// to a microsecond-wide window. `defer_ack` re-probes and drains itself in that case.
5082    #[test]
5083    fn deferring_against_an_installed_verifier_drains_immediately() -> anyhow::Result<()> {
5084        const POLYS: u16 = 2;
5085        const THRESHOLD: u8 = 2;
5086
5087        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
5088            polynomials_per_ssa: POLYS,
5089            threshold: THRESHOLD,
5090            surplus_shares: 0,
5091        });
5092        let pseudonym = SimplePseudonym::random();
5093        let peer = OffchainKeypair::random();
5094        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
5095
5096        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
5097        reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
5098        generator
5099            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
5100            .process_into_reconstructor(&reconstructor)?;
5101
5102        // Every part builder is installed and every bucket already drained.
5103        let spi = SsaPolynomialId::new(ssa_id, 0);
5104        assert!(reconstructor.cycle(&ssa_id).is_some_and(|c| c.part(0).is_some()));
5105
5106        // Simulate the racing path: an ack deferred *after* its cycle appeared.
5107        let ack = HalfKey::random();
5108        reconstructor.defer_ack(spi, (*peer.public(), ack.to_challenge()?, ack));
5109
5110        assert!(
5111            !reconstructor.pending_acks.contains_key(&ssa_id),
5112            "a bucket created after its cycle installed must drain itself, not linger unclaimed"
5113        );
5114
5115        Ok(())
5116    }
5117
5118    /// The per-polynomial sub-cap must hold, so a peer cannot make the Exit buffer without bound by
5119    /// emitting more shares for one polynomial than its own share budget permits.
5120    #[test]
5121    fn deferred_ack_buckets_are_capped() -> anyhow::Result<()> {
5122        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
5123        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
5124        let spi = SsaPolynomialId::new(ssa_id, 0);
5125        let peer = OffchainKeypair::random();
5126
5127        for _ in 0..MAX_DEFERRED_ACKS_PER_POLYNOMIAL + 32 {
5128            let ack = HalfKey::random();
5129            reconstructor.defer_ack(spi, (*peer.public(), ack.to_challenge()?, ack));
5130        }
5131
5132        assert_eq!(
5133            MAX_DEFERRED_ACKS_PER_POLYNOMIAL,
5134            reconstructor.deferred_ack_count(&ssa_id),
5135            "a polynomial's sub-bucket must not grow past the cap"
5136        );
5137
5138        Ok(())
5139    }
5140
5141    /// The per-cycle ceiling must hold even when the peer spreads its acknowledgements across many
5142    /// polynomials, each of which stays under the per-polynomial sub-cap.
5143    ///
5144    /// Without it the cycle total is `num_polys × 128` — a million entries at production
5145    /// dimensions, which is no bound at all.
5146    #[test]
5147    fn deferred_ack_buckets_are_capped_per_cycle() -> anyhow::Result<()> {
5148        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
5149        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
5150        let peer = OffchainKeypair::random();
5151
5152        // Spread over enough polynomials that no sub-bucket ever reaches its own cap, so the
5153        // per-cycle ceiling is provably the thing doing the work.
5154        let polys = (MAX_DEFERRED_ACKS_PER_CYCLE / (MAX_DEFERRED_ACKS_PER_POLYNOMIAL / 2)) + 8;
5155        'outer: for poly in 0..polys as PolynomialIndex {
5156            let spi = SsaPolynomialId::new(ssa_id, poly);
5157            for _ in 0..MAX_DEFERRED_ACKS_PER_POLYNOMIAL / 2 {
5158                let ack = HalfKey::random();
5159                reconstructor.defer_ack(spi, (*peer.public(), ack.to_challenge()?, ack));
5160                if reconstructor.deferred_ack_count(&ssa_id) > MAX_DEFERRED_ACKS_PER_CYCLE {
5161                    break 'outer;
5162                }
5163            }
5164        }
5165
5166        assert_eq!(
5167            MAX_DEFERRED_ACKS_PER_CYCLE,
5168            reconstructor.deferred_ack_count(&ssa_id),
5169            "the cycle total must not grow past the ceiling"
5170        );
5171
5172        Ok(())
5173    }
5174
5175    /// An acknowledgement appended to a bucket the drain has already taken must still be redeemed.
5176    ///
5177    /// A bucket is reachable two ways — through the `pending_acks` key, and through an `Arc` a
5178    /// `defer_ack` obtained before the drain invalidated that key. The drain can only remove the
5179    /// first. The `ssa_cycles` re-probe was supposed to cover the resulting window, but it called
5180    /// `drain_deferred_acks`, which returns early on a cache miss — and a miss is exactly what this
5181    /// interleaving produces. The append landed in an orphaned bucket, and the share sat in
5182    /// `awaiting_acks` until `max_ack_await_time` discarded it. Silently: no error, no counter, and
5183    /// a polynomial losing more than `surplus_shares` this way strands the whole cycle without any
5184    /// check ever failing.
5185    ///
5186    /// Forcing the interleaving needs the stale handle, which is why `defer_ack_into` takes the
5187    /// bucket: after the invalidate, `defer_ack`'s own `get_with` would hand out a fresh one and
5188    /// the window would close by accident.
5189    #[test]
5190    fn an_acknowledgement_deferred_into_a_drained_bucket_is_still_redeemed() -> anyhow::Result<()> {
5191        // One polynomial at threshold 2, so the two deferred acknowledgements below are exactly
5192        // what the cycle needs: the second one landing makes the difference between a recovered
5193        // SSA and a stranded one.
5194        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
5195            polynomials_per_ssa: 1,
5196            threshold: 2,
5197            surplus_shares: 0,
5198        });
5199        let pseudonym = SimplePseudonym::random();
5200        let peer = OffchainKeypair::random();
5201        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
5202
5203        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
5204        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
5205        reconstructor.new_exit_commitment(ssa_id, test_params(1, 2))?;
5206
5207        // Both shares reach the Exit ahead of the commitment, so neither has a verifier yet.
5208        let mut pending = Vec::new();
5209        for _ in 0..2 {
5210            let (msg, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
5211            let ack = HalfKey::random();
5212            let challenge = ack.to_challenge()?;
5213            let enc = share.share.encrypt(&share.id, &ack)?;
5214            reconstructor.insert_encrypted_share(
5215                peer.public(),
5216                challenge,
5217                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
5218            )?;
5219            pending.push((share.id, challenge, ack));
5220        }
5221
5222        // The first acknowledgement takes the ordinary deferral path and creates the bucket.
5223        let (_, _, first_ack) = pending[0];
5224        reconstructor.acknowledge_shares(
5225            *peer.public(),
5226            vec![VerifiedAcknowledgement::new(first_ack, &peer).leak()],
5227        )?;
5228        let bucket = reconstructor
5229            .pending_acks
5230            .get(&ssa_id)
5231            .ok_or_else(|| anyhow::anyhow!("the first acknowledgement must have created a bucket"))?;
5232
5233        // Installing the cycle drains that bucket and invalidates its key. Our handle survives.
5234        commitment.process_into_reconstructor(&reconstructor)?;
5235        assert!(
5236            reconstructor.pending_acks.get(&ssa_id).is_none(),
5237            "the drain must have taken the cache key"
5238        );
5239
5240        // The racing append: a `defer_ack` that looked the bucket up before the drain ran.
5241        let (spi, challenge, ack) = pending[1];
5242        reconstructor.defer_ack_into(&bucket, spi, (*peer.public(), challenge, ack));
5243
5244        // Two shares interpolate the only polynomial, so the SSA is recovered — but only if the
5245        // second acknowledgement was redeemed rather than parked in the orphan.
5246        assert!(
5247            reconstructor
5248                .take_ready_resolutions()
5249                .iter()
5250                .any(|resolution| matches!(resolution, ShareResolution::RecoveredSsa(_))),
5251            "the acknowledgement must have been redeemed inline, not lost with the bucket"
5252        );
5253
5254        Ok(())
5255    }
5256
5257    /// Drives a cycle to full recovery entirely through the deferral path, so the `RecoveredSsa` it
5258    /// produces ends up parked in `ready_resolutions` rather than returned from `acknowledge_shares`.
5259    ///
5260    /// Both shares arrive before the commitment does, which is the ordering the emission window
5261    /// makes routine near a cycle boundary, so both acknowledgements defer and the drain that
5262    /// installs the cycle is what reconstructs.
5263    fn park_a_recovered_ssa() -> anyhow::Result<(SsaReconstructor<TestSpec>, SsaId<SimplePseudonym>)> {
5264        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
5265            polynomials_per_ssa: 1,
5266            threshold: 2,
5267            surplus_shares: 0,
5268        });
5269        let pseudonym = SimplePseudonym::random();
5270        let peer = OffchainKeypair::random();
5271        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
5272
5273        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
5274        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
5275        reconstructor.new_exit_commitment(ssa_id, test_params(1, 2))?;
5276
5277        for _ in 0..2 {
5278            let (msg, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
5279            let ack = HalfKey::random();
5280            let challenge = ack.to_challenge()?;
5281            let enc = share.share.encrypt(&share.id, &ack)?;
5282            reconstructor.insert_encrypted_share(
5283                peer.public(),
5284                challenge,
5285                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
5286            )?;
5287            reconstructor.acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?;
5288        }
5289
5290        commitment.process_into_reconstructor(&reconstructor)?;
5291        assert_ne!(
5292            0,
5293            reconstructor
5294                .ready_resolutions_len
5295                .load(std::sync::atomic::Ordering::Acquire),
5296            "the drain must have parked the recovery"
5297        );
5298
5299        Ok((reconstructor, ssa_id))
5300    }
5301
5302    /// A parked resolution must not be gated behind the peer whose shares produced it.
5303    ///
5304    /// The pipelines call `has_pending_shares` to decide whether to hand a batch to
5305    /// `acknowledge_shares` at all, and `acknowledge_shares` is the only thing that ever collects
5306    /// `ready_resolutions`. Answering purely from `awaiting_acks` therefore made delivery of a
5307    /// recovered deposit key depend on the producing peer sending more traffic before its own cache
5308    /// entry idled out — while the buffer is global, and any batch could have carried it.
5309    #[test]
5310    fn a_parked_resolution_is_collectable_through_any_peer() -> anyhow::Result<()> {
5311        let (reconstructor, _) = park_a_recovered_ssa()?;
5312
5313        // A peer this reconstructor has never seen: no shares, no `awaiting_acks` entry.
5314        let bystander = *OffchainKeypair::random().public();
5315        assert!(
5316            !reconstructor.awaiting_acks.contains_key(&bystander),
5317            "the bystander must have no pending shares of its own"
5318        );
5319        assert!(
5320            reconstructor.has_pending_shares(&bystander),
5321            "a parked resolution must let any batch through to collect it"
5322        );
5323
5324        // And once collected, the guard goes back to answering per-peer.
5325        assert!(
5326            reconstructor
5327                .take_ready_resolutions()
5328                .iter()
5329                .any(|resolution| matches!(resolution, ShareResolution::RecoveredSsa(_)))
5330        );
5331        assert!(
5332            !reconstructor.has_pending_shares(&bystander),
5333            "with nothing parked the guard must not admit an unrelated peer"
5334        );
5335
5336        Ok(())
5337    }
5338
5339    /// Retiring a cycle must not consume the resolution it already produced.
5340    ///
5341    /// `ready_resolutions` is global and its entries name their own `SsaId`, so a parked
5342    /// `RecoveredSsa` stays collectable after its cycle is torn down — and it is worth collecting:
5343    /// the deposit key is what pays the Exit, whether or not the Session that earned it is still
5344    /// open. Draining at retirement would destroy that, and would take unrelated cycles'
5345    /// resolutions with it, since nothing in the buffer is keyed by cycle. The only point at which
5346    /// delivery genuinely becomes impossible is `Drop`, which reports whatever is left.
5347    #[test]
5348    fn retiring_a_cycle_leaves_its_resolution_collectable() -> anyhow::Result<()> {
5349        let (reconstructor, ssa_id) = park_a_recovered_ssa()?;
5350
5351        reconstructor.retire_ssa(ssa_id);
5352
5353        assert!(
5354            reconstructor
5355                .take_ready_resolutions()
5356                .iter()
5357                .any(|resolution| matches!(resolution, ShareResolution::RecoveredSsa(_))),
5358            "retirement must not swallow a recovered deposit key"
5359        );
5360
5361        Ok(())
5362    }
5363
5364    /// **H8 regression.** Reclamation is scoped to the cycle, so a share for *any* polynomial keeps
5365    /// the whole cycle alive.
5366    ///
5367    /// This used to fail. The part builders were keyed per polynomial with an idle timer, so the
5368    /// clock measured "time since a share for *this* polynomial arrived". Commitments are a
5369    /// fraction of a percent of a cycle's bytes, so every builder was installed in the opening
5370    /// moments and then waited, while shares arrive polynomial-major and spread across the whole
5371    /// cycle. Any polynomial late in the emission order had its builder evicted before its first
5372    /// share landed — unrecoverably, since the commitment cannot be retransmitted and a deferred
5373    /// ack's only drain is an installation that had already happened. The SSA never completed and
5374    /// the deposit burned.
5375    ///
5376    /// The condition was `quota / line_rate > unused_verifier_lifetime`. At the deployed 1.5 Mbps
5377    /// per-Session cap a 519 MiB cycle runs 48.4 minutes against a 30-minute default, so every
5378    /// polynomial past ≈62 % of the cycle was lost.
5379    ///
5380    /// Scaled down here to two polynomials and a half-second lifetime; the shape is identical.
5381    ///
5382    /// The essential geometry is that the cycle is *continuously* busy while any single polynomial
5383    /// is not. Shares are spaced at half the lifetime, so the cycle never goes idle, but the four
5384    /// shares of polynomial 0 take twice the lifetime to arrive — long enough that polynomial 1's
5385    /// builder, untouched since installation, would have expired under the old per-polynomial key.
5386    #[test]
5387    fn a_cycle_stays_live_while_a_single_polynomial_goes_untouched() -> anyhow::Result<()> {
5388        const POLYS: u16 = 2;
5389        const THRESHOLD: u8 = 4;
5390        /// Bounded on **both** sides, which is why this is not simply "as large as possible":
5391        ///
5392        /// * above `SHARE_SPACING`, or the cycle idles out between two consecutive shares and the test fails for a
5393        ///   reason unrelated to H8. Each iteration does rather more than sleep — polynomial evaluation, encryption,
5394        ///   insertion, and at the threshold a Lagrange combine and a scalar multiplication — so on a contended runner
5395        ///   the margin needs to be several times the sleep, not the 2× it used to be;
5396        /// * below `(THRESHOLD + 1) × SHARE_SPACING` ≈ 1250 ms, or the assertion below stops holding and the test
5397        ///   exercises nothing.
5398        ///
5399        /// 1000 ms sits inside that window with the slack on the side that grows under load:
5400        /// cumulative elapsed time only ever overshoots, while a single iteration would have to
5401        /// take four times its sleep to expire the cycle.
5402        const VERIFIER_LIFETIME: std::time::Duration = std::time::Duration::from_millis(1000);
5403        /// Comfortably inside the lifetime, so no *cycle* is ever idle long enough to expire.
5404        const SHARE_SPACING: std::time::Duration = std::time::Duration::from_millis(250);
5405
5406        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
5407            polynomials_per_ssa: POLYS,
5408            threshold: THRESHOLD,
5409            surplus_shares: 0,
5410        });
5411        let pseudonym = SimplePseudonym::random();
5412        let peer = OffchainKeypair::random();
5413        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
5414
5415        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
5416            unused_verifier_lifetime: VERIFIER_LIFETIME,
5417            ..Default::default()
5418        });
5419        reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
5420
5421        // The whole commitment set lands up front, as it does in production: every part builder is
5422        // installed now, and the later ones then wait out most of the cycle.
5423        generator
5424            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
5425            .process_into_reconstructor(&reconstructor)?;
5426
5427        let installed_at = std::time::Instant::now();
5428        let mut recovered = false;
5429        for i in 0..(POLYS as usize * THRESHOLD as usize) {
5430            std::thread::sleep(SHARE_SPACING);
5431
5432            // Shares are emitted polynomial-major, so this is the hand-over to polynomial 1 — the
5433            // point at which its builder has been idle for the whole of polynomial 0's run.
5434            if i == THRESHOLD as usize {
5435                assert!(
5436                    installed_at.elapsed() > VERIFIER_LIFETIME,
5437                    "the test must actually outlast the lifetime before polynomial 1's first share, otherwise it \
5438                     exercises nothing"
5439                );
5440            }
5441
5442            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
5443            let share = generator
5444                .next_share(&pseudonym, &msg)?
5445                .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
5446
5447            let ack = HalfKey::random();
5448            let enc = share.share.encrypt(&share.id, &ack)?;
5449            reconstructor.insert_encrypted_share(
5450                peer.public(),
5451                ack.to_challenge()?,
5452                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
5453            )?;
5454            recovered |= reconstructor
5455                .acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?
5456                .iter()
5457                .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id));
5458        }
5459
5460        if !recovered {
5461            // Name the mechanism, so a regression reports the cause and not just the symptom. Under
5462            // H8 the late polynomial's builder was gone and its shares were stranded in a bucket
5463            // whose only drain had already run.
5464            assert_eq!(
5465                0,
5466                reconstructor.deferred_ack_count(&ssa_id),
5467                "shares were stranded in a deferred-ack bucket — H8 has regressed"
5468            );
5469            panic!("the cycle failed to recover even though it was continuously busy");
5470        }
5471
5472        Ok(())
5473    }
5474
5475    /// The idle timer was not simply disabled: a cycle that receives no shares at all is still
5476    /// reclaimed on schedule.
5477    ///
5478    /// This is the other half of the H8 regression. Widening the reclamation scope is only correct
5479    /// if reclamation still happens — otherwise an abandoned cycle is pinned until session
5480    /// teardown.
5481    #[test]
5482    fn a_cycle_with_no_shares_at_all_still_expires() -> anyhow::Result<()> {
5483        const POLYS: u16 = 2;
5484        const THRESHOLD: u8 = 2;
5485        const VERIFIER_LIFETIME: std::time::Duration = std::time::Duration::from_millis(500);
5486
5487        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
5488            polynomials_per_ssa: POLYS,
5489            threshold: THRESHOLD,
5490            surplus_shares: 0,
5491        });
5492        let pseudonym = SimplePseudonym::random();
5493        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
5494
5495        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
5496            unused_verifier_lifetime: VERIFIER_LIFETIME,
5497            ..Default::default()
5498        });
5499        reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
5500        generator
5501            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
5502            .process_into_reconstructor(&reconstructor)?;
5503
5504        assert_eq!(POLYS as usize, reconstructor.installed_parts(&ssa_id));
5505
5506        std::thread::sleep(VERIFIER_LIFETIME * 3);
5507
5508        assert_eq!(
5509            0,
5510            reconstructor.live_cycles(),
5511            "a cycle that never received a share must still be reclaimed"
5512        );
5513
5514        Ok(())
5515    }
5516
5517    /// The polynomial index travels inside a peer-supplied share, so it reaches the slot lookup as
5518    /// untrusted input. Once the cycle is known its dimensions are too, which makes an out-of-range
5519    /// index definitively invalid rather than merely early — and it must not index the slot array.
5520    #[test]
5521    fn a_share_naming_a_polynomial_outside_the_cycle_is_rejected() -> anyhow::Result<()> {
5522        const POLYS: u16 = 2;
5523        const THRESHOLD: u8 = 2;
5524
5525        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
5526            polynomials_per_ssa: POLYS,
5527            threshold: THRESHOLD,
5528            surplus_shares: 0,
5529        });
5530        let pseudonym = SimplePseudonym::random();
5531        let peer = OffchainKeypair::random();
5532        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
5533
5534        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
5535        reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
5536        generator
5537            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
5538            .process_into_reconstructor(&reconstructor)?;
5539
5540        // Take a real share and re-label it for a polynomial the cycle does not have.
5541        let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
5542        let mut share = generator
5543            .next_share(&pseudonym, &msg)?
5544            .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
5545        share.id = SsaPolynomialId::new(ssa_id, POLYS as PolynomialIndex + 5);
5546
5547        let ack = HalfKey::random();
5548        let enc = share.share.encrypt(&share.id, &ack)?;
5549        reconstructor.insert_encrypted_share(
5550            peer.public(),
5551            ack.to_challenge()?,
5552            TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
5553        )?;
5554
5555        // `acknowledge_shares` logs and swallows the error, so the observable contract is that
5556        // nothing resolves, nothing is deferred, and the process is still standing.
5557        let resolutions =
5558            reconstructor.acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?;
5559        assert!(resolutions.is_empty(), "an out-of-range share must resolve to nothing");
5560        assert_eq!(
5561            0,
5562            reconstructor.deferred_ack_count(&ssa_id),
5563            "an out-of-range share must be rejected outright, not deferred forever"
5564        );
5565        assert!(
5566            reconstructor.cycle(&ssa_id).is_some(),
5567            "the cycle itself must be unharmed"
5568        );
5569
5570        Ok(())
5571    }
5572
5573    /// Shares for different polynomials of one cycle must be able to run concurrently.
5574    ///
5575    /// The cycle is a single cache entry, which makes collapsing it to a single mutex an easy and
5576    /// invisible mistake — and one that would serialise every share of a Session. This holds one
5577    /// polynomial's lock and asserts another's is still free.
5578    #[test]
5579    fn parts_of_one_cycle_lock_independently() -> anyhow::Result<()> {
5580        const POLYS: u16 = 4;
5581        const THRESHOLD: u8 = 2;
5582
5583        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
5584            polynomials_per_ssa: POLYS,
5585            threshold: THRESHOLD,
5586            surplus_shares: 0,
5587        });
5588        let pseudonym = SimplePseudonym::random();
5589        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
5590
5591        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
5592        reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
5593        generator
5594            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
5595            .process_into_reconstructor(&reconstructor)?;
5596
5597        let cycle = reconstructor
5598            .cycle(&ssa_id)
5599            .ok_or_else(|| anyhow::anyhow!("cycle must be live"))?;
5600
5601        let held = cycle.part(0).ok_or_else(|| anyhow::anyhow!("missing part 0"))?.lock();
5602        for poly in 1..POLYS as PolynomialIndex {
5603            assert!(
5604                cycle
5605                    .part(poly)
5606                    .ok_or_else(|| anyhow::anyhow!("missing part {poly}"))?
5607                    .try_lock()
5608                    .is_some(),
5609                "polynomial {poly} must not be blocked by polynomial 0 — the cycle must not share one mutex"
5610            );
5611        }
5612        // The accumulator is a separate lock too, so a part in flight does not block recovery
5613        // accounting for another part.
5614        assert!(
5615            cycle.builder().try_lock().is_some(),
5616            "the accumulator must lock separately"
5617        );
5618        drop(held);
5619
5620        Ok(())
5621    }
5622
5623    /// Verifies that the builder caches accept more entries than the old
5624    /// `MAX_POLYS_PER_SSA` size bound. After removing the hard capacity, only
5625    /// TTL governs eviction.  Also verifies that fully-committed IDs populate
5626    /// `ssa_builders` and remain cached.
5627    #[test]
5628    fn builder_caches_accept_more_entries_than_max_polys_per_ssa() -> anyhow::Result<()> {
5629        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
5630        let pseudonym = SimplePseudonym::random();
5631        let exceed = MAX_POLYS_PER_SSA as usize + 5;
5632        let mut ids = Vec::with_capacity(exceed);
5633        for i in 0..exceed {
5634            let ssa_id = SsaId::new(pseudonym, (1u32 + i as u32).try_into()?);
5635            reconstructor.new_exit_commitment(ssa_id, test_params(2, 2))?;
5636            ids.push(ssa_id);
5637        }
5638        reconstructor.commitment_builder.run_pending_tasks();
5639        for ssa_id in &ids {
5640            assert!(
5641                reconstructor.contains_builder(ssa_id),
5642                "commitment builder must retain every accepted SsaId ({ssa_id:?})"
5643            );
5644        }
5645
5646        // Complete the first few IDs through the full commitment path to populate `ssa_cycles`.
5647        // Use a 2-poly 2-threshold generator so that insert_coefficient_commitments reaches the
5648        // completion milestone and publishes a cycle.
5649        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
5650            polynomials_per_ssa: 2,
5651            threshold: 2,
5652            surplus_shares: 1,
5653        });
5654        for ssa_id in ids.iter().take(3) {
5655            let commit = generator.new_ssa_commitment(&pseudonym, ssa_id.ssa_index())?;
5656            commit.process_into_reconstructor(&reconstructor)?;
5657            reconstructor.commitment_builder.run_pending_tasks();
5658
5659            assert_eq!(
5660                2,
5661                reconstructor.installed_parts(ssa_id),
5662                "ssa_cycles must contain completed SsaId index {} ({ssa_id:?})",
5663                ssa_id.ssa_index().get(),
5664            );
5665        }
5666
5667        Ok(())
5668    }
5669
5670    /// Extracts one coefficient's commitments from a generated SSA commitment, optionally narrowed
5671    /// to a single polynomial, in the shape `insert_coefficient_commitments` expects.
5672    fn coefficient_of(
5673        commitment: &crate::SsaCommitment<TestSpec>,
5674        coeff_index: CoefficientIndex,
5675        only_poly: Option<PolynomialIndex>,
5676    ) -> anyhow::Result<Vec<(PolynomialIndex, PixGroupRepr<TestSpec>)>> {
5677        Ok(commitment
5678            .verifiers
5679            .get(&coeff_index)
5680            .ok_or_else(|| anyhow::anyhow!("missing coefficient {coeff_index}"))?
5681            .iter()
5682            .filter(|(poly_index, _)| only_poly.is_none_or(|wanted| *poly_index == wanted))
5683            .map(|(poly_index, repr)| (*poly_index, *repr))
5684            .collect())
5685    }
5686
5687    /// Retirement-frontier guard on the *first* publication point: the SSA becoming live.
5688    ///
5689    /// Since verifiers are now installed as individual polynomials complete, the part accumulator
5690    /// has to be published earlier — the moment the constant terms yield the SSA commitment. A
5691    /// `retire_ssa` racing that publication must still leave nothing behind.
5692    #[test]
5693    fn retire_ssa_frontier_prevents_builder_publication() -> anyhow::Result<()> {
5694        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
5695            polynomials_per_ssa: 2,
5696            threshold: 2,
5697            surplus_shares: 0,
5698        });
5699        let pseudonym = SimplePseudonym::random();
5700        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
5701        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
5702
5703        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
5704        reconstructor.new_exit_commitment(ssa_id, test_params(2, 2))?;
5705
5706        // Constant term of polynomial 0 only — the SSA commitment is still unknown.
5707        let state = reconstructor.insert_coefficient_commitments(
5708            ssa_id,
5709            0,
5710            proof_of(&commitment, 0),
5711            coefficient_of(&commitment, 0, Some(0))?.into_iter(),
5712        )?;
5713        assert!(
5714            state.ssa_deposit_address.is_none(),
5715            "deposit address must not be derivable from a partial constant-term set"
5716        );
5717
5718        // Simulate `retire_ssa` winning the frontier race but pausing before cache removal.
5719        let registration = reconstructor
5720            .commitment_builder
5721            .get(&ssa_id)
5722            .context("registered commitment must exist")?;
5723        assert!(registration.retirement.lock().retire(ssa_id.ssa_index()));
5724
5725        // Constant term of polynomial 1 completes the set, so this call would publish the builder.
5726        let state = reconstructor.insert_coefficient_commitments(
5727            ssa_id,
5728            0,
5729            proof_of(&commitment, 0),
5730            coefficient_of(&commitment, 0, Some(1))?.into_iter(),
5731        )?;
5732        assert!(!state.is_verifiable, "a retired cycle is never verifiable");
5733
5734        assert_eq!(
5735            0,
5736            reconstructor.live_cycles(),
5737            "retirement frontier must prevent cycle publication"
5738        );
5739
5740        Ok(())
5741    }
5742
5743    /// Retirement-frontier guard on verifier installation.
5744    ///
5745    /// Verifiers and the part accumulator are now published by the same call, so retirement racing
5746    /// it must withdraw both. Publication and retirement serialize on the Session frontier, so a
5747    /// retirement that wins the lock must prevent the installation altogether.
5748    #[test]
5749    fn retire_ssa_frontier_prevents_verifier_installation() -> anyhow::Result<()> {
5750        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
5751            polynomials_per_ssa: 2,
5752            threshold: 2,
5753            surplus_shares: 0,
5754        });
5755        let pseudonym = SimplePseudonym::random();
5756        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
5757        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
5758
5759        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
5760        reconstructor.new_exit_commitment(ssa_id, test_params(2, 2))?;
5761
5762        // Polynomial 0's constant term: nothing is published yet, the set is incomplete.
5763        reconstructor.insert_coefficient_commitments(
5764            ssa_id,
5765            0,
5766            proof_of(&commitment, 0),
5767            coefficient_of(&commitment, 0, Some(0))?.into_iter(),
5768        )?;
5769        assert_eq!(
5770            0,
5771            reconstructor.live_cycles(),
5772            "no part builder may be installed while the SSA commitment is unknown"
5773        );
5774
5775        // Retirement lands here but pauses before cache removal.
5776        let registration = reconstructor
5777            .commitment_builder
5778            .get(&ssa_id)
5779            .context("registered commitment must exist")?;
5780        assert!(registration.retirement.lock().retire(ssa_id.ssa_index()));
5781
5782        // Polynomial 1's constant term closes the set, so this call would install both verifiers.
5783        let state = reconstructor.insert_coefficient_commitments(
5784            ssa_id,
5785            0,
5786            proof_of(&commitment, 0),
5787            coefficient_of(&commitment, 0, Some(1))?.into_iter(),
5788        )?;
5789        assert!(!state.is_verifiable, "a retired cycle is never verifiable");
5790
5791        assert_eq!(
5792            0,
5793            reconstructor.live_cycles(),
5794            "retirement frontier must prevent installation of the accumulator and every part builder"
5795        );
5796
5797        Ok(())
5798    }
5799
5800    /// The commitment proof must bind everything it claims to: both of its own components, the SSA
5801    /// index it was issued for, and the commitment it opens.
5802    #[test]
5803    fn commitment_proof_must_bind_its_components_the_ssa_index_and_the_commitment() -> anyhow::Result<()> {
5804        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
5805            polynomials_per_ssa: 2,
5806            threshold: 2,
5807            surplus_shares: 0,
5808        });
5809        let pseudonym = SimplePseudonym::random();
5810        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
5811        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
5812        let proof = commitment.commitment_proof;
5813
5814        assert!(
5815            proof.verify(&ssa_id, &commitment.ssa_commitment),
5816            "the generator's own proof must verify"
5817        );
5818
5819        // Flipping a bit anywhere breaks it, whether it lands in the nonce commitment or in the
5820        // response. Some flips make the component unparseable, which is equally a rejection.
5821        let bytes = proof.to_bytes();
5822        assert_eq!(SsaCommitmentProof::<TestSpec>::SIZE, bytes.len());
5823        for position in [0, bytes.len() / 2, bytes.len() - 1] {
5824            let mut tampered = bytes.clone();
5825            tampered[position] ^= 1;
5826            if let Ok(tampered) = SsaCommitmentProof::<TestSpec>::try_from_bytes(&tampered) {
5827                assert!(
5828                    !tampered.verify(&ssa_id, &commitment.ssa_commitment),
5829                    "a proof with byte {position} flipped must not verify"
5830                );
5831            }
5832        }
5833
5834        // Bound to the SSA index, so it cannot be replayed onto another cycle even if the commitment
5835        // were somehow reused.
5836        let other_index = SsaId::new(pseudonym, SsaIndex::new(SsaIndex::MIN.get() + 1).expect("non-zero"));
5837        assert!(
5838            !proof.verify(&other_index, &commitment.ssa_commitment),
5839            "a proof must not verify against a different SSA index"
5840        );
5841
5842        // And bound to the commitment: this is the property the whole thing exists for.
5843        let unrelated = PixGroup::<TestSpec>::mul_by_generator(&PixScalar::<TestSpec>::random(
5844            &mut hopr_types::crypto_random::rng(),
5845        ));
5846        assert!(
5847            !proof.verify(&ssa_id, &unrelated),
5848            "a proof must not verify against a commitment it does not open"
5849        );
5850
5851        // A truncated or over-long encoding is refused outright.
5852        assert!(SsaCommitmentProof::<TestSpec>::try_from_bytes(&bytes[..bytes.len() - 1]).is_err());
5853        assert!(SsaCommitmentProof::<TestSpec>::try_from_bytes(&[bytes.as_slice(), &[0u8]].concat()).is_err());
5854
5855        Ok(())
5856    }
5857
5858    /// A constant-term set that carries no proof at all is refused, exactly as one carrying an
5859    /// invalid proof is: the Exit cannot tell the difference, and both mean the cycle is unusable.
5860    #[test]
5861    fn constant_terms_arriving_without_a_proof_are_refused() -> anyhow::Result<()> {
5862        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
5863            polynomials_per_ssa: 2,
5864            threshold: 2,
5865            surplus_shares: 0,
5866        });
5867        let pseudonym = SimplePseudonym::random();
5868        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
5869        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
5870
5871        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
5872        reconstructor.new_exit_commitment(ssa_id, test_params(2, 2))?;
5873
5874        let refused = reconstructor.insert_coefficient_commitments(
5875            ssa_id,
5876            0,
5877            None,
5878            coefficient_of(&commitment, 0, None)?.into_iter(),
5879        );
5880        assert!(
5881            matches!(refused, Err(PixError::UnprovenSsaCommitment)),
5882            "constant terms without a proof must be refused, got {refused:?}"
5883        );
5884        assert_eq!(0, reconstructor.live_cycles(), "no cycle may be published");
5885
5886        Ok(())
5887    }
5888
5889    /// A client commitment crafted so the Entry alone knows the *combined* deposit key must be
5890    /// refused, and must not produce a deposit address.
5891    ///
5892    /// The deposit key is `s + e`, where `s` is the sum of the Entry's polynomial constant terms and
5893    /// `e` is the Exit's commitment secret. Neither party is supposed to know the sum. But
5894    /// `SsaRequest` hands `e·G` to the Entry *before* it chooses its own constant terms
5895    /// (`protocols/start/src/lib.rs:305`, consumed at `transport/session/src/manager.rs:2879-2883`),
5896    /// so an Entry that is made to prove nothing can pick `w`, publish constant terms summing to
5897    /// `w·G − e·G`, and end up with a combined commitment of `w·G` — a key it can sweep alone.
5898    ///
5899    /// It cannot then produce shares for the polynomial whose constant term it does not know, so the
5900    /// Exit never recovers the SSA and is never paid. But the Entry controls emission order
5901    /// (`generator.rs` builds `poly_queue`, `next_share` drains `front_mut()`) and puts that
5902    /// polynomial last, so it is served nearly the whole cycle before the Exit notices — by which
5903    /// time it has already swept the deposit. Note this is distinct from the by-design burn
5904    /// semantics: there *neither* party can recover, whereas here the party that owes can.
5905    ///
5906    /// Before [`SsaCommitmentProof`] existed this construction was accepted, and the Exit published
5907    /// `addr(w·G)` as the address to watch — verified by asserting exactly that. The proof cannot be
5908    /// forged here because producing it would require `dlog(w·G − e·G)`, and knowing that together
5909    /// with `w` yields `e`. So the assertion is now inverted.
5910    #[test]
5911    fn exit_refuses_a_client_commitment_whose_deposit_key_the_entry_knows() -> anyhow::Result<()> {
5912        const POLYS: u16 = 3;
5913        const THRESHOLD: u8 = 2;
5914
5915        let mut rng = hopr_types::crypto_random::rng();
5916        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
5917        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
5918
5919        // The Exit reveals its half. This is exactly what `SsaRequest` carries to the Entry.
5920        let exit_public = reconstructor.new_exit_commitment(ssa_id, test_params(POLYS, THRESHOLD))?;
5921
5922        // The attacker picks the deposit key it wants to end up holding.
5923        let w = PixScalar::<TestSpec>::random(&mut rng);
5924        let target = PixGroup::<TestSpec>::mul_by_generator(&w);
5925
5926        // Honest constant terms for every polynomial but the last.
5927        let honest: Vec<PixGroup<TestSpec>> = (0..POLYS - 1)
5928            .map(|_| PixGroup::<TestSpec>::mul_by_generator(&PixScalar::<TestSpec>::random(&mut rng)))
5929            .collect();
5930        let honest_sum: PixGroup<TestSpec> = honest.iter().copied().sum();
5931
5932        // The last one is obtained by group subtraction. The attacker never learns its discrete log
5933        // — that would require the Exit's secret — and does not need to.
5934        let rogue = target - exit_public - honest_sum;
5935
5936        let mut constant_terms: HashMap<PolynomialIndex, PixGroupRepr<TestSpec>> = honest
5937            .iter()
5938            .enumerate()
5939            .map(|(poly_index, c0)| (poly_index as PolynomialIndex, c0.to_bytes()))
5940            .collect();
5941        constant_terms.insert((POLYS - 1) as PolynomialIndex, rogue.to_bytes());
5942
5943        // The best the attacker can offer is a proof over the client commitment it actually
5944        // published, using the only scalar it knows — which is not that commitment's discrete log.
5945        let bogus_proof = SsaCommitmentProof::prove(&ssa_id, &w, &(target - exit_public))?;
5946
5947        let rejected =
5948            reconstructor.insert_coefficient_commitments(ssa_id, 0, Some(bogus_proof), constant_terms.into_iter());
5949        assert!(
5950            matches!(rejected, Err(PixError::UnprovenSsaCommitment)),
5951            "a commitment whose discrete logarithm the sender does not know must be refused, got {rejected:?}"
5952        );
5953
5954        // Nothing about the cycle may have been published: with no deposit address the strategy is
5955        // never asked to fund an SSA the Entry could reclaim.
5956        assert!(
5957            reconstructor
5958                .commitment_builder
5959                .get(&ssa_id)
5960                .is_some_and(|b| b.lock().get_deposit_address().is_none()),
5961            "no deposit address may be derived from an unproven commitment"
5962        );
5963        // The published cycle is what makes an SSA live and able to accept recovered shares.
5964        // `commitment_builder` legitimately still exists — `new_exit_commitment` created it.
5965        assert_eq!(
5966            0,
5967            reconstructor.live_cycles(),
5968            "no cycle must be published for an unproven commitment"
5969        );
5970
5971        Ok(())
5972    }
5973}