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