hopr_protocol_pix/generator.rs
1use std::collections::VecDeque;
2
3#[cfg(feature = "rayon")]
4use hopr_utils::parallelize::cpu::rayon::prelude::*;
5use validator::Validate;
6use vsss_rs::{
7 DefaultShare, IdentifierPrimeField, Polynomial,
8 elliptic_curve::{Field, Group, PrimeField, group::GroupEncoding, rand_core::CryptoRng},
9};
10
11use crate::{
12 CONSTANT_TERM_COEFFICIENT, DEFAULT_POLY_THRESHOLD, DEFAULT_POLYS_PER_SSA, DEFAULT_SURPLUS_SHARES,
13 MAX_COMMITMENT_RETRANSMISSIONS, MAX_POLY_THRESHOLD, MAX_POLYS_PER_SSA, MAX_RETAINED_COMMITMENT_CYCLES,
14 MIN_POLY_THRESHOLD, PixGroup, PixGroupRepr, PixParams, PixScalar, PixSpec, PolynomialIndex, errors,
15 errors::PixError,
16 traits::EntryShareGenerator,
17 types::{
18 GeneratedShare, PartialSsaShare, SsaCommitment, SsaCommitmentProof, SsaId, SsaIndex, SsaPolynomialId,
19 TransposedVerifiers,
20 },
21};
22
23type RawPolynomial<S> = Vec<DefaultShare<IdentifierPrimeField<PixScalar<S>>, IdentifierPrimeField<PixScalar<S>>>>;
24
25struct IndexedPolynomial<S: PixSpec> {
26 spi: SsaPolynomialId<S::Pseudonym>,
27 raw: RawPolynomial<S>,
28 shares_generated: usize,
29 t: usize,
30}
31
32impl<S: PixSpec> IndexedPolynomial<S> {
33 pub fn next_share(&mut self, x: PixScalar<S>) -> PartialSsaShare<S> {
34 let eval = self.raw.evaluate(&x.into(), self.t);
35 self.shares_generated += 1;
36 PartialSsaShare(eval.0.to_repr())
37 }
38}
39
40/// Everything needed to re-send part of one cycle's commitment, minus the polynomials themselves.
41///
42/// A cycle's commitment ships as hundreds of packets and the peer can only use it once *every* one
43/// has landed, so it must be able to ask for the pieces a lost packet took with it. Answering that
44/// needs the same bytes again, and neither half can be recovered from what
45/// [`SsaShareGenerator`] otherwise keeps: the proof opens the *sum* of the sub-secrets, which is not
46/// retained, and a polynomial exhausted by emission is dropped from the queue along with its
47/// constant term — so an Entry that recomputed would fail exactly when the peer has been waiting
48/// longest.
49///
50/// The cost is the compressed commitments, `polynomials_per_ssa × 33 B` — 270 kB at the deployed
51/// dimensions, against the ~33 MB of polynomial state a cycle holds while it is still being served.
52/// [`MAX_RETAINED_COMMITMENT_CYCLES`] bounds how many are held at once.
53struct RetainedCommitment<S: PixSpec> {
54 ssa_index: SsaIndex,
55 /// Client commitment to the whole SSA, i.e. the sum of every constant term.
56 ssa_commitment: PixGroup<S>,
57 /// The proof of knowledge that opens [`Self::ssa_commitment`].
58 ///
59 /// Kept rather than re-proven, and re-sent verbatim: it is a non-interactive proof of a public
60 /// statement about a public commitment, so a second copy of the same proof discloses nothing a
61 /// fresh one would not, and re-proving would need the sum of the sub-secrets.
62 proof: SsaCommitmentProof<S>,
63 /// Wire-ready constant-term commitments, indexed by polynomial index.
64 constant_terms: Vec<PixGroupRepr<S>>,
65 /// Retransmission requests already answered for this cycle.
66 ///
67 /// Bounded by [`MAX_COMMITMENT_RETRANSMISSIONS`]: one request may name every polynomial, so an
68 /// answer is the whole burst again.
69 attempts_served: u8,
70}
71
72struct SsaPseudonymEntry<S: PixSpec> {
73 ssa_index: SsaIndex,
74 poly_queue: VecDeque<IndexedPolynomial<S>>,
75 /// Commitment material of the cycles most recently committed for this pseudonym, oldest first.
76 ///
77 /// Bounded by [`MAX_RETAINED_COMMITMENT_CYCLES`] and by nothing else — in particular not by
78 /// emission, which is not a proxy for the peer's need: the peer completes a cycle out of the
79 /// shares it has already buffered and the commitment it is still missing, on its own deadline,
80 /// whether or not the polynomials that produced those shares are still queued here. Releasing on
81 /// drain would take the material away exactly where the repair is worth most, since a fully
82 /// emitted cycle is one whose every share has gone out and whose only missing piece is the one a
83 /// dropped packet took.
84 retained_commitments: VecDeque<RetainedCommitment<S>>,
85 /// Position within the emission window, i.e. into the first
86 /// [`SHARE_EMISSION_WINDOW`] entries of `poly_queue`.
87 cursor: usize,
88 /// Highest SSA index a share has actually been emitted for, `None` until the first one goes out.
89 ///
90 /// Monotone. Equal to the index of the cycle at the front of `poly_queue` while that cycle is
91 /// being served, since the window no longer straddles cycle boundaries — see `front_run`. Kept as
92 /// its own field rather than derived, because it must survive the front cycle being drained and
93 /// removed. Read by [`SsaShareGenerator::emission_progress`].
94 highest_emitted: Option<SsaIndex>,
95 /// Polynomials of the *front* cycle still in `poly_queue`, or `0` when that cycle is drained and
96 /// the next one has yet to be picked up.
97 ///
98 /// This is what confines the emission window to a single cycle. Refreshed lazily in
99 /// [`EntryShareGenerator::next_share`] rather than at commit time: removals only ever touch the
100 /// front cycle, so when it hits `0` the new front cycle is necessarily untouched and its run is a
101 /// full `polynomials_per_ssa` — which holds whether or not further cycles were committed in the
102 /// meantime.
103 front_run: usize,
104 /// Shares emitted so far for the cycle currently at the front of `poly_queue`.
105 ///
106 /// Reset in lockstep with `front_run`, so it measures the front cycle only. Where
107 /// `highest_emitted` says *which* cycle is being served, this says *how far into it* — and the
108 /// difference is the whole admission decision: `highest_emitted` reaches the last committed index
109 /// on that cycle's very first share, which is 0 % of the way through the batch, not the ~85 % at
110 /// which a conforming Exit asks for the next one.
111 front_emitted: u64,
112}
113
114/// How far share emission has progressed for one pseudonym.
115///
116/// Returned by [`SsaShareGenerator::emission_progress`], and the input to the Entry's half of the
117/// successor gate.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub struct EmissionProgress {
120 /// Highest SSA index a share has been emitted for, `None` until the first one goes out.
121 pub highest_emitted: Option<SsaIndex>,
122 /// Highest SSA index committed to.
123 pub last_committed: SsaIndex,
124 /// Shares emitted for the cycle at the front of the queue.
125 pub front_emitted: u64,
126 /// Shares a whole cycle emits, i.e. `polynomials_per_ssa × (threshold + surplus)`.
127 ///
128 /// Never zero: `polynomials_per_ssa` is at least 1 and the threshold at least
129 /// [`MIN_POLY_THRESHOLD`].
130 pub shares_per_cycle: u64,
131}
132
133impl EmissionProgress {
134 /// `true` when emission has reached the last cycle this pseudonym is committed to.
135 ///
136 /// Necessary for admitting a successor batch, and nowhere near sufficient — it becomes true on
137 /// that cycle's first share. See [`front_fraction`](Self::front_fraction).
138 #[inline]
139 pub fn is_serving_last_committed(&self) -> bool {
140 self.highest_emitted == Some(self.last_committed)
141 }
142
143 /// Fraction of the front cycle's shares that have been emitted, in `0.0..=1.0`.
144 #[inline]
145 pub fn front_fraction(&self) -> f64 {
146 if self.shares_per_cycle == 0 {
147 return 0.0;
148 }
149 (self.front_emitted as f64 / self.shares_per_cycle as f64).min(1.0)
150 }
151}
152
153/// Fewest shares that can be emitted for a cycle before an Exit's early-recovery signal can fire.
154///
155/// An Entry uses this to tell a legitimate request for the next batch from one that arrives before
156/// the current batch has been earned. Nothing below the returned count can have produced an honest
157/// [`SsaReconstructorConfig::early_recovery_threshold`](crate::SsaReconstructorConfig::early_recovery_threshold)
158/// signal, at any dimensions, on a lossless link — and loss only pushes the real signal later.
159///
160/// ## Why it is not `early_threshold × shares_per_cycle`
161///
162/// Two independent reasons, and they pull the answer *up*, so the naive estimate is unsafe rather
163/// than merely imprecise.
164///
165/// The Exit's threshold counts **reconstructed polynomials**, not shares — `check_early_threshold`
166/// tests `received_indices.len()` against `ceil(threshold × num_polys)`. A polynomial reconstructs on
167/// its `threshold`-th useful share and its surplus is emitted afterwards, so shares and polynomials
168/// do not advance in proportion.
169///
170/// And emission is **windowed**: [`SHARE_EMISSION_WINDOW`] polynomials advance in lockstep, and a
171/// window emits its entire surplus before the next window starts. So every window before the one
172/// holding the boundary has already spent `threshold + surplus` per polynomial, not `threshold`.
173///
174/// At the deployed 8192 × 64 (+16) that is 27 whole windows — 552 960 shares — plus 63 full passes
175/// and 52 shares of the 64th in the 28th window, i.e. **86.8 % of the cycle**. Dividing the Exit's
176/// 0.85 by the 1.25× surplus factor gives 68 %, and admitting there would hand out the next deposit
177/// roughly 171 MiB of quota before it could possibly have been earned.
178///
179/// ## Which threshold to pass
180///
181/// [`MIN_EARLY_RECOVERY_THRESHOLD`](crate::MIN_EARLY_RECOVERY_THRESHOLD), not the caller's own
182/// `early_recovery_threshold`. The value that decides when the request actually goes out belongs to
183/// the *peer* and does not travel on the wire, and the direction of a mismatch is unforgiving: a peer
184/// configured lower asks earlier than a gate built on the local value admits, and its one-shot
185/// request is dropped with no retry path. Computing the gate at the protocol floor admits every
186/// conforming peer, and the floor is what makes "conforming" checkable locally.
187pub fn min_emission_for_early_recovery(params: &PixParams, early_threshold: f64) -> u64 {
188 let polys = params.polys_per_ssa() as u64;
189 let threshold = params.shares_per_poly() as u64;
190 let emitted_per_poly = params.emitted_shares_per_poly() as u64;
191 // Clamped because a fraction outside `0..=1` would make the arithmetic below either underflow or
192 // exceed the cycle — and a non-finite one fails *closed*, to the whole cycle, rather than being
193 // clamped. `f64::clamp` propagates `NaN`, `NaN.ceil()` is `NaN`, and casting that to an integer
194 // saturates to zero, so the one input that should be refused outright would instead return a
195 // boundary of zero shares and open the gate completely. This function is only ever called to
196 // decide whether a deposit has been earned; when its input is meaningless the answer is "no".
197 let early_threshold = if early_threshold.is_finite() {
198 early_threshold.clamp(0.0, 1.0)
199 } else {
200 1.0
201 };
202 let needed = (early_threshold * polys as f64).ceil() as u64;
203 if needed == 0 {
204 return 0;
205 }
206
207 let window = (SHARE_EMISSION_WINDOW as u64).min(polys);
208 // `needed - 1` so that a boundary landing exactly on a window edge is attributed to the window
209 // that completes it rather than to the next one.
210 let prior_windows = (needed - 1) / window;
211 let prior_polys = prior_windows * window;
212 // The last window may be narrower than `SHARE_EMISSION_WINDOW` when `polys` is not a multiple of
213 // it; a narrower window reaches the same pass in fewer shares.
214 let current_width = window.min(polys - prior_polys);
215 let in_window = needed - prior_polys;
216
217 // Whole windows are exhausted, surplus included. Inside the current one, the `needed`-th
218 // polynomial completes partway through the `threshold`-th pass — after `threshold - 1` full
219 // passes plus `in_window` shares of that pass. That is the earliest instant, not the average.
220 prior_polys * emitted_per_poly + (threshold - 1) * current_width + in_window
221}
222
223/// Number of polynomials the generator emits shares for concurrently.
224///
225/// Shares are emitted round-robin across the first `SHARE_EMISSION_WINDOW` polynomials of the
226/// queue rather than draining one polynomial to exhaustion before starting the next. Both orderings
227/// emit exactly the same shares — every share carries its own [`SsaPolynomialId`], which the Exit
228/// files by, so arrival order is irrelevant to reconstruction — but they fail very differently.
229///
230/// A share only reaches the reconstructor when the Exit *uses* the SURB carrying it, so a SURB
231/// dropped from the Exit's per-pseudonym ring buffer is a permanently lost share. That buffer
232/// overwrites its oldest entries, which is a *contiguous* run of the emission order. Draining one
233/// polynomial at a time makes such a run land on a single polynomial: lose more than
234/// `surplus_shares` of it and it can never reach `threshold`, and since the SSA is the sum of
235/// *every* polynomial's constant term, the whole cycle becomes unrecoverable — silently, because a
236/// starved polynomial never fails a check, it simply never completes. Round-robin spreads the same
237/// run across the window, so a contiguous loss of up to `surplus_shares × SHARE_EMISSION_WINDOW`
238/// shares is absorbed by the surplus that exists for exactly this purpose.
239///
240/// The window is bounded rather than spanning the whole SSA because the Exit holds a part builder's
241/// collected shares until that part reconstructs (`release_verification_state`). One polynomial at
242/// a time keeps one part live; the full 8192 would keep every part live at once, `polys × threshold`
243/// shares of peak memory. 256 keeps that peak around a megabyte while covering a contiguous loss
244/// far larger than the ring buffer's entire overshoot allowance.
245///
246/// ## The window never crosses a cycle boundary
247///
248/// It is clamped to the polynomials of the cycle at the front of the queue, so no share for cycle
249/// `k + 1` is ever emitted while any polynomial of cycle `k` is unexhausted. That is what lets an
250/// Exit treat progress on a later cycle as misbehaviour rather than as a boundary artefact: a batch
251/// of `n` cycles is served as `n` separate quotas, so the Exit is exposed to one cycle's worth of
252/// unpaid traffic at a time instead of `n`. An Entry free to spread shares across a whole batch could
253/// take `n × quota` of service while leaving every cycle short of recovery — and since an SSA is the
254/// sum of *every* polynomial's constant term, a cycle short of recovery is worth nothing at all.
255///
256/// The clamp costs no loss tolerance while `polynomials_per_ssa` is a multiple of the window, which
257/// the deployed 8192 is: polynomials in the window advance in lockstep, so a cycle drains as
258/// `polys / window` full-width blocks and the last one is swept out inside a single `next_share`
259/// call. A cycle whose polynomial count is *not* a multiple runs its final partial block at that
260/// remainder's width, absorbing a proportionally shorter contiguous loss.
261pub const SHARE_EMISSION_WINDOW: usize = 256;
262
263/// Builds a Shamir polynomial of degree `t - 1` over `secret` and commits to its constant term.
264///
265/// Only the constant term is committed to. The higher coefficients still exist — they are what
266/// makes the shares hide the secret — but no commitment to them is published, so the Exit cannot
267/// (and no longer needs to) check an individual share. See [`crate::SsaPartCommitment`].
268///
269/// This is also why the Entry's per-cycle cost collapsed: committing to every coefficient was
270/// `polys × threshold` fixed-base multiplications against an untabulated generator, over half a
271/// million of them at production dimensions, all inside one blocking task at each cycle boundary.
272fn new_polynomial_with_commitment<S: PixSpec>(
273 secret: PixScalar<S>,
274 t: usize,
275 rng: impl CryptoRng,
276) -> errors::Result<(RawPolynomial<S>, PixGroup<S>), S::Pseudonym> {
277 let mut polynomial = RawPolynomial::<S>::create(t);
278 polynomial.fill(&secret.into(), rng, t)?;
279
280 Ok((polynomial, PixGroup::<S>::mul_by_generator(&secret)))
281}
282
283/// Rejects a surplus larger than the threshold it insures.
284///
285/// The bound is deliberately loose — twice the emitted shares a polynomial needs — because the
286/// surplus is legitimately a deployment choice about return-path loss, and over-insuring a bad path
287/// is a reasonable thing to want. What it forbids is the case where the insurance costs more than
288/// the thing insured: since H5 the surplus travels in the negotiated
289/// [`PixParams`] and is billed on purchase rather than on claim, so a surplus
290/// above the threshold means an Entry paying for more redundancy than payload in every deposit.
291///
292/// This is a *configuration* bound, not a wire one. `PixParams` packs the surplus as a byte and
293/// accepts the whole range, and a peer offering an extravagant surplus is already caught where it
294/// should be — by the Exit's `quota_range`, since the surplus inflates the quota.
295fn surplus_must_not_exceed_threshold(cfg: &SsaGeneratorConfig) -> Result<(), validator::ValidationError> {
296 if cfg.surplus_shares > cfg.threshold {
297 return Err(validator::ValidationError::new(
298 "surplus_shares must not exceed threshold — the surplus is billed, so this pays for more redundancy than \
299 payload",
300 ));
301 }
302 Ok(())
303}
304
305/// Configuration for the [`SsaShareGenerator`].
306#[derive(Debug, Clone, Copy, PartialEq, Eq, smart_default::SmartDefault, validator::Validate)]
307#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
308#[validate(schema(function = "surplus_must_not_exceed_threshold", skip_on_field_errors = false))]
309pub struct SsaGeneratorConfig {
310 /// The number of polynomials to generate per SSA commitment.
311 ///
312 /// Default is [`DEFAULT_POLYS_PER_SSA`], must be between 1 and [`MAX_POLYS_PER_SSA`].
313 #[default(DEFAULT_POLYS_PER_SSA)]
314 #[validate(range(min = 1, max = MAX_POLYS_PER_SSA))]
315 pub polynomials_per_ssa: u16,
316 /// Minimum number of shares required to reconstruct each SSA polynomial.
317 ///
318 /// Default is [`DEFAULT_POLY_THRESHOLD`], must be at least [`MIN_POLY_THRESHOLD`]. The upper
319 /// bound [`MAX_POLY_THRESHOLD`] is the width of the field.
320 #[default(DEFAULT_POLY_THRESHOLD)]
321 #[validate(range(min = MIN_POLY_THRESHOLD, max = MAX_POLY_THRESHOLD))]
322 pub threshold: u8,
323 /// Additional number of shares to generate beyond the threshold for redundancy.
324 ///
325 /// Covers *lost* shares only: the Exit reconstructs from the first `threshold` distinct shares
326 /// that reach it, so any of the surplus can stand in for one that never arrives. It does not
327 /// cover *corrupt* shares — nothing checks a share on arrival any more, so a bad one is only
328 /// noticed once it has already poisoned the interpolation. See
329 /// [`crate::SsaPartCommitment`].
330 ///
331 /// Emitting them is unconditional: a polynomial leaves the queue at `threshold + surplus`
332 /// shares, whether or not any were lost, so the Exit serves this many packets per polynomial in
333 /// every case. That is why the surplus is part of the per-SSA quota rather than free service —
334 /// the Entry is buying insurance, and insurance is paid for whether or not it is claimed.
335 ///
336 /// Default is [`DEFAULT_SURPLUS_SHARES`] — but prefer
337 /// [`default_surplus_for`](crate::default_surplus_for) wherever the threshold is known, because
338 /// this is a *ratio* of it and the constant can only be the ratio evaluated at the default
339 /// threshold.
340 ///
341 /// Bounded by `threshold` rather than by the byte the wire gives it: see
342 // `surplus_must_not_exceed_threshold` is deliberately unlinked: it is crate-private, so an
343 // intra-doc link from this public field trips `rustdoc::private_intra_doc_links`, which the
344 // `nix build .#docs` job builds as an error.
345 /// `surplus_must_not_exceed_threshold`. It shares the lower half of the negotiated
346 /// [`PixParams`] word with `threshold`, so a byte is all that fits there — but
347 /// what is *representable* and what is sane to configure are different questions, and this field
348 /// used to be documented as needing no validator on the strength of the first.
349 #[default(DEFAULT_SURPLUS_SHARES)]
350 pub surplus_shares: u8,
351}
352
353/// Generator for Session Stealth Address (SSA) shares distributed over Single Use Reply Blocks (SURBs).
354pub struct SsaShareGenerator<S: PixSpec> {
355 polynomials:
356 moka::sync::Cache<S::Pseudonym, std::sync::Arc<parking_lot::Mutex<SsaPseudonymEntry<S>>>, ahash::RandomState>,
357 cfg: SsaGeneratorConfig,
358}
359
360impl<S: PixSpec> SsaShareGenerator<S> {
361 /// Creates a new share generator with the provided configuration.
362 ///
363 /// Fails if the configuration does not validate. Prefer this over [`Self::new`] anywhere the
364 /// configuration is assembled at runtime — a config built programmatically or read from a file
365 /// is input, not a constant, and turning it into a panic makes it un-handleable by the caller.
366 pub fn try_new(cfg: SsaGeneratorConfig) -> errors::Result<Self, S::Pseudonym> {
367 cfg.validate()?;
368 Ok(Self {
369 polynomials: moka::sync::CacheBuilder::default()
370 .initial_capacity(100_000)
371 .time_to_idle(std::time::Duration::from_secs(1800))
372 .build_with_hasher(ahash::RandomState::new()),
373 cfg,
374 })
375 }
376
377 /// Creates a new share generator with the provided configuration.
378 ///
379 /// # Panics
380 /// Panics if the configuration fails validation. Use [`Self::try_new`] to handle that case
381 /// instead.
382 pub fn new(cfg: SsaGeneratorConfig) -> Self {
383 Self::try_new(cfg).expect("invalid SsaGeneratorConfig")
384 }
385
386 /// Returns the configuration used to generate this [`SsaShareGenerator`].
387 #[inline]
388 pub fn config(&self) -> &SsaGeneratorConfig {
389 &self.cfg
390 }
391
392 /// Discards all polynomial state held for `pseudonym`.
393 ///
394 /// The same observable state the cache's own idle retention produces, reached deliberately: no
395 /// further share can be emitted for any cycle of this pseudonym, and
396 /// [`emission_progress`](Self::emission_progress) goes back to `None`.
397 ///
398 /// The distinction that state has been *lost* rather than never created is not recoverable from
399 /// here — an evicted entry leaves nothing behind — so a caller that needs it has to hold the fact
400 /// itself, for as long as it needs it to mean something. `hopr-transport-session` keeps it per
401 /// Session, because a successor `SsaRequest` arriving against lost state is indistinguishable from
402 /// the opening one otherwise, and the two must not be answered alike.
403 pub fn forget(&self, pseudonym: &S::Pseudonym) {
404 self.polynomials.invalidate(pseudonym);
405 }
406
407 /// How far share emission has got for `pseudonym`.
408 ///
409 /// `None` when nothing has been committed for the pseudonym yet.
410 ///
411 /// Exists so an Entry can refuse to commit to another batch while emission has not yet reached
412 /// the *end* of the batch it is already serving — an Exit asking earlier is asking for deposits
413 /// it cannot have earned yet. Emission rather than reconstruction, because the Entry never learns
414 /// what the Exit actually recovered.
415 ///
416 /// Because the window is clamped to one cycle (see [`SHARE_EMISSION_WINDOW`]),
417 /// [`is_serving_last_committed`](EmissionProgress::is_serving_last_committed) means every earlier
418 /// cycle is exhausted — exactly, at every dimension, rather than approximately near a boundary.
419 /// It becomes true on the last cycle's *first* share, though, so it answers "which cycle" and not
420 /// "how far in"; [`front_fraction`](EmissionProgress::front_fraction) is the second half, and an
421 /// admission rule needs both.
422 pub fn emission_progress(&self, pseudonym: &S::Pseudonym) -> Option<EmissionProgress> {
423 let entry = self.polynomials.get(pseudonym)?;
424 let entry = entry.lock();
425 Some(EmissionProgress {
426 highest_emitted: entry.highest_emitted,
427 last_committed: entry.ssa_index,
428 front_emitted: entry.front_emitted,
429 shares_per_cycle: self.cfg.polynomials_per_ssa as u64
430 * (self.cfg.threshold as u64 + self.cfg.surplus_shares as u64),
431 })
432 }
433
434 /// Rebuilds the commitment of an already-committed cycle, restricted to the polynomial indices
435 /// the peer says it never received.
436 ///
437 /// A cycle's commitment ships as hundreds of packets and the peer can use it only once every one
438 /// of them has landed. Nothing else in the protocol repairs a lost packet: the peer cannot
439 /// reconstruct the missing commitment, and this side cannot tell which one went missing — so
440 /// without this a single drop costs a cycle the peer has usually already been told to fund. The
441 /// peer names the gaps, this fills them.
442 ///
443 /// The result is shaped exactly like [`new_ssa_commitment`](EntryShareGenerator::new_ssa_commitment)'s,
444 /// so the caller re-sends it through the same message splitting, carrying the *same* bytes and the
445 /// same proof. It advances no state the peer is charged for: no new index, no new polynomial, and
446 /// no second deposit — the caller must not announce one, since the address is unchanged and was
447 /// announced when the cycle was first committed.
448 ///
449 /// Each run is an inclusive `(first, last)` polynomial-index pair, expected ascending and
450 /// disjoint. Indices this cycle does not have are skipped.
451 ///
452 /// # Errors
453 /// * [`PixError::MissingSsaCommitment`] — nothing retained for `ssa_index`. Either it was never committed, or
454 /// [`MAX_RETAINED_COMMITMENT_CYCLES`] newer cycles have pushed it out. Having exhausted a cycle's polynomials
455 /// does *not* put it here: whether a repair still helps depends on what the peer holds, not on what this side can
456 /// still emit.
457 /// * [`PixError::InvalidInput`] — [`MAX_COMMITMENT_RETRANSMISSIONS`] answers have already been given for this
458 /// cycle, or `runs` selects nothing. The cap is what keeps a peer that has already been served from farming whole
459 /// bursts out of this node; a request that selects nothing still spends an attempt, so malformed scopes are not
460 /// free either.
461 pub fn recommit(
462 &self,
463 pseudonym: &S::Pseudonym,
464 ssa_index: SsaIndex,
465 runs: impl IntoIterator<Item = (PolynomialIndex, PolynomialIndex)>,
466 ) -> errors::Result<SsaCommitment<S>, S::Pseudonym> {
467 let entry = self.polynomials.get(pseudonym).ok_or(PixError::MissingSsaCommitment)?;
468 let mut entry = entry.lock();
469 let retained = entry
470 .retained_commitments
471 .iter_mut()
472 .find(|retained| retained.ssa_index == ssa_index)
473 .ok_or(PixError::MissingSsaCommitment)?;
474
475 if retained.attempts_served >= MAX_COMMITMENT_RETRANSMISSIONS {
476 return Err(PixError::InvalidInput);
477 }
478 // Spent before the scope is judged, so a peer cannot probe with cheap malformed asks.
479 retained.attempts_served += 1;
480
481 let selected = selected_constant_terms::<S>(&retained.constant_terms, runs);
482 if selected.is_empty() {
483 return Err(PixError::InvalidInput);
484 }
485
486 tracing::debug!(
487 %pseudonym, %ssa_index, count = selected.len(), attempt = retained.attempts_served,
488 "re-sending part of an ssa commitment"
489 );
490
491 Ok(SsaCommitment {
492 ssa_id: SsaId::new(*pseudonym, ssa_index),
493 ssa_commitment: retained.ssa_commitment,
494 commitment_proof: retained.proof,
495 verifiers: transposed_constant_terms::<S>(selected),
496 })
497 }
498}
499
500impl<S: PixSpec> Default for SsaShareGenerator<S> {
501 fn default() -> Self {
502 Self::new(SsaGeneratorConfig::default())
503 }
504}
505
506impl<S: PixSpec> EntryShareGenerator<S> for SsaShareGenerator<S> {
507 type Error = PixError<S::Pseudonym>;
508
509 /// Generate the next [`PartialSsaShare`] for the given pseudonym and message `msg`.
510 ///
511 /// IMPORTANT: Each `msg` MUST be unique for a given pseudonym.
512 ///
513 /// Returns `None` if all polynomials for the given pseudonym have been used up.
514 /// This signals that a new SSA must be committed.
515 fn next_share(
516 &self,
517 pseudonym: &S::Pseudonym,
518 msg: &impl AsRef<[u8]>,
519 ) -> errors::Result<Option<GeneratedShare<S>>, S::Pseudonym> {
520 let Some(entry) = self.polynomials.get(pseudonym) else {
521 return Ok(None);
522 };
523
524 // If we replaced VecDeque with a lock-free alternative, we could remove the mutex, but the
525 // alternative would need to effectively deallocate, so the polynomials do not grow
526 // indefinitely when new commitments are being added.
527 let mut entry = entry.lock();
528 let SsaPseudonymEntry {
529 poly_queue,
530 cursor,
531 highest_emitted,
532 front_run,
533 front_emitted,
534 ..
535 } = &mut *entry;
536 let max_shares_per_poly = self.cfg.threshold as usize + self.cfg.surplus_shares as usize;
537
538 while !poly_queue.is_empty() {
539 // The window is always the front of the queue: `new_ssa_commitment` appends, and an
540 // exhausted polynomial is removed in place so its immediate successor shifts in.
541 //
542 // It never straddles a cycle boundary, though — see `SHARE_EMISSION_WINDOW` for why the
543 // Exit's exposure depends on that. `front_run` counts what is left of the front cycle,
544 // and the window cannot reach past it, so the next cycle's first share waits until this
545 // cycle's last polynomial is exhausted.
546 //
547 // The refresh happens here rather than at commit time because removals only ever touch
548 // the front cycle: reaching `0` therefore means the new front cycle is untouched and
549 // still has all `polynomials_per_ssa` of its polynomials queued.
550 if *front_run == 0 {
551 *front_run = (self.cfg.polynomials_per_ssa as usize).min(poly_queue.len());
552 *cursor = 0;
553 // A new cycle takes the front, so the emission counter restarts with it. This is the
554 // only place it is cleared, which is what keeps it measuring the front cycle alone.
555 *front_emitted = 0;
556 }
557
558 let window = (*front_run).min(SHARE_EMISSION_WINDOW).min(poly_queue.len()).max(1);
559 if *cursor >= window {
560 *cursor = 0;
561 }
562 let idx = *cursor;
563
564 if poly_queue[idx].shares_generated >= max_shares_per_poly {
565 // O(window) element moves, but paid once per polynomial rather than once per share.
566 poly_queue.remove(idx);
567 *front_run = front_run.saturating_sub(1);
568 continue;
569 }
570
571 let poly = &mut poly_queue[idx];
572 let x = S::msg_to_scalar(&poly.spi, msg)?;
573 // Zero would disclose the secret, so we disallow it.
574 // The chance is practically impossible.
575 if x.is_zero().into() {
576 return Err(errors::PixError::InvalidInput);
577 }
578
579 let generated = GeneratedShare {
580 id: poly.spi,
581 share: poly.next_share(x),
582 };
583 let emitted_for = poly.spi.as_ref().ssa_index();
584 if highest_emitted.is_none_or(|seen| emitted_for > seen) {
585 *highest_emitted = Some(emitted_for);
586 }
587 *front_emitted += 1;
588 *cursor = (idx + 1) % window;
589 return Ok(Some(generated));
590 }
591
592 Ok(None)
593 }
594
595 /// Generates a new SSA commitment from the sender side, for the given `pseudonym`.
596 ///
597 /// Returns the new random SSA-commitment and the corresponding SSA share verifier.
598 fn new_ssa_commitment(
599 &self,
600 pseudonym: &S::Pseudonym,
601 ssa_index: SsaIndex,
602 ) -> errors::Result<SsaCommitment<S>, S::Pseudonym> {
603 // Monotonicity is checked here, ahead of the generation below, and again under the cache's
604 // own lock at the end. Doing it twice is not redundant: this one is the cheap rejection, and
605 // the one below is the authoritative one, because the entry can be advanced by a concurrent
606 // caller in between.
607 //
608 // Without it, every racing request pays for `polynomials_per_ssa` polynomials — hundreds of
609 // thousands of EC operations at the deployed dimensions, a second or more of CPU — and then
610 // all but one throw the result away. Since a stale or malicious `SsaRequest` is exactly the
611 // shape that loses that race, the wasted work was reachable from a single inbound packet.
612 if let Some(entry) = self.polynomials.get(pseudonym)
613 && ssa_index <= entry.lock().ssa_index
614 {
615 return Err(PixError::InvalidInput);
616 }
617
618 let mut rng = hopr_types::crypto_random::rng();
619
620 // Generate sub-secrets for each polynomial
621 let sub_secrets = (0..self.cfg.polynomials_per_ssa)
622 .map(|_| <PixScalar<S> as Field>::random(&mut rng))
623 .collect::<Vec<_>>();
624
625 // Overall commitment secret is the sum of all sub-secrets
626 let our_commitment_secret = sub_secrets.iter().sum::<PixScalar<S>>();
627
628 #[cfg(not(feature = "rayon"))]
629 let sub_secrets_iter = sub_secrets.into_iter();
630
631 #[cfg(feature = "rayon")]
632 let sub_secrets_iter = sub_secrets.into_par_iter();
633
634 // Generate polynomial and constant-term commitment for each sub-secret
635 let (raw_polynomials, raw_commitments): (Vec<RawPolynomial<S>>, Vec<PixGroup<S>>) = sub_secrets_iter
636 .map(|secret| {
637 new_polynomial_with_commitment::<S>(
638 secret,
639 self.cfg.threshold as usize,
640 hopr_types::crypto_random::rng(),
641 )
642 })
643 .collect::<errors::Result<Vec<(RawPolynomial<S>, PixGroup<S>)>, S::Pseudonym>>()?
644 .into_iter()
645 .unzip();
646
647 // Compressed exactly once, here. The same bytes go on the wire now and again if the peer
648 // asks for a piece it never received, so the retained copy *is* the wire form rather than
649 // something re-derived from the group elements — compression is the dominant per-commitment
650 // cost, and two derivations of the same bytes are two chances to disagree.
651 let constant_terms: Vec<PixGroupRepr<S>> = raw_commitments.iter().map(|c| c.to_bytes()).collect();
652
653 // Built from the parameters rather than read back off the commitments: every element above
654 // was constructed with exactly this `SsaId`, and indexing would have made the whole function
655 // depend on `polynomials_per_ssa >= 1` holding — which is a validation invariant enforced
656 // three call layers away, not something visible here.
657 let ssa_id = SsaId::new(*pseudonym, ssa_index);
658 let ssa_commitment = PixGroup::<S>::generator() * our_commitment_secret;
659 // Proves we know the sum of the sub-secrets. The recipient adds its own commitment to ours
660 // to get the deposit key, and this is what stops us from having chosen our half so that we —
661 // rather than nobody — know that sum. See `SsaCommitmentProof`.
662 let commitment_proof = SsaCommitmentProof::prove(&ssa_id, &our_commitment_secret, &ssa_commitment)?;
663
664 // Cloned rather than moved: the caller gets these bytes to put on the wire, and the entry
665 // keeps them to answer a request for the pieces that never arrived. 270 kB at the deployed
666 // dimensions — see `RetainedCommitment`.
667 let retained = RetainedCommitment {
668 ssa_index,
669 ssa_commitment,
670 proof: commitment_proof,
671 constant_terms: constant_terms.clone(),
672 attempts_served: 0,
673 };
674
675 self.polynomials
676 .entry_by_ref(pseudonym)
677 .and_try_compute_with(|entry| match entry {
678 None => Ok::<_, PixError<S::Pseudonym>>(moka::ops::compute::Op::Put(std::sync::Arc::new(
679 parking_lot::Mutex::new(SsaPseudonymEntry {
680 ssa_index,
681 cursor: 0,
682 highest_emitted: None,
683 // Picked up lazily by `next_share`, like every later cycle's.
684 front_run: 0,
685 front_emitted: 0,
686 retained_commitments: VecDeque::from([retained]),
687 poly_queue: raw_polynomials
688 .into_iter()
689 .enumerate()
690 .map(|(poly_index, raw)| IndexedPolynomial {
691 spi: SsaPolynomialId::new(
692 SsaId::new(*pseudonym, ssa_index),
693 poly_index as PolynomialIndex,
694 ),
695 raw,
696 shares_generated: 0,
697 t: self.cfg.threshold as usize,
698 })
699 .collect(),
700 }),
701 ))),
702 Some(value) => {
703 let value = value.into_value();
704 {
705 let mut entry = value.lock();
706 if ssa_index <= entry.ssa_index {
707 return Err(PixError::InvalidInput);
708 }
709 entry.ssa_index = ssa_index;
710
711 entry.retained_commitments.push_back(retained);
712 // The only thing that releases retained material, so this runs on the
713 // ordinary path rather than only for a pathological peer. The index is
714 // strictly increasing — enforced just above — so the front is the oldest
715 // cycle, i.e. the one least likely to still be awaited.
716 while entry.retained_commitments.len() > MAX_RETAINED_COMMITMENT_CYCLES {
717 entry.retained_commitments.pop_front();
718 }
719
720 entry
721 .poly_queue
722 .extend(raw_polynomials.into_iter().enumerate().map(|(poly_index, raw)| {
723 IndexedPolynomial {
724 spi: SsaPolynomialId::new(
725 SsaId::new(*pseudonym, ssa_index),
726 poly_index as PolynomialIndex,
727 ),
728 raw,
729 shares_generated: 0,
730 t: self.cfg.threshold as usize,
731 }
732 }));
733 }
734
735 Ok(moka::ops::compute::Op::Nop)
736 }
737 })?;
738
739 Ok(SsaCommitment {
740 ssa_id,
741 ssa_commitment,
742 commitment_proof,
743 verifiers: transposed_constant_terms::<S>(selected_constant_terms::<S>(
744 &constant_terms,
745 [(0, constant_terms.len().saturating_sub(1) as PolynomialIndex)],
746 )),
747 })
748 }
749}
750
751/// Picks out the constant-term commitments covered by `runs`, in ascending polynomial-index order.
752///
753/// Each run is an inclusive `(first, last)` polynomial-index pair. Runs are expected ascending and
754/// disjoint — which is what the wire decoder enforces and what the Exit's own scope produces — so
755/// this concatenates rather than deduplicating, and a caller passing overlapping runs merely repeats
756/// entries the peer then discards as re-deliveries.
757///
758/// Indices past the end are skipped rather than refused: a peer naming a polynomial this cycle does
759/// not have is asking for something that cannot exist, and there is nothing to send for it.
760fn selected_constant_terms<S: PixSpec>(
761 constant_terms: &[PixGroupRepr<S>],
762 runs: impl IntoIterator<Item = (PolynomialIndex, PolynomialIndex)>,
763) -> Vec<(PolynomialIndex, PixGroupRepr<S>)> {
764 runs.into_iter()
765 .flat_map(|(first, last)| first..=last)
766 .filter_map(|poly_index| {
767 constant_terms
768 .get(poly_index as usize)
769 .map(|commitment| (poly_index, *commitment))
770 })
771 .collect()
772}
773
774/// Lays the per-polynomial constant-term commitments out in the coefficient-major form the wire
775/// messages expect.
776///
777/// The result always holds exactly one key, [`CONSTANT_TERM_COEFFICIENT`]. The map shape is kept
778/// rather than flattened to a plain `Vec` because it is what `SsaClientCommitmentMessage` splits
779/// into packets, and the wire format still admits higher coefficient indices even though PIX no
780/// longer produces any.
781fn transposed_constant_terms<S: PixSpec>(
782 constant_terms: Vec<(PolynomialIndex, PixGroupRepr<S>)>,
783) -> TransposedVerifiers<S> {
784 let mut transposed = TransposedVerifiers::<S>::new();
785 transposed.insert(CONSTANT_TERM_COEFFICIENT, constant_terms);
786 transposed
787}
788
789#[cfg(test)]
790mod tests {
791 use hopr_types::{
792 crypto::{crypto_traits, prelude::Secp256k1, types::SimplePseudonym},
793 crypto_random::Randomizable,
794 };
795 use vsss_rs::ReadableShareSet;
796
797 use super::*;
798 use crate::{tests::TestSpec, traits::EntryShareGenerator};
799
800 /// A retransmission answers only what was asked for, only for a cycle that was committed, and
801 /// only a bounded number of times.
802 ///
803 /// The cap is the load-bearing part: one request may name every polynomial of the cycle, so an
804 /// answer is the whole commitment burst again, and a peer that has already been served would
805 /// otherwise be able to farm bursts out of this node indefinitely.
806 #[test]
807 fn recommit_answers_the_named_scope_a_bounded_number_of_times() -> anyhow::Result<()> {
808 const POLYS: u16 = 8;
809 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
810 polynomials_per_ssa: POLYS,
811 threshold: 2,
812 surplus_shares: 0,
813 });
814 let pseudonym = SimplePseudonym::random();
815
816 // Nothing is retained for a pseudonym that has never committed, nor for an index it has not.
817 assert!(matches!(
818 generator.recommit(&pseudonym, SsaIndex::MIN, [(0, 0)]),
819 Err(PixError::MissingSsaCommitment)
820 ));
821
822 let original = generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
823 assert!(matches!(
824 generator.recommit(&pseudonym, 2.try_into()?, [(0, 0)]),
825 Err(PixError::MissingSsaCommitment)
826 ));
827
828 let row_of = |commitment: &SsaCommitment<TestSpec>| {
829 commitment
830 .verifiers
831 .get(&CONSTANT_TERM_COEFFICIENT)
832 .cloned()
833 .unwrap_or_default()
834 };
835 let full = row_of(&original);
836
837 // The scope is honoured exactly, and answered with the bytes that went out the first time.
838 let repair = generator.recommit(&pseudonym, SsaIndex::MIN, [(1, 2), (5, 5)])?;
839 assert_eq!(original.ssa_id, repair.ssa_id);
840 assert_eq!(original.ssa_commitment, repair.ssa_commitment);
841 assert_eq!(
842 original.commitment_proof, repair.commitment_proof,
843 "the proof opens the same public commitment, so the same one is re-sent"
844 );
845 assert_eq!(
846 vec![1, 2, 5],
847 row_of(&repair).iter().map(|(index, _)| *index).collect::<Vec<_>>()
848 );
849 for (poly_index, resent) in row_of(&repair) {
850 assert_eq!(
851 Some(&resent),
852 full.iter().find(|(index, _)| *index == poly_index).map(|(_, o)| o)
853 );
854 }
855
856 // Indices this cycle does not have are skipped; a scope of nothing but those is refused, and
857 // still spends an attempt, so malformed asks are not a free probe.
858 assert_eq!(
859 vec![POLYS as PolynomialIndex - 1],
860 row_of(&generator.recommit(&pseudonym, SsaIndex::MIN, [(POLYS - 1, POLYS + 4)])?)
861 .iter()
862 .map(|(index, _)| *index)
863 .collect::<Vec<_>>()
864 );
865 assert!(matches!(
866 generator.recommit(&pseudonym, SsaIndex::MIN, [(POLYS, POLYS)]),
867 Err(PixError::InvalidInput)
868 ));
869
870 // Three answers spent above, including the refused scope. Spend the rest of the budget, then
871 // find it closed.
872 for attempt in 3..MAX_COMMITMENT_RETRANSMISSIONS {
873 generator
874 .recommit(&pseudonym, SsaIndex::MIN, [(0, 0)])
875 .map_err(|error| anyhow::anyhow!("attempt {attempt} must be answered: {error}"))?;
876 }
877 assert!(
878 matches!(
879 generator.recommit(&pseudonym, SsaIndex::MIN, [(0, 0)]),
880 Err(PixError::InvalidInput)
881 ),
882 "the {MAX_COMMITMENT_RETRANSMISSIONS}-answer budget must be spent"
883 );
884
885 Ok(())
886 }
887
888 /// A cycle stays retransmittable once its own emission has finished, and is released only by the
889 /// retention cap.
890 ///
891 /// Emission progress says nothing about whether a repair still helps. The peer completes a cycle
892 /// out of the shares it has already buffered plus the commitment it is missing, on its own
893 /// deadline — so a fully emitted cycle is the *likeliest* one to be asked about, every share
894 /// having gone out and the only missing piece being the one a dropped packet took. Releasing the
895 /// material on drain made the repair fail exactly there.
896 #[test]
897 fn a_drained_cycle_stays_retransmittable_until_the_cap_evicts_it() -> anyhow::Result<()> {
898 const POLYS: u16 = 2;
899 const THRESHOLD: u8 = 2;
900 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
901 polynomials_per_ssa: POLYS,
902 threshold: THRESHOLD,
903 surplus_shares: 0,
904 });
905 let pseudonym = SimplePseudonym::random();
906
907 generator.new_ssa_commitment(&pseudonym, SsaIndex::MIN)?;
908 generator.new_ssa_commitment(&pseudonym, 2.try_into()?)?;
909
910 // Both are retransmittable while both are queued: a successor's commitment can be lost just
911 // as easily as the front's, and it is not being served yet.
912 generator.recommit(&pseudonym, SsaIndex::MIN, [(0, 0)])?;
913 generator.recommit(&pseudonym, 2.try_into()?, [(0, 0)])?;
914
915 // Exhaust the first cycle, then take one more share so the front rotates onto the second.
916 let per_cycle = POLYS as u32 * THRESHOLD as u32;
917 for msg in 0..=per_cycle {
918 let share = generator
919 .next_share(&pseudonym, &msg.to_be_bytes())?
920 .ok_or_else(|| anyhow::anyhow!("share {msg} must be available"))?;
921 if msg == per_cycle {
922 assert_eq!(2, share.id.ssa_index().get(), "the front must have rotated");
923 }
924 }
925
926 generator
927 .recommit(&pseudonym, SsaIndex::MIN, [(0, 0)])
928 .map_err(|error| anyhow::anyhow!("a fully emitted cycle must stay retransmittable: {error}"))?;
929
930 // Two are already retained, so committing up to one past the cap is what pushes the first out.
931 for index in 3..=MAX_RETAINED_COMMITMENT_CYCLES as u32 + 1 {
932 generator.new_ssa_commitment(&pseudonym, index.try_into()?)?;
933 }
934 assert!(
935 matches!(
936 generator.recommit(&pseudonym, SsaIndex::MIN, [(0, 0)]),
937 Err(PixError::MissingSsaCommitment)
938 ),
939 "retention must stay bounded at {MAX_RETAINED_COMMITMENT_CYCLES} cycles"
940 );
941 generator
942 .recommit(&pseudonym, 2.try_into()?, [(0, 0)])
943 .map_err(|error| anyhow::anyhow!("the oldest cycle still inside the cap must be retained: {error}"))?;
944
945 Ok(())
946 }
947
948 /// No share for a cycle may be emitted while any polynomial of an earlier cycle is unexhausted.
949 ///
950 /// The dimensions here are the adversarial ones for the clamp: `polynomials_per_ssa` far below
951 /// [`SHARE_EMISSION_WINDOW`], so an unclamped positional window would span the *whole* batch and
952 /// serve all three cycles in lockstep from the first share — leaving the Exit `3 × quota` of
953 /// traffic short of recovering any single one of them.
954 ///
955 /// Also pins that the clamp did not degrade to one polynomial at a time: within a cycle the
956 /// emission is still round-robin, which is what spreads a contiguous SURB loss across the window
957 /// instead of starving one polynomial.
958 #[test]
959 fn emission_never_crosses_a_cycle_boundary_early() -> anyhow::Result<()> {
960 const POLYS: u16 = 4;
961 const THRESHOLD: u8 = 2;
962 const SURPLUS: u8 = 1;
963 const BATCH: u32 = 3;
964 let per_poly = THRESHOLD as usize + SURPLUS as usize;
965
966 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
967 polynomials_per_ssa: POLYS,
968 threshold: THRESHOLD,
969 surplus_shares: SURPLUS,
970 });
971
972 let p = SimplePseudonym::random();
973 for idx in 1..=BATCH {
974 generator.new_ssa_commitment(&p, idx.try_into()?)?;
975 }
976 assert!(
977 POLYS as usize * BATCH as usize <= SHARE_EMISSION_WINDOW,
978 "the whole batch must fit one window, or this test is not exercising the clamp"
979 );
980
981 // Drain everything, recording which cycle each share belonged to, in order.
982 let mut emitted: Vec<(u32, u16)> = Vec::new();
983 for msg in 0..(POLYS as u32 * BATCH * per_poly as u32) {
984 let share = generator
985 .next_share(&p, &msg.to_be_bytes())?
986 .ok_or_else(|| anyhow::anyhow!("share {msg} must be available"))?;
987 emitted.push((share.id.ssa_index().get(), share.id.poly_index()));
988 }
989 assert!(
990 generator.next_share(&p, &u32::MAX.to_be_bytes())?.is_none(),
991 "the batch must be exactly spent"
992 );
993
994 let cycles: Vec<u32> = emitted.iter().map(|(ssa, _)| *ssa).collect();
995 assert!(
996 cycles.windows(2).all(|w| w[0] <= w[1]),
997 "cycle indices must never go backwards, got {cycles:?}"
998 );
999 for cycle in 1..=BATCH {
1000 assert_eq!(
1001 cycles.iter().filter(|c| **c == cycle).count(),
1002 POLYS as usize * per_poly,
1003 "cycle {cycle} must emit its whole share supply"
1004 );
1005 }
1006
1007 // Round-robin within a cycle: the first `POLYS` shares of a cycle cover every polynomial once.
1008 let first_cycle: Vec<u16> = emitted
1009 .iter()
1010 .filter(|(ssa, _)| *ssa == 1)
1011 .take(POLYS as usize)
1012 .map(|(_, poly)| *poly)
1013 .collect();
1014 let mut distinct = first_cycle.clone();
1015 distinct.sort_unstable();
1016 distinct.dedup();
1017 assert_eq!(
1018 distinct.len(),
1019 POLYS as usize,
1020 "the first pass must touch every polynomial once, not drain one at a time — got {first_cycle:?}"
1021 );
1022
1023 Ok(())
1024 }
1025
1026 /// `emission_progress` must lag the commitment index across a batch, and catch up in order.
1027 ///
1028 /// `polynomials_per_ssa` is deliberately above [`SHARE_EMISSION_WINDOW`], which is the deployed
1029 /// shape: the window then sits entirely inside one cycle, so emission reaches a cycle only once
1030 /// its predecessors are drained. That ordering is what an Entry relies on to tell "the Exit is
1031 /// asking for the next batch on time" from "the Exit is asking a whole batch early".
1032 #[test]
1033 fn emission_progress_lags_the_commitment_index_across_a_batch() -> anyhow::Result<()> {
1034 const POLYS: u16 = SHARE_EMISSION_WINDOW as u16 + 44;
1035 const THRESHOLD: u8 = 2;
1036
1037 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
1038 polynomials_per_ssa: POLYS,
1039 threshold: THRESHOLD,
1040 surplus_shares: 0,
1041 });
1042
1043 let p = SimplePseudonym::random();
1044 assert!(
1045 generator.emission_progress(&p).is_none(),
1046 "an unknown pseudonym has no progress to report"
1047 );
1048
1049 // One batch of three, as an Exit with `ssas_per_request = 3` would ask for.
1050 for idx in 1..=3u32 {
1051 generator.new_ssa_commitment(&p, idx.try_into()?)?;
1052 }
1053 let progress = generator.emission_progress(&p).expect("committed");
1054 assert_eq!(
1055 (None, SsaIndex::new(3)),
1056 (progress.highest_emitted, Some(progress.last_committed)),
1057 "the whole batch is committed, but nothing has been emitted yet"
1058 );
1059 assert_eq!(
1060 POLYS as u64 * THRESHOLD as u64,
1061 progress.shares_per_cycle,
1062 "a cycle's supply is polys x (threshold + surplus), and the surplus is zero here"
1063 );
1064
1065 let mut sent = 0u32;
1066 let emit = |n: u32, sent: &mut u32| -> anyhow::Result<()> {
1067 for _ in 0..n {
1068 *sent += 1;
1069 generator.next_share(&p, &sent.to_be_bytes())?;
1070 }
1071 Ok(())
1072 };
1073
1074 emit(10, &mut sent)?;
1075 let progress = generator.emission_progress(&p).expect("committed");
1076 assert_eq!(
1077 Some(1.try_into()?),
1078 progress.highest_emitted,
1079 "emission is still inside the first cycle of the batch"
1080 );
1081 assert!(
1082 !progress.is_serving_last_committed(),
1083 "the first cycle of a batch of three is not the last"
1084 );
1085 assert_eq!(10, progress.front_emitted);
1086
1087 // Drain the first cycle: every polynomial emits exactly `threshold` shares at zero surplus.
1088 emit(POLYS as u32 * THRESHOLD as u32, &mut sent)?;
1089 let progress = generator.emission_progress(&p).expect("committed");
1090 assert_eq!(
1091 Some(2.try_into()?),
1092 progress.highest_emitted,
1093 "emission moves to the next cycle only once its predecessor is drained"
1094 );
1095
1096 emit(POLYS as u32 * THRESHOLD as u32, &mut sent)?;
1097 let progress = generator.emission_progress(&p).expect("committed");
1098 assert!(
1099 progress.is_serving_last_committed(),
1100 "emission has reached the last cycle of the batch"
1101 );
1102
1103 // And this is the distinction the whole admission rule turns on. Reaching the last cycle says
1104 // nothing about how far into it emission has got: `is_serving_last_committed` became true on
1105 // that cycle's *first* share, ~0 % of the way through, while a conforming Exit does not ask
1106 // until ~85 %. An Entry admitting a successor batch on the index alone admits it a whole cycle
1107 // early — which is the same unfunded exposure the batch gate exists to prevent.
1108 assert!(
1109 progress.front_fraction() < 0.05,
1110 "reaching the last cycle must not imply being far into it, got {}",
1111 progress.front_fraction()
1112 );
1113
1114 emit(POLYS as u32 * THRESHOLD as u32 * 9 / 10, &mut sent)?;
1115 let progress = generator.emission_progress(&p).expect("committed");
1116 assert!(
1117 progress.is_serving_last_committed() && progress.front_fraction() >= 0.85,
1118 "nine tenths into the last cycle is where an Exit may legitimately ask for the next batch, got {}",
1119 progress.front_fraction()
1120 );
1121
1122 Ok(())
1123 }
1124
1125 /// The early-recovery boundary must be derived, not estimated from the surplus factor.
1126 ///
1127 /// Pins the deployed number, because that is what the Entry's successor gate compares against and
1128 /// it is not something a reader can check by inspection. The naive
1129 /// `early_threshold / surplus_factor` gives 68 %; the truth is 86.8 %, and the gap is a whole
1130 /// batch's worth of deposit exposure.
1131 #[test]
1132 fn early_recovery_boundary_is_computed_from_the_emission_windows() -> anyhow::Result<()> {
1133 let params =
1134 PixParams::try_new_for::<TestSpec>(DEFAULT_POLYS_PER_SSA, DEFAULT_POLY_THRESHOLD, DEFAULT_SURPLUS_SHARES)?;
1135 let cycle = params.polys_per_ssa() as u64 * params.emitted_shares_per_poly() as u64;
1136 assert_eq!(655_360, cycle);
1137
1138 // 27 whole windows (552 960 shares), then 63 full passes of the 28th plus 52 shares of its
1139 // 64th — the instant the 6964th polynomial reaches its threshold.
1140 let boundary = min_emission_for_early_recovery(¶ms, 0.85);
1141 assert_eq!(552_960 + 63 * 256 + 52, boundary);
1142
1143 let fraction = boundary as f64 / cycle as f64;
1144 assert!(
1145 (0.868..0.869).contains(&fraction),
1146 "the deployed boundary must be ~86.8% of emission, got {fraction}"
1147 );
1148 assert!(
1149 fraction > 0.85 / 1.25,
1150 "the naive surplus-factor estimate must be demonstrably below the real boundary"
1151 );
1152
1153 Ok(())
1154 }
1155
1156 /// The boundary must hold at dimensions that do not divide the emission window evenly, and at
1157 /// dimensions narrower than one window — where there is no windowing to account for at all.
1158 #[test]
1159 fn early_recovery_boundary_handles_partial_and_narrow_windows() -> anyhow::Result<()> {
1160 // Narrower than one window: a single lockstep block, so the boundary is simply the pass on
1161 // which the `needed`-th polynomial completes.
1162 let narrow = PixParams::try_new_for::<TestSpec>(10, 4, 2)?;
1163 // ceil(0.85 * 10) = 9 polynomials; 3 full passes of 10, then 9 shares of the 4th.
1164 assert_eq!(3 * 10 + 9, min_emission_for_early_recovery(&narrow, 0.85));
1165
1166 // Not a multiple of the window: the last window is narrower and reaches a pass sooner.
1167 let ragged = PixParams::try_new_for::<TestSpec>(SHARE_EMISSION_WINDOW as u16 + 100, 4, 2)?;
1168 let polys = ragged.polys_per_ssa() as u64;
1169 let needed = (0.85 * polys as f64).ceil() as u64;
1170 let boundary = min_emission_for_early_recovery(&ragged, 0.85);
1171 assert!(
1172 needed > SHARE_EMISSION_WINDOW as u64,
1173 "the boundary must fall in the second, narrower window for this case to mean anything"
1174 );
1175 assert_eq!(
1176 SHARE_EMISSION_WINDOW as u64 * 6 + 3 * 100 + (needed - SHARE_EMISSION_WINDOW as u64),
1177 boundary,
1178 "one whole window at threshold+surplus, then 3 passes of the 100-wide remainder"
1179 );
1180
1181 // Degenerate thresholds are answerable rather than panicking: everything, and nothing.
1182 assert_eq!(0, min_emission_for_early_recovery(&narrow, 0.0));
1183 let all = min_emission_for_early_recovery(&narrow, 1.0);
1184 assert_eq!(
1185 3 * 10 + 10,
1186 all,
1187 "every polynomial completes on the last pass of the threshold"
1188 );
1189
1190 Ok(())
1191 }
1192
1193 /// A non-finite threshold must close the gate, not open it.
1194 ///
1195 /// The clamp this replaced propagated `NaN`, and every step after it quietly agreed: `NaN * polys`
1196 /// is `NaN`, `NaN.ceil()` is `NaN`, and casting that to an integer saturates to *zero*. The
1197 /// early-exit for `needed == 0` then returned a boundary of zero shares — so the one input with no
1198 /// meaning at all produced the single most permissive answer this function can give, on the
1199 /// calculation the Entry uses to decide whether a deposit has been earned.
1200 ///
1201 /// `SsaReconstructorConfig` now refuses a non-finite threshold outright, so this is defence in
1202 /// depth rather than the only guard. It is worth having because the function is public, takes a
1203 /// bare `f64`, and every one of the steps above is silent.
1204 #[test]
1205 fn a_non_finite_early_recovery_threshold_closes_the_emission_gate() -> anyhow::Result<()> {
1206 let params =
1207 PixParams::try_new_for::<TestSpec>(DEFAULT_POLYS_PER_SSA, DEFAULT_POLY_THRESHOLD, DEFAULT_SURPLUS_SHARES)?;
1208 let whole_cycle = min_emission_for_early_recovery(¶ms, 1.0);
1209
1210 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1211 assert_eq!(
1212 whole_cycle,
1213 min_emission_for_early_recovery(¶ms, bad),
1214 "{bad} must demand the whole cycle, not zero shares"
1215 );
1216 }
1217
1218 Ok(())
1219 }
1220
1221 /// A stale index must be refused before any polynomial is generated.
1222 ///
1223 /// Generation is the expensive half of `new_ssa_commitment` — `polynomials_per_ssa` polynomials
1224 /// and their commitments, hundreds of thousands of EC operations at the deployed dimensions —
1225 /// and it used to run *before* the entry was taken and the index checked. Every request that
1226 /// lost a race, and every stale or replayed `SsaRequest`, paid it in full and threw the result
1227 /// away, all reachable from one inbound packet.
1228 ///
1229 /// Timing is the observable here, so the assertion is deliberately loose: dimensions large enough
1230 /// that generation dominates by orders of magnitude, and a bound far above the rejection's real
1231 /// cost but far below one generation. A tighter bound would be a flaky test rather than a
1232 /// stricter one.
1233 #[test]
1234 fn a_stale_index_is_refused_before_any_polynomial_is_generated() -> anyhow::Result<()> {
1235 const POLYS: u16 = 512;
1236
1237 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
1238 polynomials_per_ssa: POLYS,
1239 threshold: 8,
1240 surplus_shares: 2,
1241 });
1242 let p = SimplePseudonym::random();
1243
1244 let started = std::time::Instant::now();
1245 generator.new_ssa_commitment(&p, 2.try_into()?)?;
1246 let generation = started.elapsed();
1247
1248 // Every index at or below the last one: a replay, a duplicate, and a reordered predecessor.
1249 let started = std::time::Instant::now();
1250 for stale in [1u32, 2] {
1251 assert!(
1252 matches!(
1253 generator.new_ssa_commitment(&p, stale.try_into()?),
1254 Err(PixError::InvalidInput)
1255 ),
1256 "index {stale} is not above the last committed and must be refused"
1257 );
1258 }
1259 let rejections = started.elapsed();
1260
1261 assert!(
1262 rejections * 4 < generation,
1263 "two rejections took {rejections:?} against one generation's {generation:?} — the index check must \
1264 short-circuit ahead of the polynomial generation, not after it"
1265 );
1266
1267 // And the refusals left the generator's own state alone: the next legitimate index still works.
1268 generator.new_ssa_commitment(&p, 3.try_into()?)?;
1269
1270 Ok(())
1271 }
1272
1273 /// The surplus is a ratio of the threshold, and the ratio is a loss-rate tolerance.
1274 ///
1275 /// Pinned as the tolerance rather than as four literals, because the tolerance is the property
1276 /// with a physical meaning — `surplus/(threshold + surplus)` is the fraction of a polynomial's
1277 /// shares that may be lost before it cannot reconstruct. A change to
1278 /// `SURPLUS_LOSS_TOLERANCE_DIVISOR` has to restate what it did to that number.
1279 ///
1280 /// Swept over the *whole* accepted threshold range rather than the deployed multiples of four.
1281 /// Restricting it to those was what let the ratio round down unnoticed: every sampled point
1282 /// divided exactly, so integer division and the intended ratio agreed on all of them and
1283 /// disagreed on everything else.
1284 #[test]
1285 fn the_default_surplus_covers_a_fifth_of_a_polynomial_being_lost() {
1286 for threshold in MIN_POLY_THRESHOLD..=MAX_POLY_THRESHOLD {
1287 let surplus = crate::default_surplus_for(threshold);
1288 let emitted = threshold as f64 + surplus as f64;
1289 let tolerated = surplus as f64 / emitted;
1290
1291 // A floor, not an approximation: the surplus may over-cover, never under-cover.
1292 assert!(
1293 tolerated >= 0.20,
1294 "threshold {threshold} + surplus {surplus} tolerates only {tolerated:.4} loss"
1295 );
1296 // And it over-covers by less than one share, which is what makes rounding up cheap.
1297 assert!(
1298 surplus as f64 - 1.0 < threshold as f64 / crate::SURPLUS_LOSS_TOLERANCE_DIVISOR as f64,
1299 "threshold {threshold} buys {surplus} surplus shares, more than one above the ratio"
1300 );
1301 // Zero surplus is zero loss tolerance. Rounding down produced it at thresholds 2 and 3.
1302 assert!(surplus > 0, "threshold {threshold} derives no surplus at all");
1303 }
1304
1305 // Where the threshold divides exactly the tolerance is the documented 20 % on the nose, and
1306 // the deployed threshold is one of those.
1307 for threshold in [16u8, 32, 48, 64] {
1308 let surplus = crate::default_surplus_for(threshold);
1309 let tolerated = surplus as f64 / (threshold as f64 + surplus as f64);
1310 assert!(
1311 (0.19..=0.21).contains(&tolerated),
1312 "threshold {threshold} + surplus {surplus} tolerates {tolerated:.3} loss, expected ~0.20"
1313 );
1314 }
1315
1316 assert_eq!(
1317 DEFAULT_SURPLUS_SHARES,
1318 crate::default_surplus_for(DEFAULT_POLY_THRESHOLD),
1319 "the constant must stay the ratio evaluated at the default threshold, not drift from it"
1320 );
1321 }
1322
1323 /// A derived surplus must never fail the validator it is derived under.
1324 ///
1325 /// `default_surplus_for` rounds up and `surplus_must_not_exceed_threshold` bounds the surplus by
1326 /// the threshold, so the two meet at the smallest threshold there is: at
1327 /// [`MIN_POLY_THRESHOLD`] the derived surplus is 1 against a threshold of 2. Any further
1328 /// rounding-up would collide with the bound rather than merely over-insure.
1329 #[test]
1330 fn every_derived_surplus_passes_the_bound_it_is_derived_under() {
1331 for threshold in MIN_POLY_THRESHOLD..=MAX_POLY_THRESHOLD {
1332 let cfg = SsaGeneratorConfig {
1333 polynomials_per_ssa: 16,
1334 threshold,
1335 surplus_shares: crate::default_surplus_for(threshold),
1336 };
1337 assert!(
1338 cfg.validate().is_ok(),
1339 "the surplus derived for threshold {threshold} fails its own validator"
1340 );
1341 }
1342 }
1343
1344 /// A surplus above the threshold pays for more redundancy than payload, and is billed for it.
1345 ///
1346 /// The boundary rather than an arbitrary over-large value: the rule is exactly "not more than
1347 /// the thing it insures", so the interesting cases are on either side of it. A flat surplus of
1348 /// 20 — what deployments used before this became a ratio — is what fails at threshold 16.
1349 #[test]
1350 fn a_surplus_larger_than_the_threshold_is_rejected() {
1351 let at_bound = SsaGeneratorConfig {
1352 polynomials_per_ssa: 16,
1353 threshold: 16,
1354 surplus_shares: 16,
1355 };
1356 assert!(
1357 SsaShareGenerator::<TestSpec>::try_new(at_bound).is_ok(),
1358 "a surplus equal to the threshold must be allowed — over-insuring a lossy path is a real choice"
1359 );
1360
1361 let past_bound = SsaGeneratorConfig {
1362 surplus_shares: 17,
1363 ..at_bound
1364 };
1365 assert!(matches!(
1366 SsaShareGenerator::<TestSpec>::try_new(past_bound),
1367 Err(PixError::InvalidConfiguration(_))
1368 ));
1369
1370 let flat_twenty_at_low_threshold = SsaGeneratorConfig {
1371 surplus_shares: 20,
1372 ..at_bound
1373 };
1374 assert!(
1375 SsaShareGenerator::<TestSpec>::try_new(flat_twenty_at_low_threshold).is_err(),
1376 "the configuration this rule exists to catch: 20 shares of insurance against 16 of payload"
1377 );
1378 }
1379
1380 #[test]
1381 fn ssa_generator_try_new_should_reject_an_invalid_config_without_panicking() {
1382 // Zero polynomials is the case the rest of the generator quietly relies on being impossible
1383 // — `new_ssa_commitment` builds its `SsaId` from the parameters precisely so that it does
1384 // not have to index into an empty commitment vector.
1385 let cfg = SsaGeneratorConfig {
1386 polynomials_per_ssa: 0,
1387 threshold: 10,
1388 surplus_shares: 2,
1389 };
1390
1391 assert!(matches!(
1392 SsaShareGenerator::<TestSpec>::try_new(cfg),
1393 Err(PixError::InvalidConfiguration(_))
1394 ));
1395 }
1396
1397 #[test]
1398 #[should_panic(expected = "invalid SsaGeneratorConfig")]
1399 fn ssa_generator_new_should_still_panic_on_an_invalid_config() {
1400 // `new` stays panicking on purpose: it is what the benches and tests use, where a bad
1401 // constant should abort rather than be threaded through a `Result`.
1402 let _ = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
1403 polynomials_per_ssa: 0,
1404 threshold: 10,
1405 surplus_shares: 2,
1406 });
1407 }
1408
1409 #[test]
1410 fn ssa_generator_should_generate_consecutive_spis() -> anyhow::Result<()> {
1411 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
1412 polynomials_per_ssa: 10,
1413 threshold: 10,
1414 surplus_shares: 2,
1415 });
1416
1417 let p1 = SimplePseudonym::random();
1418 let c = generator.new_ssa_commitment(&p1, 1.try_into()?)?;
1419 assert_eq!(c.ssa_id.pseudonym(), &p1);
1420 assert_eq!(c.ssa_id.ssa_index(), 1.try_into()?);
1421
1422 let c = generator.new_ssa_commitment(&p1, 2.try_into()?)?;
1423 assert_eq!(c.ssa_id.pseudonym(), &p1);
1424 assert_eq!(c.ssa_id.ssa_index(), 2.try_into()?);
1425
1426 let p2 = SimplePseudonym::random();
1427 let c = generator.new_ssa_commitment(&p2, 1.try_into()?)?;
1428 assert_eq!(c.ssa_id.pseudonym(), &p2);
1429 assert_eq!(c.ssa_id.ssa_index(), 1.try_into()?);
1430
1431 let c = generator.new_ssa_commitment(&p1, 3.try_into()?)?;
1432 assert_eq!(c.ssa_id.pseudonym(), &p1);
1433 assert_eq!(c.ssa_id.ssa_index(), 3.try_into()?);
1434
1435 let c = generator.new_ssa_commitment(&p2, 2.try_into()?)?;
1436 assert_eq!(c.ssa_id.pseudonym(), &p2);
1437 assert_eq!(c.ssa_id.ssa_index(), 2.try_into()?);
1438
1439 // Repeated SSA index
1440 assert!(generator.new_ssa_commitment(&p2, 2.try_into()?).is_err());
1441
1442 Ok(())
1443 }
1444
1445 /// With fewer polynomials than [`SHARE_EMISSION_WINDOW`] the whole SSA is one window, so
1446 /// emission cycles through every polynomial before returning to any of them.
1447 #[test]
1448 fn ssa_generator_should_round_robin_within_the_emission_window() -> anyhow::Result<()> {
1449 let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
1450 polynomials_per_ssa: 3,
1451 threshold: 3,
1452 surplus_shares: 1,
1453 });
1454
1455 let p1 = SimplePseudonym::random();
1456 generator.new_ssa_commitment(&p1, 1.try_into()?)?;
1457
1458 for i in 0..12_u16 {
1459 let g = generator
1460 .next_share(&p1, &i.to_be_bytes())?
1461 .ok_or(anyhow::anyhow!("failed to generate share"))?;
1462 assert_eq!(g.id.pseudonym(), &p1);
1463 assert_eq!(g.id.ssa_index(), 1.try_into()?);
1464 assert_eq!(g.id.poly_index(), i % 3);
1465 }
1466 assert!(generator.next_share(&p1, &20_u32.to_be_bytes())?.is_none());
1467
1468 // A new cycle is appended, and the window does not reach into it until the previous one is
1469 // fully emitted — so indices restart from the beginning of the new SSA.
1470 generator.new_ssa_commitment(&p1, 2.try_into()?)?;
1471
1472 for i in 0..12_u16 {
1473 let g = generator
1474 .next_share(&p1, &i.to_be_bytes())?
1475 .ok_or(anyhow::anyhow!("failed to generate share"))?;
1476 assert_eq!(g.id.pseudonym(), &p1);
1477 assert_eq!(g.id.ssa_index(), 2.try_into()?);
1478 assert_eq!(g.id.poly_index(), i % 3);
1479 }
1480 assert!(generator.next_share(&p1, &20_u32.to_be_bytes())?.is_none());
1481
1482 Ok(())
1483 }
1484
1485 /// The property that actually protects an SSA cycle from SURB ring-buffer eviction.
1486 ///
1487 /// Evictions take a *contiguous* run of the emission order, and a polynomial dies if it loses
1488 /// more than `surplus_shares`. Spreading every run across the window is what keeps a burst from
1489 /// concentrating on one polynomial — with more polynomials than the window, any run of `n`
1490 /// shares must touch at least `min(n, window)` distinct ones.
1491 #[test]
1492 fn contiguous_runs_must_spread_across_the_emission_window() -> anyhow::Result<()> {
1493 // Deliberately more polynomials than the window, so the window is the binding constraint.
1494 let polynomials_per_ssa = (SHARE_EMISSION_WINDOW * 2) as u16;
1495 let cfg = SsaGeneratorConfig {
1496 polynomials_per_ssa,
1497 threshold: 2,
1498 surplus_shares: 1,
1499 };
1500 let generator = SsaShareGenerator::<TestSpec>::new(cfg);
1501
1502 let p = SimplePseudonym::random();
1503 generator.new_ssa_commitment(&p, 1.try_into()?)?;
1504
1505 let mut emitted = Vec::new();
1506 for i in 0..(polynomials_per_ssa as usize * 3) {
1507 let g = generator
1508 .next_share(&p, &(i as u32).to_be_bytes())?
1509 .ok_or(anyhow::anyhow!("failed to generate share"))?;
1510 emitted.push(g.id.poly_index());
1511 }
1512 assert!(generator.next_share(&p, &u32::MAX.to_be_bytes())?.is_none());
1513
1514 for run in [2_usize, 17, SHARE_EMISSION_WINDOW] {
1515 for window_start in (0..emitted.len().saturating_sub(run)).step_by(run.max(1)) {
1516 let distinct = emitted[window_start..window_start + run]
1517 .iter()
1518 .collect::<std::collections::HashSet<_>>()
1519 .len();
1520 assert_eq!(
1521 run, distinct,
1522 "a contiguous run of {run} shares starting at {window_start} hit only {distinct} distinct \
1523 polynomials; an eviction of that run would concentrate on too few of them"
1524 );
1525 }
1526 }
1527
1528 // Every polynomial still receives exactly `threshold + surplus` shares.
1529 let mut per_poly = std::collections::HashMap::new();
1530 for poly_index in &emitted {
1531 *per_poly.entry(*poly_index).or_insert(0_usize) += 1;
1532 }
1533 assert_eq!(polynomials_per_ssa as usize, per_poly.len());
1534 assert!(
1535 per_poly
1536 .values()
1537 .all(|n| *n == cfg.threshold as usize + cfg.surplus_shares as usize)
1538 );
1539
1540 Ok(())
1541 }
1542
1543 /// Every polynomial's shares must interpolate back to the constant term the generator
1544 /// committed to — the single check the Exit performs, in place of the per-share Feldman
1545 /// verification that used to run `threshold` scalar multiplications per share.
1546 #[test]
1547 fn ssa_generator_parts_must_open_their_commitments() -> anyhow::Result<()> {
1548 let cfg = SsaGeneratorConfig {
1549 polynomials_per_ssa: 10,
1550 threshold: 10,
1551 surplus_shares: 2,
1552 };
1553 let generator = SsaShareGenerator::<TestSpec>::new(cfg);
1554
1555 assert_eq!(&cfg, generator.config());
1556
1557 let p = SimplePseudonym::random();
1558 let c = generator.new_ssa_commitment(&p, 1.try_into()?)?;
1559 let commitments = c.reconstruct_part_commitments().map_err(anyhow::Error::msg)?;
1560 assert_eq!(cfg.polynomials_per_ssa as usize, commitments.len());
1561
1562 // Shares are emitted round-robin across the window, so they are grouped by polynomial here
1563 // rather than assumed to arrive in consecutive runs.
1564 let by_poly = drain_shares_by_polynomial(&generator, &p, &cfg)?;
1565 assert_eq!(cfg.polynomials_per_ssa as usize, by_poly.len());
1566
1567 for commitment in &commitments {
1568 let shares = by_poly
1569 .get(&commitment.spi().poly_index())
1570 .ok_or(anyhow::anyhow!("no shares for polynomial"))?;
1571 assert_eq!(cfg.threshold as usize + cfg.surplus_shares as usize, shares.len());
1572
1573 // Only `threshold` shares are needed; the surplus stands in for any that are lost.
1574 let reconstructed = shares[..cfg.threshold as usize]
1575 .to_vec()
1576 .combine()
1577 .map_err(anyhow::Error::msg)?
1578 .0;
1579 assert!(
1580 commitment.verify_reconstructed(&reconstructed),
1581 "polynomial {} did not open its commitment",
1582 commitment.spi().poly_index()
1583 );
1584 }
1585
1586 Ok(())
1587 }
1588
1589 #[test]
1590 fn ssa_generator_corresponds_to_standard_recoverer() -> anyhow::Result<()> {
1591 let cfg = SsaGeneratorConfig {
1592 polynomials_per_ssa: 10,
1593 threshold: 10,
1594 surplus_shares: 2,
1595 };
1596 let generator = SsaShareGenerator::<TestSpec>::new(cfg);
1597
1598 let p = SimplePseudonym::random();
1599 let c = generator.new_ssa_commitment(&p, 1.try_into()?)?;
1600 let orig_commitment = c.ssa_commitment;
1601
1602 let by_poly = drain_shares_by_polynomial(&generator, &p, &cfg)?;
1603 assert_eq!(cfg.polynomials_per_ssa as usize, by_poly.len());
1604
1605 let mut recovered_secret = crypto_traits::elliptic_curve::Scalar::<Secp256k1>::default();
1606 for shares in by_poly.values() {
1607 recovered_secret += shares[..cfg.threshold as usize]
1608 .to_vec()
1609 .combine()
1610 .map_err(anyhow::Error::msg)?
1611 .0;
1612 }
1613
1614 assert_eq!(
1615 orig_commitment.to_affine(),
1616 (crypto_traits::elliptic_curve::ProjectivePoint::<Secp256k1>::GENERATOR * recovered_secret).to_affine()
1617 );
1618
1619 Ok(())
1620 }
1621
1622 /// Turns a generated share plus the nonce it was derived from into the `(x, y)` pair the
1623 /// interpolation consumes, exactly as the reconstructor does.
1624 /// Drains the generator and buckets every share by its polynomial index.
1625 ///
1626 /// Emission is round-robin across [`SHARE_EMISSION_WINDOW`], so a polynomial's shares are not
1627 /// contiguous in the output stream; anything reconstructing a polynomial has to group first.
1628 /// Within a bucket the order is preserved, which is what lets a caller take the first
1629 /// `threshold` and treat the rest as surplus.
1630 #[allow(clippy::type_complexity)]
1631 fn drain_shares_by_polynomial(
1632 generator: &SsaShareGenerator<TestSpec>,
1633 p: &SimplePseudonym,
1634 cfg: &SsaGeneratorConfig,
1635 ) -> anyhow::Result<std::collections::BTreeMap<PolynomialIndex, Vec<crate::CompletedShare<TestSpec>>>> {
1636 let expected = cfg.polynomials_per_ssa as usize * (cfg.threshold as usize + cfg.surplus_shares as usize);
1637 let mut by_poly: std::collections::BTreeMap<PolynomialIndex, Vec<_>> = std::collections::BTreeMap::new();
1638
1639 for _ in 0..expected {
1640 let x = hopr_types::crypto_random::random_bytes::<10>();
1641 let g = generator
1642 .next_share(p, &x)?
1643 .ok_or(anyhow::anyhow!("failed to generate share"))?;
1644 by_poly
1645 .entry(g.id.poly_index())
1646 .or_default()
1647 .push(completed_share(&g, &x)?);
1648 }
1649 // The generator must be exhausted after exactly the expected number of shares.
1650 anyhow::ensure!(
1651 generator.next_share(p, &u32::MAX.to_be_bytes())?.is_none(),
1652 "generator emitted more shares than the configured dimensions allow"
1653 );
1654
1655 Ok(by_poly)
1656 }
1657
1658 fn completed_share(
1659 g: &GeneratedShare<TestSpec>,
1660 x: &impl AsRef<[u8]>,
1661 ) -> anyhow::Result<crate::CompletedShare<TestSpec>> {
1662 Ok(DefaultShare {
1663 identifier: TestSpec::msg_to_scalar(&g.id, x)?.into(),
1664 value: Option::from(crypto_traits::elliptic_curve::Scalar::<Secp256k1>::from_repr(g.share.0))
1665 .map(|s: PixScalar<TestSpec>| s.into())
1666 .ok_or(anyhow::anyhow!("share is not a field element"))?,
1667 })
1668 }
1669}