Skip to main content

hopr_protocol_pix/reconstructor/
mod.rs

1mod utils;
2
3use hopr_types::{
4    crypto::{
5        crypto_traits::elliptic_curve::Field,
6        prelude::{HalfKey, HalfKeyChallenge, OffchainPublicKey},
7    },
8    internal::prelude::Acknowledgement,
9};
10use utils::{AddShareOutcome, SsaCommitmentBuilder, SsaCycle};
11use validator::Validate;
12
13use crate::{
14    CoefficientIndex, ExitAcknowledgementShareProcessor, Group, MAX_POLY_THRESHOLD, MAX_POLYS_PER_SSA, PixGroup,
15    PixGroupRepr, PixScalar, PixSpec, PolynomialIndex, RecoveredSsa, ShareResolution, SsaCommitmentProof,
16    SsaCommitmentState, SsaPolynomialId, SsaRecoveryProgress, TaggedEncryptedPartialSsaShare, errors::PixError,
17    types::SsaId,
18};
19
20/// Configuration for the SSA reconstructor.
21#[derive(Debug, Clone, Copy, PartialEq, smart_default::SmartDefault, validator::Validate)]
22pub struct SsaReconstructorConfig {
23    /// Time until the complete commitment to an SSA must be received.
24    ///
25    /// Default is 2 minutes.
26    #[default(Self::DEFAULT_INCOMPLETE_COMMITMENT_LIFETIME)]
27    pub incomplete_commitment_lifetime: std::time::Duration,
28    /// Maximum time an SSA cycle can go without progress before it is discarded.
29    ///
30    /// Measured from the last acknowledged share *anywhere* in the cycle, not per polynomial — see
31    /// `SsaCycle` for why that distinction is load-bearing. A cycle that is still being served
32    /// therefore never expires, whatever the line rate.
33    ///
34    /// Default is 30 minutes.
35    #[default(Self::DEFAULT_UNUSED_VERIFIER_LIFETIME)]
36    pub unused_verifier_lifetime: std::time::Duration,
37    /// Maximum number of peers that can be tracked simultaneously with unacknowledged shares.
38    ///
39    /// Default is 2000, minimum is 10.
40    ///
41    /// This is a per-*peer* fan-out bound, and it guards the opposite concentration to
42    /// [`max_awaiting_acks`](Self::max_awaiting_acks): traffic spread thinly across many
43    /// first-relayers. The two cannot both be saturated at once, which is why their product is not
44    /// the reconstructor's memory bound — see `PixReconstructorConfig` in `hopr-transport` for the
45    /// bound that is.
46    #[validate(range(min = 10))]
47    #[default(Self::DEFAULT_MAX_TRACKED_PEERS)]
48    pub max_tracked_peers: usize,
49    /// Maximum number of awaited acknowledgements to extract a single share.
50    ///
51    /// This corresponds to the maximum number of unacknowledged HOPR packets awaiting acknowledgement.
52    ///
53    /// Default is 1 000 000, must be at least 10 000.
54    ///
55    /// Sizes one inner cache **per peer**, so it must cover the concentrated case: every Session on
56    /// the node returning through a single first-relayer. At the operating point
57    /// `tests/memory_profile.rs` models that is ~542 000 entries, which is what makes a cap of this
58    /// order the right one rather than an oversight.
59    #[default(Self::DEFAULT_MAX_AWAITING_ACKS)]
60    #[validate(range(min = 10000))]
61    pub max_awaiting_acks: usize,
62    /// Maximum time an acknowledgement can be awaited before it is discarded.
63    ///
64    /// Default is 30 seconds.
65    ///
66    /// Multiplies the whole awaiting-ack buffer: the reachable state is the Exit's share-emission
67    /// rate times this window, so it — not either cap above — is the dial that actually sizes the
68    /// buffer.
69    #[default(Self::DEFAULT_MAX_ACK_AWAIT_TIME)]
70    pub max_ack_await_time: std::time::Duration,
71    /// Indicates whether to use batch verification algorithm for acknowledgements.
72    ///
73    /// Default is false.
74    ///
75    /// Batching only covers the acknowledgement *signature* check. While each share also cost a
76    /// `threshold`-term multi-scalar multiplication, that MSM dominated and the choice was
77    /// immaterial — measured 2.46 MiB/s batched against 2.50 MiB/s unbatched. Committing to the
78    /// constant term alone removed the MSM, and the batching overhead is no longer hidden by it:
79    /// the same benchmark then measures 50.2 MiB/s batched against 92.8 MiB/s unbatched, so
80    /// batching costs 46 % of the sustained rate.
81    ///
82    /// **Settled against the concurrent pipeline shape**, which is what the Exit actually runs and
83    /// what the sequential figures above could not speak to. `concurrent_quota_rate` on 48 cores at
84    /// production width, aggregate MiB/s of Session quota:
85    ///
86    /// | callers | unbatched | batched  |                              |
87    /// | ------- | --------- | -------- | ---------------------------- |
88    /// | 1       | **90.9**  | 46.4     | unbatched 1.96×              |
89    /// | 10      | 130.2     | 126.2    | tie — confidence intervals overlap |
90    /// | 48      | 139.8     | **152.6**| batched 1.09×                |
91    ///
92    /// So batching does eventually pay, but only *above* the concurrency the pipeline is
93    /// configured for: `DEFAULT_ACK_INPUT_CONCURRENCY` is 10, which is precisely the row where the
94    /// two are indistinguishable. `false` stays the default because it is far better at low
95    /// concurrency and no worse at the configured one, making it the safer choice across the range
96    /// an operator can set — not because batching is slower everywhere.
97    ///
98    /// Kept configurable for the operator who raises `ack_input_concurrency` well past its default,
99    /// where the last row says the choice flips.
100    ///
101    /// **Headroom is narrower than it looks.** A deployed Exit serves 10–30 clients at 16–20 Mbps,
102    /// so it absorbs 19–72 MiB/s; against the 130 MiB/s above that is **1.8× at the top of the
103    /// range**, not the 7.3× an earlier reading of these figures claimed. That claim came from
104    /// comparing against the benchmark suite's own model of production — 100 Sessions at 1.5 Mbps,
105    /// 18.75 MiB/s — which understates real per-Session rate by 13×. The rates here are also
106    /// measured at 4096 polynomials rather than the 512 used previously, which costs about 5 %.
107    #[default(Self::DEFAULT_USE_BATCH_VERIFICATION)]
108    pub use_batch_verification: bool,
109    /// Fraction of reconstructed polynomials at which to emit an early recovery
110    /// notification, triggering pipelined SSA request preparation.
111    ///
112    /// Range: 0.0..1.0. Default: 0.85.
113    #[default(Self::DEFAULT_EARLY_RECOVERY_THRESHOLD)]
114    #[validate(range(min = 0.0, max = 1.0))]
115    pub early_recovery_threshold: f64,
116    /// Ceiling on the total live state held in the awaiting-acknowledgement buffer, across every
117    /// peer, in bytes.
118    ///
119    /// This is the *global* bound; [`max_tracked_peers`](Self::max_tracked_peers) and
120    /// [`max_awaiting_acks`](Self::max_awaiting_acks) are per-dimension backstops and their product
121    /// is not one. See [`SsaReconstructor::insert_encrypted_share`] for why the product overstates
122    /// by roughly three thousandfold and why bounding it instead would lose shares.
123    ///
124    /// Enforced at insertion time rather than by validating a workload model. A model has to assume
125    /// a Session count and a packet rate; the node enforces neither — `maximum_managed_sessions`
126    /// validates to 100 000 and `SessionCapability::NoRateControl` removes the rate limiter
127    /// entirely — so a configuration can be perfectly valid and still exceed any modelled budget.
128    /// Counting what is actually held is indifferent to all of that.
129    ///
130    /// Default is 1 GiB, which at [`AWAITING_ACK_ENTRY_BYTES`] is ~2.68 M shares in flight.
131    ///
132    /// The minimum is 25 600 B — 64 entries. That is a sanity floor, not a sizing recommendation:
133    /// its only job is to stop the budget rounding down to a handful of shares. Whether a given
134    /// value is *adequate* depends on the node's traffic, which is exactly the thing this design
135    /// stopped trying to predict, so the floor deliberately does not pretend to encode it. A node
136    /// configured near it will drop shares and say so.
137    #[default(Self::DEFAULT_MAX_ACK_BUFFER_BYTES)]
138    #[validate(range(min = 25_600))]
139    pub max_ack_buffer_bytes: usize,
140}
141
142/// Live heap one entry in the awaiting-acknowledgement buffer costs, in bytes.
143///
144/// **Measured, not derived.** `size_of` accounts for only 145 B of it — a 33 B `HalfKeyChallenge`
145/// key and a 112 B [`TaggedEncryptedPartialSsaShare`] value, both inline arrays — and moka's
146/// per-entry bookkeeping (hash map entry, LRU node, TTL timer-wheel node, the `Arc` around the
147/// value) is the other 244 B. Run `awaiting_ack_entry_cost` in `tests/memory_profile.rs` to
148/// re-derive it; at the time of writing it reports 383 B/entry at 20 000 entries and 389 B/entry at
149/// 100 000. Rounded up, because understating it would let
150/// [`max_ack_buffer_bytes`](SsaReconstructorConfig::max_ack_buffer_bytes) be exceeded.
151///
152/// Every entry is this size — the payload is fixed-width inline arrays with no indirection — which
153/// is what lets the runtime bound count entries rather than weigh each one.
154pub const AWAITING_ACK_ENTRY_BYTES: usize = 400;
155
156/// The defaults, named so that a mirror can share them instead of restating them.
157///
158/// `hopr-transport`'s `PixReconstructorConfig` is the operator-facing shape of this struct, and a
159/// second copy of these literals over there would be a second thing to keep true. Referencing them
160/// from both `#[default(…)]` sites means the two cannot disagree by construction, which is a
161/// stronger guarantee than any test comparing them after the fact.
162impl SsaReconstructorConfig {
163    /// Default [`early_recovery_threshold`](Self::early_recovery_threshold).
164    pub const DEFAULT_EARLY_RECOVERY_THRESHOLD: f64 = 0.85;
165    /// Default [`incomplete_commitment_lifetime`](Self::incomplete_commitment_lifetime).
166    pub const DEFAULT_INCOMPLETE_COMMITMENT_LIFETIME: std::time::Duration = std::time::Duration::from_secs(120);
167    /// Default [`max_ack_await_time`](Self::max_ack_await_time).
168    pub const DEFAULT_MAX_ACK_AWAIT_TIME: std::time::Duration = std::time::Duration::from_secs(30);
169    /// Default [`max_ack_buffer_bytes`](Self::max_ack_buffer_bytes).
170    pub const DEFAULT_MAX_ACK_BUFFER_BYTES: usize = 1024 * 1024 * 1024;
171    /// Default [`max_awaiting_acks`](Self::max_awaiting_acks).
172    pub const DEFAULT_MAX_AWAITING_ACKS: usize = 1_000_000;
173    /// Default [`max_tracked_peers`](Self::max_tracked_peers).
174    pub const DEFAULT_MAX_TRACKED_PEERS: usize = 2000;
175    /// Default [`unused_verifier_lifetime`](Self::unused_verifier_lifetime).
176    pub const DEFAULT_UNUSED_VERIFIER_LIFETIME: std::time::Duration = std::time::Duration::from_secs(1800);
177    /// Default [`use_batch_verification`](Self::use_batch_verification).
178    pub const DEFAULT_USE_BATCH_VERIFICATION: bool = false;
179}
180
181type EncryptedShareCache<S> =
182    moka::sync::Cache<HalfKeyChallenge, TaggedEncryptedPartialSsaShare<S, <S as PixSpec>::Pseudonym, PixScalar<S>>>;
183
184/// An acknowledgement that arrived before its polynomial's verifier was installed.
185///
186/// The peer is carried per entry because a polynomial's shares are spread across return paths, and
187/// therefore across first-relayers: one bucket can hold deferred acks from several peers.
188type DeferredAck = (OffchainPublicKey, HalfKeyChallenge, HalfKey);
189
190/// Deferred acknowledgements for one cycle, drained in one shot when its part builders install.
191///
192/// Plain `Vec`s under one mutex rather than nested caches: a bucket is only ever appended to and
193/// then drained whole, so per-entry cache bookkeeping (and its ~200 B overhead per entry) buys
194/// nothing. The mutex serialises deferrals *within one cycle* only, and deferral is O(1) work off
195/// the steady-state path.
196#[derive(Default)]
197struct DeferredAcks {
198    by_poly: std::collections::HashMap<PolynomialIndex, Vec<DeferredAck>>,
199    /// Running sum of the `by_poly` lengths, maintained so the per-cycle cap is an O(1) check.
200    ///
201    /// Recomputing it would walk every sub-bucket inside the mutex on every deferral, and there
202    /// can be one sub-bucket per entry: filling a bucket to
203    /// [`MAX_DEFERRED_ACKS_PER_CYCLE`] would then cost ~33M map-entry visits, on the path every
204    /// acknowledgement takes during the commitment window. The invariant
205    /// `total == by_poly.values().map(Vec::len).sum()` has one increment site and one reset site,
206    /// both under this mutex.
207    total: usize,
208    /// Set by the one drain this bucket will ever get, in the same critical section as the take.
209    ///
210    /// A bucket is reachable through two routes: the `pending_acks` key, and an `Arc` a
211    /// [`defer_ack`](SsaReconstructor::defer_ack) already holds. The drain removes the first but
212    /// cannot revoke the second, so an append that lands after it would sit in a bucket nothing
213    /// will ever read again. This flag is how such an append notices — see
214    /// [`Deferral::Orphaned`].
215    drained: bool,
216}
217
218type DeferredAckBucket = std::sync::Arc<parking_lot::Mutex<DeferredAcks>>;
219
220/// What became of an acknowledgement handed to [`defer_ack`](SsaReconstructor::defer_ack).
221///
222/// Fieldless — the caller still owns the acknowledgement, since [`DeferredAck`] is `Copy` — so this
223/// stays a discriminant rather than carrying 264 bytes back out of the critical section.
224#[derive(Clone, Copy)]
225enum Deferral {
226    /// Appended to a live bucket. The drain that installs the cycle will redeem it.
227    Buffered,
228    /// The bucket had already been drained, so no drain will come for this one. The caller must
229    /// redeem it inline; parking it would be silent loss.
230    Orphaned,
231    /// Over one of the two caps. Already warned about, and deliberately discarded.
232    Dropped,
233}
234
235/// Cap on deferred acknowledgements held for a single polynomial.
236///
237/// A conforming Entry emits `threshold + surplus` shares per polynomial — 96 at the default
238/// dimensions — across all return paths combined, so at the defaults this cannot be reached without
239/// the peer exceeding its own share budget. Anything above the cap is dropped rather than buffered.
240///
241/// It is *not* unreachable in general: both halves are a byte wide, so a conforming Entry may
242/// legitimately announce up to `255 + 255` and have its excess deferrals silently discarded. Both
243/// values now travel in [`PixParams`](crate::PixParams), so an Exit that cares can compare
244/// `shares_per_poly + surplus_shares` against this cap when it accepts a Session, instead of
245/// discovering the overflow one dropped acknowledgement at a time.
246///
247/// Public because it is observable behaviour, not an implementation detail: past the cap an
248/// acknowledgement is discarded, so anything measuring or exercising the deferral path has to stay
249/// underneath it or it silently measures the discard instead.
250pub const MAX_DEFERRED_ACKS_PER_POLYNOMIAL: usize = 128;
251
252/// Cap on deferred acknowledgements held across all polynomials of one cycle.
253///
254/// The per-polynomial cap alone leaves the cycle total at `num_polys × 128` — a million entries at
255/// production dimensions, which is no bound at all. This one is derived rather than chosen: the
256/// drain discards any acknowledgement whose share has already left `awaiting_acks`, and that cache
257/// expires entries after
258/// `max_ack_await_time` (30 s by default). An older deferral is therefore provably dead, so the
259/// ceiling only has to cover the shares one cycle can receive inside that window: ~181 shares/s at
260/// the deployed 1.5 Mbps per-Session cap, so ~5 400. 8192 leaves ~1.5× headroom and costs at most
261/// ~786 KB per cycle.
262///
263/// Public for the same reason as [`MAX_DEFERRED_ACKS_PER_POLYNOMIAL`].
264pub const MAX_DEFERRED_ACKS_PER_CYCLE: usize = 8192;
265
266/// Allows server-side reconstruction of SSAs.
267///
268/// There are 3 inputs that reconstructor is dependent on (in order):
269/// 1. SSA commitments from the Client (delivered via
270///    [`insert_coefficient_commitments`](ExitAcknowledgementShareProcessor::insert_coefficient_commitments))
271/// 2. Extraction of pending encrypted shares (added via
272///    [`insert_encrypted_share`](ExitAcknowledgementShareProcessor::insert_encrypted_share)
273/// 3. Decryption of pending encrypted shares via [`Acknowledgement`]s (via
274///    [`acknowledge_shares`](ExitAcknowledgementShareProcessor::acknowledge_shares))
275///
276/// It is able to track SSA for multiple different pseudonyms (Sessions).
277pub struct SsaReconstructor<S: PixSpec> {
278    commitment_builder:
279        moka::sync::Cache<SsaId<S::Pseudonym>, std::sync::Arc<parking_lot::Mutex<SsaCommitmentBuilder<S>>>>,
280    /// Post-commitment state of every live cycle: the part accumulator and all part builders,
281    /// published and reclaimed as one unit. See [`SsaCycle`].
282    ssa_cycles: moka::sync::Cache<SsaId<S::Pseudonym>, std::sync::Arc<SsaCycle<S>>>,
283    awaiting_acks: moka::sync::Cache<OffchainPublicKey, EncryptedShareCache<S>>,
284    /// Acknowledgements that arrived before their cycle's part builders were installed, bucketed by
285    /// cycle and then by polynomial.
286    ///
287    /// ## Why bucketed at all
288    ///
289    /// The bucket key is exactly the thing whose arrival unblocks the entries inside it, so a
290    /// bucket is drained once, by the installation of its own cycle, and never scanned
291    /// speculatively. That is what keeps [`Self::acknowledge_shares`] free of retry work: it only
292    /// ever *appends* to a bucket.
293    ///
294    /// The original per-peer stash had to be re-scanned in full on every `acknowledge_shares` call,
295    /// because a per-peer key says nothing about which entries have become viable. That is
296    /// quadratic in the number of acks received while a cycle's commitments are in flight, and the
297    /// per-peer key aggregates across every Session sharing a first-relayer.
298    ///
299    /// ## Why keyed by cycle, sub-bucketed by polynomial
300    ///
301    /// Part builders are installed for a whole cycle at once, so a per-polynomial *key* no longer
302    /// buys selective draining — the drain would just walk every polynomial of the cycle. Keying by
303    /// cycle makes it one lookup. The per-polynomial sub-bucket is kept because its cap is what
304    /// bounds a misbehaving peer (see [`MAX_DEFERRED_ACKS_PER_POLYNOMIAL`]).
305    ///
306    /// The capacity unit is cycles, not polynomials. Keyed per polynomial it was `2 *
307    /// MAX_POLYS_PER_SSA` entries — which one cycle can exhaust on its own, so past roughly four
308    /// concurrent cycles node-wide, moka began LRU-evicting buckets and silently dropping real
309    /// shares. A size eviction here is share loss, so the headroom is deliberate and the
310    /// `max_ack_await_time` TTL is the operative bound.
311    pending_acks: moka::sync::Cache<SsaId<S::Pseudonym>, DeferredAckBucket>,
312    /// Resolutions produced by draining deferred-ack buckets at verifier-installation time, waiting
313    /// to be picked up by the next [`Self::acknowledge_shares`] call.
314    ///
315    /// Draining happens on the commitment path (`insert_coefficient_commitments`), which is where
316    /// the verifier that unblocks the acks is installed. That deliberately keeps the share
317    /// verification off the acknowledgement hot path, but it also means the resolutions surface
318    /// somewhere that has no route to the upper layer — hence this hand-off. Acks flow continuously
319    /// while a Session is live, so pickup latency is one ack batch.
320    ready_resolutions: parking_lot::Mutex<Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>>>,
321    /// Length of [`ready_resolutions`](Self::ready_resolutions), so the common case (nothing to pick
322    /// up) costs one relaxed load instead of a mutex acquisition on every ack batch.
323    ready_resolutions_len: std::sync::atomic::AtomicUsize,
324    /// Tombstone set: SsaIds that have been retired. The commitment completion path checks this
325    /// after publishing the cycle, preventing resurrection when `retire_ssa` runs concurrently.
326    retired_ssas: moka::sync::Cache<SsaId<S::Pseudonym>, ()>,
327    /// Running estimate of the entries live in [`awaiting_acks`](Self::awaiting_acks), summed over
328    /// every peer, so the global budget costs one relaxed load per insertion.
329    ///
330    /// An *estimate*, and knowingly so — see [`Self::resync_ack_buffer`] for the one drift source
331    /// that cannot be listened for and what keeps it from accumulating.
332    ///
333    /// Behind an `Arc` because each peer's inner cache decrements it from an eviction listener, and
334    /// moka requires those to be `'static` — they cannot borrow the reconstructor that owns them.
335    ack_buffer_entries: std::sync::Arc<std::sync::atomic::AtomicUsize>,
336    /// [`max_ack_buffer_bytes`](SsaReconstructorConfig::max_ack_buffer_bytes) in entries, divided
337    /// once here rather than on every insertion.
338    max_ack_buffer_entries: usize,
339    /// When [`resync_ack_buffer`](Self::resync_ack_buffer) last ran, and the lock that keeps two
340    /// from running at once.
341    ///
342    /// `None` until the first run, so a buffer that saturates immediately is not made to wait out
343    /// an interval before its first ground-truth reading.
344    ack_buffer_resync: parking_lot::Mutex<Option<std::time::Instant>>,
345    cfg: SsaReconstructorConfig,
346}
347
348/// Result of processing a single verified acknowledgement in the SSA reconstructor.
349///
350/// The counters behind [`SsaRecoveryProgress`] are updated by `process_verified_ack` itself, on the
351/// cycle it already holds, so these variants only have to say *whether* a snapshot is worth emitting
352/// — not what changed. That is why a duplicate, a surplus share and an unmatched acknowledgement all
353/// collapse into [`NoProgress`](Self::NoProgress): none of them moves a counter, so none of them can
354/// make a snapshot differ from the last one sent.
355enum ProcessedAckResult<S: PixSpec> {
356    /// Nothing to report: the acknowledgement matched no pending share, or the share was a
357    /// duplicate, a surplus, or absorbed by an already-failed polynomial.
358    NoProgress,
359    /// The share is valid but its polynomial's verifier is not installed yet, so it cannot be
360    /// checked. Deferral, not failure: the ack is bucketed under this
361    /// [`SsaPolynomialId`] and retried once the verifier arrives.
362    VerifierNotReady(SsaPolynomialId<<S as PixSpec>::Pseudonym>),
363    /// The share advanced reconstruction without finishing it.
364    Progressed(SsaRecoveryProgress<<S as PixSpec>::Pseudonym>),
365    /// The share failed verification. Carries the SSA's aggregate fault total across all peers.
366    InvalidShare(SsaId<<S as PixSpec>::Pseudonym>, u64),
367    /// The early recovery threshold was crossed.
368    EarlyRecovery(SsaRecoveryProgress<<S as PixSpec>::Pseudonym>),
369    /// Full SSA was recovered. Carries the cycle's final progress, captured before its state was
370    /// released — afterwards there is nothing left to read it from.
371    FullRecovery(
372        RecoveredSsa<<S as PixSpec>::Pseudonym, <S as PixSpec>::AddressPrivateKey>,
373        SsaRecoveryProgress<<S as PixSpec>::Pseudonym>,
374    ),
375}
376
377/// Merges a snapshot into the batch's pending set, keeping the furthest-along one per SSA.
378///
379/// Concurrent batches share a cycle's counters, so snapshots taken microseconds apart can be
380/// unordered. Keeping the maximum means one batch never reports its own SSA going backwards.
381fn record_progress<P: PartialEq>(acc: &mut Vec<SsaRecoveryProgress<P>>, snapshot: SsaRecoveryProgress<P>) {
382    match acc.iter_mut().find(|p| p.ssa_id == snapshot.ssa_id) {
383        Some(existing) if existing.useful_shares >= snapshot.useful_shares => {}
384        Some(existing) => *existing = snapshot,
385        None => acc.push(snapshot),
386    }
387}
388
389/// One SSA's fault observation, with the relayer that carried the offending share.
390type FaultObservation<P> = (Box<OffchainPublicKey>, SsaId<P>, u64);
391
392/// Merges a fault observation into the batch's pending set, keeping the highest total per SSA.
393///
394/// The relayer travels with the total rather than being filled in at emission time: a batch can also
395/// carry faults redeemed from deferral, and those were relayed by whoever held the share at the time
396/// — not by the peer whose acknowledgements are being processed now.
397fn record_fault<P: PartialEq>(acc: &mut Vec<FaultObservation<P>>, observation: FaultObservation<P>) {
398    match acc.iter_mut().find(|(_, id, _)| *id == observation.1) {
399        Some(existing) if existing.2 >= observation.2 => {}
400        Some(existing) => *existing = observation,
401        None => acc.push(observation),
402    }
403}
404
405/// Appends a resolution unless an equal one is already present.
406fn push_unique<P: PartialEq, A>(acc: &mut Vec<ShareResolution<P, A>>, resolution: ShareResolution<P, A>) {
407    if !acc.contains(&resolution) {
408        acc.push(resolution);
409    }
410}
411
412impl<S: PixSpec + Clone> Default for SsaReconstructor<S> {
413    fn default() -> Self {
414        Self::new(Default::default())
415    }
416}
417
418impl<S: PixSpec + Clone> SsaReconstructor<S> {
419    /// Creates a new SSA reconstructor from the given configuration.
420    ///
421    /// Fails if the configuration does not validate. Prefer this over [`Self::new`] anywhere the
422    /// configuration is assembled at runtime — a config built programmatically or read from a file
423    /// is input, not a constant, and turning it into a panic makes it un-handleable by the caller.
424    pub fn try_new(cfg: SsaReconstructorConfig) -> Result<Self, PixError<S::Pseudonym>> {
425        cfg.validate()?;
426        Ok(Self {
427            commitment_builder: moka::sync::Cache::builder()
428                .time_to_idle(cfg.incomplete_commitment_lifetime)
429                .build(),
430            // Indispensable per-cycle state: never size-evicted. Built without a `max_capacity`,
431            // so only `time_to_idle` reclaims it. Active removal happens via `remove_cycle` on
432            // full recovery and `retire_ssa` on session teardown; the TTL is the backstop.
433            // A hard capacity would silently strand a live cycle.
434            //
435            // The idle timer is refreshed by an acknowledgement for *any* polynomial of the cycle,
436            // because the whole cycle is one entry. That is what makes reclamation correct at any
437            // line rate — see `SsaCycle`.
438            ssa_cycles: moka::sync::Cache::builder()
439                .time_to_idle(cfg.unused_verifier_lifetime)
440                .build(),
441            awaiting_acks: moka::sync::CacheBuilder::new(cfg.max_tracked_peers as u64)
442                .time_to_idle(cfg.max_ack_await_time)
443                // Dropping a peer entry drops its whole inner cache, and dropping a moka handle
444                // does not run that cache's eviction listener — so without this, every entry the
445                // peer still held would stay counted against the global budget forever. Invalidating
446                // routes them through the inner listener instead.
447                //
448                // `run_pending_tasks()` is deliberately not called here: it is unbounded work on
449                // whichever thread happened to trigger maintenance. That leaves the invalidation
450                // best-effort, which is precisely why `resync_ack_buffer` exists — this narrows the
451                // drift, it does not close it.
452                .eviction_listener(|_, shares: EncryptedShareCache<S>, _| shares.invalidate_all())
453                .build(),
454            // One bucket per cycle, expiring on the same clock as the shares it belongs to: an ack
455            // whose share has left `awaiting_acks` can never be used again, so there is nothing to
456            // keep. `time_to_live`, not idle — appending to a bucket must not extend the life of
457            // entries already in it.
458            //
459            // The capacity is in cycles. `MAX_POLYS_PER_SSA` is reused only as a generous count of
460            // concurrently deferring cycles; a size eviction here is share loss, so it is
461            // deliberately far above the pipelining factor and the TTL is the operative bound.
462            pending_acks: moka::sync::CacheBuilder::new(MAX_POLYS_PER_SSA as u64)
463                .time_to_live(cfg.max_ack_await_time)
464                .build(),
465            ready_resolutions: parking_lot::Mutex::new(Vec::new()),
466            ready_resolutions_len: std::sync::atomic::AtomicUsize::new(0),
467            // Tombstone set. Its immediate job is the window between `retire_ssa` running and a
468            // concurrent commitment completion publishing its cycle — but the TTL must outlive that
469            // by a long way, because retirement is also permanent: a cycle re-registered at the same
470            // `SsaId` after being abandoned must stay retired, which is what
471            // `abandoning_a_live_cycle_retires_it_rather_than_just_releasing_it` asserts. Shortening
472            // this to the width of the race would break that contract silently.
473            //
474            // Unbounded in count, deliberately for now: a size eviction here permits exactly the
475            // resurrection the tombstone prevents, so a capacity has to be chosen against the
476            // concurrent-Session budget rather than picked. That belongs with the global admission
477            // control the memory work still owes.
478            retired_ssas: moka::sync::Cache::builder()
479                .time_to_idle(cfg.unused_verifier_lifetime)
480                .build(),
481            ack_buffer_entries: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
482            // At least one, so a budget rounded below one entry refuses everything rather than
483            // dividing to zero and admitting everything.
484            max_ack_buffer_entries: (cfg.max_ack_buffer_bytes / AWAITING_ACK_ENTRY_BYTES).max(1),
485            ack_buffer_resync: parking_lot::Mutex::new(None),
486            cfg,
487        })
488    }
489
490    /// Creates a new SSA reconstructor from the given configuration.
491    ///
492    /// # Panics
493    /// Panics if the configuration fails validation. Use [`Self::try_new`] to handle that case
494    /// instead.
495    pub fn new(cfg: SsaReconstructorConfig) -> Self {
496        Self::try_new(cfg).expect("invalid SsaReconstructorConfig")
497    }
498
499    /// Returns the configuration of the reconstructor.
500    #[inline]
501    pub fn config(&self) -> &SsaReconstructorConfig {
502        &self.cfg
503    }
504
505    /// Returns `true` if the reconstructor still holds a builder (SSA-part
506    /// builder or commitment builder) for the given cycle.  Used by tests to
507    /// verify that [`retire_ssa`](ExitAcknowledgementShareProcessor::retire_ssa)
508    /// cleaned up the expected state.
509    pub fn contains_builder(&self, ssa_id: &SsaId<S::Pseudonym>) -> bool {
510        self.ssa_cycles.contains_key(ssa_id) || self.commitment_builder.contains_key(ssa_id)
511    }
512
513    /// Removes all reconstructor state for a single SSA cycle.
514    ///
515    /// Idempotent: invalidating an absent key is a no-op.
516    fn remove_cycle(&self, ssa_id: SsaId<S::Pseudonym>) {
517        self.ssa_cycles.invalidate(&ssa_id);
518        // Deferred acks for a retired cycle can never be redeemed — their part builders will not
519        // come back and their shares are about to expire.
520        self.pending_acks.invalidate(&ssa_id);
521        self.commitment_builder.invalidate(&ssa_id);
522    }
523
524    fn process_verified_ack(
525        &self,
526        ack: HalfKey,
527        ack_challenge: HalfKeyChallenge,
528        awaiting_ack_from_peer: &moka::sync::Cache<HalfKeyChallenge, TaggedEncryptedPartialSsaShare<S>>,
529    ) -> Result<ProcessedAckResult<S>, PixError<S::Pseudonym>> {
530        let Some(share) = awaiting_ack_from_peer.get(&ack_challenge) else {
531            tracing::trace!(?ack_challenge, "received ack for unknown share");
532            return Ok(ProcessedAckResult::NoProgress);
533        };
534
535        let spi = share.ssa_polynomial_id().ok_or(PixError::ShareIsEmpty)?;
536
537        // One lookup for the whole cycle: the part builders and the accumulator are published and
538        // reclaimed together, so there is no state in which one is reachable and the other is not.
539        // The lookup also refreshes the cycle's idle timer, which is what keeps a cycle that is
540        // still being served from being reclaimed underneath itself.
541        let Some(cycle) = self.ssa_cycles.get(spi.as_ref()) else {
542            // Not an error: the constant-term set is still incomplete, so no part builder exists
543            // yet. Leave the share in `awaiting_acks` and hand the caller the key it needs to
544            // bucket the ack.
545            return Ok(ProcessedAckResult::VerifierNotReady(spi));
546        };
547
548        // The polynomial index comes from the peer's own share, so it is untrusted. Once the cycle
549        // is known its dimensions are too, which makes an out-of-range index definitively invalid
550        // rather than merely early — there is no later state in which it becomes meaningful.
551        let Some(part) = cycle.part(spi.poly_index()) else {
552            tracing::error!(%spi, num_polys = cycle.num_polys(), "share names a polynomial outside the cycle");
553            return Err(PixError::InvalidInput);
554        };
555
556        // Cycle confirmed — safe to consume the share.
557        awaiting_ack_from_peer.remove(&ack_challenge);
558
559        // The share cannot be empty at this point because we prevent empty share insertions
560        let partial_share = share.partial_share.decrypt(spi.pseudonym(), &ack)?;
561
562        let ssa_id = *spi.as_ref();
563
564        // The part lock is released before the accumulator is taken below. That order is the one
565        // callers must keep, and neither lock is ever held across the other.
566        let ssa_part = match part.lock().add_share(share.nonce, partial_share) {
567            Ok(AddShareOutcome::Completed(share)) => {
568                tracing::trace!(%spi, "ssa part complete");
569                cycle.record_useful_share();
570                cycle.record_completed_part();
571                share
572            }
573            Ok(AddShareOutcome::Useful) => {
574                tracing::trace!(%spi, "ssa part not yet complete, waiting for more shares");
575                cycle.record_useful_share();
576                // Snapshot here rather than making the caller look the cycle up again: this is the
577                // steady-state outcome for all but one share in `threshold`, so a second cache get
578                // would double the lookups on the hot path.
579                return Ok(ProcessedAckResult::Progressed(cycle.progress()));
580            }
581            // Expected traffic, not a fault: a conforming Entry emits `threshold + surplus` shares
582            // per polynomial, so every polynomial ends its life absorbing surplus.
583            Ok(AddShareOutcome::Surplus) => {
584                tracing::trace!(%spi, "share arrived after its polynomial was reconstructed");
585                return Ok(ProcessedAckResult::NoProgress);
586            }
587            Ok(AddShareOutcome::Duplicate) => {
588                tracing::trace!(%spi, "duplicate evaluation identifier");
589                return Ok(ProcessedAckResult::NoProgress);
590            }
591            Ok(AddShareOutcome::Absorbed) => {
592                tracing::trace!(%spi, "share for a polynomial that already failed its commitment");
593                return Ok(ProcessedAckResult::NoProgress);
594            }
595            Err(PixError::VsssError(vsss_rs::Error::InvalidShare)) => {
596                // Counted rather than raised: the caller reports it as a resolution, and the count
597                // has to be taken here because the cycle that holds it is in hand.
598                //
599                // Almost always this means the polynomial's reconstructed constant term did not
600                // open its commitment, in which case the offending share is one of the `threshold`
601                // that went into it and cannot be singled out. The whole cycle is lost either way,
602                // since the SSA needs every polynomial.
603                let observed_total = cycle.record_invalid_share();
604                tracing::error!(%spi, observed_total, "ssa part failed to open its commitment");
605                return Ok(ProcessedAckResult::InvalidShare(ssa_id, observed_total));
606            }
607            Err(e) => return Err(e),
608        };
609
610        let mut builder_guard = cycle.builder().lock();
611        let ssa = match builder_guard.add_recovered_ssa_part(spi.poly_index(), ssa_part) {
612            Ok(ssa) => ssa,
613            Err(error) => {
614                // As terminal as `scalar_to_private_key` returning `None` below, and torn down the
615                // same way. Propagating alone would leave the accumulator and every part builder in
616                // place, and each further share for the cycle would refresh the idle timer that is
617                // supposed to reclaim them — so a Session that keeps sending holds a cycle that can
618                // never reconstruct for as long as it likes.
619                //
620                // The lock goes first: `remove_cycle` drops the last `Arc` to this very cycle.
621                drop(builder_guard);
622                tracing::error!(%spi, %error, "ssa part could not be added to its accumulator");
623                self.remove_cycle(ssa_id);
624                return Err(error);
625            }
626        };
627        match ssa {
628            Some(scalar) => {
629                // Read the final progress while the cycle is still live: `remove_cycle` below drops
630                // the counters along with everything else, so this is the last chance to report them.
631                let progress = cycle.progress();
632                // Release the accumulator lock before retiring, so `remove_cycle` — which drops
633                // the last `Arc` to this very cycle — does not run while it is held.
634                drop(builder_guard);
635                let Some(ssa) = S::scalar_to_private_key(scalar) else {
636                    tracing::error!(%spi, "ssa reconstruction failed");
637                    self.remove_cycle(ssa_id);
638                    return Err(PixError::InvalidSsa);
639                };
640                // Full recovery: this cycle's state is no longer needed.
641                self.remove_cycle(ssa_id);
642                tracing::info!(%ssa_id, "ssa recovered");
643                Ok(ProcessedAckResult::FullRecovery(RecoveredSsa { ssa_id, ssa }, progress))
644            }
645            None => {
646                tracing::trace!(%spi, "ssa not yet complete, waiting for more ssa parts");
647                // Check early threshold while we hold the lock
648                let early = builder_guard.check_early_threshold(self.cfg.early_recovery_threshold);
649                drop(builder_guard);
650                let progress = cycle.progress();
651                if early {
652                    tracing::info!(%ssa_id, "early recovery threshold reached");
653                    Ok(ProcessedAckResult::EarlyRecovery(progress))
654                } else {
655                    Ok(ProcessedAckResult::Progressed(progress))
656                }
657            }
658        }
659    }
660
661    /// Buckets an acknowledgement whose cycle's part builders have not been installed yet.
662    ///
663    /// O(1) — this is the entire cost the acknowledgement path pays for a deferral.
664    fn defer_ack(&self, spi: SsaPolynomialId<S::Pseudonym>, deferred: DeferredAck) {
665        let bucket = self.pending_acks.get_with(*spi.as_ref(), || {
666            std::sync::Arc::new(parking_lot::Mutex::new(Default::default()))
667        });
668        self.defer_ack_into(&bucket, spi, deferred);
669    }
670
671    /// The bucket half of [`defer_ack`](Self::defer_ack), taking the bucket rather than looking it
672    /// up.
673    ///
674    /// Split out so a test can hold a handle across the drain that invalidates the cache key,
675    /// which is the interleaving this guards against and the one thing a single thread cannot
676    /// otherwise produce — after the invalidate, `get_with` hands out a *fresh* bucket.
677    fn defer_ack_into(&self, bucket: &DeferredAckBucket, spi: SsaPolynomialId<S::Pseudonym>, deferred: DeferredAck) {
678        let ssa_id = *spi.as_ref();
679        let outcome = {
680            let mut bucket = bucket.lock();
681            if bucket.drained {
682                Deferral::Orphaned
683            } else if bucket.total >= MAX_DEFERRED_ACKS_PER_CYCLE {
684                // The cycle as a whole is holding more than the shares it could plausibly have
685                // received inside `max_ack_await_time`, so the excess cannot be redeemable.
686                tracing::warn!(
687                    %ssa_id,
688                    cap = MAX_DEFERRED_ACKS_PER_CYCLE,
689                    "dropping deferred acknowledgement: cycle bucket is full"
690                );
691                Deferral::Dropped
692            } else {
693                // Reborrowed off the guard so `by_poly` and `total` are disjoint field borrows.
694                let bucket = &mut *bucket;
695                let per_poly = bucket.by_poly.entry(spi.poly_index()).or_default();
696                if per_poly.len() >= MAX_DEFERRED_ACKS_PER_POLYNOMIAL {
697                    // Only reachable if the peer emits more shares for one polynomial than its own
698                    // `threshold + surplus` budget allows, so the excess is almost certainly
699                    // duplicate.
700                    tracing::warn!(
701                        %spi,
702                        cap = MAX_DEFERRED_ACKS_PER_POLYNOMIAL,
703                        "dropping deferred acknowledgement: polynomial bucket is full"
704                    );
705                    Deferral::Dropped
706                } else {
707                    per_poly.push(deferred);
708                    bucket.total += 1;
709                    Deferral::Buffered
710                }
711            }
712        };
713
714        match outcome {
715            // The drain took this bucket while we were on our way into it. Redeeming here is what
716            // makes the mutex the whole synchronisation point: the drain's take and this append
717            // are serialised by it, so exactly one of them owns the ack.
718            Deferral::Orphaned => {
719                tracing::trace!(%spi, "redeeming an acknowledgement deferred into a drained bucket");
720                self.redeem_deferred_acks(&ssa_id, std::iter::once(deferred));
721            }
722            // Close the race against a concurrent installation. The decision to defer was made on
723            // a cycle lookup that missed; if the cycle has appeared since, the drain that would
724            // have redeemed this ack may already have run against a bucket we never saw.
725            Deferral::Buffered => {
726                if self.ssa_cycles.contains_key(&ssa_id) {
727                    self.drain_deferred_acks(&ssa_id);
728                }
729            }
730            Deferral::Dropped => {}
731        }
732    }
733
734    /// Redeems the acknowledgements that were waiting for this cycle's part builders.
735    ///
736    /// Called from the commitment path immediately after the cycle is installed, so each bucket is
737    /// processed exactly once and never speculatively re-scanned. Resolutions are parked in
738    /// [`ready_resolutions`](Self::ready_resolutions) for the next `acknowledge_shares` call, since
739    /// the commitment path has no route to the upper layer.
740    fn drain_deferred_acks(&self, ssa_id: &SsaId<S::Pseudonym>) {
741        let Some(bucket) = self.pending_acks.get(ssa_id) else {
742            return;
743        };
744        self.pending_acks.invalidate(ssa_id);
745
746        // Take and tombstone in one critical section. A `defer_ack_into` that looked this bucket
747        // up before the invalidate still holds an `Arc` to it; the flag is what stops its append
748        // from disappearing into a bucket nothing will read again.
749        let deferred = {
750            let mut bucket = bucket.lock();
751            bucket.drained = true;
752            bucket.total = 0;
753            std::mem::take(&mut bucket.by_poly)
754        };
755        if deferred.is_empty() {
756            return;
757        }
758
759        self.redeem_deferred_acks(ssa_id, deferred.into_values().flatten());
760    }
761
762    /// Processes acknowledgements whose verifier has since been installed, parking whatever they
763    /// resolve to.
764    ///
765    /// Shared by the two routes that can redeem a deferral — the drain on the commitment path and
766    /// an [`orphaned`](Deferral::Orphaned) append — so both produce the same resolutions in the
767    /// same order.
768    fn redeem_deferred_acks(&self, ssa_id: &SsaId<S::Pseudonym>, deferred: impl IntoIterator<Item = DeferredAck>) {
769        let mut resolved = Vec::new();
770        // The furthest-along snapshot any redeemed ack produced. Shares recovered here would
771        // otherwise be invisible to the consumer until some later batch happened to touch this same
772        // SSA, since only `acknowledge_shares` emits snapshots.
773        let mut progress = Vec::new();
774        for (peer, challenge, ack) in deferred {
775            // The share lives in the peer's own awaiting-acks cache; if the peer entry is gone the
776            // share has expired with it and the ack is dead.
777            let Some(awaiting) = self.awaiting_acks.get(&peer) else {
778                continue;
779            };
780            match self.process_verified_ack(ack, challenge, &awaiting) {
781                Ok(ProcessedAckResult::FullRecovery(ssa, snapshot)) => {
782                    record_progress(&mut progress, snapshot);
783                    resolved.push(ShareResolution::RecoveredSsa(ssa));
784                }
785                Ok(ProcessedAckResult::EarlyRecovery(snapshot)) => {
786                    let id = snapshot.ssa_id;
787                    record_progress(&mut progress, snapshot);
788                    resolved.push(ShareResolution::AlmostRecoveredSsa(id));
789                }
790                Ok(ProcessedAckResult::Progressed(snapshot)) => record_progress(&mut progress, snapshot),
791                Ok(ProcessedAckResult::InvalidShare(id, observed_total)) => {
792                    tracing::error!(%id, observed_total, "deferred share could not be verified");
793                    resolved.push(ShareResolution::InvalidShares {
794                        peer: peer.into(),
795                        ssa_id: id,
796                        observed_total,
797                    });
798                }
799                Ok(ProcessedAckResult::NoProgress) => {}
800                Ok(ProcessedAckResult::VerifierNotReady(_)) => {
801                    // The cycle was installed and then immediately withdrawn, which only the
802                    // retirement path does. Re-bucketing would leak, so drop.
803                    tracing::trace!(%ssa_id, "cycle withdrawn while draining deferred acknowledgements");
804                }
805                Err(error) => tracing::debug!(%ssa_id, %error, "failed to process deferred acknowledgement"),
806            }
807        }
808
809        // Snapshots go in ahead of the terminal events they belong to, matching what
810        // `acknowledge_shares` emits — the consumer sees one order regardless of which path resolved
811        // a share.
812        if !progress.is_empty() {
813            let mut ordered = progress.into_iter().map(ShareResolution::Progress).collect::<Vec<_>>();
814            ordered.append(&mut resolved);
815            resolved = ordered;
816        }
817
818        if !resolved.is_empty() {
819            tracing::debug!(%ssa_id, num = resolved.len(), "redeemed deferred acknowledgements");
820            let mut ready = self.ready_resolutions.lock();
821            ready.extend(resolved);
822            self.ready_resolutions_len
823                .store(ready.len(), std::sync::atomic::Ordering::Release);
824        }
825    }
826
827    /// Takes any resolutions parked by [`drain_deferred_acks`](Self::drain_deferred_acks).
828    ///
829    /// One relaxed load in the common case — the buckets are empty whenever the Entry finishes the
830    /// constant-term pass before the shares that reference it arrive.
831    fn take_ready_resolutions(&self) -> Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>> {
832        if self.ready_resolutions_len.load(std::sync::atomic::Ordering::Acquire) == 0 {
833            return Vec::new();
834        }
835        let mut ready = self.ready_resolutions.lock();
836        self.ready_resolutions_len
837            .store(0, std::sync::atomic::Ordering::Release);
838        std::mem::take(&mut *ready)
839    }
840
841    /// Minimum wall time between two [`resync_ack_buffer`](Self::resync_ack_buffer) passes.
842    ///
843    /// A saturated buffer would otherwise turn every rejected insertion into an
844    /// `O(max_tracked_peers)` scan — the overload path amplifying its own cost, which is the shape
845    /// of bug this budget exists to prevent.
846    ///
847    /// Derived from [`max_ack_await_time`](SsaReconstructorConfig::max_ack_await_time) rather than
848    /// fixed, because what a resync reclaims is entries that have aged out of *that* window. A
849    /// constant would be wrong at both ends: too slow for a short window, so a drained buffer keeps
850    /// refusing shares long after it emptied, and needlessly eager for a long one.
851    ///
852    /// The resulting staleness — up to ~1.9 s at the 30 s default — only bites when the counter has
853    /// drifted high *and* nothing is touching the caches, since redemption and any cache access
854    /// drive moka's expiry maintenance and fire the listener directly. That is the traffic-stopped
855    /// case, where refusing a share costs nothing.
856    fn ack_buffer_resync_interval(&self) -> std::time::Duration {
857        (self.cfg.max_ack_await_time / 16).max(std::time::Duration::from_millis(1))
858    }
859
860    /// Recomputes [`ack_buffer_entries`](Self::ack_buffer_entries) from what the caches actually
861    /// hold.
862    ///
863    /// # Why a counter needs a backstop at all
864    ///
865    /// Entries leave the buffer four ways: redeemed by their acknowledgement, expired, size-evicted
866    /// from their peer's cache, or dropped wholesale when the peer's entry leaves `awaiting_acks`.
867    /// The inner eviction listener catches the first three exactly. The fourth cannot be caught:
868    /// dropping a moka handle does not run its eviction listener, so the outer listener falls back
869    /// to `invalidate_all`, which is best-effort and races an insertion landing on the very cache
870    /// being discarded.
871    ///
872    /// Left alone, that residue only ever accumulates *upward*, and an over-count is far worse than
873    /// an under-count: it would eventually refuse every share while the buffer sat empty, turning a
874    /// memory ceiling into a permanent outage of the acknowledgement path. (The sibling
875    /// `HoprUnacknowledgedTicketProcessor` in `hopr-protocol-hopr` has the same nesting and the same
876    /// residue; there it only skews metrics.)
877    ///
878    /// So the counter is treated as a hint that is allowed to be wrong, and ground truth is
879    /// consulted at the one moment being wrong would cost something — when it says the buffer is
880    /// full. `try_lock` rather than `lock`: a caller that finds a resync already running should
881    /// proceed on the current estimate, not queue up behind it.
882    fn resync_ack_buffer(&self) {
883        let Some(mut last_run) = self.ack_buffer_resync.try_lock() else {
884            return;
885        };
886        if last_run.is_some_and(|at| at.elapsed() < self.ack_buffer_resync_interval()) {
887            return;
888        }
889
890        let held = self.count_ack_buffer_entries();
891        let previous = self.ack_buffer_entries.swap(held, std::sync::atomic::Ordering::Relaxed);
892        *last_run = Some(std::time::Instant::now());
893
894        if previous != held {
895            tracing::debug!(
896                previous,
897                held,
898                "resynchronised the awaiting-acknowledgement buffer count"
899            );
900        }
901    }
902
903    /// Ground truth: the entries actually held across every peer.
904    ///
905    /// Never reads [`ack_buffer_entries`](Self::ack_buffer_entries), so a test asserting the two
906    /// agree is testing the counter rather than agreeing with it — the same reason
907    /// `deferred_ack_count` recomputes from `by_poly` instead of reading `DeferredAcks::total`.
908    ///
909    /// `O(max_tracked_peers)`, and each `run_pending_tasks` is bounded by that cache's pending write
910    /// queue rather than its size. Both callers keep it off the steady-state path.
911    fn count_ack_buffer_entries(&self) -> usize {
912        self.awaiting_acks.run_pending_tasks();
913        self.awaiting_acks
914            .iter()
915            .map(|(_, shares)| {
916                shares.run_pending_tasks();
917                shares.entry_count() as usize
918            })
919            .sum()
920    }
921
922    /// The published cycle, if it is still live.
923    #[cfg(test)]
924    fn cycle(&self, ssa_id: &SsaId<S::Pseudonym>) -> Option<std::sync::Arc<SsaCycle<S>>> {
925        self.ssa_cycles.get(ssa_id)
926    }
927
928    /// Number of live cycles across all Sessions.
929    #[cfg(test)]
930    fn live_cycles(&self) -> u64 {
931        self.ssa_cycles.run_pending_tasks();
932        self.ssa_cycles.entry_count()
933    }
934
935    /// Number of part builders installed for a cycle, or `0` if the cycle is not live.
936    ///
937    /// The per-polynomial cache entry count used to express this. It has to be asked of the cycle
938    /// now, because the cache holds one entry per cycle rather than one per polynomial — so
939    /// `entry_count()` alone can no longer tell "every part installed" from "one part installed".
940    #[cfg(test)]
941    fn installed_parts(&self, ssa_id: &SsaId<S::Pseudonym>) -> usize {
942        self.ssa_cycles.get(ssa_id).map(|c| c.num_polys()).unwrap_or(0)
943    }
944
945    /// Total deferred acknowledgements bucketed for a cycle.
946    ///
947    /// Recomputed from `by_poly` rather than read off `DeferredAcks::total` deliberately: a
948    /// counter that has drifted from the map is exactly what this should catch, and an accessor
949    /// reading the counter would agree with it whatever it said.
950    #[cfg(test)]
951    fn deferred_ack_count(&self, ssa_id: &SsaId<S::Pseudonym>) -> usize {
952        self.pending_acks
953            .get(ssa_id)
954            .map(|b| b.lock().by_poly.values().map(Vec::len).sum())
955            .unwrap_or(0)
956    }
957}
958
959/// Ownership of an Exit SSA commitment, released when dropped.
960///
961/// Registering an Exit commitment is the first fallible step of many: the request still has to be
962/// encoded, sent, and answered. Every early return between here and the point where a permanent
963/// owner takes over would otherwise strand the commitment in the reconstructor until its own
964/// lifetime expired, and a stranded commitment is not inert — its `SsaId` is occupied, so a retry at
965/// the same index is rejected as a duplicate.
966///
967/// Move-only by design: no `Clone`, no `Copy`, so there is exactly one release point. A success path
968/// hands ownership on with [`disarm`](Self::disarm) rather than letting the guard fall out of scope.
969///
970/// Dropping releases the registration **without** retiring the SSA, so the same index can be
971/// requested again — see `SsaReconstructor::release_abandoned_commitment` for why that
972/// distinction is load-bearing.
973#[must_use = "dropping the guard immediately releases the SSA it owns"]
974pub struct SsaCommitmentGuard<S: PixSpec + Clone> {
975    /// `None` once disarmed, which is the only state in which `Drop` does nothing.
976    owned: Option<OwnedCommitment<S>>,
977}
978
979/// What an [`SsaCommitmentGuard`] needs to release its SSA: where it is registered, and which one.
980type OwnedCommitment<S> = (std::sync::Arc<SsaReconstructor<S>>, SsaId<<S as PixSpec>::Pseudonym>);
981
982/// A registered Exit commitment, paired with ownership of its lifetime.
983type GuardedExitCommitment<S> = (PixGroup<S>, SsaCommitmentGuard<S>);
984
985impl<S: PixSpec + Clone> std::fmt::Debug for SsaCommitmentGuard<S> {
986    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
987        f.debug_struct("SsaCommitmentGuard")
988            .field("ssa_id", &self.owned.as_ref().map(|(_, id)| id))
989            .finish()
990    }
991}
992
993impl<S: PixSpec + Clone> SsaCommitmentGuard<S> {
994    /// The SSA this guard owns, or `None` if it has been disarmed.
995    pub fn ssa_id(&self) -> Option<&SsaId<S::Pseudonym>> {
996        self.owned.as_ref().map(|(_, id)| id)
997    }
998
999    /// Gives up ownership without retiring, returning the SSA that is now the caller's to release.
1000    ///
1001    /// Returns `None` if the guard was already disarmed.
1002    pub fn disarm(mut self) -> Option<SsaId<S::Pseudonym>> {
1003        self.owned.take().map(|(_, ssa_id)| ssa_id)
1004    }
1005}
1006
1007impl<S: PixSpec + Clone> Drop for SsaCommitmentGuard<S> {
1008    fn drop(&mut self) {
1009        if let Some((reconstructor, ssa_id)) = self.owned.take() {
1010            reconstructor.release_abandoned_commitment(ssa_id);
1011        }
1012    }
1013}
1014
1015impl<S: PixSpec + Clone> SsaReconstructor<S> {
1016    /// Releases a commitment that was registered but never taken over by an owner.
1017    ///
1018    /// Deliberately **not** [`retire_ssa`](ExitAcknowledgementShareProcessor::retire_ssa), which
1019    /// additionally writes the resurrection tombstone. The tombstone is permanent for that `SsaId`
1020    /// for as long as it is retained, and it takes effect at the moment a cycle is *published* — so a
1021    /// retry at the same index would re-register, accept the peer's commitments, publish a deposit
1022    /// address, and then be silently undone at completion. The peer funds an SSA that can never be
1023    /// reconstructed, and nothing on either side reports a failure.
1024    ///
1025    /// Same-index retry is not a corner case: the SSA index is advanced only after every fallible
1026    /// step of a request has succeeded, so a request that failed keeps its index by design and the
1027    /// next attempt reuses it.
1028    ///
1029    /// Escalates to a full retirement if a cycle did go live, which means the peer was asked and
1030    /// answered — and therefore that ownership should already have been transferred with
1031    /// [`disarm`](SsaCommitmentGuard::disarm). That branch is a caller error, and retiring is the
1032    /// safe response to it, because a live cycle is exactly what the tombstone exists to protect.
1033    fn release_abandoned_commitment(&self, ssa_id: SsaId<S::Pseudonym>) {
1034        if self.ssa_cycles.contains_key(&ssa_id) {
1035            tracing::warn!(%ssa_id, "abandoned ssa commitment was already live — retiring it");
1036            self.retire_ssa(ssa_id);
1037        } else {
1038            tracing::debug!(%ssa_id, "releasing ssa commitment abandoned by its owner");
1039            self.remove_cycle(ssa_id);
1040        }
1041    }
1042
1043    /// [`new_exit_commitment`](ExitAcknowledgementShareProcessor::new_exit_commitment), with the
1044    /// registration owned by an [`SsaCommitmentGuard`].
1045    ///
1046    /// No guard is produced on failure, so a rejected duplicate never retires the registration that
1047    /// caused the rejection.
1048    pub fn new_guarded_exit_commitment(
1049        self: &std::sync::Arc<Self>,
1050        id: SsaId<S::Pseudonym>,
1051        polys_per_ssa: usize,
1052        shares_per_poly: usize,
1053    ) -> Result<GuardedExitCommitment<S>, PixError<S::Pseudonym>> {
1054        let exit_commitment = self.new_exit_commitment(id, polys_per_ssa, shares_per_poly)?;
1055        Ok((
1056            exit_commitment,
1057            SsaCommitmentGuard {
1058                owned: Some((self.clone(), id)),
1059            },
1060        ))
1061    }
1062}
1063
1064impl<S: PixSpec> Drop for SsaReconstructor<S> {
1065    /// Reports terminal resolutions that were never collected.
1066    ///
1067    /// `ready_resolutions` is a hand-off the *commitment* path fills and
1068    /// only `acknowledge_shares` empties, so delivery waits on the next acknowledgement batch from
1069    /// any peer. That is the common case and not the guaranteed one: a Session whose final cycle
1070    /// recovers through the deferred-ack drain, and which then stops sending because the cycle it
1071    /// was funding is complete, leaves the last resolution sitting here.
1072    ///
1073    /// Retirement is not the deadline — a retired cycle's resolution stays collectable, since the
1074    /// buffer is global and its entries name their own `SsaId`. This is, and nothing here can
1075    /// deliver: the commitment path has no route to the upper layer, which is why these were parked
1076    /// rather than returned. So the most that can be done is to refuse to lose them quietly. A
1077    /// `RecoveredSsa` reported here is a deposit key the Exit held and never handed on.
1078    ///
1079    /// The real fix is for the reconstructor to push rather than be pulled, which needs a sink on
1080    /// its constructor; that is bundled with threading a real `SsaReconstructorConfig` through the
1081    /// three sites in `hopr-transport` that hard-code `::default()`.
1082    fn drop(&mut self) {
1083        if self.ready_resolutions_len.load(std::sync::atomic::Ordering::Acquire) == 0 {
1084            return;
1085        }
1086        for resolution in self.ready_resolutions.lock().drain(..) {
1087            tracing::error!(
1088                ?resolution,
1089                "pix resolution was never collected and is lost with the reconstructor"
1090            );
1091        }
1092    }
1093}
1094
1095impl<S: PixSpec + Clone> ExitAcknowledgementShareProcessor<S> for SsaReconstructor<S> {
1096    type Error = PixError<S::Pseudonym>;
1097
1098    fn has_pending_shares(&self, peer: &OffchainPublicKey) -> bool {
1099        // The parked-resolution check is not redundant with the per-peer one. Callers use this to
1100        // skip `acknowledge_shares` entirely, and that is the only thing which ever collects
1101        // `ready_resolutions` — a buffer the *commitment* path fills, holding terminal events up
1102        // to and including a recovered deposit key. It is global rather than per-peer and its
1103        // contents name their own `SsaId`, so any batch can correctly carry it out; gating it
1104        // behind the producing peer's `awaiting_acks` entry, which expires on its own timer,
1105        // would strand it for no reason.
1106        self.ready_resolutions_len.load(std::sync::atomic::Ordering::Acquire) > 0
1107            || self.awaiting_acks.contains_key(peer)
1108    }
1109
1110    fn is_expected_error(&self, error: &Self::Error) -> bool {
1111        matches!(error, PixError::UnexpectedShare)
1112    }
1113
1114    fn retire_ssa(&self, ssa_id: SsaId<S::Pseudonym>) {
1115        // Mark tombstone BEFORE removing state so the commitment completion path can detect
1116        // retirement and undo its publication.
1117        self.retired_ssas.insert(ssa_id, ());
1118
1119        // Every key is the SsaId itself, so there is nothing to enumerate and no way for part of a
1120        // cycle to survive the removal of the rest.
1121        self.remove_cycle(ssa_id);
1122    }
1123
1124    fn new_exit_commitment(
1125        &self,
1126        id: SsaId<S::Pseudonym>,
1127        polys_per_ssa: usize,
1128        shares_per_poly: usize,
1129    ) -> Result<PixGroup<S>, Self::Error> {
1130        if !(1..=MAX_POLYS_PER_SSA as usize).contains(&polys_per_ssa)
1131            || !(2..=MAX_POLY_THRESHOLD as usize).contains(&shares_per_poly)
1132        {
1133            return Err(PixError::InvalidInput);
1134        }
1135
1136        let exit_commitment_secret = PixScalar::<S>::random(&mut hopr_types::crypto_random::rng());
1137        let exit_commitment_public = PixGroup::<S>::mul_by_generator(&exit_commitment_secret);
1138
1139        self.commitment_builder
1140            .entry(id)
1141            .and_try_compute_with(|entry| match entry {
1142                Some(_) => Err(PixError::DuplicateCommitment),
1143                None => Ok(moka::ops::compute::Op::Put(std::sync::Arc::new(
1144                    parking_lot::Mutex::new(SsaCommitmentBuilder::new(
1145                        id,
1146                        shares_per_poly,
1147                        polys_per_ssa,
1148                        exit_commitment_secret,
1149                        exit_commitment_public,
1150                    )),
1151                ))),
1152            })?;
1153
1154        Ok(exit_commitment_public)
1155    }
1156
1157    fn insert_coefficient_commitments(
1158        &self,
1159        ssa_id: SsaId<S::Pseudonym>,
1160        index: CoefficientIndex,
1161        proof: Option<SsaCommitmentProof<S>>,
1162        commitments: impl Iterator<Item = (PolynomialIndex, PixGroupRepr<S>)>,
1163    ) -> Result<SsaCommitmentState<S::Pseudonym, S::DepositAddress>, Self::Error> {
1164        let mut res = SsaCommitmentState::new(ssa_id);
1165
1166        // The Server commitment must be present first
1167        let Some(builder) = self.commitment_builder.get(&ssa_id) else {
1168            return Err(PixError::MissingSsaCommitment);
1169        };
1170
1171        let progress = {
1172            let mut builder = builder.lock();
1173            res.is_first_encountered = builder.is_empty();
1174            res.ssa_deposit_address = builder.get_deposit_address().copied();
1175            builder.add_transposed(index, proof, commitments)?
1176        };
1177
1178        // `ssa_deposit_address` was read *before* the insertion, so it being absent here means the
1179        // address (if any) was discovered by this very call.
1180        res.deposit_address_first_encountered = res.ssa_deposit_address.is_none();
1181
1182        let Some(full_ssa_commitment) = progress.full_commitment else {
1183            res.deposit_address_first_encountered = false; // Not yet encountered
1184            tracing::trace!(%ssa_id, "ssa commitment not yet complete, waiting for more constant terms");
1185            return Ok(res);
1186        };
1187        res.ssa_deposit_address = Some(S::group_to_deposit_address(full_ssa_commitment).ok_or(PixError::InvalidSsa)?);
1188
1189        // The accumulator and every part builder go in as one entry. There is deliberately no
1190        // ordering to get right here: a share can never observe a cycle in which one is reachable
1191        // and the other is not, which used to be a permanent-drop hazard.
1192        let installed = if let Some(ssa_builder) = progress.ssa_builder {
1193            let num_polys = ssa_builder.num_polys();
1194            let cycle = SsaCycle::new(ssa_id, ssa_builder, progress.new_verifiers)?;
1195            self.ssa_cycles.insert(ssa_id, std::sync::Arc::new(cycle));
1196            tracing::debug!(%ssa_id, num_polys, "ssa commitment known — cycle is live");
1197            true
1198        } else {
1199            false
1200        };
1201
1202        // Tombstone checked *after* publishing, so that retirement racing this call cannot slip
1203        // between a check and a write. If it did run, undo what this call published — the cycle's
1204        // state was already torn down and republishing it would resurrect it.
1205        if installed && self.retired_ssas.contains_key(&ssa_id) {
1206            self.remove_cycle(ssa_id);
1207            tracing::trace!(%ssa_id, "ssa commitment progressed but cycle was retired — dropped published state");
1208            res.deposit_address_first_encountered = false;
1209            return Ok(res);
1210        }
1211
1212        // Installing the cycle unblocks every acknowledgement bucketed under it. Doing this here
1213        // rather than on the acknowledgement path is what keeps `acknowledge_shares` free of retry
1214        // scanning.
1215        if installed {
1216            self.drain_deferred_acks(&ssa_id);
1217        }
1218
1219        res.is_verifiable = progress.fully_committed;
1220        if progress.fully_committed {
1221            tracing::trace!(%ssa_id, "ssa commitment completed");
1222        }
1223
1224        Ok(res)
1225    }
1226
1227    /// Buffers an encrypted share until its acknowledgement arrives, subject to the global byte
1228    /// budget.
1229    ///
1230    /// # The bound is global, and it is not the product of the two caps
1231    ///
1232    /// [`max_tracked_peers`](SsaReconstructorConfig::max_tracked_peers) and
1233    /// [`max_awaiting_acks`](SsaReconstructorConfig::max_awaiting_acks) bound one dimension each,
1234    /// and their product — 2 000 × 1 000 000 by default, some 800 GB — is neither reachable nor the
1235    /// right thing to bound. Not reachable, because an entry exists only for a share this node has
1236    /// already *sent*, so filling it would take 66 M packets/s of egress inside the default 30 s
1237    /// window. Not right, because the two guard **mutually exclusive** concentrations:
1238    /// `max_awaiting_acks` sizes one cache per peer and has to cover every Session returning through
1239    /// a single first-relayer, while `max_tracked_peers` covers traffic spread thin. Squeezing the
1240    /// product would push one of them below what its own case needs, and a `max_awaiting_acks` set
1241    /// too low does not save memory — it size-evicts shares before their acknowledgements arrive.
1242    ///
1243    /// So the real bound is [`max_ack_buffer_bytes`](SsaReconstructorConfig::max_ack_buffer_bytes),
1244    /// counted here across all peers at once. Validating a workload model instead would not do:
1245    /// a model has to assume a Session count and a packet rate, and this node enforces neither.
1246    ///
1247    /// # Behaviour at the ceiling
1248    ///
1249    /// The newest share is refused, rather than the oldest evicted: there is no cheap global
1250    /// "oldest" across per-peer caches, and the oldest is nearest its TTL anyway. Either way a full
1251    /// buffer means share loss — the packet is already on the wire and its acknowledgement will find
1252    /// nothing — which is the honest cost of a hard ceiling. [`PixError::AckBufferFull`] is
1253    /// deliberately not an expected error so the caller logs it.
1254    ///
1255    /// The check and the insertion are not atomic. Concurrent inserters can overshoot the ceiling by
1256    /// their own number, which is the right trade: a lock on this path would cost more than the few
1257    /// hundred kilobytes of overshoot it would prevent.
1258    fn insert_encrypted_share(
1259        &self,
1260        peer: &OffchainPublicKey,
1261        challenge: HalfKeyChallenge,
1262        tagged_enc_share: TaggedEncryptedPartialSsaShare<S>,
1263    ) -> Result<(), Self::Error> {
1264        if tagged_enc_share.partial_share.is_empty() {
1265            return Err(PixError::ShareIsEmpty);
1266        }
1267
1268        // One relaxed load in the steady state. Only a buffer that has actually reached its ceiling
1269        // pays for the ground-truth pass, and even then at most one caller at a time does.
1270        if self.ack_buffer_entries.load(std::sync::atomic::Ordering::Relaxed) >= self.max_ack_buffer_entries {
1271            self.resync_ack_buffer();
1272            if self.ack_buffer_entries.load(std::sync::atomic::Ordering::Relaxed) >= self.max_ack_buffer_entries {
1273                tracing::error!(
1274                    %peer,
1275                    budget_bytes = self.cfg.max_ack_buffer_bytes,
1276                    "awaiting-acknowledgement buffer is full — dropping share"
1277                );
1278                return Err(PixError::AckBufferFull);
1279            }
1280        }
1281
1282        // Incremented unconditionally, including when this replaces an entry under the same
1283        // challenge. The inner listener fires for `Replaced` too, so the pair nets to zero.
1284        self.ack_buffer_entries
1285            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1286        self.awaiting_acks
1287            .get_with_by_ref(peer, || {
1288                // Inner cache keyed by HalfKeyChallenge — each entry gets its own TTL
1289                // so a late-arriving share gets the full max_ack_await_time window.
1290                let released = self.ack_buffer_entries.clone();
1291                moka::sync::CacheBuilder::new(self.cfg.max_awaiting_acks as u64)
1292                    .time_to_live(self.cfg.max_ack_await_time)
1293                    // Every way an entry can leave this cache — redeemed by its acknowledgement,
1294                    // expired, or size-evicted — arrives here, which is what keeps the global count
1295                    // falling without the removal sites having to know about it.
1296                    .eviction_listener(move |_, _, _| {
1297                        released.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
1298                    })
1299                    .build()
1300            })
1301            .insert(challenge, tagged_enc_share);
1302
1303        Ok(())
1304    }
1305
1306    fn acknowledge_shares(
1307        &self,
1308        peer: OffchainPublicKey,
1309        acks: Vec<Acknowledgement>,
1310    ) -> Result<Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>>, Self::Error> {
1311        let Some((awaiting_ack_from_peer, half_keys_challenges)) = crate::ack_verify::verify_expected_acknowledgements(
1312            peer,
1313            acks,
1314            &self.awaiting_acks,
1315            self.cfg.use_batch_verification,
1316        ) else {
1317            return Err(PixError::UnexpectedShare);
1318        };
1319
1320        // Three accumulators rather than one deduplicating set, because the emission contract is an
1321        // ordering as well as a multiplicity: every SSA's counters precede its terminal event.
1322        //
1323        // All three are `Vec`s deduplicated by linear scan. A per-peer batch resolves shares for the
1324        // cycle that peer is relaying for — one, or a small handful while a Session pipelines the
1325        // next — so the scan is over a one- or two-element list and a hash set would cost more than
1326        // it saves.
1327        let mut progress: Vec<SsaRecoveryProgress<S::Pseudonym>> = Vec::new();
1328        let mut faults: Vec<FaultObservation<S::Pseudonym>> = Vec::new();
1329        let mut terminal: Vec<ShareResolution<S::Pseudonym, S::AddressPrivateKey>> = Vec::new();
1330
1331        // Collect anything redeemed while verifiers were being installed. No retry scanning happens
1332        // here: a deferred ack is retried exactly once, by the installation of the verifier it was
1333        // waiting for (see `drain_deferred_acks`).
1334        for resolution in self.take_ready_resolutions() {
1335            match resolution {
1336                ShareResolution::Progress(snapshot) => record_progress(&mut progress, snapshot),
1337                ShareResolution::InvalidShares {
1338                    peer,
1339                    ssa_id,
1340                    observed_total,
1341                } => record_fault(&mut faults, (peer, ssa_id, observed_total)),
1342                other => push_unique(&mut terminal, other),
1343            }
1344        }
1345
1346        for (ack, ack_challenge) in half_keys_challenges {
1347            match self.process_verified_ack(ack, ack_challenge, &awaiting_ack_from_peer) {
1348                Ok(ProcessedAckResult::FullRecovery(ssa, snapshot)) => {
1349                    record_progress(&mut progress, snapshot);
1350                    push_unique(&mut terminal, ShareResolution::RecoveredSsa(ssa));
1351                }
1352                Ok(ProcessedAckResult::EarlyRecovery(snapshot)) => {
1353                    let ssa_id = snapshot.ssa_id;
1354                    record_progress(&mut progress, snapshot);
1355                    push_unique(&mut terminal, ShareResolution::AlmostRecoveredSsa(ssa_id));
1356                }
1357                Ok(ProcessedAckResult::Progressed(snapshot)) => record_progress(&mut progress, snapshot),
1358                Ok(ProcessedAckResult::InvalidShare(ssa_id, observed_total)) => {
1359                    tracing::error!(%ssa_id, observed_total, "encountered share that could not be verified");
1360                    record_fault(&mut faults, (Box::new(peer), ssa_id, observed_total));
1361                }
1362                Ok(ProcessedAckResult::NoProgress) => {}
1363                Ok(ProcessedAckResult::VerifierNotReady(spi)) => {
1364                    // The share stays in `awaiting_acks`; bucket the ack under the polynomial whose
1365                    // verifier it needs, so installing that verifier redeems it.
1366                    tracing::trace!(%peer, %spi, "verifier not yet installed, deferring acknowledgement");
1367                    self.defer_ack(spi, (peer, ack_challenge, ack));
1368                }
1369                Err(PixError::ShareIsEmpty) => tracing::trace!(%peer, "received empty share"),
1370                Err(error) => {
1371                    tracing::error!(%error, "failed to process acknowledgement");
1372                }
1373            }
1374        }
1375
1376        let mut res = Vec::with_capacity(progress.len() + faults.len() + terminal.len());
1377        res.extend(progress.into_iter().map(ShareResolution::Progress));
1378        res.extend(
1379            faults
1380                .into_iter()
1381                .map(|(peer, ssa_id, observed_total)| ShareResolution::InvalidShares {
1382                    peer,
1383                    ssa_id,
1384                    observed_total,
1385                }),
1386        );
1387        res.append(&mut terminal);
1388        Ok(res)
1389    }
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394    use std::collections::HashMap;
1395
1396    use hopr_types::{
1397        crypto::{crypto_traits, prelude::*},
1398        crypto_random::Randomizable,
1399        internal::prelude::VerifiedAcknowledgement,
1400    };
1401    use vsss_rs::elliptic_curve::Field;
1402
1403    use super::{utils::SsaBuilder, *};
1404    use crate::{
1405        DEFAULT_POLY_THRESHOLD, DEFAULT_POLYS_PER_SSA, GroupEncoding, PartialSsaShare, SsaGeneratorConfig, SsaIndex,
1406        SsaShareGenerator,
1407        tests::TestSpec,
1408        traits::{EntryShareGenerator, ExitAcknowledgementShareProcessor},
1409    };
1410
1411    #[test]
1412    fn ssa_reconstructor_try_new_should_reject_an_invalid_config_without_panicking() {
1413        let cfg = SsaReconstructorConfig {
1414            // Outside the validated 0.0..=1.0 range.
1415            early_recovery_threshold: 1.5,
1416            ..Default::default()
1417        };
1418
1419        assert!(matches!(
1420            SsaReconstructor::<TestSpec>::try_new(cfg),
1421            Err(PixError::InvalidConfiguration(_))
1422        ));
1423    }
1424
1425    #[test]
1426    #[should_panic(expected = "invalid SsaReconstructorConfig")]
1427    fn ssa_reconstructor_new_should_still_panic_on_an_invalid_config() {
1428        let _ = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
1429            early_recovery_threshold: 1.5,
1430            ..Default::default()
1431        });
1432    }
1433
1434    /// A distinct, insertable share and the acknowledgement that redeems it.
1435    ///
1436    /// The contents do not matter to the budget — only that each entry has its own key — so this
1437    /// skips the generator entirely and builds a `PartialSsaShare::default()` under a fresh
1438    /// acknowledgement key.
1439    fn budget_share(
1440        spi: &SsaPolynomialId<SimplePseudonym>,
1441    ) -> anyhow::Result<(HalfKey, HalfKeyChallenge, TaggedEncryptedPartialSsaShare<TestSpec>)> {
1442        let ack_key = HalfKey::random();
1443        let challenge = ack_key.to_challenge()?;
1444        Ok((
1445            ack_key,
1446            challenge,
1447            TaggedEncryptedPartialSsaShare {
1448                pseudonym: *spi.pseudonym(),
1449                nonce: crypto_traits::elliptic_curve::Scalar::<Secp256k1>::random(&mut hopr_types::crypto_random::rng()),
1450                partial_share: PartialSsaShare::default().encrypt(spi, &ack_key)?,
1451            },
1452        ))
1453    }
1454
1455    /// A reconstructor whose acknowledgement buffer holds exactly `entries`.
1456    fn reconstructor_with_ack_budget(entries: usize) -> SsaReconstructor<TestSpec> {
1457        SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
1458            max_ack_buffer_bytes: entries * AWAITING_ACK_ENTRY_BYTES,
1459            ..Default::default()
1460        })
1461    }
1462
1463    #[test]
1464    fn ack_buffer_refuses_shares_past_its_byte_budget() -> anyhow::Result<()> {
1465        // The validated minimum, so the budget is the smallest one a real config can ask for.
1466        const BUDGET: usize = 64;
1467
1468        let reconstructor = reconstructor_with_ack_budget(BUDGET);
1469        let spi = SsaPolynomialId::new(SsaId::new(SimplePseudonym::random(), 1.try_into()?), 0);
1470        let peer = OffchainKeypair::random();
1471
1472        for i in 0..BUDGET {
1473            let (_, challenge, share) = budget_share(&spi)?;
1474            reconstructor
1475                .insert_encrypted_share(peer.public(), challenge, share)
1476                .map_err(|error| anyhow::anyhow!("insertion {i} must fit the budget: {error}"))?;
1477        }
1478
1479        let (_, challenge, share) = budget_share(&spi)?;
1480        assert!(
1481            matches!(
1482                reconstructor.insert_encrypted_share(peer.public(), challenge, share),
1483                Err(PixError::AckBufferFull)
1484            ),
1485            "the share past the budget must be refused rather than buffered"
1486        );
1487        assert_eq!(
1488            BUDGET,
1489            reconstructor.count_ack_buffer_entries(),
1490            "the buffer must hold exactly its budget — no more, and the refusal must not have dropped any"
1491        );
1492
1493        Ok(())
1494    }
1495
1496    /// Redeeming an acknowledgement must give its budget back.
1497    ///
1498    /// This is the release path the inner eviction listener exists for: `process_verified_ack`
1499    /// calls `remove`, which moka reports as an `Explicit` removal. A listener that only handled
1500    /// expiry and size eviction would leak here, and the buffer would fill permanently on a node
1501    /// that was working perfectly.
1502    #[test]
1503    fn redeeming_an_acknowledgement_returns_its_budget() -> anyhow::Result<()> {
1504        const BUDGET: usize = 64;
1505
1506        let reconstructor = reconstructor_with_ack_budget(BUDGET);
1507        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1508        let spi = SsaPolynomialId::new(ssa_id, 0);
1509        let peer = OffchainKeypair::random();
1510        reconstructor.new_exit_commitment(ssa_id, DEFAULT_POLYS_PER_SSA as usize, DEFAULT_POLY_THRESHOLD as usize)?;
1511
1512        let mut redeemable = None;
1513        for _ in 0..BUDGET {
1514            let (ack_key, challenge, share) = budget_share(&spi)?;
1515            reconstructor.insert_encrypted_share(peer.public(), challenge, share)?;
1516            redeemable = Some((ack_key, challenge));
1517        }
1518
1519        let (ack_key, challenge) = redeemable.expect("the loop ran at least once");
1520        let (_, refused, share) = budget_share(&spi)?;
1521        assert!(
1522            matches!(
1523                reconstructor.insert_encrypted_share(peer.public(), refused, share),
1524                Err(PixError::AckBufferFull)
1525            ),
1526            "precondition: the buffer is full"
1527        );
1528
1529        // The cycle is not published, so this defers rather than redeeming — and deliberately
1530        // leaves the share in place. Budget must therefore *not* be returned yet.
1531        let peer_cache = reconstructor
1532            .awaiting_acks
1533            .get(peer.public())
1534            .expect("the peer holds shares");
1535        assert!(matches!(
1536            reconstructor.process_verified_ack(ack_key, challenge, &peer_cache),
1537            Ok(ProcessedAckResult::VerifierNotReady(_))
1538        ));
1539        assert_eq!(
1540            BUDGET,
1541            reconstructor.count_ack_buffer_entries(),
1542            "a deferral retains the share, so it must still be charged for"
1543        );
1544
1545        // An outright removal is the redemption path's effect on the buffer.
1546        peer_cache.remove(&challenge);
1547        assert_eq!(
1548            BUDGET - 1,
1549            reconstructor.count_ack_buffer_entries(),
1550            "redeeming must free the entry"
1551        );
1552        reconstructor
1553            .insert_encrypted_share(peer.public(), refused, share)
1554            .map_err(|error| anyhow::anyhow!("the freed slot must be reusable: {error}"))?;
1555
1556        Ok(())
1557    }
1558
1559    /// Expiry must give budget back too — the release path that runs constantly in production.
1560    ///
1561    /// Most shares are never acknowledged in the window that matters; they age out. If the TTL did
1562    /// not release budget, a busy Exit would fill its buffer once and never accept another share.
1563    #[test]
1564    fn expiring_shares_return_their_budget() -> anyhow::Result<()> {
1565        const BUDGET: usize = 64;
1566        const WINDOW: std::time::Duration = std::time::Duration::from_millis(100);
1567
1568        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
1569            max_ack_buffer_bytes: BUDGET * AWAITING_ACK_ENTRY_BYTES,
1570            max_ack_await_time: WINDOW,
1571            ..Default::default()
1572        });
1573        let spi = SsaPolynomialId::new(SsaId::new(SimplePseudonym::random(), 1.try_into()?), 0);
1574        let peer = OffchainKeypair::random();
1575
1576        for _ in 0..BUDGET {
1577            let (_, challenge, share) = budget_share(&spi)?;
1578            reconstructor.insert_encrypted_share(peer.public(), challenge, share)?;
1579        }
1580        let (_, challenge, share) = budget_share(&spi)?;
1581        assert!(
1582            matches!(
1583                reconstructor.insert_encrypted_share(peer.public(), challenge, share),
1584                Err(PixError::AckBufferFull)
1585            ),
1586            "precondition: the buffer is full"
1587        );
1588
1589        std::thread::sleep(WINDOW * 2);
1590
1591        // moka expires lazily, so the entries are still charged for until maintenance runs. The
1592        // insertion path reaches that through `resync_ack_buffer`, which is what this asserts: a
1593        // buffer full of expired shares must let the next one in without any caller intervening.
1594        let (_, challenge, share) = budget_share(&spi)?;
1595        reconstructor
1596            .insert_encrypted_share(peer.public(), challenge, share)
1597            .map_err(|error| anyhow::anyhow!("expired entries must free their budget: {error}"))?;
1598
1599        assert_eq!(
1600            1,
1601            reconstructor.count_ack_buffer_entries(),
1602            "only the share inserted after the window should remain"
1603        );
1604
1605        Ok(())
1606    }
1607
1608    /// The counter is a hint; this asserts it does not become a lying one.
1609    ///
1610    /// The churn below deliberately includes the drift source no listener can catch — evicting a
1611    /// peer's whole entry from `awaiting_acks`, which drops its inner cache rather than draining
1612    /// it. Over-counting is the dangerous direction: it would eventually refuse every share while
1613    /// the buffer sat empty. `count_ack_buffer_entries` recomputes from the caches instead of
1614    /// reading the counter, so this compares the counter against the truth rather than against
1615    /// itself.
1616    #[test]
1617    fn the_ack_buffer_counter_does_not_inflate_as_peers_churn() -> anyhow::Result<()> {
1618        const PEERS: usize = 40;
1619        const TRACKED: usize = 10;
1620        const PER_PEER: usize = 5;
1621
1622        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
1623            // Well above anything inserted here: the point is peer churn, not the ceiling.
1624            max_ack_buffer_bytes: 4096 * AWAITING_ACK_ENTRY_BYTES,
1625            // Forces the outer cache to evict peers — and with them, whole inner caches — four
1626            // times over.
1627            max_tracked_peers: TRACKED,
1628            ..Default::default()
1629        });
1630        let spi = SsaPolynomialId::new(SsaId::new(SimplePseudonym::random(), 1.try_into()?), 0);
1631
1632        for _ in 0..PEERS {
1633            let peer = OffchainKeypair::random();
1634            for _ in 0..PER_PEER {
1635                let (_, challenge, share) = budget_share(&spi)?;
1636                reconstructor.insert_encrypted_share(peer.public(), challenge, share)?;
1637            }
1638        }
1639
1640        let held = reconstructor.count_ack_buffer_entries();
1641        assert!(
1642            held <= TRACKED * PER_PEER,
1643            "the outer cache bounds what can be held: {held} entries across at most {TRACKED} peers"
1644        );
1645
1646        // A resync is what makes the counter authoritative again; without the backstop the residue
1647        // from dropped inner caches would sit on it permanently.
1648        reconstructor.resync_ack_buffer();
1649        assert_eq!(
1650            held,
1651            reconstructor
1652                .ack_buffer_entries
1653                .load(std::sync::atomic::Ordering::Relaxed),
1654            "the counter must agree with what the caches actually hold after churn"
1655        );
1656
1657        Ok(())
1658    }
1659
1660    /// The bound no longer depends on a workload model — which is the whole of M2.
1661    ///
1662    /// Both caps are set so their product is orders of magnitude past the budget, exactly the
1663    /// configuration the old modelled validation would have waved through. What is held is decided
1664    /// by the byte budget and nothing else: no Session count, no assumed packet rate, no
1665    /// acknowledgement window.
1666    #[test]
1667    fn the_ack_buffer_budget_binds_regardless_of_the_configured_caps() -> anyhow::Result<()> {
1668        const BUDGET: usize = 64;
1669        /// Enough peers that neither cap is anywhere near binding, few enough that the test is not
1670        /// dominated by keypair generation.
1671        const PEERS: usize = 16;
1672
1673        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
1674            max_ack_buffer_bytes: BUDGET * AWAITING_ACK_ENTRY_BYTES,
1675            max_tracked_peers: 2000,
1676            max_awaiting_acks: 1_000_000,
1677            // An hour, against a 30 s default: the dial the modelled check keyed on, turned to a
1678            // value it would have rejected outright.
1679            max_ack_await_time: std::time::Duration::from_secs(3600),
1680            ..Default::default()
1681        });
1682        let spi = SsaPolynomialId::new(SsaId::new(SimplePseudonym::random(), 1.try_into()?), 0);
1683
1684        // Spread across peers so neither per-peer nor per-dimension cap is anywhere near binding.
1685        let peers = (0..PEERS).map(|_| OffchainKeypair::random()).collect::<Vec<_>>();
1686        let mut accepted = 0;
1687        for i in 0..(BUDGET * 4) {
1688            let (_, challenge, share) = budget_share(&spi)?;
1689            if reconstructor
1690                .insert_encrypted_share(peers[i % PEERS].public(), challenge, share)
1691                .is_ok()
1692            {
1693                accepted += 1;
1694            }
1695        }
1696
1697        assert_eq!(
1698            BUDGET, accepted,
1699            "the byte budget must be what binds, not the caps whose product is 2e9 entries"
1700        );
1701        Ok(())
1702    }
1703
1704    /// Pulls from the generator until it yields a share for `poly_index`, discarding the rest.
1705    ///
1706    /// Shares are emitted round-robin across [`crate::SHARE_EMISSION_WINDOW`] polynomials, so a test
1707    /// that needs to drive one polynomial to its threshold cannot assume consecutive calls stay on
1708    /// it. Discarding the others is sound here: these tests assert on one polynomial's behaviour,
1709    /// and a polynomial that never receives its shares simply stays incomplete.
1710    fn next_share_for_poly(
1711        generator: &SsaShareGenerator<TestSpec>,
1712        pseudonym: &SimplePseudonym,
1713        poly_index: PolynomialIndex,
1714    ) -> anyhow::Result<([u8; 20], crate::GeneratedShare<TestSpec>)> {
1715        loop {
1716            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
1717            let share = generator
1718                .next_share(pseudonym, &msg)?
1719                .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
1720            if share.id.poly_index() == poly_index {
1721                return Ok((msg, share));
1722            }
1723        }
1724    }
1725
1726    /// The proof a generated commitment carries, attached only to the constant-term batch — the
1727    /// shape the wire uses, since that batch is what determines the commitment being opened.
1728    fn proof_of(
1729        commitment: &crate::SsaCommitment<TestSpec>,
1730        coeff_index: CoefficientIndex,
1731    ) -> Option<SsaCommitmentProof<TestSpec>> {
1732        (coeff_index == 0).then_some(commitment.commitment_proof)
1733    }
1734
1735    /// Proof matching the all-identity commitment sets some fixtures use as filler: their sum is the
1736    /// identity, whose discrete logarithm is zero, so the proof is honest rather than a bypass.
1737    fn identity_proof(ssa_id: &SsaId<SimplePseudonym>) -> SsaCommitmentProof<TestSpec> {
1738        let zero = <PixScalar<TestSpec> as Field>::ZERO;
1739        SsaCommitmentProof::prove(ssa_id, &zero, &PixGroup::<TestSpec>::mul_by_generator(&zero))
1740            .expect("identity proof must be constructible")
1741    }
1742
1743    #[test]
1744    fn reconstructor_rejects_invalid_exit_commitment_inputs() -> anyhow::Result<()> {
1745        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
1746
1747        let make_ssa_id = || SsaId::new(SimplePseudonym::random(), 1.try_into().unwrap());
1748
1749        // polys_per_ssa == 0
1750        assert!(matches!(
1751            reconstructor.new_exit_commitment(make_ssa_id(), 0, 2),
1752            Err(PixError::InvalidInput)
1753        ));
1754
1755        // polys_per_ssa exceeds MAX
1756        assert!(matches!(
1757            reconstructor.new_exit_commitment(make_ssa_id(), MAX_POLYS_PER_SSA as usize + 1, 2),
1758            Err(PixError::InvalidInput)
1759        ));
1760
1761        // shares_per_poly == 0
1762        assert!(matches!(
1763            reconstructor.new_exit_commitment(make_ssa_id(), 2, 0),
1764            Err(PixError::InvalidInput)
1765        ));
1766
1767        // shares_per_poly == 1 (below minimum of 2)
1768        assert!(matches!(
1769            reconstructor.new_exit_commitment(make_ssa_id(), 2, 1),
1770            Err(PixError::InvalidInput)
1771        ));
1772
1773        // shares_per_poly exceeds MAX
1774        assert!(matches!(
1775            reconstructor.new_exit_commitment(make_ssa_id(), 2, MAX_POLY_THRESHOLD as usize + 1),
1776            Err(PixError::InvalidInput)
1777        ));
1778
1779        // Valid inputs still work
1780        assert!(reconstructor.new_exit_commitment(make_ssa_id(), 2, 2).is_ok());
1781
1782        Ok(())
1783    }
1784
1785    #[test]
1786    fn reconstructor_invalid_commitment_inputs() -> anyhow::Result<()> {
1787        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
1788
1789        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1790
1791        reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
1792
1793        // 1. Any non-constant coefficient index is ignored rather than rejected, whether or not it is within the
1794        //    threshold — PIX commits to nothing but the constant term, and a peer that still sends a full Feldman
1795        //    matrix must not be treated as hostile.
1796        for coeff_index in [1, 2, CoefficientIndex::MAX] {
1797            let ignored =
1798                reconstructor.insert_coefficient_commitments(ssa_id, coeff_index, None, HashMap::new().into_iter())?;
1799            assert!(ignored.ssa_deposit_address.is_none());
1800            assert!(!ignored.is_verifiable);
1801        }
1802        assert!(
1803            reconstructor
1804                .commitment_builder
1805                .get(&ssa_id)
1806                .ok_or_else(|| anyhow::anyhow!("missing builder"))?
1807                .lock()
1808                .is_empty(),
1809            "ignored commitments must not enter the builder's state"
1810        );
1811
1812        // 2. Invalid polynomial index (>= polys_per_ssa)
1813        let mut invalid_poly_map = HashMap::new();
1814        invalid_poly_map.insert(2 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
1815        let result = reconstructor.insert_coefficient_commitments(ssa_id, 0, None, invalid_poly_map.into_iter());
1816        assert!(matches!(result, Err(PixError::InvalidInput)));
1817
1818        Ok(())
1819    }
1820
1821    #[test]
1822    fn reconstructor_should_not_accept_client_commitments_without_priod_exit_commitment() -> anyhow::Result<()> {
1823        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
1824
1825        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1826
1827        let mut poly_map = HashMap::new();
1828        for poly in 0..2 {
1829            poly_map.insert(poly as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
1830        }
1831
1832        let res = reconstructor.insert_coefficient_commitments(ssa_id, 0, None, poly_map.into_iter());
1833
1834        assert!(matches!(res, Err(PixError::MissingSsaCommitment)));
1835
1836        Ok(())
1837    }
1838
1839    #[test]
1840    fn reconstructor_duplicate_commitments() -> anyhow::Result<()> {
1841        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
1842
1843        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1844
1845        reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
1846
1847        // Fill every constant term, which is the whole commitment
1848        let mut poly_map = HashMap::new();
1849        for poly in 0..2 {
1850            poly_map.insert(poly as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
1851        }
1852        reconstructor.insert_coefficient_commitments(ssa_id, 0, Some(identity_proof(&ssa_id)), poly_map.into_iter())?;
1853
1854        // Now adding more should fail with DuplicateCommitment
1855        let result = reconstructor.insert_coefficient_commitments(ssa_id, 0, None, HashMap::new().into_iter());
1856        assert!(matches!(result, Err(PixError::DuplicateCommitment)));
1857
1858        // A trailing non-constant coefficient, on the other hand, is simply ignored: a peer that
1859        // still emits the full Feldman matrix sends the bulk of it *after* the constant-term pass
1860        // has completed, and that must not read as a duplicate-commitment attack.
1861        let mut trailing = HashMap::new();
1862        trailing.insert(0 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
1863        let ignored = reconstructor.insert_coefficient_commitments(ssa_id, 1, None, trailing.into_iter())?;
1864        assert!(
1865            ignored.is_verifiable,
1866            "ignoring a message must not make a completed cycle look incomplete"
1867        );
1868
1869        Ok(())
1870    }
1871
1872    #[test]
1873    fn reconstructor_duplicate_per_polynomial_commitment() -> anyhow::Result<()> {
1874        // Regression test for the per-polynomial duplicate check inside add_transposed.
1875        // Previously the same polynomial's slot silently overwrote; now it returns
1876        // DuplicateCommitment.
1877        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
1878
1879        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1880
1881        reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
1882
1883        // Insert the constant term of poly 0
1884        let mut poly_map_1 = HashMap::new();
1885        poly_map_1.insert(0 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
1886        reconstructor.insert_coefficient_commitments(ssa_id, 0, None, poly_map_1.into_iter())?;
1887
1888        // Insert the constant term of poly 0 again — must fail
1889        let mut poly_map_2 = HashMap::new();
1890        poly_map_2.insert(0 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
1891        let result = reconstructor.insert_coefficient_commitments(ssa_id, 0, None, poly_map_2.into_iter());
1892        assert!(matches!(result, Err(PixError::DuplicateCommitment)));
1893
1894        // Poly 1's constant term is a different slot and must still be accepted
1895        let mut poly_map_3 = HashMap::new();
1896        poly_map_3.insert(1 as PolynomialIndex, PixGroupRepr::<TestSpec>::default());
1897        assert!(
1898            reconstructor
1899                .insert_coefficient_commitments(ssa_id, 0, Some(identity_proof(&ssa_id)), poly_map_3.into_iter())
1900                .is_ok()
1901        );
1902
1903        Ok(())
1904    }
1905
1906    #[test]
1907    fn reconstructor_missing_verifier_retains_share() -> anyhow::Result<()> {
1908        // Regression test for the share-loss race:
1909        // When the polynomial's verifier is not installed yet, the share must NOT be removed
1910        // from the awaiting_acks cache — it must remain available for the retry that happens
1911        // when the verifier arrives.
1912        //
1913        // The implementation guarantees this: `process_verified_ack` looks the share up with
1914        // `.get()` and only `.remove()`s it after the verifier lookup succeeds, so the
1915        // `VerifierNotReady` deferral leaves the share in place. This test asserts that retention.
1916        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig { ..Default::default() });
1917
1918        let ack_key = HalfKey::random();
1919        let challenge = ack_key.to_challenge()?;
1920
1921        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1922        let spi = SsaPolynomialId::new(ssa_id, 0);
1923
1924        let partial_share = PartialSsaShare::default().encrypt(&spi, &ack_key)?;
1925        let peer = OffchainKeypair::random();
1926        let nonce = crypto_traits::elliptic_curve::Scalar::<Secp256k1>::random(&mut hopr_types::crypto_random::rng());
1927
1928        reconstructor.new_exit_commitment(ssa_id, DEFAULT_POLYS_PER_SSA as usize, DEFAULT_POLY_THRESHOLD as usize)?;
1929
1930        reconstructor.insert_encrypted_share(
1931            peer.public(),
1932            challenge,
1933            TaggedEncryptedPartialSsaShare {
1934                pseudonym: *spi.pseudonym(),
1935                nonce,
1936                partial_share,
1937            },
1938        )?;
1939
1940        // Verify the share exists before processing
1941        let peer_cache = reconstructor.awaiting_acks.get(peer.public());
1942        assert!(peer_cache.is_some(), "share must be inserted before processing");
1943        assert!(
1944            peer_cache.as_ref().unwrap().contains_key(&challenge),
1945            "share must be present in the peer cache before processing"
1946        );
1947
1948        // Process the ack — the verifier is not installed, so this defers rather than failing,
1949        // and reports the polynomial the ack must be bucketed under.
1950        let peer_cache_ref = reconstructor.awaiting_acks.get(peer.public()).unwrap();
1951        let result = reconstructor.process_verified_ack(ack_key, challenge, &peer_cache_ref);
1952        assert!(
1953            matches!(result, Ok(ProcessedAckResult::VerifierNotReady(reported)) if reported == spi),
1954            "expected deferral naming polynomial {spi:?}"
1955        );
1956
1957        // The share MUST NOT be destroyed by the deferral: the implementation only removes it
1958        // after the verifier lookup succeeds, so it stays available for the retry.
1959        let peer_cache_after = reconstructor.awaiting_acks.get(peer.public());
1960        assert!(peer_cache_after.is_some(), "share must be retained when deferred");
1961        assert!(
1962            peer_cache_after.as_ref().unwrap().contains_key(&challenge),
1963            "share must be retained when deferred"
1964        );
1965
1966        Ok(())
1967    }
1968
1969    #[test]
1970    fn reconstructor_defers_ack_when_verifier_is_not_installed() -> anyhow::Result<()> {
1971        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig { ..Default::default() });
1972
1973        let ack_key = HalfKey::random();
1974        let challenge = ack_key.to_challenge()?;
1975
1976        // Add a pending share but NO commitment (so no verifier is created)
1977        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1978        let spi = SsaPolynomialId::new(ssa_id, 0);
1979
1980        // We need a valid-looking encrypted share even if it's junk.
1981        // EncryptedPartialSsaShare is basically a wrapper around bytes.
1982        let partial_share = PartialSsaShare::default().encrypt(&spi, &ack_key)?;
1983
1984        let peer = OffchainKeypair::random();
1985        let nonce = crypto_traits::elliptic_curve::Scalar::<Secp256k1>::random(&mut hopr_types::crypto_random::rng());
1986
1987        reconstructor.new_exit_commitment(ssa_id, DEFAULT_POLYS_PER_SSA as usize, DEFAULT_POLY_THRESHOLD as usize)?;
1988
1989        reconstructor.insert_encrypted_share(
1990            peer.public(),
1991            challenge,
1992            TaggedEncryptedPartialSsaShare {
1993                pseudonym: *spi.pseudonym(),
1994                nonce,
1995                partial_share,
1996            },
1997        )?;
1998
1999        let result = reconstructor.process_verified_ack(
2000            ack_key,
2001            challenge,
2002            reconstructor
2003                .awaiting_acks
2004                .get(peer.public())
2005                .as_ref()
2006                .ok_or(anyhow::anyhow!("missing peer"))?,
2007        );
2008        assert!(
2009            matches!(result, Ok(ProcessedAckResult::VerifierNotReady(reported)) if reported == spi),
2010            "an ack with no installed verifier must be deferred, not failed"
2011        );
2012
2013        Ok(())
2014    }
2015
2016    #[test]
2017    fn reconstructor_rejects_duplicate_share_via_different_challenges() -> anyhow::Result<()> {
2018        // 1 poly, threshold=2 → need 2 shares per polynomial to reconstruct.
2019        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2020            polynomials_per_ssa: 1,
2021            threshold: 2,
2022            surplus_shares: 0,
2023        });
2024
2025        let pseudonym = SimplePseudonym::random();
2026        let peer = OffchainKeypair::random();
2027        let ssa_id = SsaId::new(pseudonym, 1.try_into()?);
2028
2029        let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2030
2031        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
2032        let _server_commitment = reconstructor.new_exit_commitment(ssa_id, 1, 2)?;
2033        commitment_msg.process_into_reconstructor(&reconstructor)?;
2034
2035        // --- Step 1: Generate the first share ---
2036        let msg1: [u8; 20] = hopr_types::crypto_random::random_bytes();
2037        let Some(first) = generator.next_share(&pseudonym, &msg1)? else {
2038            anyhow::bail!("expected first share");
2039        };
2040        // Clone the PartialSsaShare so we can re-encrypt it as a duplicate later
2041        let first_share = first.share.clone();
2042        let ack1 = HalfKey::random();
2043        let challenge1 = ack1.to_challenge()?;
2044        let enc1 = first.share.encrypt(&first.id, &ack1)?;
2045        reconstructor.insert_encrypted_share(
2046            peer.public(),
2047            challenge1,
2048            TaggedEncryptedPartialSsaShare::new(pseudonym, &msg1, enc1)?,
2049        )?;
2050
2051        // --- Step 2: Re-encrypt the SAME share under a different challenge (true duplicate) ---
2052        // The PartialSsaShare retains the same scalar value and derives the same identifier
2053        // (X-coordinate) from msg1, so it will be recognised as a duplicate at share-insertion time.
2054        let dup_ack = HalfKey::random();
2055        let dup_challenge = dup_ack.to_challenge()?;
2056        let enc_dup = first_share.encrypt(&first.id, &dup_ack)?;
2057        reconstructor.insert_encrypted_share(
2058            peer.public(),
2059            dup_challenge,
2060            TaggedEncryptedPartialSsaShare::new(pseudonym, &msg1, enc_dup)?,
2061        )?;
2062
2063        // --- Step 3: Process the first ack — share accepted, not yet complete ---
2064        let resolution1 = reconstructor.process_verified_ack(
2065            ack1,
2066            challenge1,
2067            reconstructor
2068                .awaiting_acks
2069                .get(peer.public())
2070                .as_ref()
2071                .ok_or(anyhow::anyhow!("missing peer"))?,
2072        )?;
2073        assert!(
2074            matches!(resolution1, ProcessedAckResult::Progressed(p) if p.useful_shares == 1),
2075            "first share should count as progress without completing the SSA"
2076        );
2077
2078        // --- Step 4: Process the duplicate ---
2079        // The SsaPartBuilder has 1/2 shares. The duplicate share has the same identifier
2080        // (same X-coordinate from msg1), so it hits the
2081        // `any(|s| s.identifier == share.identifier)` check in SsaPartBuilder::add_share.
2082        //
2083        // The point of the assertion is the contrast with step 3: a duplicate is not merely
2084        // "incomplete", it is *not progress*, so it must leave the counters where they were.
2085        let resolution_dup = reconstructor.process_verified_ack(
2086            dup_ack,
2087            dup_challenge,
2088            reconstructor
2089                .awaiting_acks
2090                .get(peer.public())
2091                .as_ref()
2092                .ok_or(anyhow::anyhow!("missing peer"))?,
2093        )?;
2094        assert!(
2095            matches!(resolution_dup, ProcessedAckResult::NoProgress),
2096            "duplicate share must not register as progress"
2097        );
2098        assert_eq!(
2099            1,
2100            reconstructor
2101                .cycle(&ssa_id)
2102                .ok_or(anyhow::anyhow!("cycle went away"))?
2103                .progress()
2104                .useful_shares,
2105            "the duplicate must not have advanced the useful-share count"
2106        );
2107
2108        // --- Step 5: Generate and process the second distinct share ---
2109        let msg2: [u8; 20] = hopr_types::crypto_random::random_bytes();
2110        let Some(second) = generator.next_share(&pseudonym, &msg2)? else {
2111            anyhow::bail!("expected second share");
2112        };
2113        let ack2 = HalfKey::random();
2114        let challenge2 = ack2.to_challenge()?;
2115        let enc2 = second.share.encrypt(&second.id, &ack2)?;
2116        reconstructor.insert_encrypted_share(
2117            peer.public(),
2118            challenge2,
2119            TaggedEncryptedPartialSsaShare::new(pseudonym, &msg2, enc2)?,
2120        )?;
2121
2122        let resolution2 = reconstructor.process_verified_ack(
2123            ack2,
2124            challenge2,
2125            reconstructor
2126                .awaiting_acks
2127                .get(peer.public())
2128                .as_ref()
2129                .ok_or(anyhow::anyhow!("missing peer"))?,
2130        )?;
2131        assert!(
2132            matches!(resolution2, ProcessedAckResult::FullRecovery(ref r, _) if r.ssa_id == ssa_id),
2133            "second unique share should complete SSA reconstruction"
2134        );
2135
2136        Ok(())
2137    }
2138
2139    #[test]
2140    fn reconstructor_must_not_accept_empty_encrypted_share() -> anyhow::Result<()> {
2141        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig { ..Default::default() });
2142
2143        let ack_key = HalfKey::random();
2144        let challenge = ack_key.to_challenge()?;
2145
2146        let peer = OffchainKeypair::random();
2147
2148        assert!(
2149            reconstructor
2150                .insert_encrypted_share(
2151                    peer.public(),
2152                    challenge,
2153                    TaggedEncryptedPartialSsaShare {
2154                        pseudonym: SimplePseudonym::random(),
2155                        nonce: Default::default(),
2156                        partial_share: Default::default(),
2157                    }
2158                )
2159                .is_err()
2160        );
2161
2162        Ok(())
2163    }
2164
2165    // -----------------------------------------------------------------------
2166    // early_recovery_threshold tests
2167    // -----------------------------------------------------------------------
2168
2169    /// Helper: create an SsaBuilder that accepts zero-valued sub-secrets.
2170    fn make_builder(num_polys: usize) -> SsaBuilder<TestSpec> {
2171        let exit_secret = PixScalar::<TestSpec>::default();
2172        let full_commitment = PixGroup::<TestSpec>::default();
2173        SsaBuilder::new(full_commitment, exit_secret, num_polys)
2174    }
2175
2176    /// Helper: add `n` zero-valued polynomial parts to `builder`, returning
2177    /// the result of each call.
2178    fn add_parts(
2179        builder: &mut SsaBuilder<TestSpec>,
2180        n: usize,
2181    ) -> crate::errors::Result<Vec<Option<PixScalar<TestSpec>>>, <TestSpec as PixSpec>::Pseudonym> {
2182        let mut results = Vec::with_capacity(n);
2183        for i in 0..n {
2184            let sub = PixScalar::<TestSpec>::default();
2185            results.push(builder.add_recovered_ssa_part(i as PolynomialIndex, sub)?);
2186        }
2187        Ok(results)
2188    }
2189
2190    #[test]
2191    fn ssa_builder_early_threshold_below() -> anyhow::Result<()> {
2192        // num_polys=10, threshold=0.85 → ceil(0.85×10)=9.
2193        // Adding 8 parts should NOT reach the threshold.
2194        let mut builder = make_builder(10);
2195        add_parts(&mut builder, 8)?;
2196        assert!(!builder.check_early_threshold(0.85));
2197        Ok(())
2198    }
2199
2200    #[test]
2201    fn ssa_builder_early_threshold_hits_ceil_at_9() -> anyhow::Result<()> {
2202        // num_polys=10, threshold=0.85 → ceil(0.85×10)=9.
2203        // Adding 9 parts SHOULD fire on the first check.
2204        let mut builder = make_builder(10);
2205        add_parts(&mut builder, 9)?;
2206        assert!(builder.check_early_threshold(0.85));
2207        // Second call must return false (idempotent guard).
2208        assert!(!builder.check_early_threshold(0.85));
2209        Ok(())
2210    }
2211
2212    #[test]
2213    fn ssa_builder_threshold_1_dot_0_fires_at_full_recovery() -> anyhow::Result<()> {
2214        // num_polys=10, threshold=1.0 → ceil(1.0×10)=10.
2215        // Only fires when ALL 10 polynomial parts are received.
2216        let mut builder = make_builder(10);
2217        add_parts(&mut builder, 9)?;
2218        assert!(!builder.check_early_threshold(1.0));
2219        add_parts(&mut builder, 1)?; // 10th part → completes SSA
2220        // After full recovery, early_notified is set by add_recovered_ssa_part.
2221        // check_early_threshold should still report false.
2222        assert!(!builder.check_early_threshold(1.0));
2223        Ok(())
2224    }
2225
2226    #[test]
2227    fn process_verified_ack_emits_early_and_full_recovery() -> anyhow::Result<()> {
2228        // Use a small SSA config where we can observe both events.
2229        // 4 polynomials, threshold=4, surplus=0 → 16 shares total.
2230        // early_recovery_threshold=0.5 → ceil(0.5×4)=2.
2231        // After 2 polynomial parts → EarlyRecovery.
2232        // After all 4         → FullRecovery.
2233        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2234            polynomials_per_ssa: 4,
2235            threshold: 4,
2236            surplus_shares: 0,
2237        });
2238
2239        let pseudonym = SimplePseudonym::random();
2240        let peer = OffchainKeypair::random();
2241        let ssa_id = SsaId::new(pseudonym, 1.try_into()?);
2242
2243        let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2244
2245        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
2246            early_recovery_threshold: 0.5,
2247            ..Default::default()
2248        });
2249
2250        let _server_commitment = reconstructor.new_exit_commitment(ssa_id, 4, 4)?;
2251
2252        commitment_msg.process_into_reconstructor(&reconstructor)?;
2253
2254        // No shares inserted yet — has_pending_shares must be false.
2255        assert!(
2256            !reconstructor.has_pending_shares(peer.public()),
2257            "no shares inserted yet"
2258        );
2259
2260        // Generate and insert all 16 encrypted shares
2261        let mut acks = Vec::new();
2262        while let Some((msg, share)) = {
2263            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
2264            generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
2265        }? {
2266            let ack = HalfKey::random();
2267            let ack_challenge = ack.to_challenge()?;
2268            let enc_share = share.share.encrypt(&share.id, &ack)?;
2269
2270            reconstructor.insert_encrypted_share(
2271                peer.public(),
2272                ack_challenge,
2273                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
2274            )?;
2275            acks.push(VerifiedAcknowledgement::new(ack, &peer).leak());
2276        }
2277
2278        // After inserting encrypted shares, the peer must have pending shares.
2279        assert!(
2280            reconstructor.has_pending_shares(peer.public()),
2281            "shares were just inserted"
2282        );
2283
2284        // Process all acks in one batch
2285        let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
2286
2287        // Both events MUST be present
2288        let has_early = resolutions
2289            .iter()
2290            .any(|r| matches!(r, ShareResolution::AlmostRecoveredSsa(id) if *id == ssa_id));
2291        let has_full = resolutions
2292            .iter()
2293            .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id));
2294
2295        assert!(has_early, "expected AlmostRecoveredSsa event");
2296        assert!(has_full, "expected RecoveredSsa event");
2297
2298        Ok(())
2299    }
2300
2301    /// Sets up one fully committed cycle and returns every share's acknowledgement, unprocessed.
2302    ///
2303    /// Shares are inserted but not acknowledged, so the caller can feed acks in whatever grouping
2304    /// the test needs and read the cycle's counters in between.
2305    #[allow(clippy::type_complexity)]
2306    fn cycle_with_pending_acks(
2307        polys: u16,
2308        threshold: u8,
2309        surplus: u8,
2310        peer: &OffchainKeypair,
2311    ) -> anyhow::Result<(SsaReconstructor<TestSpec>, SsaId<SimplePseudonym>, Vec<Acknowledgement>)> {
2312        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2313            polynomials_per_ssa: polys,
2314            threshold,
2315            surplus_shares: surplus,
2316        });
2317        let pseudonym = SimplePseudonym::random();
2318        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2319
2320        let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2321        let reconstructor = SsaReconstructor::<TestSpec>::default();
2322        reconstructor.new_exit_commitment(ssa_id, polys as usize, threshold as usize)?;
2323        commitment_msg.process_into_reconstructor(&reconstructor)?;
2324
2325        let mut acks = Vec::new();
2326        while let Some((msg, share)) = {
2327            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
2328            generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
2329        }? {
2330            let ack = HalfKey::random();
2331            let ack_challenge = ack.to_challenge()?;
2332            let enc_share = share.share.encrypt(&share.id, &ack)?;
2333            reconstructor.insert_encrypted_share(
2334                peer.public(),
2335                ack_challenge,
2336                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
2337            )?;
2338            acks.push(VerifiedAcknowledgement::new(ack, peer).leak());
2339        }
2340
2341        Ok((reconstructor, ssa_id, acks))
2342    }
2343
2344    #[test]
2345    fn progress_counts_only_the_shares_that_advance_reconstruction() -> anyhow::Result<()> {
2346        // 2 polynomials × threshold 2, plus 1 surplus share each: 6 shares on the wire, of which
2347        // only 4 can ever be useful. The gap between those two numbers is the whole point — a
2348        // consumer sizing a progress ratio against packets received would read 6/4.
2349        const POLYS: u16 = 2;
2350        const THRESHOLD: u8 = 2;
2351        let peer = OffchainKeypair::random();
2352        let (reconstructor, ssa_id, acks) = cycle_with_pending_acks(POLYS, THRESHOLD, 1, &peer)?;
2353        assert_eq!(
2354            6,
2355            acks.len(),
2356            "generator should emit (threshold + surplus) per polynomial"
2357        );
2358
2359        let mut snapshots = Vec::new();
2360        let mut recovered = false;
2361        // One ack at a time, so each share's individual contribution is observable.
2362        for ack in acks {
2363            for resolution in reconstructor.acknowledge_shares(*peer.public(), vec![ack])? {
2364                match resolution {
2365                    ShareResolution::Progress(p) => snapshots.push(p),
2366                    ShareResolution::RecoveredSsa(r) => {
2367                        assert_eq!(ssa_id, r.ssa_id);
2368                        recovered = true;
2369                    }
2370                    _ => {}
2371                }
2372            }
2373        }
2374
2375        assert!(recovered, "the cycle must reconstruct from its own shares");
2376
2377        // Shares are emitted polynomial-major, so the useful ones are shares 1,2 and 4,5 — the
2378        // third share of each polynomial arrives after that polynomial is already reconstructed.
2379        assert_eq!(
2380            vec![1, 2, 3, 4],
2381            snapshots.iter().map(|p| p.useful_shares).collect::<Vec<_>>(),
2382            "each snapshot must advance by exactly one, and surplus shares must emit none at all"
2383        );
2384        let last = snapshots.last().ok_or(anyhow::anyhow!("no progress emitted"))?;
2385        assert_eq!(
2386            (POLYS as u64 * THRESHOLD as u64),
2387            last.target_useful_shares,
2388            "target must be polynomials × threshold, matching the negotiated dimensions"
2389        );
2390        assert_eq!(
2391            last.useful_shares, last.target_useful_shares,
2392            "a completed cycle must report itself as complete"
2393        );
2394        assert_eq!(
2395            POLYS, last.recovered_polynomials,
2396            "every polynomial must be accounted for"
2397        );
2398
2399        Ok(())
2400    }
2401
2402    #[test]
2403    fn progress_precedes_the_terminal_event_it_belongs_to() -> anyhow::Result<()> {
2404        // The emission order is a contract: a consumer that acts on RecoveredSsa must already have
2405        // been told the counters that justify it, including for the batch that completes the cycle —
2406        // the one whose snapshot is taken from a cycle that no longer exists by the time the batch
2407        // returns.
2408        let peer = OffchainKeypair::random();
2409        let (reconstructor, ssa_id, acks) = cycle_with_pending_acks(2, 2, 0, &peer)?;
2410
2411        let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
2412
2413        let first_terminal = resolutions
2414            .iter()
2415            .position(|r| {
2416                matches!(
2417                    r,
2418                    ShareResolution::RecoveredSsa(_) | ShareResolution::AlmostRecoveredSsa(_)
2419                )
2420            })
2421            .ok_or(anyhow::anyhow!("expected a terminal resolution"))?;
2422        let last_progress = resolutions
2423            .iter()
2424            .rposition(|r| matches!(r, ShareResolution::Progress(_)))
2425            .ok_or(anyhow::anyhow!("expected a progress resolution"))?;
2426        assert!(
2427            last_progress < first_terminal,
2428            "every Progress must precede every terminal event, got {resolutions:?}"
2429        );
2430
2431        // Exactly one snapshot per SSA per batch, even though four shares moved the counters.
2432        let progress = resolutions
2433            .iter()
2434            .filter_map(|r| match r {
2435                ShareResolution::Progress(p) => Some(p),
2436                _ => None,
2437            })
2438            .collect::<Vec<_>>();
2439        assert_eq!(1, progress.len(), "one snapshot per SSA per batch, got {progress:?}");
2440        assert_eq!(ssa_id, progress[0].ssa_id);
2441        assert_eq!(
2442            4, progress[0].useful_shares,
2443            "the surviving snapshot must be the furthest-along one"
2444        );
2445
2446        Ok(())
2447    }
2448
2449    #[test]
2450    fn an_abandoned_commitment_guard_releases_its_registration() -> anyhow::Result<()> {
2451        let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
2452        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
2453
2454        let (_commitment, guard) = reconstructor.new_guarded_exit_commitment(ssa_id, 2, 2)?;
2455        assert_eq!(Some(&ssa_id), guard.ssa_id());
2456        assert!(
2457            reconstructor.contains_builder(&ssa_id),
2458            "the commitment must be registered while the guard holds it"
2459        );
2460
2461        drop(guard);
2462        assert!(
2463            !reconstructor.contains_builder(&ssa_id),
2464            "dropping the guard must release the registration it owned"
2465        );
2466
2467        Ok(())
2468    }
2469
2470    /// Abandoning a guard whose cycle already went live escalates to a full retirement.
2471    ///
2472    /// Reaching this is a caller error — a live cycle means the peer was asked and answered, so
2473    /// ownership should already have been handed on with `disarm()`. Retiring is the safe response
2474    /// rather than the merely tidy one: the tombstone is what stops a commitment completion racing
2475    /// the teardown from republishing the cycle that was just dismantled.
2476    #[test]
2477    fn abandoning_a_live_cycle_retires_it_rather_than_just_releasing_it() -> anyhow::Result<()> {
2478        const POLYS: u16 = 2;
2479        const THRESHOLD: u8 = 2;
2480        let pseudonym = SimplePseudonym::random();
2481        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2482        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2483            polynomials_per_ssa: POLYS,
2484            threshold: THRESHOLD,
2485            surplus_shares: 0,
2486        });
2487
2488        let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
2489        let (_commitment, guard) =
2490            reconstructor.new_guarded_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
2491
2492        // Take the cycle live under the guard, which is the state a correct caller would have
2493        // disarmed out of.
2494        generator
2495            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
2496            .process_into_reconstructor(reconstructor.as_ref())?;
2497        assert!(reconstructor.cycle(&ssa_id).is_some(), "the cycle must be live");
2498
2499        drop(guard);
2500        assert!(
2501            reconstructor.cycle(&ssa_id).is_none(),
2502            "abandoning a live cycle must tear it down"
2503        );
2504
2505        // A tombstone was written, so this SsaId is spent: a fresh cycle at the same index is
2506        // published and then withdrawn at completion. That is the retirement contract, and the
2507        // contrast with `an_ssa_index_stays_usable_after_its_request_is_abandoned` is the point.
2508        reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
2509        // A fresh generator: the original has already spent this pseudonym's index, and the identity
2510        // of the replacement commitment is irrelevant to what is being tested.
2511        let replacement = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2512            polynomials_per_ssa: POLYS,
2513            threshold: THRESHOLD,
2514            surplus_shares: 0,
2515        })
2516        .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2517        let mut final_state = None;
2518        for (coeff_idx, coeffs) in replacement.verifiers.clone() {
2519            let proof = (coeff_idx == crate::CONSTANT_TERM_COEFFICIENT).then_some(replacement.commitment_proof);
2520            final_state =
2521                Some(reconstructor.insert_coefficient_commitments(ssa_id, coeff_idx, proof, coeffs.into_iter())?);
2522        }
2523        assert!(
2524            !final_state
2525                .ok_or(anyhow::anyhow!("commitment carried no batches"))?
2526                .is_verifiable,
2527            "a retired SsaId must stay retired"
2528        );
2529
2530        Ok(())
2531    }
2532
2533    #[test]
2534    fn a_disarmed_commitment_guard_leaves_its_ssa_alone() -> anyhow::Result<()> {
2535        let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
2536        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
2537
2538        let (_commitment, guard) = reconstructor.new_guarded_exit_commitment(ssa_id, 2, 2)?;
2539        assert_eq!(Some(ssa_id), guard.disarm());
2540        assert!(
2541            reconstructor.contains_builder(&ssa_id),
2542            "a disarmed guard must not retire the commitment it handed on"
2543        );
2544
2545        Ok(())
2546    }
2547
2548    /// Abandoning a request must leave its SSA index usable, because that index is what the next
2549    /// attempt will use — it is advanced only once every fallible step has succeeded.
2550    ///
2551    /// Releasing through the full retirement path instead writes the resurrection tombstone, and the
2552    /// resulting failure is the quietest one in the protocol: the retry accepts the peer's
2553    /// commitments and publishes a deposit address, then has its cycle undone at completion. The peer
2554    /// funds an SSA that can never be reconstructed and neither side reports anything wrong.
2555    #[test]
2556    fn an_ssa_index_stays_usable_after_its_request_is_abandoned() -> anyhow::Result<()> {
2557        const POLYS: u16 = 2;
2558        const THRESHOLD: u8 = 2;
2559        let pseudonym = SimplePseudonym::random();
2560        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2561
2562        let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
2563
2564        // First attempt is abandoned before the peer is ever asked for commitments.
2565        let (_commitment, guard) =
2566            reconstructor.new_guarded_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
2567        drop(guard);
2568
2569        // Retry at the same index, which is what a failed request leaves behind.
2570        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2571            polynomials_per_ssa: POLYS,
2572            threshold: THRESHOLD,
2573            surplus_shares: 0,
2574        });
2575        reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
2576        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2577        let mut final_state = None;
2578        for (coeff_idx, coeffs) in commitment.verifiers.clone() {
2579            let proof = (coeff_idx == crate::CONSTANT_TERM_COEFFICIENT).then_some(commitment.commitment_proof);
2580            final_state =
2581                Some(reconstructor.insert_coefficient_commitments(ssa_id, coeff_idx, proof, coeffs.into_iter())?);
2582        }
2583        let state = final_state.ok_or(anyhow::anyhow!("commitment carried no batches"))?;
2584
2585        assert!(
2586            state.is_verifiable,
2587            "the retry must become verifiable — otherwise the peer is asked to fund a cycle that cannot reconstruct"
2588        );
2589        assert!(state.ssa_deposit_address.is_some(), "a verifiable cycle has an address");
2590        assert!(
2591            reconstructor.cycle(&ssa_id).is_some(),
2592            "the retry's cycle must be live, not published and then withdrawn"
2593        );
2594
2595        Ok(())
2596    }
2597
2598    #[test]
2599    fn a_rejected_duplicate_registration_does_not_retire_the_original() -> anyhow::Result<()> {
2600        // The failure path must not produce a guard: if it did, dropping the error's guard would
2601        // retire the very registration whose presence caused the rejection — turning a harmless
2602        // duplicate request into the loss of a live cycle.
2603        let reconstructor = std::sync::Arc::new(SsaReconstructor::<TestSpec>::default());
2604        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
2605
2606        let (_commitment, guard) = reconstructor.new_guarded_exit_commitment(ssa_id, 2, 2)?;
2607
2608        assert!(
2609            matches!(
2610                reconstructor.new_guarded_exit_commitment(ssa_id, 2, 2),
2611                Err(PixError::DuplicateCommitment)
2612            ),
2613            "a second registration at the same index must be rejected"
2614        );
2615        assert!(
2616            reconstructor.contains_builder(&ssa_id),
2617            "the rejected duplicate must leave the original registration intact"
2618        );
2619
2620        drop(guard);
2621        Ok(())
2622    }
2623
2624    /// **L2 regression.** A polynomial index repeated inside one batch must be rejected, not merely
2625    /// one repeated across batches.
2626    ///
2627    /// The two-phase check tested each entry against `committed_polynomials`, which holds only what
2628    /// earlier calls inserted, so two entries sharing an index both found the slot vacant. The
2629    /// second insert then rebound the first — the single-assignment invariant the two phases exist
2630    /// to enforce — and `total_committed` counted two occupants of one slot. The practical bite:
2631    /// a batch carrying every polynomial with a repeat among them can never complete the set, and
2632    /// the peer has no way to supply the one it displaced, because every retry is rejected as a
2633    /// duplicate against the slots the batch did fill.
2634    ///
2635    /// The wire decoder rejects intra-message duplicates today. This builder is not meant to depend
2636    /// on that, which is why the check is here.
2637    #[test]
2638    fn a_polynomial_repeated_within_one_batch_is_rejected() -> anyhow::Result<()> {
2639        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2640            polynomials_per_ssa: 2,
2641            threshold: 2,
2642            surplus_shares: 0,
2643        });
2644        let pseudonym = SimplePseudonym::random();
2645        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2646
2647        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2648        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
2649        reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
2650
2651        // Polynomial 0 twice, and polynomial 1 not at all — the shape that would otherwise leave the
2652        // set permanently one short while reporting two commitments received.
2653        let mut batch = coefficient_of(&commitment, 0, Some(0))?;
2654        batch.push(batch[0]);
2655        let result =
2656            reconstructor.insert_coefficient_commitments(ssa_id, 0, proof_of(&commitment, 0), batch.into_iter());
2657        assert!(
2658            matches!(&result, Err(crate::errors::PixError::DuplicateCommitment)),
2659            "a repeat inside the batch must be rejected, got {result:?}"
2660        );
2661
2662        // Rejected transactionally: neither entry was written, so the honest batch still lands.
2663        let retry = reconstructor.insert_coefficient_commitments(
2664            ssa_id,
2665            0,
2666            proof_of(&commitment, 0),
2667            coefficient_of(&commitment, 0, None)?.into_iter(),
2668        )?;
2669        assert!(
2670            retry.is_verifiable && retry.ssa_deposit_address.is_some(),
2671            "the corrected batch must complete the commitment"
2672        );
2673
2674        Ok(())
2675    }
2676
2677    #[test]
2678    fn malformed_commitment_does_not_poison_corrected_retransmission() -> anyhow::Result<()> {
2679        // Regression test for M2: a malformed coefficient that fails EC point
2680        // decoding must NOT leave the commitment builder permanently poisoned.
2681        // After submitting malformed bytes, a retry with correct bytes must
2682        // succeed and complete the SSA.
2683        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2684            polynomials_per_ssa: 2,
2685            threshold: 2,
2686            surplus_shares: 0,
2687        });
2688        let pseudonym = SimplePseudonym::random();
2689        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2690
2691        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2692
2693        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
2694        reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
2695
2696        // Step 1: Submit polynomial 0's constant term — must succeed, but the SSA commitment needs
2697        // every constant term, so no deposit address yet.
2698        let partial = reconstructor.insert_coefficient_commitments(
2699            ssa_id,
2700            0,
2701            proof_of(&commitment, 0),
2702            coefficient_of(&commitment, 0, Some(0))?.into_iter(),
2703        )?;
2704        assert!(
2705            partial.ssa_deposit_address.is_none(),
2706            "the deposit address needs every constant term"
2707        );
2708        assert!(!partial.is_verifiable, "not yet complete");
2709
2710        // Step 2: Submit a malformed constant term for polynomial 1 (bytes with an invalid EC
2711        // compressed-point prefix of 0xff) — must return InvalidInput.
2712        let mut malformed = PixGroupRepr::<TestSpec>::default(); // zero-filled
2713        AsMut::<[u8]>::as_mut(&mut malformed).fill(0xff);
2714        let malformed_result = reconstructor.insert_coefficient_commitments(
2715            ssa_id,
2716            0,
2717            proof_of(&commitment, 0),
2718            [(1, malformed)].into_iter(),
2719        );
2720        assert!(
2721            matches!(&malformed_result, Err(crate::errors::PixError::InvalidInput)),
2722            "malformed commitment must be rejected, got {malformed_result:?}"
2723        );
2724
2725        // Step 3: Retry with the correct bytes — must succeed and complete the SSA commitment.
2726        let retry = reconstructor.insert_coefficient_commitments(
2727            ssa_id,
2728            0,
2729            proof_of(&commitment, 0),
2730            coefficient_of(&commitment, 0, Some(1))?.into_iter(),
2731        );
2732        assert!(
2733            matches!(&retry, Ok(state) if state.is_verifiable && state.ssa_deposit_address.is_some()),
2734            "corrected retransmission must complete the SSA, got {retry:?}"
2735        );
2736
2737        Ok(())
2738    }
2739
2740    /// Regression test for M13: the validation applied when a commitment *arrives* is the only
2741    /// validation it ever gets, so it must include the prime-order-subgroup test.
2742    ///
2743    /// A commitment that passes occupies its polynomial's slot permanently — re-insertion is
2744    /// rejected as a duplicate. A decodable-but-small-order point admitted here would take the
2745    /// slot and then make every reconstruction of that polynomial fail, with no way to retransmit
2746    /// a correction. This matters in practice: the default build uses BabyJubJub, whose cofactor is
2747    /// 8, so small-order points do exist and do pass a plain on-curve check.
2748    ///
2749    /// What this test covers is the *arrival point*, not the subgroup filter itself: `TestSpec` is
2750    /// secp256k1, cofactor 1, so no small-order point exists to feed it. The subgroup case is
2751    /// `pix_group_element_rejects_a_small_order_point` in `hopr-crypto-packet`, which also records
2752    /// why the filter cannot be isolated by any test — the Baby JubJub backend's own `from_bytes`
2753    /// already rejects, so `is_torsion_free` is defence in depth rather than the acting check.
2754    #[test]
2755    fn decode_commitment_is_the_single_validation_point() {
2756        use vsss_rs::elliptic_curve::group::GroupEncoding;
2757
2758        use crate::SsaPartCommitment;
2759
2760        // A well-formed commitment (the generator) decodes.
2761        let generator_repr = PixGroup::<TestSpec>::generator().to_bytes();
2762        assert!(
2763            SsaPartCommitment::<TestSpec>::decode_commitment(&generator_repr).is_ok(),
2764            "the generator is a valid constant-term commitment (scalar coefficient 1)"
2765        );
2766
2767        // Undecodable bytes are rejected.
2768        let mut malformed = PixGroupRepr::<TestSpec>::default();
2769        AsMut::<[u8]>::as_mut(&mut malformed).fill(0xff);
2770        assert!(
2771            matches!(
2772                SsaPartCommitment::<TestSpec>::decode_commitment(&malformed),
2773                Err(PixError::InvalidInput)
2774            ),
2775            "undecodable bytes must be rejected"
2776        );
2777    }
2778
2779    /// A reconstructed polynomial part must release its collected shares, while **keeping its
2780    /// cache entry**.
2781    ///
2782    /// That buffer dominates reconstructor memory — `threshold` shares held for every one of
2783    /// `polys` polynomials — and it cannot be read again once the part is reconstructed.
2784    ///
2785    /// Retaining the (now-stripped) cache entry is equally deliberate: evicting it would make every
2786    /// late or surplus share for that polynomial look like a not-yet-installed verifier, so the ack
2787    /// would be deferred into a bucket that nothing will ever drain. The stripped builder keeps the
2788    /// cheap already-reconstructed path instead.
2789    #[test]
2790    fn reconstructed_polynomial_releases_verification_state_but_keeps_its_entry() -> anyhow::Result<()> {
2791        // 2 polynomials, threshold 2, no surplus: finishing polynomial 0 does *not* complete the
2792        // SSA, so the cycle is still live and its per-polynomial state is observable.
2793        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2794            polynomials_per_ssa: 2,
2795            threshold: 2,
2796            surplus_shares: 0,
2797        });
2798        let pseudonym = SimplePseudonym::random();
2799        let peer = OffchainKeypair::random();
2800        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2801        let poly_0 = SsaPolynomialId::new(ssa_id, 0);
2802
2803        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2804        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
2805        reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
2806        commitment.process_into_reconstructor(&reconstructor)?;
2807
2808        // Freshly installed: no shares collected yet.
2809        let cycle = reconstructor
2810            .cycle(&ssa_id)
2811            .ok_or_else(|| anyhow::anyhow!("cycle must be live"))?;
2812        assert_eq!(
2813            0,
2814            cycle
2815                .part(poly_0.poly_index())
2816                .ok_or_else(|| anyhow::anyhow!("part builder for polynomial 0 must be installed"))?
2817                .lock()
2818                .verification_state_len()
2819        );
2820        drop(cycle);
2821
2822        // Feed exactly enough shares to reconstruct polynomial 0, skipping the ones the round-robin
2823        // emission hands out for polynomial 1 in between.
2824        for _ in 0..2 {
2825            let (msg, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
2826
2827            let ack = HalfKey::random();
2828            let challenge = ack.to_challenge()?;
2829            let enc = share.share.encrypt(&share.id, &ack)?;
2830            reconstructor.insert_encrypted_share(
2831                peer.public(),
2832                challenge,
2833                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
2834            )?;
2835            reconstructor.acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?;
2836        }
2837
2838        // The SSA as a whole is not recovered (polynomial 1 is untouched), so the cycle — and
2839        // polynomial 0's cache entry — must still be there.
2840        assert!(
2841            reconstructor.contains_builder(&ssa_id),
2842            "the cycle must still be live while polynomial 1 is outstanding"
2843        );
2844        let cycle = reconstructor
2845            .cycle(&ssa_id)
2846            .ok_or_else(|| anyhow::anyhow!("a reconstructed polynomial must keep its slot"))?;
2847        assert_eq!(
2848            0,
2849            cycle
2850                .part(poly_0.poly_index())
2851                .ok_or_else(|| anyhow::anyhow!("polynomial 0 must keep its slot"))?
2852                .lock()
2853                .verification_state_len(),
2854            "a reconstructed polynomial must hold no shares"
2855        );
2856
2857        // Polynomial 1 is untouched and must still be installed, awaiting its own shares.
2858        assert!(
2859            cycle.part(1).is_some(),
2860            "part builder for polynomial 1 must be installed"
2861        );
2862
2863        Ok(())
2864    }
2865
2866    /// A failed interpolation must leave the part terminal, exactly as a failed commitment opening
2867    /// does.
2868    ///
2869    /// The two paths were asymmetric: the commitment mismatch released the share buffer and set
2870    /// `failed`, while a `combine()` error propagated with `?` and did neither. That left the part
2871    /// holding a full share set with no terminal flag, so neither early return in `add_share`
2872    /// fired and every remaining share for the polynomial was pushed and re-ran the interpolation
2873    /// over a larger set — `O(threshold²)` per share, against a buffer that should already have
2874    /// been released, re-reporting the same fault each time.
2875    ///
2876    /// The interpolation is forced to fail here by giving the builder a one-share threshold, which
2877    /// `vsss_rs` rejects outright. How the failure arises is not the property under test; that the
2878    /// part is left terminal either way is.
2879    #[test]
2880    fn a_failed_interpolation_leaves_the_part_terminal() -> anyhow::Result<()> {
2881        use utils::{AddShareOutcome, SsaPartBuilder};
2882
2883        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2884            polynomials_per_ssa: 1,
2885            threshold: 2,
2886            surplus_shares: 2,
2887        });
2888        let pseudonym = SimplePseudonym::random();
2889        let spi = SsaPolynomialId::new(SsaId::new(pseudonym, SsaIndex::MIN), 0);
2890        generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2891
2892        // The commitment is never reached — `verify_reconstructed` runs only on a value that
2893        // interpolated — so the generator is a stand-in for any well-formed constant term.
2894        let mut part = SsaPartBuilder::<TestSpec>::new(
2895            crate::SsaPartCommitment::from_decoded_commitment(spi, PixGroup::<TestSpec>::generator()),
2896            1,
2897        );
2898
2899        let mut rng = hopr_types::crypto_random::rng();
2900        let (_, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
2901        assert!(
2902            part.add_share(PixScalar::<TestSpec>::random(&mut rng), share.share)
2903                .is_err(),
2904            "one share is below what `combine` accepts, so the interpolation must fail"
2905        );
2906        assert_eq!(
2907            0,
2908            part.verification_state_len(),
2909            "a part that can never reconstruct must release its share buffer"
2910        );
2911
2912        // Every later share is absorbed instead of re-running the interpolation and re-reporting.
2913        let (_, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
2914        assert!(
2915            matches!(
2916                part.add_share(PixScalar::<TestSpec>::random(&mut rng), share.share)?,
2917                AddShareOutcome::Absorbed
2918            ),
2919            "a failed part must absorb later shares silently"
2920        );
2921        assert_eq!(0, part.verification_state_len());
2922
2923        Ok(())
2924    }
2925
2926    #[test]
2927    fn full_recovery_retires_all_reconstructor_state() -> anyhow::Result<()> {
2928        // 4 polynomials, threshold 4, no surplus → 16 shares, fully recoverable.
2929        // Multiple polynomials on purpose: this exercises the whole `0..num_polys`
2930        // cleanup loop, so a wrong-key or off-by-one in `remove_cycle` would surface.
2931        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2932            polynomials_per_ssa: 4,
2933            threshold: 4,
2934            surplus_shares: 0,
2935        });
2936        let pseudonym = SimplePseudonym::random();
2937        let peer = OffchainKeypair::random();
2938        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
2939
2940        let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
2941        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
2942        reconstructor.new_exit_commitment(ssa_id, 4, 4)?;
2943        commitment_msg.process_into_reconstructor(&reconstructor)?;
2944
2945        // Precondition: the completed cycle holds every part builder and its accumulator.
2946        assert_eq!(
2947            4,
2948            reconstructor.installed_parts(&ssa_id),
2949            "4 part builders present after completion"
2950        );
2951        assert_eq!(1, reconstructor.live_cycles(), "the cycle is live after completion");
2952
2953        // Drive full recovery: generate every share, insert it encrypted, acknowledge.
2954        let mut acks = Vec::new();
2955        while let Some((msg, share)) = {
2956            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
2957            generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
2958        }? {
2959            let ack = HalfKey::random();
2960            let ack_challenge = ack.to_challenge()?;
2961            let enc_share = share.share.encrypt(&share.id, &ack)?;
2962            reconstructor.insert_encrypted_share(
2963                peer.public(),
2964                ack_challenge,
2965                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
2966            )?;
2967            acks.push(VerifiedAcknowledgement::new(ack, &peer).leak());
2968        }
2969        let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
2970        assert!(
2971            resolutions
2972                .iter()
2973                .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id)),
2974            "cycle must fully recover"
2975        );
2976
2977        // Behaviour under test: full recovery must retire ALL of the cycle's
2978        // reconstructor state, rather than leave it to linger until the idle TTL.
2979        reconstructor.commitment_builder.run_pending_tasks();
2980        assert_eq!(
2981            0,
2982            reconstructor.live_cycles(),
2983            "the cycle must be retired on full recovery"
2984        );
2985        assert!(
2986            !reconstructor.commitment_builder.contains_key(&ssa_id),
2987            "commitment builder must be retired on full recovery"
2988        );
2989
2990        Ok(())
2991    }
2992
2993    #[test]
2994    fn retire_ssa_removes_cycle_state_and_is_idempotent() -> anyhow::Result<()> {
2995        // 3 polynomials so the cleanup loop is again exercised over several keys.
2996        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
2997            polynomials_per_ssa: 3,
2998            threshold: 4,
2999            surplus_shares: 0,
3000        });
3001        let pseudonym = SimplePseudonym::random();
3002        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3003
3004        let commitment_msg = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3005        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
3006        reconstructor.new_exit_commitment(ssa_id, 3, 4)?;
3007        commitment_msg.process_into_reconstructor(&reconstructor)?;
3008
3009        assert_eq!(
3010            3,
3011            reconstructor.installed_parts(&ssa_id),
3012            "3 part builders present after completion"
3013        );
3014
3015        // Explicit retirement (as invoked on session teardown) drops everything.
3016        reconstructor.retire_ssa(ssa_id);
3017        reconstructor.commitment_builder.run_pending_tasks();
3018        assert_eq!(
3019            0,
3020            reconstructor.live_cycles(),
3021            "the cycle must be removed by retire_ssa"
3022        );
3023        assert!(!reconstructor.commitment_builder.contains_key(&ssa_id));
3024
3025        // Idempotent: retiring the same (now-empty) cycle again is a harmless no-op.
3026        reconstructor.retire_ssa(ssa_id);
3027
3028        // Retiring a cycle that was never created must not panic and must leave the caches
3029        // untouched.
3030        let never_seen = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3031        reconstructor.retire_ssa(never_seen);
3032        assert_eq!(0, reconstructor.live_cycles());
3033
3034        Ok(())
3035    }
3036
3037    /// Deferred acknowledgements are bucketed by the cycle they are waiting for, sub-bucketed by
3038    /// polynomial, and never by peer.
3039    ///
3040    /// All three halves matter. Keying by cycle is what lets a bucket be drained by exactly one
3041    /// event (its own cycle installing) instead of being rescanned speculatively. The
3042    /// per-polynomial sub-bucket is what the cap is expressed against. And a sub-bucket
3043    /// deliberately holding several peers' acks is not an accident: one polynomial's shares are
3044    /// spread across return paths, hence across first-relayers, so the peer has to be carried per
3045    /// entry rather than being the key.
3046    #[test]
3047    fn deferred_acks_are_bucketed_by_cycle_and_polynomial_across_peers() -> anyhow::Result<()> {
3048        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3049
3050        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3051        let other_ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3052        let spi_0 = SsaPolynomialId::new(ssa_id, 0);
3053        let spi_1 = SsaPolynomialId::new(ssa_id, 1);
3054        let spi_other = SsaPolynomialId::new(other_ssa_id, 0);
3055
3056        let peer_a = OffchainKeypair::random();
3057        let peer_b = OffchainKeypair::random();
3058        let ack_a = HalfKey::random();
3059        let ack_b = HalfKey::random();
3060        let ack_other_poly = HalfKey::random();
3061        let ack_other_cycle = HalfKey::random();
3062
3063        // Two peers defer for the same polynomial; a third ack belongs to another polynomial of the
3064        // same cycle; a fourth to a different cycle entirely.
3065        reconstructor.defer_ack(spi_0, (*peer_a.public(), ack_a.to_challenge()?, ack_a));
3066        reconstructor.defer_ack(spi_0, (*peer_b.public(), ack_b.to_challenge()?, ack_b));
3067        reconstructor.defer_ack(
3068            spi_1,
3069            (*peer_a.public(), ack_other_poly.to_challenge()?, ack_other_poly),
3070        );
3071        reconstructor.defer_ack(
3072            spi_other,
3073            (*peer_a.public(), ack_other_cycle.to_challenge()?, ack_other_cycle),
3074        );
3075
3076        let bucket = reconstructor
3077            .pending_acks
3078            .get(&ssa_id)
3079            .ok_or_else(|| anyhow::anyhow!("missing bucket for the cycle"))?;
3080        {
3081            let bucket = bucket.lock();
3082            assert_eq!(
3083                2,
3084                bucket.by_poly.get(&0).map(Vec::len).unwrap_or(0),
3085                "one sub-bucket holds both peers' acks"
3086            );
3087            assert_eq!(
3088                1,
3089                bucket.by_poly.get(&1).map(Vec::len).unwrap_or(0),
3090                "a different polynomial keeps its own sub-bucket"
3091            );
3092        }
3093        assert_eq!(3, reconstructor.deferred_ack_count(&ssa_id));
3094        assert_eq!(1, reconstructor.deferred_ack_count(&other_ssa_id));
3095
3096        // Draining one cycle's bucket must not touch another's. No share exists in `awaiting_acks`,
3097        // so nothing is redeemed — the point is the bucket bookkeeping.
3098        reconstructor.drain_deferred_acks(&ssa_id);
3099        assert!(
3100            !reconstructor.pending_acks.contains_key(&ssa_id),
3101            "a drained bucket is removed, with all of its polynomials"
3102        );
3103        assert!(
3104            reconstructor.pending_acks.contains_key(&other_ssa_id),
3105            "draining one cycle must not disturb another"
3106        );
3107
3108        Ok(())
3109    }
3110
3111    /// Every polynomial becomes reconstructible on the call that completes the constant-term set,
3112    /// and none before it.
3113    ///
3114    /// A polynomial's whole commitment *is* its constant term, so there is no partially committed
3115    /// row to wait on; but a part builder is still useless until the [`SsaBuilder`] exists to
3116    /// receive what it reconstructs, and that needs the constant terms of *all* polynomials.
3117    /// The two therefore coincide, and this pins that they do.
3118    #[test]
3119    fn verifiers_are_installed_when_the_constant_term_set_completes() -> anyhow::Result<()> {
3120        const POLYS: u16 = 6;
3121        const THRESHOLD: u8 = 4;
3122
3123        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3124            polynomials_per_ssa: POLYS,
3125            threshold: THRESHOLD,
3126            surplus_shares: 0,
3127        });
3128        let pseudonym = SimplePseudonym::random();
3129        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3130        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3131
3132        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3133        reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3134
3135        // One polynomial's constant term per call. Nothing is published until the last one.
3136        for poly in 0..POLYS as PolynomialIndex {
3137            let state = reconstructor.insert_coefficient_commitments(
3138                ssa_id,
3139                0,
3140                proof_of(&commitment, 0),
3141                coefficient_of(&commitment, 0, Some(poly))?.into_iter(),
3142            )?;
3143
3144            let last = poly == POLYS as PolynomialIndex - 1;
3145            assert_eq!(
3146                state.ssa_deposit_address.is_some(),
3147                last,
3148                "the deposit address is the sum of every constant term (poly {poly})"
3149            );
3150            assert_eq!(
3151                state.is_verifiable, last,
3152                "the cycle becomes verifiable exactly when the constant-term set closes (poly {poly})"
3153            );
3154
3155            let expected = if last { POLYS as usize } else { 0 };
3156            assert_eq!(
3157                expected,
3158                reconstructor.installed_parts(&ssa_id),
3159                "after polynomial {poly} there must be {expected} part builders installed"
3160            );
3161        }
3162
3163        Ok(())
3164    }
3165
3166    /// An Entry that still emits the full Feldman matrix must not break the cycle: the Exit ignores
3167    /// every non-constant coefficient, whether it arrives before, during or after the constant-term
3168    /// pass, and recovery proceeds unaffected.
3169    #[test]
3170    fn non_constant_coefficients_are_ignored_wherever_they_arrive() -> anyhow::Result<()> {
3171        const POLYS: u16 = 2;
3172        const THRESHOLD: u8 = 2;
3173
3174        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3175            polynomials_per_ssa: POLYS,
3176            threshold: THRESHOLD,
3177            surplus_shares: 0,
3178        });
3179        let pseudonym = SimplePseudonym::random();
3180        let peer = OffchainKeypair::random();
3181        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3182        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3183
3184        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
3185            early_recovery_threshold: 1.0,
3186            ..Default::default()
3187        });
3188        reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3189
3190        // Stand-in for what a Feldman-emitting Entry would send: a well-formed commitment under a
3191        // non-constant coefficient index, for every polynomial.
3192        let higher: Vec<(PolynomialIndex, PixGroupRepr<TestSpec>)> = (0..POLYS as PolynomialIndex)
3193            .map(|poly| {
3194                (
3195                    poly,
3196                    PixGroup::<TestSpec>::mul_by_generator(&PixScalar::<TestSpec>::random(
3197                        &mut hopr_types::crypto_random::rng(),
3198                    ))
3199                    .to_bytes(),
3200                )
3201            })
3202            .collect();
3203
3204        // Before the constant-term pass.
3205        reconstructor.insert_coefficient_commitments(ssa_id, 1, None, higher.clone().into_iter())?;
3206
3207        // Interleaved with it.
3208        reconstructor.insert_coefficient_commitments(
3209            ssa_id,
3210            0,
3211            proof_of(&commitment, 0),
3212            coefficient_of(&commitment, 0, Some(0))?.into_iter(),
3213        )?;
3214        reconstructor.insert_coefficient_commitments(ssa_id, 1, None, higher.clone().into_iter())?;
3215        let state = reconstructor.insert_coefficient_commitments(
3216            ssa_id,
3217            0,
3218            proof_of(&commitment, 0),
3219            coefficient_of(&commitment, 0, Some(1))?.into_iter(),
3220        )?;
3221        assert!(state.is_verifiable, "the constant-term pass alone completes the cycle");
3222
3223        // And after it — the bulk of what such an Entry sends. Must not read as a duplicate.
3224        reconstructor.insert_coefficient_commitments(ssa_id, 1, None, higher.into_iter())?;
3225
3226        // Recovery is unaffected.
3227        let mut acks = Vec::new();
3228        while let Some((msg, share)) = {
3229            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3230            generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
3231        }? {
3232            let ack = HalfKey::random();
3233            let enc_share = share.share.encrypt(&share.id, &ack)?;
3234            reconstructor.insert_encrypted_share(
3235                peer.public(),
3236                ack.to_challenge()?,
3237                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
3238            )?;
3239            acks.push(VerifiedAcknowledgement::new(ack, &peer).leak());
3240        }
3241
3242        let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
3243        assert!(
3244            resolutions
3245                .iter()
3246                .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id)),
3247            "the SSA must recover from the constant terms alone, got {resolutions:?}"
3248        );
3249
3250        Ok(())
3251    }
3252
3253    /// A single corrupted share is invisible until the polynomial is interpolated — that is the
3254    /// price of dropping the per-coefficient commitments — and it must surface exactly then, once,
3255    /// with no further noise from the shares that follow it.
3256    #[test]
3257    fn a_corrupted_share_is_reported_once_at_the_threshold_th_share() -> anyhow::Result<()> {
3258        const POLYS: u16 = 2;
3259        const THRESHOLD: u8 = 4;
3260        const SURPLUS: u8 = 2;
3261
3262        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3263            polynomials_per_ssa: POLYS,
3264            threshold: THRESHOLD,
3265            surplus_shares: SURPLUS,
3266        });
3267        let pseudonym = SimplePseudonym::random();
3268        let peer = OffchainKeypair::random();
3269        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3270
3271        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3272        reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3273        generator
3274            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
3275            .process_into_reconstructor(&reconstructor)?;
3276
3277        // Corrupt the very first share of polynomial 0, then feed its whole budget one at a time.
3278        let mut invalid_reports: Vec<u64> = Vec::new();
3279        for i in 0..THRESHOLD as usize + SURPLUS as usize {
3280            let (msg, mut share) = next_share_for_poly(&generator, &pseudonym, 0)?;
3281
3282            if i == 0 {
3283                // Flip a low bit of the share value. It stays a valid field element, so nothing
3284                // rejects it on arrival — only the interpolation notices.
3285                AsMut::<[u8]>::as_mut(&mut share.share.0)[31] ^= 1;
3286            }
3287
3288            let ack = HalfKey::random();
3289            let enc = share.share.clone().encrypt(&share.id, &ack)?;
3290            reconstructor.insert_encrypted_share(
3291                peer.public(),
3292                ack.to_challenge()?,
3293                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
3294            )?;
3295            let resolutions = reconstructor
3296                .acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?;
3297
3298            let reported = resolutions
3299                .iter()
3300                .filter_map(|r| match r {
3301                    ShareResolution::InvalidShares {
3302                        ssa_id: id,
3303                        observed_total,
3304                        ..
3305                    } if *id == ssa_id => Some(*observed_total),
3306                    _ => None,
3307                })
3308                .collect::<Vec<_>>();
3309            if i + 1 < THRESHOLD as usize {
3310                assert!(
3311                    reported.is_empty(),
3312                    "share {i} must pass unremarked — nothing checks it yet"
3313                );
3314            }
3315            invalid_reports.extend(reported);
3316        }
3317
3318        assert_eq!(
3319            vec![1],
3320            invalid_reports,
3321            "the corrupted set must be reported exactly once, at the threshold-th share, as the cycle's first fault"
3322        );
3323
3324        Ok(())
3325    }
3326
3327    /// The fault total is a property of the cycle, not of whoever relayed the offending share.
3328    ///
3329    /// A Session's shares reach the Exit through whichever relayer the return path happens to use,
3330    /// so a per-peer total would let an Entry stay under any limit by spreading its bad shares —
3331    /// and would make the number the consumer enforces on depend on routing rather than on conduct.
3332    #[test]
3333    fn fault_totals_aggregate_over_the_cycle_not_the_peer() -> anyhow::Result<()> {
3334        const POLYS: u16 = 2;
3335        const THRESHOLD: u8 = 2;
3336
3337        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3338            polynomials_per_ssa: POLYS,
3339            threshold: THRESHOLD,
3340            surplus_shares: 0,
3341        });
3342        let pseudonym = SimplePseudonym::random();
3343        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3344        // One relayer per polynomial, which is what shares arriving over different return paths
3345        // looks like from here.
3346        let relayers = [OffchainKeypair::random(), OffchainKeypair::random()];
3347
3348        let reconstructor = SsaReconstructor::<TestSpec>::default();
3349        reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3350        generator
3351            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
3352            .process_into_reconstructor(&reconstructor)?;
3353
3354        // Emission is round-robin across the window, and with no surplus every share matters — so
3355        // the cycle's whole budget is drawn first and grouped, rather than filtered as it comes.
3356        let mut by_poly: std::collections::BTreeMap<PolynomialIndex, Vec<_>> = Default::default();
3357        for _ in 0..POLYS as usize * THRESHOLD as usize {
3358            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3359            let share = generator
3360                .next_share(&pseudonym, &msg)?
3361                .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
3362            by_poly.entry(share.id.poly_index()).or_default().push((msg, share));
3363        }
3364
3365        let mut totals = Vec::new();
3366        for poly in 0..POLYS {
3367            let relayer = &relayers[poly as usize];
3368            let shares = by_poly
3369                .remove(&poly)
3370                .ok_or_else(|| anyhow::anyhow!("no shares for polynomial {poly}"))?;
3371            assert_eq!(THRESHOLD as usize, shares.len());
3372
3373            for (i, (msg, mut share)) in shares.into_iter().enumerate() {
3374                // Corrupt one share of each polynomial, so each one fails on its own threshold-th.
3375                if i == 0 {
3376                    AsMut::<[u8]>::as_mut(&mut share.share.0)[31] ^= 1;
3377                }
3378
3379                let ack = HalfKey::random();
3380                let enc = share.share.clone().encrypt(&share.id, &ack)?;
3381                reconstructor.insert_encrypted_share(
3382                    relayer.public(),
3383                    ack.to_challenge()?,
3384                    TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
3385                )?;
3386                totals.extend(
3387                    reconstructor
3388                        .acknowledge_shares(
3389                            *relayer.public(),
3390                            vec![VerifiedAcknowledgement::new(ack, relayer).leak()],
3391                        )?
3392                        .into_iter()
3393                        .filter_map(|r| match r {
3394                            ShareResolution::InvalidShares { observed_total, .. } => Some(observed_total),
3395                            _ => None,
3396                        }),
3397                );
3398            }
3399        }
3400
3401        assert_eq!(
3402            vec![1, 2],
3403            totals,
3404            "the second relayer's fault must be charged to the cycle's running total, not restart it"
3405        );
3406        assert_eq!(
3407            2,
3408            reconstructor
3409                .cycle(&ssa_id)
3410                .ok_or(anyhow::anyhow!("cycle went away"))?
3411                .invalid_shares(),
3412            "the cycle must hold the aggregate"
3413        );
3414
3415        Ok(())
3416    }
3417
3418    /// End-to-end deferral: shares that arrive before their polynomial's row is committed must be
3419    /// redeemed when the verifier installs, and the resulting resolutions must reach the caller.
3420    ///
3421    /// This is the path that used to be a full stash re-scan on every acknowledgement batch.
3422    #[test]
3423    fn shares_arriving_before_their_verifier_are_redeemed_on_installation() -> anyhow::Result<()> {
3424        const POLYS: u16 = 2;
3425        const THRESHOLD: u8 = 2;
3426
3427        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3428            polynomials_per_ssa: POLYS,
3429            threshold: THRESHOLD,
3430            surplus_shares: 0,
3431        });
3432        let pseudonym = SimplePseudonym::random();
3433        let peer = OffchainKeypair::random();
3434        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3435        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3436
3437        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3438        reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3439
3440        // Only part of the constant-term pass so far, so the SSA commitment is still unknown and no
3441        // part builder can be installed — a recovered part would have nowhere to go.
3442        reconstructor.insert_coefficient_commitments(
3443            ssa_id,
3444            0,
3445            proof_of(&commitment, 0),
3446            coefficient_of(&commitment, 0, Some(0))?.into_iter(),
3447        )?;
3448
3449        // All shares for the whole cycle arrive now — every one of them ahead of its verifier.
3450        let mut acks = Vec::new();
3451        while let Some((msg, share)) = {
3452            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3453            generator.next_share(&pseudonym, &msg).map(|v| v.map(|u| (msg, u)))
3454        }? {
3455            let ack = HalfKey::random();
3456            let ack_challenge = ack.to_challenge()?;
3457            let enc_share = share.share.encrypt(&share.id, &ack)?;
3458            reconstructor.insert_encrypted_share(
3459                peer.public(),
3460                ack_challenge,
3461                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc_share)?,
3462            )?;
3463            acks.push(VerifiedAcknowledgement::new(ack, &peer).leak());
3464        }
3465
3466        // Nothing can resolve yet, and every ack must have been bucketed rather than dropped.
3467        let resolutions = reconstructor.acknowledge_shares(*peer.public(), acks)?;
3468        assert!(
3469            resolutions.is_empty(),
3470            "no share can resolve before its polynomial is committed"
3471        );
3472        assert_eq!(
3473            (POLYS as usize * THRESHOLD as usize),
3474            reconstructor.deferred_ack_count(&ssa_id),
3475            "every early ack must be bucketed under its own cycle"
3476        );
3477
3478        // The last constant term closes the set, which installs every part builder and redeems the
3479        // deferred acknowledgements.
3480        let state = reconstructor.insert_coefficient_commitments(
3481            ssa_id,
3482            0,
3483            proof_of(&commitment, 0),
3484            coefficient_of(&commitment, 0, Some(1))?.into_iter(),
3485        )?;
3486        assert!(state.is_verifiable);
3487
3488        // The bucket is consumed by the drain, not left to be re-scanned.
3489        assert!(
3490            !reconstructor.pending_acks.contains_key(&ssa_id),
3491            "the cycle's bucket must be consumed by the drain"
3492        );
3493
3494        // The redeemed resolutions surface on the next acknowledgement batch. An empty ack batch is
3495        // enough — the work is already done, only the hand-off remains.
3496        let resolutions = reconstructor.acknowledge_shares(*peer.public(), Vec::new())?;
3497        assert!(
3498            resolutions
3499                .iter()
3500                .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id)),
3501            "deferred shares must recover the SSA once their verifiers install, got {resolutions:?}"
3502        );
3503
3504        Ok(())
3505    }
3506
3507    /// A fault found while draining deferred acknowledgements must stay attributed to the relayer
3508    /// that carried the offending share.
3509    ///
3510    /// The drain runs on the commitment path, so its findings are handed to whichever
3511    /// `acknowledge_shares` call happens to collect them next — routinely a different peer's. Filling
3512    /// the relayer in at emission time would name that unrelated peer, which is worse than naming
3513    /// none: the field exists to attribute misbehaviour.
3514    #[test]
3515    fn a_fault_redeemed_from_deferral_keeps_its_own_relayer() -> anyhow::Result<()> {
3516        const POLYS: u16 = 1;
3517        const THRESHOLD: u8 = 2;
3518
3519        // One surplus share, kept back to give `collector` an `awaiting_acks` entry.
3520        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3521            polynomials_per_ssa: POLYS,
3522            threshold: THRESHOLD,
3523            surplus_shares: 1,
3524        });
3525        let pseudonym = SimplePseudonym::random();
3526        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3527        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3528
3529        // `carrier` relays the shares; `collector` is an unrelated peer whose later batch picks up
3530        // the parked resolutions.
3531        let carrier = OffchainKeypair::random();
3532        let collector = OffchainKeypair::random();
3533
3534        let reconstructor = SsaReconstructor::<TestSpec>::default();
3535        reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3536
3537        // Shares arrive over `carrier` before any commitment, so they defer. One is corrupted, so the
3538        // drain is what discovers the fault.
3539        let mut acks = Vec::new();
3540        for i in 0..THRESHOLD {
3541            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3542            let mut share = generator
3543                .next_share(&pseudonym, &msg)?
3544                .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
3545            if i == 0 {
3546                AsMut::<[u8]>::as_mut(&mut share.share.0)[31] ^= 1;
3547            }
3548            let ack = HalfKey::random();
3549            let enc = share.share.clone().encrypt(&share.id, &ack)?;
3550            reconstructor.insert_encrypted_share(
3551                carrier.public(),
3552                ack.to_challenge()?,
3553                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
3554            )?;
3555            acks.push(VerifiedAcknowledgement::new(ack, &carrier).leak());
3556        }
3557        assert!(
3558            reconstructor.acknowledge_shares(*carrier.public(), acks)?.is_empty(),
3559            "shares must defer while the commitment is unknown"
3560        );
3561
3562        // Completing the commitment installs the part builder and drains the bucket, which is where
3563        // the corrupted set is detected.
3564        reconstructor.insert_coefficient_commitments(
3565            ssa_id,
3566            0,
3567            proof_of(&commitment, 0),
3568            coefficient_of(&commitment, 0, None)?.into_iter(),
3569        )?;
3570
3571        // The surplus share gives `collector` an entry in `awaiting_acks`, which is what makes its
3572        // batch acceptable. It is never acknowledged — the batch below is empty, so the only thing
3573        // `collector` contributes is being the wrong answer for the `peer` field.
3574        let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3575        let surplus = generator
3576            .next_share(&pseudonym, &msg)?
3577            .ok_or_else(|| anyhow::anyhow!("generator must yield the surplus share"))?;
3578        let filler = HalfKey::random();
3579        reconstructor.insert_encrypted_share(
3580            collector.public(),
3581            filler.to_challenge()?,
3582            TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, surplus.share.encrypt(&surplus.id, &filler)?)?,
3583        )?;
3584
3585        let faults = reconstructor
3586            .acknowledge_shares(*collector.public(), Vec::new())?
3587            .into_iter()
3588            .filter_map(|r| match r {
3589                ShareResolution::InvalidShares { peer, ssa_id, .. } => Some((peer, ssa_id)),
3590                _ => None,
3591            })
3592            .collect::<Vec<_>>();
3593
3594        assert_eq!(1, faults.len(), "the drained fault must surface exactly once");
3595        assert_eq!(ssa_id, faults[0].1);
3596        assert_eq!(
3597            carrier.public(),
3598            faults[0].0.as_ref(),
3599            "the fault must name the relayer that carried the share, not the peer that collected it"
3600        );
3601
3602        Ok(())
3603    }
3604
3605    /// Deferring is decided on a cycle lookup that missed, so a cycle installing concurrently would
3606    /// leave the ack in a bucket whose one and only drain has already run — a share silently lost
3607    /// to a microsecond-wide window. `defer_ack` re-probes and drains itself in that case.
3608    #[test]
3609    fn deferring_against_an_installed_verifier_drains_immediately() -> anyhow::Result<()> {
3610        const POLYS: u16 = 2;
3611        const THRESHOLD: u8 = 2;
3612
3613        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3614            polynomials_per_ssa: POLYS,
3615            threshold: THRESHOLD,
3616            surplus_shares: 0,
3617        });
3618        let pseudonym = SimplePseudonym::random();
3619        let peer = OffchainKeypair::random();
3620        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3621
3622        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3623        reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3624        generator
3625            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
3626            .process_into_reconstructor(&reconstructor)?;
3627
3628        // Every part builder is installed and every bucket already drained.
3629        let spi = SsaPolynomialId::new(ssa_id, 0);
3630        assert!(reconstructor.cycle(&ssa_id).is_some_and(|c| c.part(0).is_some()));
3631
3632        // Simulate the racing path: an ack deferred *after* its cycle appeared.
3633        let ack = HalfKey::random();
3634        reconstructor.defer_ack(spi, (*peer.public(), ack.to_challenge()?, ack));
3635
3636        assert!(
3637            !reconstructor.pending_acks.contains_key(&ssa_id),
3638            "a bucket created after its cycle installed must drain itself, not linger unclaimed"
3639        );
3640
3641        Ok(())
3642    }
3643
3644    /// The per-polynomial sub-cap must hold, so a peer cannot make the Exit buffer without bound by
3645    /// emitting more shares for one polynomial than its own share budget permits.
3646    #[test]
3647    fn deferred_ack_buckets_are_capped() -> anyhow::Result<()> {
3648        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3649        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3650        let spi = SsaPolynomialId::new(ssa_id, 0);
3651        let peer = OffchainKeypair::random();
3652
3653        for _ in 0..MAX_DEFERRED_ACKS_PER_POLYNOMIAL + 32 {
3654            let ack = HalfKey::random();
3655            reconstructor.defer_ack(spi, (*peer.public(), ack.to_challenge()?, ack));
3656        }
3657
3658        assert_eq!(
3659            MAX_DEFERRED_ACKS_PER_POLYNOMIAL,
3660            reconstructor.deferred_ack_count(&ssa_id),
3661            "a polynomial's sub-bucket must not grow past the cap"
3662        );
3663
3664        Ok(())
3665    }
3666
3667    /// The per-cycle ceiling must hold even when the peer spreads its acknowledgements across many
3668    /// polynomials, each of which stays under the per-polynomial sub-cap.
3669    ///
3670    /// Without it the cycle total is `num_polys × 128` — a million entries at production
3671    /// dimensions, which is no bound at all.
3672    #[test]
3673    fn deferred_ack_buckets_are_capped_per_cycle() -> anyhow::Result<()> {
3674        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
3675        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
3676        let peer = OffchainKeypair::random();
3677
3678        // Spread over enough polynomials that no sub-bucket ever reaches its own cap, so the
3679        // per-cycle ceiling is provably the thing doing the work.
3680        let polys = (MAX_DEFERRED_ACKS_PER_CYCLE / (MAX_DEFERRED_ACKS_PER_POLYNOMIAL / 2)) + 8;
3681        'outer: for poly in 0..polys as PolynomialIndex {
3682            let spi = SsaPolynomialId::new(ssa_id, poly);
3683            for _ in 0..MAX_DEFERRED_ACKS_PER_POLYNOMIAL / 2 {
3684                let ack = HalfKey::random();
3685                reconstructor.defer_ack(spi, (*peer.public(), ack.to_challenge()?, ack));
3686                if reconstructor.deferred_ack_count(&ssa_id) > MAX_DEFERRED_ACKS_PER_CYCLE {
3687                    break 'outer;
3688                }
3689            }
3690        }
3691
3692        assert_eq!(
3693            MAX_DEFERRED_ACKS_PER_CYCLE,
3694            reconstructor.deferred_ack_count(&ssa_id),
3695            "the cycle total must not grow past the ceiling"
3696        );
3697
3698        Ok(())
3699    }
3700
3701    /// An acknowledgement appended to a bucket the drain has already taken must still be redeemed.
3702    ///
3703    /// A bucket is reachable two ways — through the `pending_acks` key, and through an `Arc` a
3704    /// `defer_ack` obtained before the drain invalidated that key. The drain can only remove the
3705    /// first. The `ssa_cycles` re-probe was supposed to cover the resulting window, but it called
3706    /// `drain_deferred_acks`, which returns early on a cache miss — and a miss is exactly what this
3707    /// interleaving produces. The append landed in an orphaned bucket, and the share sat in
3708    /// `awaiting_acks` until `max_ack_await_time` discarded it. Silently: no error, no counter, and
3709    /// a polynomial losing more than `surplus_shares` this way strands the whole cycle without any
3710    /// check ever failing.
3711    ///
3712    /// Forcing the interleaving needs the stale handle, which is why `defer_ack_into` takes the
3713    /// bucket: after the invalidate, `defer_ack`'s own `get_with` would hand out a fresh one and
3714    /// the window would close by accident.
3715    #[test]
3716    fn an_acknowledgement_deferred_into_a_drained_bucket_is_still_redeemed() -> anyhow::Result<()> {
3717        // One polynomial at threshold 2, so the two deferred acknowledgements below are exactly
3718        // what the cycle needs: the second one landing makes the difference between a recovered
3719        // SSA and a stranded one.
3720        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3721            polynomials_per_ssa: 1,
3722            threshold: 2,
3723            surplus_shares: 0,
3724        });
3725        let pseudonym = SimplePseudonym::random();
3726        let peer = OffchainKeypair::random();
3727        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3728
3729        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3730        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
3731        reconstructor.new_exit_commitment(ssa_id, 1, 2)?;
3732
3733        // Both shares reach the Exit ahead of the commitment, so neither has a verifier yet.
3734        let mut pending = Vec::new();
3735        for _ in 0..2 {
3736            let (msg, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
3737            let ack = HalfKey::random();
3738            let challenge = ack.to_challenge()?;
3739            let enc = share.share.encrypt(&share.id, &ack)?;
3740            reconstructor.insert_encrypted_share(
3741                peer.public(),
3742                challenge,
3743                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
3744            )?;
3745            pending.push((share.id, challenge, ack));
3746        }
3747
3748        // The first acknowledgement takes the ordinary deferral path and creates the bucket.
3749        let (_, _, first_ack) = pending[0];
3750        reconstructor.acknowledge_shares(
3751            *peer.public(),
3752            vec![VerifiedAcknowledgement::new(first_ack, &peer).leak()],
3753        )?;
3754        let bucket = reconstructor
3755            .pending_acks
3756            .get(&ssa_id)
3757            .ok_or_else(|| anyhow::anyhow!("the first acknowledgement must have created a bucket"))?;
3758
3759        // Installing the cycle drains that bucket and invalidates its key. Our handle survives.
3760        commitment.process_into_reconstructor(&reconstructor)?;
3761        assert!(
3762            reconstructor.pending_acks.get(&ssa_id).is_none(),
3763            "the drain must have taken the cache key"
3764        );
3765
3766        // The racing append: a `defer_ack` that looked the bucket up before the drain ran.
3767        let (spi, challenge, ack) = pending[1];
3768        reconstructor.defer_ack_into(&bucket, spi, (*peer.public(), challenge, ack));
3769
3770        // Two shares interpolate the only polynomial, so the SSA is recovered — but only if the
3771        // second acknowledgement was redeemed rather than parked in the orphan.
3772        assert!(
3773            reconstructor
3774                .take_ready_resolutions()
3775                .iter()
3776                .any(|resolution| matches!(resolution, ShareResolution::RecoveredSsa(_))),
3777            "the acknowledgement must have been redeemed inline, not lost with the bucket"
3778        );
3779
3780        Ok(())
3781    }
3782
3783    /// Drives a cycle to full recovery entirely through the deferral path, so the `RecoveredSsa` it
3784    /// produces ends up parked in `ready_resolutions` rather than returned from `acknowledge_shares`.
3785    ///
3786    /// Both shares arrive before the commitment does, which is the ordering the emission window
3787    /// makes routine near a cycle boundary, so both acknowledgements defer and the drain that
3788    /// installs the cycle is what reconstructs.
3789    fn park_a_recovered_ssa() -> anyhow::Result<(SsaReconstructor<TestSpec>, SsaId<SimplePseudonym>)> {
3790        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3791            polynomials_per_ssa: 1,
3792            threshold: 2,
3793            surplus_shares: 0,
3794        });
3795        let pseudonym = SimplePseudonym::random();
3796        let peer = OffchainKeypair::random();
3797        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3798
3799        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
3800        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
3801        reconstructor.new_exit_commitment(ssa_id, 1, 2)?;
3802
3803        for _ in 0..2 {
3804            let (msg, share) = next_share_for_poly(&generator, &pseudonym, 0)?;
3805            let ack = HalfKey::random();
3806            let challenge = ack.to_challenge()?;
3807            let enc = share.share.encrypt(&share.id, &ack)?;
3808            reconstructor.insert_encrypted_share(
3809                peer.public(),
3810                challenge,
3811                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
3812            )?;
3813            reconstructor.acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?;
3814        }
3815
3816        commitment.process_into_reconstructor(&reconstructor)?;
3817        assert_ne!(
3818            0,
3819            reconstructor
3820                .ready_resolutions_len
3821                .load(std::sync::atomic::Ordering::Acquire),
3822            "the drain must have parked the recovery"
3823        );
3824
3825        Ok((reconstructor, ssa_id))
3826    }
3827
3828    /// A parked resolution must not be gated behind the peer whose shares produced it.
3829    ///
3830    /// The pipelines call `has_pending_shares` to decide whether to hand a batch to
3831    /// `acknowledge_shares` at all, and `acknowledge_shares` is the only thing that ever collects
3832    /// `ready_resolutions`. Answering purely from `awaiting_acks` therefore made delivery of a
3833    /// recovered deposit key depend on the producing peer sending more traffic before its own cache
3834    /// entry idled out — while the buffer is global, and any batch could have carried it.
3835    #[test]
3836    fn a_parked_resolution_is_collectable_through_any_peer() -> anyhow::Result<()> {
3837        let (reconstructor, _) = park_a_recovered_ssa()?;
3838
3839        // A peer this reconstructor has never seen: no shares, no `awaiting_acks` entry.
3840        let bystander = *OffchainKeypair::random().public();
3841        assert!(
3842            !reconstructor.awaiting_acks.contains_key(&bystander),
3843            "the bystander must have no pending shares of its own"
3844        );
3845        assert!(
3846            reconstructor.has_pending_shares(&bystander),
3847            "a parked resolution must let any batch through to collect it"
3848        );
3849
3850        // And once collected, the guard goes back to answering per-peer.
3851        assert!(
3852            reconstructor
3853                .take_ready_resolutions()
3854                .iter()
3855                .any(|resolution| matches!(resolution, ShareResolution::RecoveredSsa(_)))
3856        );
3857        assert!(
3858            !reconstructor.has_pending_shares(&bystander),
3859            "with nothing parked the guard must not admit an unrelated peer"
3860        );
3861
3862        Ok(())
3863    }
3864
3865    /// Retiring a cycle must not consume the resolution it already produced.
3866    ///
3867    /// `ready_resolutions` is global and its entries name their own `SsaId`, so a parked
3868    /// `RecoveredSsa` stays collectable after its cycle is torn down — and it is worth collecting:
3869    /// the deposit key is what pays the Exit, whether or not the Session that earned it is still
3870    /// open. Draining at retirement would destroy that, and would take unrelated cycles'
3871    /// resolutions with it, since nothing in the buffer is keyed by cycle. The only point at which
3872    /// delivery genuinely becomes impossible is `Drop`, which reports whatever is left.
3873    #[test]
3874    fn retiring_a_cycle_leaves_its_resolution_collectable() -> anyhow::Result<()> {
3875        let (reconstructor, ssa_id) = park_a_recovered_ssa()?;
3876
3877        reconstructor.retire_ssa(ssa_id);
3878
3879        assert!(
3880            reconstructor
3881                .take_ready_resolutions()
3882                .iter()
3883                .any(|resolution| matches!(resolution, ShareResolution::RecoveredSsa(_))),
3884            "retirement must not swallow a recovered deposit key"
3885        );
3886
3887        Ok(())
3888    }
3889
3890    /// **H8 regression.** Reclamation is scoped to the cycle, so a share for *any* polynomial keeps
3891    /// the whole cycle alive.
3892    ///
3893    /// This used to fail. The part builders were keyed per polynomial with an idle timer, so the
3894    /// clock measured "time since a share for *this* polynomial arrived". Commitments are a
3895    /// fraction of a percent of a cycle's bytes, so every builder was installed in the opening
3896    /// moments and then waited, while shares arrive polynomial-major and spread across the whole
3897    /// cycle. Any polynomial late in the emission order had its builder evicted before its first
3898    /// share landed — unrecoverably, since the commitment cannot be retransmitted and a deferred
3899    /// ack's only drain is an installation that had already happened. The SSA never completed and
3900    /// the deposit burned.
3901    ///
3902    /// The condition was `quota / line_rate > unused_verifier_lifetime`. At the deployed 1.5 Mbps
3903    /// per-Session cap a 519 MiB cycle runs 48.4 minutes against a 30-minute default, so every
3904    /// polynomial past ≈62 % of the cycle was lost.
3905    ///
3906    /// Scaled down here to two polynomials and a half-second lifetime; the shape is identical.
3907    ///
3908    /// The essential geometry is that the cycle is *continuously* busy while any single polynomial
3909    /// is not. Shares are spaced at half the lifetime, so the cycle never goes idle, but the four
3910    /// shares of polynomial 0 take twice the lifetime to arrive — long enough that polynomial 1's
3911    /// builder, untouched since installation, would have expired under the old per-polynomial key.
3912    #[test]
3913    fn a_cycle_stays_live_while_a_single_polynomial_goes_untouched() -> anyhow::Result<()> {
3914        const POLYS: u16 = 2;
3915        const THRESHOLD: u8 = 4;
3916        /// Bounded on **both** sides, which is why this is not simply "as large as possible":
3917        ///
3918        /// * above `SHARE_SPACING`, or the cycle idles out between two consecutive shares and the test fails for a
3919        ///   reason unrelated to H8. Each iteration does rather more than sleep — polynomial evaluation, encryption,
3920        ///   insertion, and at the threshold a Lagrange combine and a scalar multiplication — so on a contended runner
3921        ///   the margin needs to be several times the sleep, not the 2× it used to be;
3922        /// * below `(THRESHOLD + 1) × SHARE_SPACING` ≈ 1250 ms, or the assertion below stops holding and the test
3923        ///   exercises nothing.
3924        ///
3925        /// 1000 ms sits inside that window with the slack on the side that grows under load:
3926        /// cumulative elapsed time only ever overshoots, while a single iteration would have to
3927        /// take four times its sleep to expire the cycle.
3928        const VERIFIER_LIFETIME: std::time::Duration = std::time::Duration::from_millis(1000);
3929        /// Comfortably inside the lifetime, so no *cycle* is ever idle long enough to expire.
3930        const SHARE_SPACING: std::time::Duration = std::time::Duration::from_millis(250);
3931
3932        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
3933            polynomials_per_ssa: POLYS,
3934            threshold: THRESHOLD,
3935            surplus_shares: 0,
3936        });
3937        let pseudonym = SimplePseudonym::random();
3938        let peer = OffchainKeypair::random();
3939        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
3940
3941        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
3942            unused_verifier_lifetime: VERIFIER_LIFETIME,
3943            ..Default::default()
3944        });
3945        reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
3946
3947        // The whole commitment set lands up front, as it does in production: every part builder is
3948        // installed now, and the later ones then wait out most of the cycle.
3949        generator
3950            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
3951            .process_into_reconstructor(&reconstructor)?;
3952
3953        let installed_at = std::time::Instant::now();
3954        let mut recovered = false;
3955        for i in 0..(POLYS as usize * THRESHOLD as usize) {
3956            std::thread::sleep(SHARE_SPACING);
3957
3958            // Shares are emitted polynomial-major, so this is the hand-over to polynomial 1 — the
3959            // point at which its builder has been idle for the whole of polynomial 0's run.
3960            if i == THRESHOLD as usize {
3961                assert!(
3962                    installed_at.elapsed() > VERIFIER_LIFETIME,
3963                    "the test must actually outlast the lifetime before polynomial 1's first share, otherwise it \
3964                     exercises nothing"
3965                );
3966            }
3967
3968            let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
3969            let share = generator
3970                .next_share(&pseudonym, &msg)?
3971                .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
3972
3973            let ack = HalfKey::random();
3974            let enc = share.share.encrypt(&share.id, &ack)?;
3975            reconstructor.insert_encrypted_share(
3976                peer.public(),
3977                ack.to_challenge()?,
3978                TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
3979            )?;
3980            recovered |= reconstructor
3981                .acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?
3982                .iter()
3983                .any(|r| matches!(r, ShareResolution::RecoveredSsa(r) if r.ssa_id == ssa_id));
3984        }
3985
3986        if !recovered {
3987            // Name the mechanism, so a regression reports the cause and not just the symptom. Under
3988            // H8 the late polynomial's builder was gone and its shares were stranded in a bucket
3989            // whose only drain had already run.
3990            assert_eq!(
3991                0,
3992                reconstructor.deferred_ack_count(&ssa_id),
3993                "shares were stranded in a deferred-ack bucket — H8 has regressed"
3994            );
3995            panic!("the cycle failed to recover even though it was continuously busy");
3996        }
3997
3998        Ok(())
3999    }
4000
4001    /// The idle timer was not simply disabled: a cycle that receives no shares at all is still
4002    /// reclaimed on schedule.
4003    ///
4004    /// This is the other half of the H8 regression. Widening the reclamation scope is only correct
4005    /// if reclamation still happens — otherwise an abandoned cycle is pinned until session
4006    /// teardown.
4007    #[test]
4008    fn a_cycle_with_no_shares_at_all_still_expires() -> anyhow::Result<()> {
4009        const POLYS: u16 = 2;
4010        const THRESHOLD: u8 = 2;
4011        const VERIFIER_LIFETIME: std::time::Duration = std::time::Duration::from_millis(500);
4012
4013        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4014            polynomials_per_ssa: POLYS,
4015            threshold: THRESHOLD,
4016            surplus_shares: 0,
4017        });
4018        let pseudonym = SimplePseudonym::random();
4019        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4020
4021        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig {
4022            unused_verifier_lifetime: VERIFIER_LIFETIME,
4023            ..Default::default()
4024        });
4025        reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
4026        generator
4027            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
4028            .process_into_reconstructor(&reconstructor)?;
4029
4030        assert_eq!(POLYS as usize, reconstructor.installed_parts(&ssa_id));
4031
4032        std::thread::sleep(VERIFIER_LIFETIME * 3);
4033
4034        assert_eq!(
4035            0,
4036            reconstructor.live_cycles(),
4037            "a cycle that never received a share must still be reclaimed"
4038        );
4039
4040        Ok(())
4041    }
4042
4043    /// The polynomial index travels inside a peer-supplied share, so it reaches the slot lookup as
4044    /// untrusted input. Once the cycle is known its dimensions are too, which makes an out-of-range
4045    /// index definitively invalid rather than merely early — and it must not index the slot array.
4046    #[test]
4047    fn a_share_naming_a_polynomial_outside_the_cycle_is_rejected() -> anyhow::Result<()> {
4048        const POLYS: u16 = 2;
4049        const THRESHOLD: u8 = 2;
4050
4051        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4052            polynomials_per_ssa: POLYS,
4053            threshold: THRESHOLD,
4054            surplus_shares: 0,
4055        });
4056        let pseudonym = SimplePseudonym::random();
4057        let peer = OffchainKeypair::random();
4058        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4059
4060        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4061        reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
4062        generator
4063            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
4064            .process_into_reconstructor(&reconstructor)?;
4065
4066        // Take a real share and re-label it for a polynomial the cycle does not have.
4067        let msg: [u8; 20] = hopr_types::crypto_random::random_bytes();
4068        let mut share = generator
4069            .next_share(&pseudonym, &msg)?
4070            .ok_or_else(|| anyhow::anyhow!("generator must yield a share"))?;
4071        share.id = SsaPolynomialId::new(ssa_id, POLYS as PolynomialIndex + 5);
4072
4073        let ack = HalfKey::random();
4074        let enc = share.share.encrypt(&share.id, &ack)?;
4075        reconstructor.insert_encrypted_share(
4076            peer.public(),
4077            ack.to_challenge()?,
4078            TaggedEncryptedPartialSsaShare::new(pseudonym, &msg, enc)?,
4079        )?;
4080
4081        // `acknowledge_shares` logs and swallows the error, so the observable contract is that
4082        // nothing resolves, nothing is deferred, and the process is still standing.
4083        let resolutions =
4084            reconstructor.acknowledge_shares(*peer.public(), vec![VerifiedAcknowledgement::new(ack, &peer).leak()])?;
4085        assert!(resolutions.is_empty(), "an out-of-range share must resolve to nothing");
4086        assert_eq!(
4087            0,
4088            reconstructor.deferred_ack_count(&ssa_id),
4089            "an out-of-range share must be rejected outright, not deferred forever"
4090        );
4091        assert!(
4092            reconstructor.cycle(&ssa_id).is_some(),
4093            "the cycle itself must be unharmed"
4094        );
4095
4096        Ok(())
4097    }
4098
4099    /// Shares for different polynomials of one cycle must be able to run concurrently.
4100    ///
4101    /// The cycle is a single cache entry, which makes collapsing it to a single mutex an easy and
4102    /// invisible mistake — and one that would serialise every share of a Session. This holds one
4103    /// polynomial's lock and asserts another's is still free.
4104    #[test]
4105    fn parts_of_one_cycle_lock_independently() -> anyhow::Result<()> {
4106        const POLYS: u16 = 4;
4107        const THRESHOLD: u8 = 2;
4108
4109        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4110            polynomials_per_ssa: POLYS,
4111            threshold: THRESHOLD,
4112            surplus_shares: 0,
4113        });
4114        let pseudonym = SimplePseudonym::random();
4115        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4116
4117        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4118        reconstructor.new_exit_commitment(ssa_id, POLYS as usize, THRESHOLD as usize)?;
4119        generator
4120            .new_ssa_commitment(&pseudonym, SsaIndex::MIN)?
4121            .process_into_reconstructor(&reconstructor)?;
4122
4123        let cycle = reconstructor
4124            .cycle(&ssa_id)
4125            .ok_or_else(|| anyhow::anyhow!("cycle must be live"))?;
4126
4127        let held = cycle.part(0).ok_or_else(|| anyhow::anyhow!("missing part 0"))?.lock();
4128        for poly in 1..POLYS as PolynomialIndex {
4129            assert!(
4130                cycle
4131                    .part(poly)
4132                    .ok_or_else(|| anyhow::anyhow!("missing part {poly}"))?
4133                    .try_lock()
4134                    .is_some(),
4135                "polynomial {poly} must not be blocked by polynomial 0 — the cycle must not share one mutex"
4136            );
4137        }
4138        // The accumulator is a separate lock too, so a part in flight does not block recovery
4139        // accounting for another part.
4140        assert!(
4141            cycle.builder().try_lock().is_some(),
4142            "the accumulator must lock separately"
4143        );
4144        drop(held);
4145
4146        Ok(())
4147    }
4148
4149    /// Verifies that the builder caches accept more entries than the old
4150    /// `MAX_POLYS_PER_SSA` size bound. After removing the hard capacity, only
4151    /// TTL governs eviction.  Also verifies that fully-committed IDs populate
4152    /// `ssa_builders` and remain cached.
4153    #[test]
4154    fn builder_caches_accept_more_entries_than_max_polys_per_ssa() -> anyhow::Result<()> {
4155        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4156        let pseudonym = SimplePseudonym::random();
4157        let exceed = MAX_POLYS_PER_SSA as usize + 5;
4158        let mut ids = Vec::with_capacity(exceed);
4159        for i in 0..exceed {
4160            let ssa_id = SsaId::new(pseudonym, (1u32 + i as u32).try_into()?);
4161            reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
4162            ids.push(ssa_id);
4163        }
4164        reconstructor.commitment_builder.run_pending_tasks();
4165        for ssa_id in &ids {
4166            assert!(
4167                reconstructor.contains_builder(ssa_id),
4168                "commitment builder must retain every accepted SsaId ({ssa_id:?})"
4169            );
4170        }
4171
4172        // Complete the first few IDs through the full commitment path to populate `ssa_cycles`.
4173        // Use a 2-poly 2-threshold generator so that insert_coefficient_commitments reaches the
4174        // completion milestone and publishes a cycle.
4175        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4176            polynomials_per_ssa: 2,
4177            threshold: 2,
4178            surplus_shares: 1,
4179        });
4180        for ssa_id in ids.iter().take(3) {
4181            let commit = generator.new_ssa_commitment(&pseudonym, ssa_id.ssa_index())?;
4182            commit.process_into_reconstructor(&reconstructor)?;
4183            reconstructor.commitment_builder.run_pending_tasks();
4184
4185            assert_eq!(
4186                2,
4187                reconstructor.installed_parts(ssa_id),
4188                "ssa_cycles must contain completed SsaId index {} ({ssa_id:?})",
4189                ssa_id.ssa_index().get(),
4190            );
4191        }
4192
4193        Ok(())
4194    }
4195
4196    /// Extracts one coefficient's commitments from a generated SSA commitment, optionally narrowed
4197    /// to a single polynomial, in the shape `insert_coefficient_commitments` expects.
4198    fn coefficient_of(
4199        commitment: &crate::SsaCommitment<TestSpec>,
4200        coeff_index: CoefficientIndex,
4201        only_poly: Option<PolynomialIndex>,
4202    ) -> anyhow::Result<Vec<(PolynomialIndex, PixGroupRepr<TestSpec>)>> {
4203        Ok(commitment
4204            .verifiers
4205            .get(&coeff_index)
4206            .ok_or_else(|| anyhow::anyhow!("missing coefficient {coeff_index}"))?
4207            .iter()
4208            .filter(|(poly_index, _)| only_poly.is_none_or(|wanted| *poly_index == wanted))
4209            .map(|(poly_index, repr)| (*poly_index, *repr))
4210            .collect())
4211    }
4212
4213    /// Tombstone guard on the *first* publication point: the SSA becoming live.
4214    ///
4215    /// Since verifiers are now installed as individual polynomials complete, the part accumulator
4216    /// has to be published earlier — the moment the constant terms yield the SSA commitment. A
4217    /// `retire_ssa` racing that publication must still leave nothing behind.
4218    #[test]
4219    fn retire_ssa_tombstone_prevents_builder_publication() -> anyhow::Result<()> {
4220        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4221            polynomials_per_ssa: 2,
4222            threshold: 2,
4223            surplus_shares: 0,
4224        });
4225        let pseudonym = SimplePseudonym::random();
4226        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4227        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4228
4229        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4230        reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
4231
4232        // Constant term of polynomial 0 only — the SSA commitment is still unknown.
4233        let state = reconstructor.insert_coefficient_commitments(
4234            ssa_id,
4235            0,
4236            proof_of(&commitment, 0),
4237            coefficient_of(&commitment, 0, Some(0))?.into_iter(),
4238        )?;
4239        assert!(
4240            state.ssa_deposit_address.is_none(),
4241            "deposit address must not be derivable from a partial constant-term set"
4242        );
4243
4244        // Simulate `retire_ssa` racing the completion by setting only the tombstone.
4245        reconstructor.retired_ssas.insert(ssa_id, ());
4246
4247        // Constant term of polynomial 1 completes the set, so this call would publish the builder.
4248        let state = reconstructor.insert_coefficient_commitments(
4249            ssa_id,
4250            0,
4251            proof_of(&commitment, 0),
4252            coefficient_of(&commitment, 0, Some(1))?.into_iter(),
4253        )?;
4254        assert!(!state.is_verifiable, "a retired cycle is never verifiable");
4255
4256        assert_eq!(
4257            0,
4258            reconstructor.live_cycles(),
4259            "tombstone must prevent cycle publication"
4260        );
4261
4262        Ok(())
4263    }
4264
4265    /// Tombstone guard on verifier installation.
4266    ///
4267    /// Verifiers and the part accumulator are now published by the same call, so retirement racing
4268    /// it must withdraw both. The guard is checked *after* publishing — so that retirement cannot
4269    /// slip between a check and a write — which means the withdrawal path is what this pins.
4270    #[test]
4271    fn retire_ssa_tombstone_prevents_verifier_installation() -> anyhow::Result<()> {
4272        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4273            polynomials_per_ssa: 2,
4274            threshold: 2,
4275            surplus_shares: 0,
4276        });
4277        let pseudonym = SimplePseudonym::random();
4278        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4279        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4280
4281        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4282        reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
4283
4284        // Polynomial 0's constant term: nothing is published yet, the set is incomplete.
4285        reconstructor.insert_coefficient_commitments(
4286            ssa_id,
4287            0,
4288            proof_of(&commitment, 0),
4289            coefficient_of(&commitment, 0, Some(0))?.into_iter(),
4290        )?;
4291        assert_eq!(
4292            0,
4293            reconstructor.live_cycles(),
4294            "no part builder may be installed while the SSA commitment is unknown"
4295        );
4296
4297        // Retirement lands here.
4298        reconstructor.retired_ssas.insert(ssa_id, ());
4299
4300        // Polynomial 1's constant term closes the set, so this call would install both verifiers.
4301        let state = reconstructor.insert_coefficient_commitments(
4302            ssa_id,
4303            0,
4304            proof_of(&commitment, 0),
4305            coefficient_of(&commitment, 0, Some(1))?.into_iter(),
4306        )?;
4307        assert!(!state.is_verifiable, "a retired cycle is never verifiable");
4308
4309        assert_eq!(
4310            0,
4311            reconstructor.live_cycles(),
4312            "tombstone must withdraw the cycle published after retirement — accumulator and every part builder"
4313        );
4314
4315        Ok(())
4316    }
4317
4318    /// The commitment proof must bind everything it claims to: both of its own components, the SSA
4319    /// index it was issued for, and the commitment it opens.
4320    #[test]
4321    fn commitment_proof_must_bind_its_components_the_ssa_index_and_the_commitment() -> anyhow::Result<()> {
4322        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4323            polynomials_per_ssa: 2,
4324            threshold: 2,
4325            surplus_shares: 0,
4326        });
4327        let pseudonym = SimplePseudonym::random();
4328        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4329        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4330        let proof = commitment.commitment_proof;
4331
4332        assert!(
4333            proof.verify(&ssa_id, &commitment.ssa_commitment),
4334            "the generator's own proof must verify"
4335        );
4336
4337        // Flipping a bit anywhere breaks it, whether it lands in the nonce commitment or in the
4338        // response. Some flips make the component unparseable, which is equally a rejection.
4339        let bytes = proof.to_bytes();
4340        assert_eq!(SsaCommitmentProof::<TestSpec>::SIZE, bytes.len());
4341        for position in [0, bytes.len() / 2, bytes.len() - 1] {
4342            let mut tampered = bytes.clone();
4343            tampered[position] ^= 1;
4344            if let Ok(tampered) = SsaCommitmentProof::<TestSpec>::try_from_bytes(&tampered) {
4345                assert!(
4346                    !tampered.verify(&ssa_id, &commitment.ssa_commitment),
4347                    "a proof with byte {position} flipped must not verify"
4348                );
4349            }
4350        }
4351
4352        // Bound to the SSA index, so it cannot be replayed onto another cycle even if the commitment
4353        // were somehow reused.
4354        let other_index = SsaId::new(pseudonym, SsaIndex::new(SsaIndex::MIN.get() + 1).expect("non-zero"));
4355        assert!(
4356            !proof.verify(&other_index, &commitment.ssa_commitment),
4357            "a proof must not verify against a different SSA index"
4358        );
4359
4360        // And bound to the commitment: this is the property the whole thing exists for.
4361        let unrelated = PixGroup::<TestSpec>::mul_by_generator(&PixScalar::<TestSpec>::random(
4362            &mut hopr_types::crypto_random::rng(),
4363        ));
4364        assert!(
4365            !proof.verify(&ssa_id, &unrelated),
4366            "a proof must not verify against a commitment it does not open"
4367        );
4368
4369        // A truncated or over-long encoding is refused outright.
4370        assert!(SsaCommitmentProof::<TestSpec>::try_from_bytes(&bytes[..bytes.len() - 1]).is_err());
4371        assert!(SsaCommitmentProof::<TestSpec>::try_from_bytes(&[bytes.as_slice(), &[0u8]].concat()).is_err());
4372
4373        Ok(())
4374    }
4375
4376    /// A constant-term set that carries no proof at all is refused, exactly as one carrying an
4377    /// invalid proof is: the Exit cannot tell the difference, and both mean the cycle is unusable.
4378    #[test]
4379    fn constant_terms_arriving_without_a_proof_are_refused() -> anyhow::Result<()> {
4380        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
4381            polynomials_per_ssa: 2,
4382            threshold: 2,
4383            surplus_shares: 0,
4384        });
4385        let pseudonym = SimplePseudonym::random();
4386        let ssa_id = SsaId::new(pseudonym, SsaIndex::MIN);
4387        let commitment = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
4388
4389        let reconstructor = SsaReconstructor::<TestSpec>::new(Default::default());
4390        reconstructor.new_exit_commitment(ssa_id, 2, 2)?;
4391
4392        let refused = reconstructor.insert_coefficient_commitments(
4393            ssa_id,
4394            0,
4395            None,
4396            coefficient_of(&commitment, 0, None)?.into_iter(),
4397        );
4398        assert!(
4399            matches!(refused, Err(PixError::UnprovenSsaCommitment)),
4400            "constant terms without a proof must be refused, got {refused:?}"
4401        );
4402        assert_eq!(0, reconstructor.live_cycles(), "no cycle may be published");
4403
4404        Ok(())
4405    }
4406
4407    /// A client commitment crafted so the Entry alone knows the *combined* deposit key must be
4408    /// refused, and must not produce a deposit address.
4409    ///
4410    /// The deposit key is `s + e`, where `s` is the sum of the Entry's polynomial constant terms and
4411    /// `e` is the Exit's commitment secret. Neither party is supposed to know the sum. But
4412    /// `SsaRequest` hands `e·G` to the Entry *before* it chooses its own constant terms
4413    /// (`protocols/start/src/lib.rs:305`, consumed at `transport/session/src/manager.rs:2879-2883`),
4414    /// so an Entry that is made to prove nothing can pick `w`, publish constant terms summing to
4415    /// `w·G − e·G`, and end up with a combined commitment of `w·G` — a key it can sweep alone.
4416    ///
4417    /// It cannot then produce shares for the polynomial whose constant term it does not know, so the
4418    /// Exit never recovers the SSA and is never paid. But the Entry controls emission order
4419    /// (`generator.rs` builds `poly_queue`, `next_share` drains `front_mut()`) and puts that
4420    /// polynomial last, so it is served nearly the whole cycle before the Exit notices — by which
4421    /// time it has already swept the deposit. Note this is distinct from the by-design burn
4422    /// semantics: there *neither* party can recover, whereas here the party that owes can.
4423    ///
4424    /// Before [`SsaCommitmentProof`] existed this construction was accepted, and the Exit published
4425    /// `addr(w·G)` as the address to watch — verified by asserting exactly that. The proof cannot be
4426    /// forged here because producing it would require `dlog(w·G − e·G)`, and knowing that together
4427    /// with `w` yields `e`. So the assertion is now inverted.
4428    #[test]
4429    fn exit_refuses_a_client_commitment_whose_deposit_key_the_entry_knows() -> anyhow::Result<()> {
4430        const POLYS: usize = 3;
4431        const THRESHOLD: usize = 2;
4432
4433        let mut rng = hopr_types::crypto_random::rng();
4434        let ssa_id = SsaId::new(SimplePseudonym::random(), SsaIndex::MIN);
4435        let reconstructor = SsaReconstructor::<TestSpec>::new(SsaReconstructorConfig::default());
4436
4437        // The Exit reveals its half. This is exactly what `SsaRequest` carries to the Entry.
4438        let exit_public = reconstructor.new_exit_commitment(ssa_id, POLYS, THRESHOLD)?;
4439
4440        // The attacker picks the deposit key it wants to end up holding.
4441        let w = PixScalar::<TestSpec>::random(&mut rng);
4442        let target = PixGroup::<TestSpec>::mul_by_generator(&w);
4443
4444        // Honest constant terms for every polynomial but the last.
4445        let honest: Vec<PixGroup<TestSpec>> = (0..POLYS - 1)
4446            .map(|_| PixGroup::<TestSpec>::mul_by_generator(&PixScalar::<TestSpec>::random(&mut rng)))
4447            .collect();
4448        let honest_sum: PixGroup<TestSpec> = honest.iter().copied().sum();
4449
4450        // The last one is obtained by group subtraction. The attacker never learns its discrete log
4451        // — that would require the Exit's secret — and does not need to.
4452        let rogue = target - exit_public - honest_sum;
4453
4454        let mut constant_terms: HashMap<PolynomialIndex, PixGroupRepr<TestSpec>> = honest
4455            .iter()
4456            .enumerate()
4457            .map(|(poly_index, c0)| (poly_index as PolynomialIndex, c0.to_bytes()))
4458            .collect();
4459        constant_terms.insert((POLYS - 1) as PolynomialIndex, rogue.to_bytes());
4460
4461        // The best the attacker can offer is a proof over the client commitment it actually
4462        // published, using the only scalar it knows — which is not that commitment's discrete log.
4463        let bogus_proof = SsaCommitmentProof::prove(&ssa_id, &w, &(target - exit_public))?;
4464
4465        let rejected =
4466            reconstructor.insert_coefficient_commitments(ssa_id, 0, Some(bogus_proof), constant_terms.into_iter());
4467        assert!(
4468            matches!(rejected, Err(PixError::UnprovenSsaCommitment)),
4469            "a commitment whose discrete logarithm the sender does not know must be refused, got {rejected:?}"
4470        );
4471
4472        // Nothing about the cycle may have been published: with no deposit address the strategy is
4473        // never asked to fund an SSA the Entry could reclaim.
4474        assert!(
4475            reconstructor
4476                .commitment_builder
4477                .get(&ssa_id)
4478                .is_some_and(|b| b.lock().get_deposit_address().is_none()),
4479            "no deposit address may be derived from an unproven commitment"
4480        );
4481        // The published cycle is what makes an SSA live and able to accept recovered shares.
4482        // `commitment_builder` legitimately still exists — `new_exit_commitment` created it.
4483        assert_eq!(
4484            0,
4485            reconstructor.live_cycles(),
4486            "no cycle must be published for an unproven commitment"
4487        );
4488
4489        Ok(())
4490    }
4491}