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_POLY_THRESHOLD, MAX_POLYS_PER_SSA, MIN_POLY_THRESHOLD, PixGroup, PixScalar, PixSpec, PolynomialIndex,
14    SsaPartCommitment, 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
40struct SsaPseudonymEntry<S: PixSpec> {
41    ssa_index: SsaIndex,
42    poly_queue: VecDeque<IndexedPolynomial<S>>,
43    /// Position within the emission window, i.e. into the first
44    /// [`SHARE_EMISSION_WINDOW`] entries of `poly_queue`.
45    cursor: usize,
46}
47
48/// Number of polynomials the generator emits shares for concurrently.
49///
50/// Shares are emitted round-robin across the first `SHARE_EMISSION_WINDOW` polynomials of the
51/// queue rather than draining one polynomial to exhaustion before starting the next. Both orderings
52/// emit exactly the same shares — every share carries its own [`SsaPolynomialId`], which the Exit
53/// files by, so arrival order is irrelevant to reconstruction — but they fail very differently.
54///
55/// A share only reaches the reconstructor when the Exit *uses* the SURB carrying it, so a SURB
56/// dropped from the Exit's per-pseudonym ring buffer is a permanently lost share. That buffer
57/// overwrites its oldest entries, which is a *contiguous* run of the emission order. Draining one
58/// polynomial at a time makes such a run land on a single polynomial: lose more than
59/// `surplus_shares` of it and it can never reach `threshold`, and since the SSA is the sum of
60/// *every* polynomial's constant term, the whole cycle becomes unrecoverable — silently, because a
61/// starved polynomial never fails a check, it simply never completes. Round-robin spreads the same
62/// run across the window, so a contiguous loss of up to `surplus_shares × SHARE_EMISSION_WINDOW`
63/// shares is absorbed by the surplus that exists for exactly this purpose.
64///
65/// The window is bounded rather than spanning the whole SSA because the Exit holds a part builder's
66/// collected shares until that part reconstructs (`release_verification_state`). One polynomial at
67/// a time keeps one part live; the full 8192 would keep every part live at once, `polys × threshold`
68/// shares of peak memory. 256 keeps that peak around a megabyte while covering a contiguous loss
69/// far larger than the ring buffer's entire overshoot allowance.
70pub const SHARE_EMISSION_WINDOW: usize = 256;
71
72/// Builds a Shamir polynomial of degree `t - 1` over `secret` and commits to its constant term.
73///
74/// Only the constant term is committed to. The higher coefficients still exist — they are what
75/// makes the shares hide the secret — but no commitment to them is published, so the Exit cannot
76/// (and no longer needs to) check an individual share. See [`SsaPartCommitment`].
77///
78/// This is also why the Entry's per-cycle cost collapsed: committing to every coefficient was
79/// `polys × threshold` fixed-base multiplications against an untabulated generator, over half a
80/// million of them at production dimensions, all inside one blocking task at each cycle boundary.
81fn new_polynomial_with_commitment<S: PixSpec>(
82    secret: PixScalar<S>,
83    t: usize,
84    rng: impl CryptoRng,
85) -> errors::Result<(RawPolynomial<S>, PixGroup<S>), S::Pseudonym> {
86    let mut polynomial = RawPolynomial::<S>::create(t);
87    polynomial.fill(&secret.into(), rng, t)?;
88
89    Ok((polynomial, PixGroup::<S>::mul_by_generator(&secret)))
90}
91
92/// Rejects a surplus larger than the threshold it insures.
93///
94/// The bound is deliberately loose — twice the emitted shares a polynomial needs — because the
95/// surplus is legitimately a deployment choice about return-path loss, and over-insuring a bad path
96/// is a reasonable thing to want. What it forbids is the case where the insurance costs more than
97/// the thing insured: since H5 the surplus travels in the negotiated
98/// [`PixParams`](crate::PixParams) and is billed on purchase rather than on claim, so a surplus
99/// above the threshold means an Entry paying for more redundancy than payload in every deposit.
100///
101/// This is a *configuration* bound, not a wire one. `PixParams` packs the surplus as a byte and
102/// accepts the whole range, and a peer offering an extravagant surplus is already caught where it
103/// should be — by the Exit's `quota_range`, since the surplus inflates the quota.
104fn surplus_must_not_exceed_threshold(cfg: &SsaGeneratorConfig) -> Result<(), validator::ValidationError> {
105    if cfg.surplus_shares > cfg.threshold {
106        return Err(validator::ValidationError::new(
107            "surplus_shares must not exceed threshold — the surplus is billed, so this pays for more redundancy than \
108             payload",
109        ));
110    }
111    Ok(())
112}
113
114/// Configuration for the [`SsaShareGenerator`].
115#[derive(Debug, Clone, Copy, PartialEq, Eq, smart_default::SmartDefault, validator::Validate)]
116#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
117#[validate(schema(function = "surplus_must_not_exceed_threshold", skip_on_field_errors = false))]
118pub struct SsaGeneratorConfig {
119    /// The number of polynomials to generate per SSA commitment.
120    ///
121    /// Default is [`DEFAULT_POLYS_PER_SSA`], must be between 1 and [`MAX_POLYS_PER_SSA`].
122    #[default(DEFAULT_POLYS_PER_SSA)]
123    #[validate(range(min = 1, max = MAX_POLYS_PER_SSA))]
124    pub polynomials_per_ssa: u16,
125    /// Minimum number of shares required to reconstruct each SSA polynomial.
126    ///
127    /// Default is [`DEFAULT_POLY_THRESHOLD`], must be at least [`MIN_POLY_THRESHOLD`]. The upper
128    /// bound [`MAX_POLY_THRESHOLD`] is the width of the field.
129    #[default(DEFAULT_POLY_THRESHOLD)]
130    #[validate(range(min = MIN_POLY_THRESHOLD, max = MAX_POLY_THRESHOLD))]
131    pub threshold: u8,
132    /// Additional number of shares to generate beyond the threshold for redundancy.
133    ///
134    /// Covers *lost* shares only: the Exit reconstructs from the first `threshold` distinct shares
135    /// that reach it, so any of the surplus can stand in for one that never arrives. It does not
136    /// cover *corrupt* shares — nothing checks a share on arrival any more, so a bad one is only
137    /// noticed once it has already poisoned the interpolation. See
138    /// [`SsaPartCommitment`].
139    ///
140    /// Emitting them is unconditional: a polynomial leaves the queue at `threshold + surplus`
141    /// shares, whether or not any were lost, so the Exit serves this many packets per polynomial in
142    /// every case. That is why the surplus is part of the per-SSA quota rather than free service —
143    /// the Entry is buying insurance, and insurance is paid for whether or not it is claimed.
144    ///
145    /// Default is [`DEFAULT_SURPLUS_SHARES`] — but prefer
146    /// [`default_surplus_for`](crate::default_surplus_for) wherever the threshold is known, because
147    /// this is a *ratio* of it and the constant can only be the ratio evaluated at the default
148    /// threshold.
149    ///
150    /// Bounded by `threshold` rather than by the byte the wire gives it: see
151    // `surplus_must_not_exceed_threshold` is deliberately unlinked: it is crate-private, so an
152    // intra-doc link from this public field trips `rustdoc::private_intra_doc_links`, which the
153    // `nix build .#docs` job builds as an error.
154    /// `surplus_must_not_exceed_threshold`. It shares the lower half of the negotiated
155    /// [`PixParams`](crate::PixParams) word with `threshold`, so a byte is all that fits there — but
156    /// what is *representable* and what is sane to configure are different questions, and this field
157    /// used to be documented as needing no validator on the strength of the first.
158    #[default(DEFAULT_SURPLUS_SHARES)]
159    pub surplus_shares: u8,
160}
161
162/// Generator for Session Stealth Address (SSA) shares distributed over Single Use Reply Blocks (SURBs).
163pub struct SsaShareGenerator<S: PixSpec> {
164    polynomials:
165        moka::sync::Cache<S::Pseudonym, std::sync::Arc<parking_lot::Mutex<SsaPseudonymEntry<S>>>, ahash::RandomState>,
166    cfg: SsaGeneratorConfig,
167}
168
169impl<S: PixSpec> SsaShareGenerator<S> {
170    /// Creates a new share generator with the provided configuration.
171    ///
172    /// Fails if the configuration does not validate. Prefer this over [`Self::new`] anywhere the
173    /// configuration is assembled at runtime — a config built programmatically or read from a file
174    /// is input, not a constant, and turning it into a panic makes it un-handleable by the caller.
175    pub fn try_new(cfg: SsaGeneratorConfig) -> errors::Result<Self, S::Pseudonym> {
176        cfg.validate()?;
177        Ok(Self {
178            polynomials: moka::sync::CacheBuilder::default()
179                .initial_capacity(100_000)
180                .time_to_idle(std::time::Duration::from_secs(1800))
181                .build_with_hasher(ahash::RandomState::new()),
182            cfg,
183        })
184    }
185
186    /// Creates a new share generator with the provided configuration.
187    ///
188    /// # Panics
189    /// Panics if the configuration fails validation. Use [`Self::try_new`] to handle that case
190    /// instead.
191    pub fn new(cfg: SsaGeneratorConfig) -> Self {
192        Self::try_new(cfg).expect("invalid SsaGeneratorConfig")
193    }
194
195    /// Returns the configuration used to generate this [`SsaShareGenerator`].
196    #[inline]
197    pub fn config(&self) -> &SsaGeneratorConfig {
198        &self.cfg
199    }
200}
201
202impl<S: PixSpec> Default for SsaShareGenerator<S> {
203    fn default() -> Self {
204        Self::new(SsaGeneratorConfig::default())
205    }
206}
207
208impl<S: PixSpec> EntryShareGenerator<S> for SsaShareGenerator<S> {
209    type Error = PixError<S::Pseudonym>;
210
211    /// Generate the next [`PartialSsaShare`] for the given pseudonym and message `msg`.
212    ///
213    /// IMPORTANT: Each `msg` MUST be unique for a given pseudonym.
214    ///
215    /// Returns `None` if all polynomials for the given pseudonym have been used up.
216    /// This signals that a new SSA must be committed.
217    fn next_share(
218        &self,
219        pseudonym: &S::Pseudonym,
220        msg: &impl AsRef<[u8]>,
221    ) -> errors::Result<Option<GeneratedShare<S>>, S::Pseudonym> {
222        let Some(entry) = self.polynomials.get(pseudonym) else {
223            return Ok(None);
224        };
225
226        // If we replaced VecDeque with a lock-free alternative, we could remove the mutex, but the
227        // alternative would need to effectively deallocate, so the polynomials do not grow
228        // indefinitely when new commitments are being added.
229        let mut entry = entry.lock();
230        let SsaPseudonymEntry { poly_queue, cursor, .. } = &mut *entry;
231        let max_shares_per_poly = self.cfg.threshold as usize + self.cfg.surplus_shares as usize;
232
233        while !poly_queue.is_empty() {
234            // The window is always the front of the queue: `new_ssa_commitment` appends, and an
235            // exhausted polynomial is removed in place so its immediate successor shifts in.
236            //
237            // It *can* straddle an SSA boundary, and routinely does. The width is recomputed here
238            // every call, so once the current cycle is down to fewer than `SHARE_EMISSION_WINDOW`
239            // live polynomials and the next has been appended, the window covers the tail of one and
240            // the head of the other — which is the normal state near a boundary, since
241            // `early_recovery_threshold` exists precisely to commit the next cycle before this one
242            // drains.
243            //
244            // Emission stays correct: every share carries its own `SsaPolynomialId` and the Exit
245            // files by it. What follows is that the next cycle's shares can reach the Exit before
246            // its constant terms do, so they take the deferral path — bear that in mind when sizing
247            // `MAX_DEFERRED_ACKS_PER_CYCLE`, which would otherwise look like it only has to cover
248            // the commitment window.
249            let window = poly_queue.len().min(SHARE_EMISSION_WINDOW.max(1));
250            if *cursor >= window {
251                *cursor = 0;
252            }
253            let idx = *cursor;
254
255            if poly_queue[idx].shares_generated >= max_shares_per_poly {
256                // O(window) element moves, but paid once per polynomial rather than once per share.
257                poly_queue.remove(idx);
258                continue;
259            }
260
261            let poly = &mut poly_queue[idx];
262            let x = S::msg_to_scalar(&poly.spi, msg)?;
263            // Zero would disclose the secret, so we disallow it.
264            // The chance is practically impossible.
265            if x.is_zero().into() {
266                return Err(errors::PixError::InvalidInput);
267            }
268
269            let generated = GeneratedShare {
270                id: poly.spi,
271                share: poly.next_share(x),
272            };
273            *cursor = (idx + 1) % window;
274            return Ok(Some(generated));
275        }
276
277        Ok(None)
278    }
279
280    /// Generates a new SSA commitment from the sender side, for the given `pseudonym`.
281    ///
282    /// Returns the new random SSA-commitment and the corresponding SSA share verifier.
283    fn new_ssa_commitment(
284        &self,
285        pseudonym: &S::Pseudonym,
286        ssa_index: SsaIndex,
287    ) -> errors::Result<SsaCommitment<S>, S::Pseudonym> {
288        let mut rng = hopr_types::crypto_random::rng();
289
290        // Generate sub-secrets for each polynomial
291        let sub_secrets = (0..self.cfg.polynomials_per_ssa)
292            .map(|_| <PixScalar<S> as Field>::random(&mut rng))
293            .collect::<Vec<_>>();
294
295        // Overall commitment secret is the sum of all sub-secrets
296        let our_commitment_secret = sub_secrets.iter().sum::<PixScalar<S>>();
297
298        #[cfg(not(feature = "rayon"))]
299        let sub_secrets_iter = sub_secrets.into_iter();
300
301        #[cfg(feature = "rayon")]
302        let sub_secrets_iter = sub_secrets.into_par_iter();
303
304        // Generate polynomial and constant-term commitment for each sub-secret
305        let (raw_polynomials, raw_commitments): (Vec<RawPolynomial<S>>, Vec<PixGroup<S>>) = sub_secrets_iter
306            .map(|secret| {
307                new_polynomial_with_commitment::<S>(
308                    secret,
309                    self.cfg.threshold as usize,
310                    hopr_types::crypto_random::rng(),
311                )
312            })
313            .collect::<errors::Result<Vec<(RawPolynomial<S>, PixGroup<S>)>, S::Pseudonym>>()?
314            .into_iter()
315            .unzip();
316
317        let mut commitments: Vec<SsaPartCommitment<S>> = Vec::with_capacity(raw_commitments.len());
318
319        self.polynomials
320            .entry_by_ref(pseudonym)
321            .and_try_compute_with(|entry| match entry {
322                None => {
323                    commitments.extend(
324                        raw_commitments
325                            .into_iter()
326                            .enumerate()
327                            .map(|(poly_index, constant_term)| SsaPartCommitment {
328                                spi: SsaPolynomialId::new(
329                                    SsaId::new(*pseudonym, ssa_index),
330                                    poly_index as PolynomialIndex,
331                                ),
332                                constant_term,
333                            }),
334                    );
335                    Ok::<_, PixError<S::Pseudonym>>(moka::ops::compute::Op::Put(std::sync::Arc::new(
336                        parking_lot::Mutex::new(SsaPseudonymEntry {
337                            ssa_index,
338                            cursor: 0,
339                            poly_queue: raw_polynomials
340                                .into_iter()
341                                .enumerate()
342                                .map(|(poly_index, raw)| IndexedPolynomial {
343                                    spi: SsaPolynomialId::new(
344                                        SsaId::new(*pseudonym, ssa_index),
345                                        poly_index as PolynomialIndex,
346                                    ),
347                                    raw,
348                                    shares_generated: 0,
349                                    t: self.cfg.threshold as usize,
350                                })
351                                .collect(),
352                        }),
353                    )))
354                }
355                Some(value) => {
356                    let value = value.into_value();
357                    {
358                        let mut entry = value.lock();
359                        if ssa_index <= entry.ssa_index {
360                            return Err(PixError::InvalidInput);
361                        }
362                        entry.ssa_index = ssa_index;
363
364                        commitments.extend(raw_commitments.into_iter().enumerate().map(
365                            |(poly_index, constant_term)| SsaPartCommitment {
366                                spi: SsaPolynomialId::new(
367                                    SsaId::new(*pseudonym, ssa_index),
368                                    poly_index as PolynomialIndex,
369                                ),
370                                constant_term,
371                            },
372                        ));
373
374                        entry
375                            .poly_queue
376                            .extend(raw_polynomials.into_iter().enumerate().map(|(poly_index, raw)| {
377                                IndexedPolynomial {
378                                    spi: SsaPolynomialId::new(
379                                        SsaId::new(*pseudonym, ssa_index),
380                                        poly_index as PolynomialIndex,
381                                    ),
382                                    raw,
383                                    shares_generated: 0,
384                                    t: self.cfg.threshold as usize,
385                                }
386                            }));
387                    }
388
389                    Ok(moka::ops::compute::Op::Nop)
390                }
391            })?;
392
393        // Built from the parameters rather than read back off `commitments[0]`: every element above
394        // was constructed with exactly this `SsaId`, and indexing would have made the whole function
395        // depend on `polynomials_per_ssa >= 1` holding — which is a validation invariant enforced
396        // three call layers away, not something visible here.
397        let ssa_id = SsaId::new(*pseudonym, ssa_index);
398        let ssa_commitment = PixGroup::<S>::generator() * our_commitment_secret;
399        Ok(SsaCommitment {
400            ssa_id,
401            ssa_commitment,
402            // Proves we know the sum of the sub-secrets. The recipient adds its own commitment to
403            // ours to get the deposit key, and this is what stops us from having chosen our half so
404            // that we — rather than nobody — know that sum. See `SsaCommitmentProof`.
405            commitment_proof: SsaCommitmentProof::prove(&ssa_id, &our_commitment_secret, &ssa_commitment)?,
406            verifiers: transposed_constant_terms(commitments),
407        })
408    }
409}
410
411/// Lays the per-polynomial constant-term commitments out in the coefficient-major form the wire
412/// messages expect.
413///
414/// The result always holds exactly one key, [`CONSTANT_TERM_COEFFICIENT`]. The map shape is kept
415/// rather than flattened to a plain `Vec` because it is what `SsaClientCommitmentMessage` splits
416/// into packets, and the wire format still admits higher coefficient indices even though PIX no
417/// longer produces any.
418pub(crate) fn transposed_constant_terms<S: PixSpec>(commitments: Vec<SsaPartCommitment<S>>) -> TransposedVerifiers<S> {
419    let mut transposed = TransposedVerifiers::<S>::new();
420    transposed.insert(
421        CONSTANT_TERM_COEFFICIENT,
422        commitments
423            .into_iter()
424            .map(|c| (c.spi.poly_index(), c.constant_term.to_bytes()))
425            .collect(),
426    );
427    transposed
428}
429
430#[cfg(test)]
431mod tests {
432    use hopr_types::{
433        crypto::{crypto_traits, prelude::Secp256k1, types::SimplePseudonym},
434        crypto_random::Randomizable,
435    };
436    use vsss_rs::ReadableShareSet;
437
438    use super::*;
439    use crate::{tests::TestSpec, traits::EntryShareGenerator};
440
441    /// The surplus is a ratio of the threshold, and the ratio is a loss-rate tolerance.
442    ///
443    /// Pinned as the tolerance rather than as four literals, because the tolerance is the property
444    /// with a physical meaning — `surplus/(threshold + surplus)` is the fraction of a polynomial's
445    /// shares that may be lost before it cannot reconstruct. A change to
446    /// `SURPLUS_LOSS_TOLERANCE_DIVISOR` has to restate what it did to that number.
447    ///
448    /// Swept over the *whole* accepted threshold range rather than the deployed multiples of four.
449    /// Restricting it to those was what let the ratio round down unnoticed: every sampled point
450    /// divided exactly, so integer division and the intended ratio agreed on all of them and
451    /// disagreed on everything else.
452    #[test]
453    fn the_default_surplus_covers_a_fifth_of_a_polynomial_being_lost() {
454        for threshold in MIN_POLY_THRESHOLD..=MAX_POLY_THRESHOLD {
455            let surplus = crate::default_surplus_for(threshold);
456            let emitted = threshold as f64 + surplus as f64;
457            let tolerated = surplus as f64 / emitted;
458
459            // A floor, not an approximation: the surplus may over-cover, never under-cover.
460            assert!(
461                tolerated >= 0.20,
462                "threshold {threshold} + surplus {surplus} tolerates only {tolerated:.4} loss"
463            );
464            // And it over-covers by less than one share, which is what makes rounding up cheap.
465            assert!(
466                surplus as f64 - 1.0 < threshold as f64 / crate::SURPLUS_LOSS_TOLERANCE_DIVISOR as f64,
467                "threshold {threshold} buys {surplus} surplus shares, more than one above the ratio"
468            );
469            // Zero surplus is zero loss tolerance. Rounding down produced it at thresholds 2 and 3.
470            assert!(surplus > 0, "threshold {threshold} derives no surplus at all");
471        }
472
473        // Where the threshold divides exactly the tolerance is the documented 20 % on the nose, and
474        // the deployed threshold is one of those.
475        for threshold in [16u8, 32, 48, 64] {
476            let surplus = crate::default_surplus_for(threshold);
477            let tolerated = surplus as f64 / (threshold as f64 + surplus as f64);
478            assert!(
479                (0.19..=0.21).contains(&tolerated),
480                "threshold {threshold} + surplus {surplus} tolerates {tolerated:.3} loss, expected ~0.20"
481            );
482        }
483
484        assert_eq!(
485            DEFAULT_SURPLUS_SHARES,
486            crate::default_surplus_for(DEFAULT_POLY_THRESHOLD),
487            "the constant must stay the ratio evaluated at the default threshold, not drift from it"
488        );
489    }
490
491    /// A derived surplus must never fail the validator it is derived under.
492    ///
493    /// `default_surplus_for` rounds up and `surplus_must_not_exceed_threshold` bounds the surplus by
494    /// the threshold, so the two meet at the smallest threshold there is: at
495    /// [`MIN_POLY_THRESHOLD`] the derived surplus is 1 against a threshold of 2. Any further
496    /// rounding-up would collide with the bound rather than merely over-insure.
497    #[test]
498    fn every_derived_surplus_passes_the_bound_it_is_derived_under() {
499        for threshold in MIN_POLY_THRESHOLD..=MAX_POLY_THRESHOLD {
500            let cfg = SsaGeneratorConfig {
501                polynomials_per_ssa: 16,
502                threshold,
503                surplus_shares: crate::default_surplus_for(threshold),
504            };
505            assert!(
506                cfg.validate().is_ok(),
507                "the surplus derived for threshold {threshold} fails its own validator"
508            );
509        }
510    }
511
512    /// A surplus above the threshold pays for more redundancy than payload, and is billed for it.
513    ///
514    /// The boundary rather than an arbitrary over-large value: the rule is exactly "not more than
515    /// the thing it insures", so the interesting cases are on either side of it. A flat surplus of
516    /// 20 — what deployments used before this became a ratio — is what fails at threshold 16.
517    #[test]
518    fn a_surplus_larger_than_the_threshold_is_rejected() {
519        let at_bound = SsaGeneratorConfig {
520            polynomials_per_ssa: 16,
521            threshold: 16,
522            surplus_shares: 16,
523        };
524        assert!(
525            SsaShareGenerator::<TestSpec>::try_new(at_bound).is_ok(),
526            "a surplus equal to the threshold must be allowed — over-insuring a lossy path is a real choice"
527        );
528
529        let past_bound = SsaGeneratorConfig {
530            surplus_shares: 17,
531            ..at_bound
532        };
533        assert!(matches!(
534            SsaShareGenerator::<TestSpec>::try_new(past_bound),
535            Err(PixError::InvalidConfiguration(_))
536        ));
537
538        let flat_twenty_at_low_threshold = SsaGeneratorConfig {
539            surplus_shares: 20,
540            ..at_bound
541        };
542        assert!(
543            SsaShareGenerator::<TestSpec>::try_new(flat_twenty_at_low_threshold).is_err(),
544            "the configuration this rule exists to catch: 20 shares of insurance against 16 of payload"
545        );
546    }
547
548    #[test]
549    fn ssa_generator_try_new_should_reject_an_invalid_config_without_panicking() {
550        // Zero polynomials is the case the rest of the generator quietly relies on being impossible
551        // — `new_ssa_commitment` builds its `SsaId` from the parameters precisely so that it does
552        // not have to index into an empty commitment vector.
553        let cfg = SsaGeneratorConfig {
554            polynomials_per_ssa: 0,
555            threshold: 10,
556            surplus_shares: 2,
557        };
558
559        assert!(matches!(
560            SsaShareGenerator::<TestSpec>::try_new(cfg),
561            Err(PixError::InvalidConfiguration(_))
562        ));
563    }
564
565    #[test]
566    #[should_panic(expected = "invalid SsaGeneratorConfig")]
567    fn ssa_generator_new_should_still_panic_on_an_invalid_config() {
568        // `new` stays panicking on purpose: it is what the benches and tests use, where a bad
569        // constant should abort rather than be threaded through a `Result`.
570        let _ = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
571            polynomials_per_ssa: 0,
572            threshold: 10,
573            surplus_shares: 2,
574        });
575    }
576
577    #[test]
578    fn ssa_generator_should_generate_consecutive_spis() -> anyhow::Result<()> {
579        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
580            polynomials_per_ssa: 10,
581            threshold: 10,
582            surplus_shares: 2,
583        });
584
585        let p1 = SimplePseudonym::random();
586        let c = generator.new_ssa_commitment(&p1, 1.try_into()?)?;
587        assert_eq!(c.ssa_id.pseudonym(), &p1);
588        assert_eq!(c.ssa_id.ssa_index(), 1.try_into()?);
589
590        let c = generator.new_ssa_commitment(&p1, 2.try_into()?)?;
591        assert_eq!(c.ssa_id.pseudonym(), &p1);
592        assert_eq!(c.ssa_id.ssa_index(), 2.try_into()?);
593
594        let p2 = SimplePseudonym::random();
595        let c = generator.new_ssa_commitment(&p2, 1.try_into()?)?;
596        assert_eq!(c.ssa_id.pseudonym(), &p2);
597        assert_eq!(c.ssa_id.ssa_index(), 1.try_into()?);
598
599        let c = generator.new_ssa_commitment(&p1, 3.try_into()?)?;
600        assert_eq!(c.ssa_id.pseudonym(), &p1);
601        assert_eq!(c.ssa_id.ssa_index(), 3.try_into()?);
602
603        let c = generator.new_ssa_commitment(&p2, 2.try_into()?)?;
604        assert_eq!(c.ssa_id.pseudonym(), &p2);
605        assert_eq!(c.ssa_id.ssa_index(), 2.try_into()?);
606
607        // Repeated SSA index
608        assert!(generator.new_ssa_commitment(&p2, 2.try_into()?).is_err());
609
610        Ok(())
611    }
612
613    /// With fewer polynomials than [`SHARE_EMISSION_WINDOW`] the whole SSA is one window, so
614    /// emission cycles through every polynomial before returning to any of them.
615    #[test]
616    fn ssa_generator_should_round_robin_within_the_emission_window() -> anyhow::Result<()> {
617        let generator = SsaShareGenerator::<TestSpec>::new(SsaGeneratorConfig {
618            polynomials_per_ssa: 3,
619            threshold: 3,
620            surplus_shares: 1,
621        });
622
623        let p1 = SimplePseudonym::random();
624        generator.new_ssa_commitment(&p1, 1.try_into()?)?;
625
626        for i in 0..12_u16 {
627            let g = generator
628                .next_share(&p1, &i.to_be_bytes())?
629                .ok_or(anyhow::anyhow!("failed to generate share"))?;
630            assert_eq!(g.id.pseudonym(), &p1);
631            assert_eq!(g.id.ssa_index(), 1.try_into()?);
632            assert_eq!(g.id.poly_index(), i % 3);
633        }
634        assert!(generator.next_share(&p1, &20_u32.to_be_bytes())?.is_none());
635
636        // A new cycle is appended, and the window does not reach into it until the previous one is
637        // fully emitted — so indices restart from the beginning of the new SSA.
638        generator.new_ssa_commitment(&p1, 2.try_into()?)?;
639
640        for i in 0..12_u16 {
641            let g = generator
642                .next_share(&p1, &i.to_be_bytes())?
643                .ok_or(anyhow::anyhow!("failed to generate share"))?;
644            assert_eq!(g.id.pseudonym(), &p1);
645            assert_eq!(g.id.ssa_index(), 2.try_into()?);
646            assert_eq!(g.id.poly_index(), i % 3);
647        }
648        assert!(generator.next_share(&p1, &20_u32.to_be_bytes())?.is_none());
649
650        Ok(())
651    }
652
653    /// The property that actually protects an SSA cycle from SURB ring-buffer eviction.
654    ///
655    /// Evictions take a *contiguous* run of the emission order, and a polynomial dies if it loses
656    /// more than `surplus_shares`. Spreading every run across the window is what keeps a burst from
657    /// concentrating on one polynomial — with more polynomials than the window, any run of `n`
658    /// shares must touch at least `min(n, window)` distinct ones.
659    #[test]
660    fn contiguous_runs_must_spread_across_the_emission_window() -> anyhow::Result<()> {
661        // Deliberately more polynomials than the window, so the window is the binding constraint.
662        let polynomials_per_ssa = (SHARE_EMISSION_WINDOW * 2) as u16;
663        let cfg = SsaGeneratorConfig {
664            polynomials_per_ssa,
665            threshold: 2,
666            surplus_shares: 1,
667        };
668        let generator = SsaShareGenerator::<TestSpec>::new(cfg);
669
670        let p = SimplePseudonym::random();
671        generator.new_ssa_commitment(&p, 1.try_into()?)?;
672
673        let mut emitted = Vec::new();
674        for i in 0..(polynomials_per_ssa as usize * 3) {
675            let g = generator
676                .next_share(&p, &(i as u32).to_be_bytes())?
677                .ok_or(anyhow::anyhow!("failed to generate share"))?;
678            emitted.push(g.id.poly_index());
679        }
680        assert!(generator.next_share(&p, &u32::MAX.to_be_bytes())?.is_none());
681
682        for run in [2_usize, 17, SHARE_EMISSION_WINDOW] {
683            for window_start in (0..emitted.len().saturating_sub(run)).step_by(run.max(1)) {
684                let distinct = emitted[window_start..window_start + run]
685                    .iter()
686                    .collect::<std::collections::HashSet<_>>()
687                    .len();
688                assert_eq!(
689                    run, distinct,
690                    "a contiguous run of {run} shares starting at {window_start} hit only {distinct} distinct \
691                     polynomials; an eviction of that run would concentrate on too few of them"
692                );
693            }
694        }
695
696        // Every polynomial still receives exactly `threshold + surplus` shares.
697        let mut per_poly = std::collections::HashMap::new();
698        for poly_index in &emitted {
699            *per_poly.entry(*poly_index).or_insert(0_usize) += 1;
700        }
701        assert_eq!(polynomials_per_ssa as usize, per_poly.len());
702        assert!(
703            per_poly
704                .values()
705                .all(|n| *n == cfg.threshold as usize + cfg.surplus_shares as usize)
706        );
707
708        Ok(())
709    }
710
711    /// Every polynomial's shares must interpolate back to the constant term the generator
712    /// committed to — the single check the Exit performs, in place of the per-share Feldman
713    /// verification that used to run `threshold` scalar multiplications per share.
714    #[test]
715    fn ssa_generator_parts_must_open_their_commitments() -> anyhow::Result<()> {
716        let cfg = SsaGeneratorConfig {
717            polynomials_per_ssa: 10,
718            threshold: 10,
719            surplus_shares: 2,
720        };
721        let generator = SsaShareGenerator::<TestSpec>::new(cfg);
722
723        assert_eq!(&cfg, generator.config());
724
725        let p = SimplePseudonym::random();
726        let c = generator.new_ssa_commitment(&p, 1.try_into()?)?;
727        let commitments = c.reconstruct_part_commitments().map_err(anyhow::Error::msg)?;
728        assert_eq!(cfg.polynomials_per_ssa as usize, commitments.len());
729
730        // Shares are emitted round-robin across the window, so they are grouped by polynomial here
731        // rather than assumed to arrive in consecutive runs.
732        let by_poly = drain_shares_by_polynomial(&generator, &p, &cfg)?;
733        assert_eq!(cfg.polynomials_per_ssa as usize, by_poly.len());
734
735        for commitment in &commitments {
736            let shares = by_poly
737                .get(&commitment.spi().poly_index())
738                .ok_or(anyhow::anyhow!("no shares for polynomial"))?;
739            assert_eq!(cfg.threshold as usize + cfg.surplus_shares as usize, shares.len());
740
741            // Only `threshold` shares are needed; the surplus stands in for any that are lost.
742            let reconstructed = shares[..cfg.threshold as usize]
743                .to_vec()
744                .combine()
745                .map_err(anyhow::Error::msg)?
746                .0;
747            assert!(
748                commitment.verify_reconstructed(&reconstructed),
749                "polynomial {} did not open its commitment",
750                commitment.spi().poly_index()
751            );
752        }
753
754        Ok(())
755    }
756
757    #[test]
758    fn ssa_generator_corresponds_to_standard_recoverer() -> anyhow::Result<()> {
759        let cfg = SsaGeneratorConfig {
760            polynomials_per_ssa: 10,
761            threshold: 10,
762            surplus_shares: 2,
763        };
764        let generator = SsaShareGenerator::<TestSpec>::new(cfg);
765
766        let p = SimplePseudonym::random();
767        let c = generator.new_ssa_commitment(&p, 1.try_into()?)?;
768        let orig_commitment = c.ssa_commitment;
769
770        let by_poly = drain_shares_by_polynomial(&generator, &p, &cfg)?;
771        assert_eq!(cfg.polynomials_per_ssa as usize, by_poly.len());
772
773        let mut recovered_secret = crypto_traits::elliptic_curve::Scalar::<Secp256k1>::default();
774        for shares in by_poly.values() {
775            recovered_secret += shares[..cfg.threshold as usize]
776                .to_vec()
777                .combine()
778                .map_err(anyhow::Error::msg)?
779                .0;
780        }
781
782        assert_eq!(
783            orig_commitment.to_affine(),
784            (crypto_traits::elliptic_curve::ProjectivePoint::<Secp256k1>::GENERATOR * recovered_secret).to_affine()
785        );
786
787        Ok(())
788    }
789
790    /// Turns a generated share plus the nonce it was derived from into the `(x, y)` pair the
791    /// interpolation consumes, exactly as the reconstructor does.
792    /// Drains the generator and buckets every share by its polynomial index.
793    ///
794    /// Emission is round-robin across [`SHARE_EMISSION_WINDOW`], so a polynomial's shares are not
795    /// contiguous in the output stream; anything reconstructing a polynomial has to group first.
796    /// Within a bucket the order is preserved, which is what lets a caller take the first
797    /// `threshold` and treat the rest as surplus.
798    #[allow(clippy::type_complexity)]
799    fn drain_shares_by_polynomial(
800        generator: &SsaShareGenerator<TestSpec>,
801        p: &SimplePseudonym,
802        cfg: &SsaGeneratorConfig,
803    ) -> anyhow::Result<std::collections::BTreeMap<PolynomialIndex, Vec<crate::CompletedShare<TestSpec>>>> {
804        let expected = cfg.polynomials_per_ssa as usize * (cfg.threshold as usize + cfg.surplus_shares as usize);
805        let mut by_poly: std::collections::BTreeMap<PolynomialIndex, Vec<_>> = std::collections::BTreeMap::new();
806
807        for _ in 0..expected {
808            let x = hopr_types::crypto_random::random_bytes::<10>();
809            let g = generator
810                .next_share(p, &x)?
811                .ok_or(anyhow::anyhow!("failed to generate share"))?;
812            by_poly
813                .entry(g.id.poly_index())
814                .or_default()
815                .push(completed_share(&g, &x)?);
816        }
817        // The generator must be exhausted after exactly the expected number of shares.
818        anyhow::ensure!(
819            generator.next_share(p, &u32::MAX.to_be_bytes())?.is_none(),
820            "generator emitted more shares than the configured dimensions allow"
821        );
822
823        Ok(by_poly)
824    }
825
826    fn completed_share(
827        g: &GeneratedShare<TestSpec>,
828        x: &impl AsRef<[u8]>,
829    ) -> anyhow::Result<crate::CompletedShare<TestSpec>> {
830        Ok(DefaultShare {
831            identifier: TestSpec::msg_to_scalar(&g.id, x)?.into(),
832            value: Option::from(crypto_traits::elliptic_curve::Scalar::<Secp256k1>::from_repr(g.share.0))
833                .map(|s: PixScalar<TestSpec>| s.into())
834                .ok_or(anyhow::anyhow!("share is not a field element"))?,
835        })
836    }
837}