hopr_protocol_pix/reconstructor/utils.rs
1use vsss_rs::{
2 ReadableShareSet, Share, ShareElement,
3 elliptic_curve::group::{Group, GroupEncoding},
4};
5
6use crate::{
7 CONSTANT_TERM_COEFFICIENT, CoefficientIndex, CompletedShare, PartialSsaShare, PixGroup, PixGroupRepr, PixParams,
8 PixScalar, PixSpec, PolynomialIndex, SsaCommitmentProof, SsaIndex, SsaPartCommitment, SsaPolynomialId,
9 SsaRecoveryProgress, errors, into_completed_share, types::SsaId,
10};
11
12/// Relaxed ordering suffices for every progress counter in [`SsaCycle`].
13///
14/// The counters are pure telemetry: they are never read to decide whether a share may be applied,
15/// and the [supervisor](SsaRecoveryProgress) that consumes them keeps its own monotonic maximum and
16/// treats a stale snapshot as benign. So no counter needs to be ordered against the reconstruction
17/// state it describes, and a snapshot taken while a concurrent batch is mid-flight is allowed to
18/// straddle it.
19const PROGRESS_ORDERING: std::sync::atomic::Ordering = std::sync::atomic::Ordering::Relaxed;
20
21/// Progress counters whose lifetime can outlast the reconstruction buffers that produced them.
22///
23/// Full recovery releases the accumulator and every polynomial builder immediately, but shares are
24/// bound into SURBs before that point and can still be waiting in the Exit's FIFO buffer. Keeping
25/// only these counters lets those already-paid shares report liveness without retaining any secret
26/// reconstruction material.
27struct RecoveryCounters {
28 useful_shares: std::sync::atomic::AtomicU64,
29 shares_seen: std::sync::atomic::AtomicU64,
30 recovered_polynomials: std::sync::atomic::AtomicU32,
31 invalid_shares: std::sync::atomic::AtomicU64,
32 target_useful_shares: u64,
33}
34
35impl RecoveryCounters {
36 fn new(target_useful_shares: u64) -> Self {
37 Self {
38 useful_shares: Default::default(),
39 shares_seen: Default::default(),
40 recovered_polynomials: Default::default(),
41 invalid_shares: Default::default(),
42 target_useful_shares,
43 }
44 }
45
46 fn record_useful_share(&self) {
47 self.shares_seen.fetch_add(1, PROGRESS_ORDERING);
48 self.useful_shares.fetch_add(1, PROGRESS_ORDERING);
49 }
50
51 fn record_surplus_share(&self) {
52 self.shares_seen.fetch_add(1, PROGRESS_ORDERING);
53 }
54
55 fn record_completed_part(&self) {
56 self.recovered_polynomials.fetch_add(1, PROGRESS_ORDERING);
57 }
58
59 fn record_invalid_share(&self) -> u64 {
60 self.invalid_shares.fetch_add(1, PROGRESS_ORDERING) + 1
61 }
62
63 fn snapshot<P>(&self, ssa_id: SsaId<P>) -> SsaRecoveryProgress<P> {
64 let useful_shares = self.useful_shares.load(PROGRESS_ORDERING);
65 SsaRecoveryProgress {
66 ssa_id,
67 useful_shares,
68 shares_seen: self.shares_seen.load(PROGRESS_ORDERING).max(useful_shares),
69 target_useful_shares: self.target_useful_shares,
70 recovered_polynomials: self.recovered_polynomials.load(PROGRESS_ORDERING).min(u16::MAX as u32) as u16,
71 }
72 }
73}
74
75/// Per-polynomial post-reconstruction allowance shared by a live cycle and its compact recovered
76/// tail.
77///
78/// The shared atomics close the retirement race: an acknowledgement that acquired the live cycle
79/// just before recovery and one that acquired the recovered tail just after it contend for the same
80/// slot. Snapshotting ordinary integers at retirement would let that race credit one surplus share
81/// twice.
82#[derive(Clone)]
83struct SurplusBudget {
84 seen: std::sync::Arc<[std::sync::atomic::AtomicUsize]>,
85 max_per_polynomial: usize,
86}
87
88impl SurplusBudget {
89 fn new(num_polys: usize, max_per_polynomial: usize) -> Self {
90 let seen = (0..num_polys)
91 .map(|_| std::sync::atomic::AtomicUsize::new(0))
92 .collect::<Vec<_>>();
93 Self {
94 seen: seen.into(),
95 max_per_polynomial,
96 }
97 }
98
99 fn try_credit(&self, poly_index: PolynomialIndex) -> Option<bool> {
100 let seen = self.seen.get(poly_index as usize)?;
101 Some(
102 seen.fetch_update(PROGRESS_ORDERING, PROGRESS_ORDERING, |current| {
103 (current < self.max_per_polynomial).then_some(current + 1)
104 })
105 .is_ok(),
106 )
107 }
108}
109
110/// Result of validating a share against a recovered cycle's compact tail.
111pub(crate) enum RecoveredTailCredit<P> {
112 /// A structurally valid share consumed one negotiated per-polynomial surplus slot.
113 Credited(SsaRecoveryProgress<P>),
114 /// The polynomial has already consumed every surplus slot the Entry paid for.
115 OverBudget,
116 /// The peer named a polynomial outside the recovered cycle.
117 InvalidPolynomial,
118 /// A structurally invalid share, with the cycle's aggregate fault total.
119 InvalidShare(u64),
120}
121
122/// Minimal state retained after cryptographic recovery so buffered, already-paid SURBs can finish
123/// draining.
124///
125/// There is no polynomial, commitment, share set, or recovered secret here. The only mutable state
126/// is an absolute progress counter and one bounded counter per polynomial. The latter is
127/// security-critical: a cycle-wide allowance would let an Entry replay one completed polynomial and
128/// spend the surplus budget belonging to every other polynomial.
129///
130/// The polynomial commitment is deliberately not retained, so this path cannot prove that a new
131/// evaluation belongs to the recovered polynomial. It therefore grants **liveness only**, never
132/// useful/payment progress, and only from the per-polynomial allowance already included in the
133/// cycle's paid quota. The supervisor independently caps the aggregate at the negotiated full-cycle
134/// volume. Those bounds, rather than an authentication claim this compact state cannot make, are
135/// what prevent an Entry-selected non-zero evaluation from buying unbounded service.
136#[derive(Clone)]
137pub(crate) struct RecoveredSsaTail {
138 ssa_index: SsaIndex,
139 num_polys: usize,
140 counters: std::sync::Arc<RecoveryCounters>,
141 surplus_budget: SurplusBudget,
142}
143
144impl RecoveredSsaTail {
145 pub(crate) fn ssa_index(&self) -> SsaIndex {
146 self.ssa_index
147 }
148
149 pub(crate) fn credit_share<S: PixSpec>(
150 &self,
151 ssa_id: SsaId<S::Pseudonym>,
152 poly_index: PolynomialIndex,
153 msg: PixScalar<S>,
154 share: &PartialSsaShare<S>,
155 ) -> errors::Result<RecoveredTailCredit<S::Pseudonym>, S::Pseudonym> {
156 if poly_index as usize >= self.num_polys {
157 return Ok(RecoveredTailCredit::InvalidPolynomial);
158 }
159
160 // Validate before touching the budget. A malformed share must neither become liveness nor
161 // burn a slot that a later conforming buffered SURB is entitled to use.
162 match checked_completed_share(msg, share) {
163 Ok(_) => {}
164 Err(errors::PixError::VsssError(vsss_rs::Error::InvalidShare)) => {
165 return Ok(RecoveredTailCredit::InvalidShare(self.counters.record_invalid_share()));
166 }
167 Err(error) => return Err(error),
168 }
169
170 match self.surplus_budget.try_credit(poly_index) {
171 Some(true) => {
172 self.counters.record_surplus_share();
173 Ok(RecoveredTailCredit::Credited(self.counters.snapshot(ssa_id)))
174 }
175 Some(false) => Ok(RecoveredTailCredit::OverBudget),
176 None => Ok(RecoveredTailCredit::InvalidPolynomial),
177 }
178 }
179}
180
181/// All post-commitment state of one SSA cycle, held as a single unit.
182///
183/// ## Why one entry per cycle rather than one per polynomial
184///
185/// The accumulator and every part builder are produced by the same call — see
186/// [`CommitmentProgress`] — needed by the same code path, and dead at the same moment. Splitting
187/// them across caches previously required holding three lifetimes in lockstep by hand, and getting
188/// that wrong was **H8**: the part builders were keyed per polynomial with an idle timer, so the
189/// clock measured "time since a share for *this* polynomial arrived". Commitments land in a cycle's
190/// opening moments while shares arrive polynomial-major across all of it, so any polynomial late in
191/// the emission order had its builder reclaimed before its first share — unrecoverably, since the
192/// commitment cannot be retransmitted.
193///
194/// Keyed by [`SsaId`], the idle timer measures "time since the *cycle* was active", which is the
195/// property that actually matters and is correct at any line rate.
196///
197/// ## Locking
198///
199/// One mutex **per part**, plus one for the accumulator — never one around the whole cycle. A
200/// single cycle-wide lock would serialise every share of a Session behind one mutex. Callers take
201/// the part lock and the accumulator lock in that order, and never hold both.
202pub struct SsaCycle<S: PixSpec> {
203 id: SsaId<S::Pseudonym>,
204 builder: parking_lot::Mutex<SsaBuilder<S>>,
205 /// Part builders indexed by [`PolynomialIndex`]; length is always `num_polys`.
206 parts: Box<[parking_lot::Mutex<SsaPartBuilder<S>>]>,
207 /// Shared payment, liveness, completed-part, and fault counters. They remain reachable from
208 /// [`RecoveredSsaTail`] after the heavyweight cycle is released.
209 counters: std::sync::Arc<RecoveryCounters>,
210 /// Per-polynomial surplus accounting shared with the compact post-recovery tail.
211 surplus_budget: SurplusBudget,
212}
213
214impl<S: PixSpec> SsaCycle<S> {
215 /// Assembles a cycle from the accumulator and the full set of part builders.
216 ///
217 /// `parts` arrives in arbitrary order — it is drained from a `HashMap` — so each builder is
218 /// placed by its own polynomial index rather than by iteration order. A missing or duplicated
219 /// index is rejected here, which is what makes [`part`](Self::part) safe to index by an
220 /// untrusted value later. Each builder is also checked to belong to `id`, so a part cannot be
221 /// filed under a cycle it does not describe.
222 pub fn new(
223 id: SsaId<S::Pseudonym>,
224 builder: SsaBuilder<S>,
225 parts: Vec<SsaPartBuilder<S>>,
226 ) -> errors::Result<Self, S::Pseudonym> {
227 let num_polys = builder.num_polys();
228 let mut slots: Vec<Option<SsaPartBuilder<S>>> = (0..num_polys).map(|_| None).collect();
229
230 for part in parts {
231 let spi = part.spi();
232 if spi.as_ref() != &id {
233 return Err(errors::PixError::InvalidInput);
234 }
235 let slot = slots
236 .get_mut(spi.poly_index() as usize)
237 .ok_or(errors::PixError::InvalidInput)?;
238 if slot.replace(part).is_some() {
239 return Err(errors::PixError::DuplicateCommitment);
240 }
241 }
242
243 let parts = slots
244 .into_iter()
245 .map(|slot| slot.ok_or(errors::PixError::InvalidInput))
246 .collect::<errors::Result<Vec<_>, S::Pseudonym>>()?;
247
248 // Taken from the negotiated `PixParams` by way of the builder, not recomputed here. This
249 // number reaches `SsaRecoveryProgress::target_useful_shares`, which a consumer compares
250 // against its own expectation to detect a protocol violation — so a second derivation of it
251 // is a second thing that can disagree, and the disagreement would read as the violation.
252 let target_useful_shares = builder.target_useful_shares();
253
254 // A cycle with no polynomials can never recover, so it is refused here rather than built and
255 // left to fail later. `PixParams::try_new` rejects `polys_per_ssa == 0`, which is what makes
256 // this unreachable through the commitment path — but both this constructor and
257 // `SsaBuilder::new` are public, so the guard is what makes that a property of this type
258 // rather than of its current callers.
259 //
260 // A guard and not a `?` inside the assertion below: `debug_assert_eq!` compiles to nothing
261 // when `debug_assertions` is off, so anything fallible inside it is control flow present in
262 // one profile and absent in the other. This runs in both.
263 let Some(first_part) = parts.first() else {
264 return Err(errors::PixError::InvalidInput);
265 };
266
267 let max_credited_surplus = first_part.max_credited_surplus();
268 if parts
269 .iter()
270 .any(|part| part.max_credited_surplus() != max_credited_surplus)
271 {
272 return Err(errors::PixError::InvalidInput);
273 }
274
275 // The old derivation, kept as a cross-check rather than deleted: every part builder was
276 // handed the same `SsaCommitmentBuilder::poly_threshold`, so any of them reports the
277 // negotiated threshold, and the product must match what the params said. Debug-only because
278 // this is an internal consistency claim about two paths out of one `PixParams`, not
279 // untrusted input — a release build has nothing to gain by re-checking it, and a test build
280 // fails loudly if the two ever part company.
281 debug_assert_eq!(
282 target_useful_shares,
283 num_polys as u64 * first_part.min_shares() as u64,
284 "the params-derived recovery target disagrees with the part builders' own threshold"
285 );
286
287 Ok(Self {
288 id,
289 builder: parking_lot::Mutex::new(builder),
290 parts: parts.into_iter().map(parking_lot::Mutex::new).collect(),
291 counters: std::sync::Arc::new(RecoveryCounters::new(target_useful_shares)),
292 surplus_budget: SurplusBudget::new(num_polys, max_credited_surplus),
293 })
294 }
295
296 /// Number of polynomials this cycle is composed of.
297 pub fn num_polys(&self) -> usize {
298 self.parts.len()
299 }
300
301 /// Records a share that advanced reconstruction — both a useful share and a share seen.
302 ///
303 /// `shares_seen` first, so that in program order the counters never pass through a state where
304 /// `useful_shares` leads. [`SsaRecoveryProgress::shares_seen`] promises to be at least
305 /// `useful_shares`, and a consumer computing the surplus as the difference would otherwise
306 /// underflow a `u64` on the intermediate state. The two are separate `Relaxed` atomics, so this
307 /// alone does not settle what another thread observes — [`progress`](Self::progress) clamps for
308 /// that — but it removes the window rather than only papering over it.
309 pub fn record_useful_share(&self) {
310 self.counters.record_useful_share();
311 }
312
313 /// Records a share that arrived for an already-reconstructed polynomial.
314 ///
315 /// Liveness only: the surplus a conforming Entry emits advances nothing, but it is still evidence
316 /// that the Entry is serving this cycle, which is what the Exit's egress gate and recovery-idle
317 /// deadline are asking about.
318 pub fn record_surplus_share(&self) {
319 self.counters.record_surplus_share();
320 }
321
322 /// Records a polynomial part that reconstructed and opened its commitment.
323 pub fn record_completed_part(&self) {
324 self.counters.record_completed_part();
325 }
326
327 /// Records a share that failed verification, returning the cycle's total afterwards.
328 pub fn record_invalid_share(&self) -> u64 {
329 self.counters.record_invalid_share()
330 }
331
332 /// Shares that have failed verification for this cycle so far, across all peers.
333 #[cfg(test)]
334 pub fn invalid_shares(&self) -> u64 {
335 self.counters.invalid_shares.load(PROGRESS_ORDERING)
336 }
337
338 /// Absolute recovery progress for this cycle.
339 ///
340 /// The two share counters are independent `Relaxed` atomics, so a reader can observe them in
341 /// either order regardless of the order they were written in. `useful_shares` is loaded first and
342 /// `shares_seen` clamped up to it, which is what actually makes
343 /// [`SsaRecoveryProgress::shares_seen`]'s "always at least `useful_shares`" a contract rather
344 /// than a description: a consumer deriving the surplus as the difference cannot underflow. The
345 /// clamp is engaged only for the instant a concurrent [`record_useful_share`](Self::record_useful_share)
346 /// is mid-flight, and overstates `shares_seen` by at most the number of such writers.
347 pub fn progress(&self) -> SsaRecoveryProgress<S::Pseudonym> {
348 self.counters.snapshot(self.id)
349 }
350
351 /// Consumes one negotiated surplus slot for `poly_index`.
352 ///
353 /// Called only after [`SsaPartBuilder::add_share`] has decoded and validated the share as a
354 /// post-reconstruction candidate. `None` means the untrusted polynomial index is out of range;
355 /// `Some(false)` means the Entry has exhausted this polynomial's own budget.
356 pub fn credit_surplus(&self, poly_index: PolynomialIndex) -> Option<bool> {
357 self.surplus_budget.try_credit(poly_index)
358 }
359
360 /// Produces the compact state that survives full recovery while buffered SURBs drain.
361 pub(crate) fn recovered_tail(&self) -> RecoveredSsaTail {
362 RecoveredSsaTail {
363 ssa_index: self.id.ssa_index(),
364 num_polys: self.parts.len(),
365 counters: self.counters.clone(),
366 surplus_budget: self.surplus_budget.clone(),
367 }
368 }
369
370 /// The part builder for one polynomial, or `None` if the index is out of range.
371 ///
372 /// The index originates from a peer-supplied share, so this is a checked lookup and must stay
373 /// one.
374 pub fn part(&self, poly_index: PolynomialIndex) -> Option<&parking_lot::Mutex<SsaPartBuilder<S>>> {
375 self.parts.get(poly_index as usize)
376 }
377
378 /// The accumulator that sums recovered parts into the SSA scalar.
379 pub fn builder(&self) -> &parking_lot::Mutex<SsaBuilder<S>> {
380 &self.builder
381 }
382}
383
384/// Reconstruct a single SSA from a set of SSA parts recovered from polynomials.
385pub struct SsaBuilder<S: PixSpec> {
386 pub full_commitment: PixGroup<S>,
387 num_polys: usize,
388 /// Carried from the negotiated [`PixParams`] rather than recomputed. See
389 /// [`SsaCommitmentBuilder::target_useful_shares`].
390 target_useful_shares: u64,
391 builder: PixScalar<S>,
392 received_indices: ahash::HashSet<PolynomialIndex>,
393 early_notified: bool,
394}
395
396impl<S: PixSpec> SsaBuilder<S> {
397 pub fn new(
398 full_commitment: PixGroup<S>,
399 exit_secret_scalar: PixScalar<S>,
400 num_polys: usize,
401 target_useful_shares: u64,
402 ) -> Self {
403 use ahash::HashSetExt;
404
405 Self {
406 full_commitment,
407 builder: exit_secret_scalar,
408 num_polys,
409 target_useful_shares,
410 received_indices: ahash::HashSet::with_capacity(num_polys),
411 early_notified: false,
412 }
413 }
414
415 /// Useful shares that constitute full recovery, as negotiated.
416 pub fn target_useful_shares(&self) -> u64 {
417 self.target_useful_shares
418 }
419
420 /// Number of polynomials this SSA is composed of.
421 pub fn num_polys(&self) -> usize {
422 self.num_polys
423 }
424
425 /// Returns `true` once, when the number of received polynomial parts reaches
426 /// `ceil(threshold * num_polys)` for the first time. Subsequent calls return
427 /// `false` (idempotent guard — fires at most once per SSA lifecycle).
428 ///
429 /// `threshold` is assumed finite, which
430 /// [`validate_early_recovery_threshold`](super::validate_early_recovery_threshold) is what makes
431 /// true — `SsaReconstructorConfig` is the only source of it, and its constructor validates. The
432 /// cast below is why that matters rather than being a formality: `NaN` survives the multiply and
433 /// the `ceil`, then saturates to `0`, so an unvalidated threshold fires this on the first part
434 /// instead of the configured fraction of them.
435 pub fn check_early_threshold(&mut self, threshold: f64) -> bool {
436 if self.early_notified {
437 return false;
438 }
439 let needed = (threshold * self.num_polys as f64).ceil() as usize;
440 if self.received_indices.len() >= needed {
441 self.early_notified = true;
442 true
443 } else {
444 false
445 }
446 }
447
448 pub fn add_recovered_ssa_part(
449 &mut self,
450 index: PolynomialIndex,
451 sub_secret: PixScalar<S>,
452 ) -> errors::Result<Option<PixScalar<S>>, S::Pseudonym> {
453 if !self.received_indices.insert(index) {
454 return Ok(None);
455 }
456
457 self.builder += sub_secret;
458
459 if self.received_indices.len() < self.num_polys {
460 // SSA private scalar is not yet complete
461 return Ok(None);
462 }
463
464 // This is computed only once when we have all the polynomials reconstructed
465 if self.full_commitment == (PixGroup::<S>::generator() * self.builder) {
466 self.early_notified = true;
467 Ok(Some(self.builder))
468 } else {
469 Err(errors::PixError::InvalidSsa)
470 }
471 }
472}
473
474/// What a share contributed to its polynomial.
475///
476/// The non-contributing outcomes are kept apart because they are not equivalent to a caller
477/// counting progress: a `Duplicate` says the peer re-sent an evaluation point, a `Surplus` is a
478/// structurally valid candidate for the cycle's bounded post-reconstruction allowance, and
479/// `Absorbed` says the polynomial has already failed and nothing can change that.
480pub enum AddShareOutcome<S: PixSpec> {
481 /// Same evaluation identifier as a share already collected for this polynomial.
482 Duplicate,
483 /// Arrived after the polynomial was already reconstructed and passed structural validation.
484 /// The cycle's shared per-polynomial counter decides whether budget remains.
485 Surplus,
486 /// Arrived after the polynomial failed to open its commitment, and was discarded.
487 Absorbed,
488 /// New and distinct, but the threshold is not reached yet.
489 Useful,
490 /// Reached the threshold; the constant term reconstructed and opened its commitment.
491 Completed(PixScalar<S>),
492}
493
494/// Collects shares of a single polynomial and reconstructs its constant term.
495///
496/// ## Where verification happens
497///
498/// Nothing is checked per share beyond what interpolation itself requires (a non-zero, distinct
499/// x-coordinate and a decodable y). The one cryptographic check is against
500/// [`SsaPartCommitment`], run **once**, on the reconstructed constant term. See that type for why
501/// this is sufficient here and what it costs — briefly: PIX has a single shareholder, so
502/// "the recovered `a₀` is the committed one" is the whole property, and it is exact.
503pub struct SsaPartBuilder<S: PixSpec> {
504 commitment: SsaPartCommitment<S>,
505 /// Shares needed to interpolate, i.e. the negotiated polynomial threshold.
506 ///
507 /// Comes from [`SsaCommitmentBuilder::poly_threshold`], never from the commitment: there is
508 /// only one commitment per polynomial now, so its size says nothing about the degree.
509 min_shares: usize,
510 shares: Vec<CompletedShare<S>>,
511 reconstructed: Option<PixScalar<S>>,
512 /// Set when the part could not be reconstructed — either the interpolation itself failed, or
513 /// the value it produced failed to open [`Self::commitment`].
514 ///
515 /// The failure is reported exactly once; every later share for this polynomial is absorbed
516 /// silently. There is nothing to be gained from re-running the interpolation — the share set
517 /// cannot be repaired without knowing *which* share is bad, and the cycle is already lost
518 /// because [`SsaBuilder`] needs every polynomial. Both failure paths must therefore set this
519 /// *and* release the share buffer, or the "exactly once" only holds for one of them.
520 failed: bool,
521 /// How many post-reconstruction shares this polynomial may contribute as liveness evidence.
522 /// [`SsaCycle::new`] verifies that every part received the same negotiated value, then builds
523 /// the shared per-polynomial counters that enforce it across the retirement boundary.
524 ///
525 /// The negotiated `surplus_shares`, i.e. exactly what a conforming Entry emits per polynomial
526 /// once the threshold is met — and, since `20845807ae`, exactly what the per-SSA quota charges
527 /// for. Crediting to the negotiated figure therefore credits precisely the traffic that was paid
528 /// for, and nothing beyond it.
529 ///
530 /// A bound is needed at all because reconstruction releases the collected share set, so
531 /// duplicate detection is gone from that moment on: every later share for the polynomial is
532 /// indistinguishable from every other. Without a cap an Entry could complete one polynomial,
533 /// replay its shares forever, and keep the Exit's egress gate and idle deadline refreshed while
534 /// touching none of the remaining `polys - 1`. The cap makes the free ride finite —
535 /// `polys × surplus_shares` per cycle, which is the conforming volume.
536 max_credited_surplus: usize,
537}
538
539impl<S: PixSpec> SsaPartBuilder<S> {
540 pub fn new(commitment: SsaPartCommitment<S>, min_shares: usize, max_credited_surplus: usize) -> Self {
541 Self {
542 commitment,
543 min_shares,
544 shares: Vec::new(),
545 reconstructed: None,
546 failed: false,
547 max_credited_surplus,
548 }
549 }
550
551 /// [`SsaPolynomialId`] of the polynomial this builder reconstructs.
552 ///
553 /// Remains valid after the collected shares have been released.
554 pub(crate) fn spi(&self) -> SsaPolynomialId<S::Pseudonym> {
555 self.commitment.spi
556 }
557
558 /// Shares needed to interpolate this polynomial — the negotiated threshold.
559 pub(crate) fn min_shares(&self) -> usize {
560 self.min_shares
561 }
562
563 pub(crate) fn max_credited_surplus(&self) -> usize {
564 self.max_credited_surplus
565 }
566
567 /// Frees the collected shares, which are only needed until the part is reconstructed.
568 ///
569 /// After that point they cannot be read again — the early returns in
570 /// [`add_share`](Self::add_share) short-circuit every later call before it touches them.
571 ///
572 /// At production dimensions this buffer is `threshold × size_of::<CompletedShare>()` held for
573 /// *every* one of the `polys` polynomials until the cycle is retired. Since the Entry emits
574 /// shares polynomial-major, releasing here means only the polynomials still in flight hold any.
575 ///
576 /// Assigns a fresh empty `Vec` rather than `clear()`, so the backing allocation is actually
577 /// returned instead of being retained at capacity.
578 fn release_verification_state(&mut self) {
579 self.shares = Vec::new();
580 }
581
582 /// Number of collected shares still held.
583 ///
584 /// Drops to zero once the part is reconstructed, or once it has failed its commitment.
585 #[cfg(test)]
586 pub(crate) fn verification_state_len(&self) -> usize {
587 self.shares.len()
588 }
589
590 pub fn add_share(
591 &mut self,
592 msg: PixScalar<S>,
593 share: PartialSsaShare<S>,
594 ) -> errors::Result<AddShareOutcome<S>, S::Pseudonym> {
595 // First, and before decoding: a failed part reports its fault exactly once, and every later
596 // share for it is absorbed in silence. Validating ahead of this would turn each of those
597 // into another `InvalidShare`, and the Session layer closes on the first one it is told
598 // about — so one already-doomed polynomial would report a close once per remaining share
599 // instead of once.
600 if self.failed {
601 return Ok(AddShareOutcome::Absorbed);
602 }
603
604 let share = checked_completed_share(msg, &share)?;
605
606 if self.reconstructed.is_some() {
607 return Ok(AddShareOutcome::Surplus);
608 }
609
610 // Reject duplicate shares — the same identifier is the same X-coordinate, which carries no
611 // new information and makes the interpolation singular.
612 //
613 // Scanning the collected shares is sufficient to classify duplicates, and no separate set of
614 // seen identifiers is needed: `self.shares` is only released once the part is reconstructed
615 // or has failed, and both of those short-circuit above. So every call that reaches here
616 // still has the full set in hand.
617 if self.shares.iter().any(|s| s.identifier == share.identifier) {
618 return Ok(AddShareOutcome::Duplicate);
619 }
620
621 self.shares.push(share);
622
623 if self.shares.len() < self.min_shares {
624 return Ok(AddShareOutcome::Useful);
625 }
626
627 let reconstructed = match self.shares.combine() {
628 Ok(combined) => combined.0,
629 Err(error) => {
630 // Terminal, exactly like a failed commitment opening below, so it has to be
631 // recorded the same way. Propagating with `?` alone would leave the part with a
632 // full share set, no `reconstructed` and no `failed`, so neither early return
633 // above would fire: every remaining share for this polynomial would be pushed and
634 // re-run the interpolation over a larger set, and would re-report the same fault.
635 self.release_verification_state();
636 self.failed = true;
637 return Err(error.into());
638 }
639 };
640 self.release_verification_state();
641
642 // The only elliptic curve operation on the share path: one fixed-base multiplication per
643 // polynomial, against `threshold` per share previously.
644 if !self.commitment.verify_reconstructed(&reconstructed) {
645 self.failed = true;
646 return Err(vsss_rs::Error::InvalidShare.into());
647 }
648
649 self.reconstructed = Some(reconstructed);
650 Ok(AddShareOutcome::Completed(reconstructed))
651 }
652}
653
654/// Decodes a partial share and enforces the inexpensive structural checks that must precede both
655/// live-cycle and recovered-tail liveness accounting.
656fn checked_completed_share<S: PixSpec>(
657 msg: PixScalar<S>,
658 share: &PartialSsaShare<S>,
659) -> errors::Result<CompletedShare<S>, S::Pseudonym> {
660 let share = into_completed_share(msg, share)?;
661
662 // A zero x-coordinate evaluates the polynomial at its constant term and would divide by zero
663 // in the Lagrange basis; a zero y is degenerate in the same way. These used to be the opening
664 // lines of the per-share Feldman check, and they are the part worth keeping — unlike the check
665 // itself, they cost nothing.
666 //
667 // This must run before either post-reconstruction path consumes a surplus slot. Otherwise a
668 // completed polynomial becomes a laundering channel: malformed shares addressed to it count as
669 // evidence that the Entry is alive.
670 if (share.value().is_zero() | share.identifier().is_zero()).into() {
671 return Err(vsss_rs::Error::InvalidShare.into());
672 }
673
674 Ok(share)
675}
676
677/// Incremental outcome of feeding coefficient commitments into an [`SsaCommitmentBuilder`].
678///
679/// Everything happens on one call: the constant-term set completing is simultaneously the moment
680/// the SSA commitment becomes known *and* the moment every polynomial becomes reconstructible,
681/// since a polynomial's whole commitment is its constant term. The fields stay separate because
682/// the caller must publish them in a specific order — see `insert_coefficient_commitments`.
683pub struct CommitmentProgress<S: PixSpec> {
684 /// Full SSA commitment (Client + Exit), once every constant term has arrived.
685 pub full_commitment: Option<PixGroup<S>>,
686 /// The SSA part accumulator, yielded exactly once — on the call that completes the
687 /// constant-term set. The caller must publish it before any share can be reconstructed.
688 pub ssa_builder: Option<SsaBuilder<S>>,
689 /// Per-polynomial part builders, all yielded together on that same call.
690 pub new_verifiers: Vec<SsaPartBuilder<S>>,
691 /// `true` on the call that hands the part builders out.
692 pub fully_committed: bool,
693}
694
695impl<S: PixSpec> CommitmentProgress<S> {
696 fn empty() -> Self {
697 Self {
698 full_commitment: None,
699 ssa_builder: None,
700 new_verifiers: Vec::new(),
701 fully_committed: false,
702 }
703 }
704}
705
706/// Builds a complete SSA from the incoming client constant-term commitments of the SSA-part
707/// polynomials for a specific Session Stealth Address (SSA).
708///
709/// One commitment per polynomial arrives, so "the SSA commitment is known" and "every polynomial
710/// is reconstructible" are the same event: `committed_polynomials.len() == num_polys`.
711pub struct SsaCommitmentBuilder<S: PixSpec> {
712 id: SsaId<S::Pseudonym>,
713 /// Shares needed to reconstruct one polynomial, as negotiated at session establishment.
714 ///
715 /// The commitments no longer carry the degree, so this is the only source for it. It is
716 /// handed to every [`SsaPartBuilder`] as its `min_shares`.
717 poly_threshold: usize,
718 /// Shares the peer said it would emit per polynomial *beyond* [`Self::poly_threshold`], as
719 /// negotiated at session establishment.
720 ///
721 /// Handed to every [`SsaPartBuilder`] as its `max_credited_surplus`. Kept alongside the
722 /// threshold rather than derived from it: the two are independent halves of the negotiated
723 /// [`PixParams`] word, and the ratio between them is an operator choice.
724 poly_surplus: usize,
725 num_polys: usize,
726 /// Useful shares that constitute full recovery, taken from
727 /// [`PixParams::target_useful_shares`] rather than recomputed here.
728 ///
729 /// Carried down to [`SsaCycle`] so the number a consumer compares against its own expectation
730 /// has one source. It reaches [`SsaRecoveryProgress::target_useful_shares`], which is documented
731 /// as a protocol-violation detector — and two derivations of a detector can disagree, at which
732 /// point the disagreement reads as the violation.
733 target_useful_shares: u64,
734 /// Constant-term commitments received so far, **decoded**: each is decompressed and
735 /// subgroup-checked exactly once, on arrival. Keeping the compressed representation instead
736 /// would force a second decompression when the part builders are created, and decompression
737 /// is the dominant per-commitment cost.
738 ///
739 /// Drained when the part builders are handed out.
740 committed_polynomials: std::collections::HashMap<PolynomialIndex, PixGroup<S>>,
741 /// Commitments received so far, counted across polynomials that have since been handed out.
742 /// Used for `is_empty`, which would otherwise report empty again after the drain.
743 total_committed: usize,
744 complete: bool,
745 exit_commitment_secret: PixScalar<S>,
746 exit_commitment_public: PixGroup<S>,
747 /// First [`SsaCommitmentProof`] the peer supplied, checked once the constant-term set is
748 /// complete — that is the point at which the commitment it opens becomes known.
749 ///
750 /// Only the first is kept: any single valid proof is sufficient, and Schnorr proofs are
751 /// randomised, so there is nothing to reconcile between several of them.
752 commitment_proof: Option<SsaCommitmentProof<S>>,
753 full_ssa_commitment: Option<(PixGroup<S>, S::DepositAddress)>,
754}
755
756impl<S: PixSpec> SsaCommitmentBuilder<S> {
757 /// Takes the whole negotiated triple rather than the two fields it used to read.
758 ///
759 /// The surplus is the third, and it was the one previously dropped on the floor at the call
760 /// site: the Session layer held a [`PixParams`] and destructured two fields out of it. Keeping
761 /// the type intact is what makes the per-polynomial surplus budget available down here — see
762 /// [`SsaPartBuilder::max_credited_surplus`].
763 pub fn new(
764 id: SsaId<S::Pseudonym>,
765 params: PixParams,
766 exit_commitment_secret: PixScalar<S>,
767 exit_commitment_public: PixGroup<S>,
768 ) -> Self {
769 Self {
770 id,
771 poly_threshold: params.shares_per_poly() as usize,
772 poly_surplus: params.surplus_shares() as usize,
773 num_polys: params.polys_per_ssa() as usize,
774 target_useful_shares: params.target_useful_shares(),
775 exit_commitment_secret,
776 exit_commitment_public,
777 committed_polynomials: std::collections::HashMap::new(),
778 total_committed: 0,
779 complete: false,
780 commitment_proof: None,
781 full_ssa_commitment: None,
782 }
783 }
784
785 /// `true` if not a single coefficient commitment has been received yet.
786 ///
787 /// Counts rather than inspecting `committed_polynomials`, which is drained when the part
788 /// builders are handed out and would therefore report empty again afterwards.
789 pub fn is_empty(&self) -> bool {
790 self.total_committed == 0
791 }
792
793 pub fn get_deposit_address(&self) -> Option<&S::DepositAddress> {
794 self.full_ssa_commitment.as_ref().map(|(_, a)| a)
795 }
796
797 /// Polynomial-index runs whose constant-term commitment has not arrived, lowest first.
798 ///
799 /// Empty once the set is complete. The runs are what the Exit puts on the wire to ask the peer
800 /// to re-send the pieces a lost packet took with it: loss is chunk-shaped, because the sender
801 /// splits the set into packet-sized slices of *consecutive* polynomial indices, so one lost
802 /// packet is one run of a couple of dozen indices and "nothing arrived at all" is a single run.
803 ///
804 /// Truncated to `max_runs`, which is how many the asking message can carry. Lowest-first rather
805 /// than an arbitrary subset so that successive asks converge on the same prefix instead of
806 /// interleaving, and so the answer to one is never a subset of the answer to the next.
807 ///
808 /// An empty result therefore means the set is *complete*, and nothing else — `max_runs` is raised
809 /// to one rather than honoured at zero, so a caller that branches on emptiness cannot be told
810 /// "nothing is missing" by its own budget.
811 pub fn missing_runs(&self, max_runs: usize) -> Vec<(PolynomialIndex, PolynomialIndex)> {
812 // Not merely an optimisation: the map is drained when the part builders are handed out, so
813 // reading it after completion would report every polynomial as missing.
814 if self.complete {
815 return Vec::new();
816 }
817
818 let max_runs = max_runs.max(1);
819 let mut runs: Vec<(PolynomialIndex, PolynomialIndex)> = Vec::new();
820 for index in 0..self.num_polys as PolynomialIndex {
821 if self.committed_polynomials.contains_key(&index) {
822 continue;
823 }
824 match runs.last_mut() {
825 // Extends the open run rather than starting a new one; `index` only ever grows.
826 Some((_, last)) if *last + 1 == index => *last = index,
827 _ => {
828 if runs.len() == max_runs {
829 break;
830 }
831 runs.push((index, index));
832 }
833 }
834 }
835 runs
836 }
837
838 pub fn add_transposed(
839 &mut self,
840 coeff_index: CoefficientIndex,
841 proof: Option<SsaCommitmentProof<S>>,
842 polynomial_coeff_commitments: impl Iterator<Item = (PolynomialIndex, PixGroupRepr<S>)>,
843 ) -> errors::Result<CommitmentProgress<S>, S::Pseudonym> {
844 // Commitments to non-constant coefficients carry nothing this side can use: shares are
845 // checked in aggregate, against the constant term, once the part is reconstructed (see
846 // `SsaPartCommitment`). Ignore rather than reject, so a peer that still sends the full
847 // Feldman matrix merely wastes its own bandwidth.
848 //
849 // Deliberately ahead of the `complete` guard below: such a peer sends the bulk of them
850 // *after* the constant-term pass has finished, and those must not be mistaken for a
851 // duplicate-commitment attack. Nothing is decoded here either, so the ~152 µs per
852 // commitment is not spent on data that is about to be dropped.
853 if coeff_index != CONSTANT_TERM_COEFFICIENT {
854 tracing::debug!(
855 id = %self.id,
856 coeff_index,
857 "ignoring commitments to a non-constant polynomial coefficient"
858 );
859 return Ok(CommitmentProgress {
860 full_commitment: self.full_ssa_commitment.as_ref().map(|(c, _)| *c),
861 // Report the state as it stands; ignoring a message must not make a completed
862 // cycle look incomplete.
863 fully_committed: self.complete,
864 ..CommitmentProgress::empty()
865 });
866 }
867
868 // Every slot is already filled, so nothing this message carries can change the outcome.
869 //
870 // Reported as the completed state rather than as a duplicate: the peer re-sends parts of the
871 // set on request (see `missing_runs`), and a re-sent copy travels the same mixed path as the
872 // original it repairs, so it can arrive after the set completed. The per-slot check below
873 // cannot speak for these — the map is drained at completion — which is the only reason this
874 // guard exists at all.
875 if self.complete {
876 tracing::trace!(id = %self.id, "ignoring commitments for an already complete ssa");
877 return Ok(CommitmentProgress {
878 full_commitment: self.full_ssa_commitment.as_ref().map(|(c, _)| *c),
879 fully_committed: true,
880 ..CommitmentProgress::empty()
881 });
882 }
883
884 // Retain the first proof offered. It cannot be checked yet: the commitment it opens is the
885 // sum of *all* constant terms, so verification waits for the milestone below. Recorded
886 // before the transactional insert so that a batch which later bails on a duplicate still
887 // leaves the proof available — it is not part of the state the duplicate check protects.
888 if self.commitment_proof.is_none() {
889 self.commitment_proof = proof;
890 }
891
892 // Collect and validate all items before mutating state (transactional).
893 //
894 // Decoding here is the *only* decode of each commitment: the resulting group element is
895 // what gets stored, so building the part builders below never decompresses again.
896 //
897 // The check is `decode_commitment` — decodable *and* inside the prime-order subgroup. It
898 // must not be weaker: a commitment that passes here occupies its slot permanently, because
899 // re-insertion is rejected as a duplicate. A weaker check would let a
900 // decodable-but-small-order point take a slot and then fail unconditionally at completion,
901 // with no way to retransmit a correction.
902 let mut validated: Vec<(PolynomialIndex, PixGroup<S>)> = Vec::new();
903 for (polynomial_index, polynomial_coeff_commitment) in polynomial_coeff_commitments {
904 if polynomial_index >= self.num_polys as PolynomialIndex {
905 return Err(errors::PixError::InvalidInput);
906 }
907 validated.push((
908 polynomial_index,
909 SsaPartCommitment::<S>::decode_commitment(&polynomial_coeff_commitment)?,
910 ));
911 }
912
913 // Classify every entry against the slots before any insertion (transactional).
914 //
915 // A slot re-offered the commitment it already holds is *skipped*, not rejected. The peer
916 // re-sends parts of the set on request (see `missing_runs`), and those travel the same mixed
917 // path as the burst they repair, so a re-sent copy and its original can overtake one another;
918 // refusing the whole message over one such overlap would drop the very entries the ask was
919 // for. Only a *different* commitment for an occupied slot is an error, which is exactly the
920 // single-assignment invariant this two-phase check exists to enforce — a slot that has been
921 // decoded and accepted can never be rebound.
922 //
923 // A repeat *within* the batch is classified the same way, and against `seen` rather than only
924 // against `committed_polynomials`: two entries sharing an index would both see a vacant slot,
925 // the second insert would silently rebind the first, and `total_committed` would count two
926 // occupants of one slot — so a batch of `num_polys` entries containing a repeat could never
927 // complete the set. The wire decoder rejects intra-message duplicates today, but this builder
928 // is not meant to depend on that.
929 let mut seen: std::collections::HashMap<PolynomialIndex, PixGroup<S>> =
930 std::collections::HashMap::with_capacity(validated.len());
931 let mut fresh: Vec<(PolynomialIndex, PixGroup<S>)> = Vec::with_capacity(validated.len());
932 for (polynomial_index, commitment) in validated {
933 let occupant = self
934 .committed_polynomials
935 .get(&polynomial_index)
936 .or_else(|| seen.get(&polynomial_index));
937 match occupant {
938 Some(held) if *held == commitment => {
939 tracing::trace!(id = %self.id, polynomial_index, "ignoring a re-delivered commitment");
940 }
941 Some(_) => return Err(errors::PixError::DuplicateCommitment),
942 None => {
943 seen.insert(polynomial_index, commitment);
944 fresh.push((polynomial_index, commitment));
945 }
946 }
947 }
948
949 // Second phase: insert into confirmed-vacant slots, maintaining the progress counter.
950 for (polynomial_index, polynomial_coeff_commitment) in fresh {
951 self.committed_polynomials
952 .insert(polynomial_index, polynomial_coeff_commitment);
953 self.total_committed += 1;
954 }
955
956 tracing::trace!(
957 id = %self.id,
958 "SSA commitment is {:.2}% complete",
959 self.total_committed as f64 * 100.0 / self.num_polys as f64
960 );
961
962 let mut progress = CommitmentProgress::empty();
963
964 // The one milestone: every constant term is in, so the SSA commitment — and with it the
965 // deposit address, the part accumulator and every polynomial's part builder — becomes
966 // known. Reading the map must happen before the drain below empties it.
967 if self.full_ssa_commitment.is_none() && self.committed_polynomials.len() == self.num_polys {
968 // Constant terms are already decoded; summing them needs no decompression.
969 let client_ssa_commitment = self.committed_polynomials.values().copied().sum::<PixGroup<S>>();
970 tracing::debug!(id = %self.id, commitment = const_hex::encode(client_ssa_commitment.to_bytes()), "SSA client commitment");
971
972 // The client commitment is now known, so its proof of knowledge can finally be checked.
973 //
974 // This gate must sit *before* `full_ssa_commitment` is recorded and before the
975 // `SsaBuilder` is handed out: those are what produce the deposit address and make the
976 // cycle live. Rejecting here means an unproven commitment never reaches the deposit
977 // path at all, which is the whole point — a peer that does not know the discrete
978 // logarithm of what it published may know the discrete logarithm of the *sum* with our
979 // own commitment, and could then sweep the deposit itself.
980 if !self
981 .commitment_proof
982 .as_ref()
983 .is_some_and(|proof| proof.verify(&self.id, &client_ssa_commitment))
984 {
985 tracing::error!(id = %self.id, "client ssa commitment has no valid proof of knowledge");
986 return Err(errors::PixError::UnprovenSsaCommitment);
987 }
988
989 let full_ssa_commitment = client_ssa_commitment + self.exit_commitment_public;
990
991 // Treat the failed conversion to deposit address as error
992 let deposit_addr =
993 S::group_to_deposit_address(full_ssa_commitment).ok_or(errors::PixError::InvalidInput)?;
994
995 // A zero threshold would make every part builder reconstruct from no shares at all.
996 // `PixParams` cannot hold one — `try_new` enforces `MIN_POLY_THRESHOLD` — so this is
997 // defensive, but it must be checked before the drain below, so a failure cannot strand
998 // the commitments it has already taken.
999 if self.poly_threshold == 0 {
1000 return Err(errors::PixError::InvalidInput);
1001 }
1002
1003 self.full_ssa_commitment = Some((full_ssa_commitment, deposit_addr));
1004 progress.ssa_builder = Some(SsaBuilder::new(
1005 full_ssa_commitment,
1006 self.exit_commitment_secret,
1007 self.num_polys,
1008 self.target_useful_shares,
1009 ));
1010
1011 // Hand out every polynomial's part builder in this same call. Each commitment was
1012 // decoded and subgroup-checked on arrival, so this costs no elliptic curve work, and
1013 // the map is drained as it goes so the builder never holds both representations.
1014 //
1015 // They all become available at once because a polynomial's entire commitment *is* its
1016 // constant term: there is no partially committed row to wait on. Shares that arrived
1017 // before this point were deferred by the reconstructor and are redeemed by the caller
1018 // right after these are installed.
1019 progress.new_verifiers.reserve(self.committed_polynomials.len());
1020 for (poly_index, constant_term) in self.committed_polynomials.drain() {
1021 progress.new_verifiers.push(SsaPartBuilder::new(
1022 SsaPartCommitment::from_decoded_commitment(
1023 SsaPolynomialId::new(self.id, poly_index),
1024 constant_term,
1025 ),
1026 self.poly_threshold,
1027 self.poly_surplus,
1028 ));
1029 }
1030
1031 tracing::debug!(id = %self.id, "SSA is fully committed for verification");
1032 self.complete = true;
1033 progress.fully_committed = true;
1034 }
1035
1036 progress.full_commitment = self.full_ssa_commitment.as_ref().map(|(c, _)| *c);
1037
1038 Ok(progress)
1039 }
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044 use super::SurplusBudget;
1045
1046 /// The live-cycle and recovered-tail views are clones of this same allocation. Even if an
1047 /// acknowledgement races retirement, exactly one view may spend the final paid slot.
1048 #[test]
1049 fn surplus_budget_clones_cannot_double_spend_a_slot() {
1050 const CONTENDERS: usize = 32;
1051 let budget = SurplusBudget::new(1, 1);
1052 let barrier = std::sync::Arc::new(std::sync::Barrier::new(CONTENDERS));
1053 let contenders = (0..CONTENDERS)
1054 .map(|_| {
1055 let budget = budget.clone();
1056 let barrier = barrier.clone();
1057 std::thread::spawn(move || {
1058 barrier.wait();
1059 budget.try_credit(0) == Some(true)
1060 })
1061 })
1062 .collect::<Vec<_>>();
1063
1064 let credited = contenders
1065 .into_iter()
1066 .map(|contender| contender.join().expect("surplus-budget contender must not panic"))
1067 .filter(|credited| *credited)
1068 .count();
1069 assert_eq!(1, credited, "a retirement race must not duplicate paid liveness");
1070 assert_eq!(Some(false), budget.try_credit(0), "the shared slot must remain spent");
1071 }
1072}