Skip to main content

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(&params, 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(&params, 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(&params, 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}