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}