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, PixScalar,
8 PixSpec, PolynomialIndex, SsaCommitmentProof, SsaPartCommitment, SsaPolynomialId, SsaRecoveryProgress, errors,
9 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/// All post-commitment state of one SSA cycle, held as a single unit.
22///
23/// ## Why one entry per cycle rather than one per polynomial
24///
25/// The accumulator and every part builder are produced by the same call — see
26/// [`CommitmentProgress`] — needed by the same code path, and dead at the same moment. Splitting
27/// them across caches previously required holding three lifetimes in lockstep by hand, and getting
28/// that wrong was **H8**: the part builders were keyed per polynomial with an idle timer, so the
29/// clock measured "time since a share for *this* polynomial arrived". Commitments land in a cycle's
30/// opening moments while shares arrive polynomial-major across all of it, so any polynomial late in
31/// the emission order had its builder reclaimed before its first share — unrecoverably, since the
32/// commitment cannot be retransmitted.
33///
34/// Keyed by [`SsaId`], the idle timer measures "time since the *cycle* was active", which is the
35/// property that actually matters and is correct at any line rate.
36///
37/// ## Locking
38///
39/// One mutex **per part**, plus one for the accumulator — never one around the whole cycle. A
40/// single cycle-wide lock would serialise every share of a Session behind one mutex. Callers take
41/// the part lock and the accumulator lock in that order, and never hold both.
42pub struct SsaCycle<S: PixSpec> {
43 id: SsaId<S::Pseudonym>,
44 builder: parking_lot::Mutex<SsaBuilder<S>>,
45 /// Part builders indexed by [`PolynomialIndex`]; length is always `num_polys`.
46 parts: Box<[parking_lot::Mutex<SsaPartBuilder<S>>]>,
47 /// Shares that advanced reconstruction: new, distinct, and below their part's threshold when
48 /// they arrived. Duplicates, surplus shares and shares absorbed by a failed part are excluded,
49 /// so this is the numerator of a progress ratio rather than a received-packet count.
50 useful_shares: std::sync::atomic::AtomicU64,
51 /// Parts whose constant term has been reconstructed and opened its commitment.
52 recovered_polynomials: std::sync::atomic::AtomicU32,
53 /// Shares that failed verification, aggregated across every peer that relayed for this cycle.
54 ///
55 /// A failure is charged once per offending share, not once per polynomial: a part reports its
56 /// failure exactly once and absorbs everything after it as
57 /// [`AddShareOutcome::Absorbed`].
58 invalid_shares: std::sync::atomic::AtomicU64,
59 /// `num_polys × poly_threshold` — the useful-share count that constitutes full recovery.
60 ///
61 /// Fixed at construction, and must equal the dimensions negotiated at session establishment:
62 /// the consumer treats a mismatch as a protocol violation rather than as drift.
63 target_useful_shares: u64,
64}
65
66impl<S: PixSpec> SsaCycle<S> {
67 /// Assembles a cycle from the accumulator and the full set of part builders.
68 ///
69 /// `parts` arrives in arbitrary order — it is drained from a `HashMap` — so each builder is
70 /// placed by its own polynomial index rather than by iteration order. A missing or duplicated
71 /// index is rejected here, which is what makes [`part`](Self::part) safe to index by an
72 /// untrusted value later. Each builder is also checked to belong to `id`, so a part cannot be
73 /// filed under a cycle it does not describe.
74 pub fn new(
75 id: SsaId<S::Pseudonym>,
76 builder: SsaBuilder<S>,
77 parts: Vec<SsaPartBuilder<S>>,
78 ) -> errors::Result<Self, S::Pseudonym> {
79 let num_polys = builder.num_polys();
80 let mut slots: Vec<Option<SsaPartBuilder<S>>> = (0..num_polys).map(|_| None).collect();
81
82 for part in parts {
83 let spi = part.spi();
84 if spi.as_ref() != &id {
85 return Err(errors::PixError::InvalidInput);
86 }
87 let slot = slots
88 .get_mut(spi.poly_index() as usize)
89 .ok_or(errors::PixError::InvalidInput)?;
90 if slot.replace(part).is_some() {
91 return Err(errors::PixError::DuplicateCommitment);
92 }
93 }
94
95 let parts = slots
96 .into_iter()
97 .map(|slot| slot.ok_or(errors::PixError::InvalidInput))
98 .collect::<errors::Result<Vec<_>, S::Pseudonym>>()?;
99
100 // Every part builder was handed the same `SsaCommitmentBuilder::poly_threshold`, so any of
101 // them reports the negotiated threshold. Read it before the builders are wrapped in mutexes,
102 // which would otherwise make this a lock acquisition.
103 let poly_threshold = parts.first().ok_or(errors::PixError::InvalidInput)?.min_shares() as u64;
104
105 Ok(Self {
106 id,
107 builder: parking_lot::Mutex::new(builder),
108 parts: parts.into_iter().map(parking_lot::Mutex::new).collect(),
109 useful_shares: Default::default(),
110 recovered_polynomials: Default::default(),
111 invalid_shares: Default::default(),
112 target_useful_shares: num_polys as u64 * poly_threshold,
113 })
114 }
115
116 /// Number of polynomials this cycle is composed of.
117 pub fn num_polys(&self) -> usize {
118 self.parts.len()
119 }
120
121 /// Records a share that advanced reconstruction.
122 pub fn record_useful_share(&self) {
123 self.useful_shares.fetch_add(1, PROGRESS_ORDERING);
124 }
125
126 /// Records a polynomial part that reconstructed and opened its commitment.
127 pub fn record_completed_part(&self) {
128 self.recovered_polynomials.fetch_add(1, PROGRESS_ORDERING);
129 }
130
131 /// Records a share that failed verification, returning the cycle's total afterwards.
132 pub fn record_invalid_share(&self) -> u64 {
133 self.invalid_shares.fetch_add(1, PROGRESS_ORDERING) + 1
134 }
135
136 /// Shares that have failed verification for this cycle so far, across all peers.
137 #[cfg(test)]
138 pub fn invalid_shares(&self) -> u64 {
139 self.invalid_shares.load(PROGRESS_ORDERING)
140 }
141
142 /// Absolute recovery progress for this cycle.
143 pub fn progress(&self) -> SsaRecoveryProgress<S::Pseudonym> {
144 SsaRecoveryProgress {
145 ssa_id: self.id,
146 useful_shares: self.useful_shares.load(PROGRESS_ORDERING),
147 target_useful_shares: self.target_useful_shares,
148 // Bounded by `num_polys`, itself bounded by `MAX_POLYS_PER_SSA`, so the saturation
149 // below is unreachable rather than lossy.
150 recovered_polynomials: self.recovered_polynomials.load(PROGRESS_ORDERING).min(u16::MAX as u32) as u16,
151 }
152 }
153
154 /// The part builder for one polynomial, or `None` if the index is out of range.
155 ///
156 /// The index originates from a peer-supplied share, so this is a checked lookup and must stay
157 /// one.
158 pub fn part(&self, poly_index: PolynomialIndex) -> Option<&parking_lot::Mutex<SsaPartBuilder<S>>> {
159 self.parts.get(poly_index as usize)
160 }
161
162 /// The accumulator that sums recovered parts into the SSA scalar.
163 pub fn builder(&self) -> &parking_lot::Mutex<SsaBuilder<S>> {
164 &self.builder
165 }
166}
167
168/// Reconstruct a single SSA from a set of SSA parts recovered from polynomials.
169pub struct SsaBuilder<S: PixSpec> {
170 pub full_commitment: PixGroup<S>,
171 num_polys: usize,
172 builder: PixScalar<S>,
173 received_indices: ahash::HashSet<PolynomialIndex>,
174 early_notified: bool,
175}
176
177impl<S: PixSpec> SsaBuilder<S> {
178 pub fn new(full_commitment: PixGroup<S>, exit_secret_scalar: PixScalar<S>, num_polys: usize) -> Self {
179 use ahash::HashSetExt;
180
181 Self {
182 full_commitment,
183 builder: exit_secret_scalar,
184 num_polys,
185 received_indices: ahash::HashSet::with_capacity(num_polys),
186 early_notified: false,
187 }
188 }
189
190 /// Number of polynomials this SSA is composed of.
191 pub fn num_polys(&self) -> usize {
192 self.num_polys
193 }
194
195 /// Returns `true` once, when the number of received polynomial parts reaches
196 /// `ceil(threshold * num_polys)` for the first time. Subsequent calls return
197 /// `false` (idempotent guard — fires at most once per SSA lifecycle).
198 pub fn check_early_threshold(&mut self, threshold: f64) -> bool {
199 if self.early_notified {
200 return false;
201 }
202 let needed = (threshold * self.num_polys as f64).ceil() as usize;
203 if self.received_indices.len() >= needed {
204 self.early_notified = true;
205 true
206 } else {
207 false
208 }
209 }
210
211 pub fn add_recovered_ssa_part(
212 &mut self,
213 index: PolynomialIndex,
214 sub_secret: PixScalar<S>,
215 ) -> errors::Result<Option<PixScalar<S>>, S::Pseudonym> {
216 if !self.received_indices.insert(index) {
217 return Ok(None);
218 }
219
220 self.builder += sub_secret;
221
222 if self.received_indices.len() < self.num_polys {
223 // SSA private scalar is not yet complete
224 return Ok(None);
225 }
226
227 // This is computed only once when we have all the polynomials reconstructed
228 if self.full_commitment == (PixGroup::<S>::generator() * self.builder) {
229 self.early_notified = true;
230 Ok(Some(self.builder))
231 } else {
232 Err(errors::PixError::InvalidSsa)
233 }
234 }
235}
236
237/// What a share contributed to its polynomial.
238///
239/// The three non-contributing outcomes are kept apart from one another because they are not
240/// equivalent to a caller counting progress: a `Duplicate` says the peer re-sent an evaluation
241/// point, a `Surplus` says it is still emitting for a polynomial that is already done (expected —
242/// the Entry sends `threshold + surplus` shares per polynomial), and `Absorbed` says the polynomial
243/// has already failed and nothing can change that.
244pub enum AddShareOutcome<S: PixSpec> {
245 /// Same evaluation identifier as a share already collected for this polynomial.
246 Duplicate,
247 /// Arrived after the polynomial was already reconstructed.
248 Surplus,
249 /// Arrived after the polynomial failed to open its commitment, and was discarded.
250 Absorbed,
251 /// New and distinct, but the threshold is not reached yet.
252 Useful,
253 /// Reached the threshold; the constant term reconstructed and opened its commitment.
254 Completed(PixScalar<S>),
255}
256
257/// Collects shares of a single polynomial and reconstructs its constant term.
258///
259/// ## Where verification happens
260///
261/// Nothing is checked per share beyond what interpolation itself requires (a non-zero, distinct
262/// x-coordinate and a decodable y). The one cryptographic check is against
263/// [`SsaPartCommitment`], run **once**, on the reconstructed constant term. See that type for why
264/// this is sufficient here and what it costs — briefly: PIX has a single shareholder, so
265/// "the recovered `a₀` is the committed one" is the whole property, and it is exact.
266pub struct SsaPartBuilder<S: PixSpec> {
267 commitment: SsaPartCommitment<S>,
268 /// Shares needed to interpolate, i.e. the negotiated polynomial threshold.
269 ///
270 /// Comes from [`SsaCommitmentBuilder::poly_threshold`], never from the commitment: there is
271 /// only one commitment per polynomial now, so its size says nothing about the degree.
272 min_shares: usize,
273 shares: Vec<CompletedShare<S>>,
274 reconstructed: Option<PixScalar<S>>,
275 /// Set when the part could not be reconstructed — either the interpolation itself failed, or
276 /// the value it produced failed to open [`Self::commitment`].
277 ///
278 /// The failure is reported exactly once; every later share for this polynomial is absorbed
279 /// silently. There is nothing to be gained from re-running the interpolation — the share set
280 /// cannot be repaired without knowing *which* share is bad, and the cycle is already lost
281 /// because [`SsaBuilder`] needs every polynomial. Both failure paths must therefore set this
282 /// *and* release the share buffer, or the "exactly once" only holds for one of them.
283 failed: bool,
284}
285
286impl<S: PixSpec> SsaPartBuilder<S> {
287 pub fn new(commitment: SsaPartCommitment<S>, min_shares: usize) -> Self {
288 Self {
289 commitment,
290 min_shares,
291 shares: Vec::new(),
292 reconstructed: None,
293 failed: false,
294 }
295 }
296
297 /// [`SsaPolynomialId`] of the polynomial this builder reconstructs.
298 ///
299 /// Remains valid after the collected shares have been released.
300 pub(crate) fn spi(&self) -> SsaPolynomialId<S::Pseudonym> {
301 self.commitment.spi
302 }
303
304 /// Shares needed to interpolate this polynomial — the negotiated threshold.
305 pub(crate) fn min_shares(&self) -> usize {
306 self.min_shares
307 }
308
309 /// Frees the collected shares, which are only needed until the part is reconstructed.
310 ///
311 /// After that point they cannot be read again — the early returns in
312 /// [`add_share`](Self::add_share) short-circuit every later call before it touches them.
313 ///
314 /// At production dimensions this buffer is `threshold × size_of::<CompletedShare>()` held for
315 /// *every* one of the `polys` polynomials until the cycle is retired. Since the Entry emits
316 /// shares polynomial-major, releasing here means only the polynomials still in flight hold any.
317 ///
318 /// Assigns a fresh empty `Vec` rather than `clear()`, so the backing allocation is actually
319 /// returned instead of being retained at capacity.
320 fn release_verification_state(&mut self) {
321 self.shares = Vec::new();
322 }
323
324 /// Number of collected shares still held.
325 ///
326 /// Drops to zero once the part is reconstructed, or once it has failed its commitment.
327 #[cfg(test)]
328 pub(crate) fn verification_state_len(&self) -> usize {
329 self.shares.len()
330 }
331
332 pub fn add_share(
333 &mut self,
334 msg: PixScalar<S>,
335 share: PartialSsaShare<S>,
336 ) -> errors::Result<AddShareOutcome<S>, S::Pseudonym> {
337 if self.reconstructed.is_some() {
338 return Ok(AddShareOutcome::Surplus);
339 }
340 if self.failed {
341 return Ok(AddShareOutcome::Absorbed);
342 }
343
344 let share = into_completed_share(msg, &share)?;
345
346 // A zero x-coordinate evaluates the polynomial at its constant term and would divide by
347 // zero in the Lagrange basis; a zero y is degenerate in the same way. These used to be the
348 // opening lines of the per-share Feldman check, and they are the part worth keeping —
349 // unlike the check itself, they cost nothing.
350 if (share.value().is_zero() | share.identifier().is_zero()).into() {
351 return Err(vsss_rs::Error::InvalidShare.into());
352 }
353
354 // Reject duplicate shares — the same identifier is the same X-coordinate, which carries no
355 // new information and makes the interpolation singular.
356 //
357 // Scanning the collected shares is sufficient to classify duplicates, and no separate set of
358 // seen identifiers is needed: `self.shares` is only released once the part is reconstructed
359 // or has failed, and both of those short-circuit above. So every call that reaches here
360 // still has the full set in hand.
361 if self.shares.iter().any(|s| s.identifier == share.identifier) {
362 return Ok(AddShareOutcome::Duplicate);
363 }
364
365 self.shares.push(share);
366
367 if self.shares.len() < self.min_shares {
368 return Ok(AddShareOutcome::Useful);
369 }
370
371 let reconstructed = match self.shares.combine() {
372 Ok(combined) => combined.0,
373 Err(error) => {
374 // Terminal, exactly like a failed commitment opening below, so it has to be
375 // recorded the same way. Propagating with `?` alone would leave the part with a
376 // full share set, no `reconstructed` and no `failed`, so neither early return
377 // above would fire: every remaining share for this polynomial would be pushed and
378 // re-run the interpolation over a larger set, and would re-report the same fault.
379 self.release_verification_state();
380 self.failed = true;
381 return Err(error.into());
382 }
383 };
384 self.release_verification_state();
385
386 // The only elliptic curve operation on the share path: one fixed-base multiplication per
387 // polynomial, against `threshold` per share previously.
388 if !self.commitment.verify_reconstructed(&reconstructed) {
389 self.failed = true;
390 return Err(vsss_rs::Error::InvalidShare.into());
391 }
392
393 self.reconstructed = Some(reconstructed);
394 Ok(AddShareOutcome::Completed(reconstructed))
395 }
396}
397
398/// Incremental outcome of feeding coefficient commitments into an [`SsaCommitmentBuilder`].
399///
400/// Everything happens on one call: the constant-term set completing is simultaneously the moment
401/// the SSA commitment becomes known *and* the moment every polynomial becomes reconstructible,
402/// since a polynomial's whole commitment is its constant term. The fields stay separate because
403/// the caller must publish them in a specific order — see `insert_coefficient_commitments`.
404pub struct CommitmentProgress<S: PixSpec> {
405 /// Full SSA commitment (Client + Exit), once every constant term has arrived.
406 pub full_commitment: Option<PixGroup<S>>,
407 /// The SSA part accumulator, yielded exactly once — on the call that completes the
408 /// constant-term set. The caller must publish it before any share can be reconstructed.
409 pub ssa_builder: Option<SsaBuilder<S>>,
410 /// Per-polynomial part builders, all yielded together on that same call.
411 pub new_verifiers: Vec<SsaPartBuilder<S>>,
412 /// `true` on the call that hands the part builders out.
413 pub fully_committed: bool,
414}
415
416impl<S: PixSpec> CommitmentProgress<S> {
417 fn empty() -> Self {
418 Self {
419 full_commitment: None,
420 ssa_builder: None,
421 new_verifiers: Vec::new(),
422 fully_committed: false,
423 }
424 }
425}
426
427/// Builds a complete SSA from the incoming client constant-term commitments of the SSA-part
428/// polynomials for a specific Session Stealth Address (SSA).
429///
430/// One commitment per polynomial arrives, so "the SSA commitment is known" and "every polynomial
431/// is reconstructible" are the same event: `committed_polynomials.len() == num_polys`.
432pub struct SsaCommitmentBuilder<S: PixSpec> {
433 id: SsaId<S::Pseudonym>,
434 /// Shares needed to reconstruct one polynomial, as negotiated at session establishment.
435 ///
436 /// The commitments no longer carry the degree, so this is the only source for it. It is
437 /// handed to every [`SsaPartBuilder`] as its `min_shares`.
438 poly_threshold: usize,
439 num_polys: usize,
440 /// Constant-term commitments received so far, **decoded**: each is decompressed and
441 /// subgroup-checked exactly once, on arrival. Keeping the compressed representation instead
442 /// would force a second decompression when the part builders are created, and decompression
443 /// is the dominant per-commitment cost.
444 ///
445 /// Drained when the part builders are handed out.
446 committed_polynomials: std::collections::HashMap<PolynomialIndex, PixGroup<S>>,
447 /// Commitments received so far, counted across polynomials that have since been handed out.
448 /// Used for `is_empty`, which would otherwise report empty again after the drain.
449 total_committed: usize,
450 complete: bool,
451 exit_commitment_secret: PixScalar<S>,
452 exit_commitment_public: PixGroup<S>,
453 /// First [`SsaCommitmentProof`] the peer supplied, checked once the constant-term set is
454 /// complete — that is the point at which the commitment it opens becomes known.
455 ///
456 /// Only the first is kept: any single valid proof is sufficient, and Schnorr proofs are
457 /// randomised, so there is nothing to reconcile between several of them.
458 commitment_proof: Option<SsaCommitmentProof<S>>,
459 full_ssa_commitment: Option<(PixGroup<S>, S::DepositAddress)>,
460}
461
462impl<S: PixSpec> SsaCommitmentBuilder<S> {
463 pub fn new(
464 id: SsaId<S::Pseudonym>,
465 poly_threshold: usize,
466 num_polys: usize,
467 exit_commitment_secret: PixScalar<S>,
468 exit_commitment_public: PixGroup<S>,
469 ) -> Self {
470 Self {
471 id,
472 poly_threshold,
473 num_polys,
474 exit_commitment_secret,
475 exit_commitment_public,
476 committed_polynomials: std::collections::HashMap::new(),
477 total_committed: 0,
478 complete: false,
479 commitment_proof: None,
480 full_ssa_commitment: None,
481 }
482 }
483
484 /// `true` if not a single coefficient commitment has been received yet.
485 ///
486 /// Counts rather than inspecting `committed_polynomials`, which is drained when the part
487 /// builders are handed out and would therefore report empty again afterwards.
488 pub fn is_empty(&self) -> bool {
489 self.total_committed == 0
490 }
491
492 pub fn get_deposit_address(&self) -> Option<&S::DepositAddress> {
493 self.full_ssa_commitment.as_ref().map(|(_, a)| a)
494 }
495
496 pub fn add_transposed(
497 &mut self,
498 coeff_index: CoefficientIndex,
499 proof: Option<SsaCommitmentProof<S>>,
500 polynomial_coeff_commitments: impl Iterator<Item = (PolynomialIndex, PixGroupRepr<S>)>,
501 ) -> errors::Result<CommitmentProgress<S>, S::Pseudonym> {
502 // Commitments to non-constant coefficients carry nothing this side can use: shares are
503 // checked in aggregate, against the constant term, once the part is reconstructed (see
504 // `SsaPartCommitment`). Ignore rather than reject, so a peer that still sends the full
505 // Feldman matrix merely wastes its own bandwidth.
506 //
507 // Deliberately ahead of the `complete` guard below: such a peer sends the bulk of them
508 // *after* the constant-term pass has finished, and those must not be mistaken for a
509 // duplicate-commitment attack. Nothing is decoded here either, so the ~152 µs per
510 // commitment is not spent on data that is about to be dropped.
511 if coeff_index != CONSTANT_TERM_COEFFICIENT {
512 tracing::debug!(
513 id = %self.id,
514 coeff_index,
515 "ignoring commitments to a non-constant polynomial coefficient"
516 );
517 return Ok(CommitmentProgress {
518 full_commitment: self.full_ssa_commitment.as_ref().map(|(c, _)| *c),
519 // Report the state as it stands; ignoring a message must not make a completed
520 // cycle look incomplete.
521 fully_committed: self.complete,
522 ..CommitmentProgress::empty()
523 });
524 }
525
526 // Cannot add more commitments if we already have all
527 if self.complete {
528 return Err(errors::PixError::DuplicateCommitment);
529 }
530
531 // Retain the first proof offered. It cannot be checked yet: the commitment it opens is the
532 // sum of *all* constant terms, so verification waits for the milestone below. Recorded
533 // before the transactional insert so that a batch which later bails on a duplicate still
534 // leaves the proof available — it is not part of the state the duplicate check protects.
535 if self.commitment_proof.is_none() {
536 self.commitment_proof = proof;
537 }
538
539 // Collect and validate all items before mutating state (transactional).
540 //
541 // Decoding here is the *only* decode of each commitment: the resulting group element is
542 // what gets stored, so building the part builders below never decompresses again.
543 //
544 // The check is `decode_commitment` — decodable *and* inside the prime-order subgroup. It
545 // must not be weaker: a commitment that passes here occupies its slot permanently, because
546 // re-insertion is rejected as a duplicate. A weaker check would let a
547 // decodable-but-small-order point take a slot and then fail unconditionally at completion,
548 // with no way to retransmit a correction.
549 let mut validated: Vec<(PolynomialIndex, PixGroup<S>)> = Vec::new();
550 for (polynomial_index, polynomial_coeff_commitment) in polynomial_coeff_commitments {
551 if polynomial_index >= self.num_polys as PolynomialIndex {
552 return Err(errors::PixError::InvalidInput);
553 }
554 validated.push((
555 polynomial_index,
556 SsaPartCommitment::<S>::decode_commitment(&polynomial_coeff_commitment)?,
557 ));
558 }
559
560 // Check for duplicate occupancy before any insertion (transactional).
561 //
562 // A repeat *within* the batch counts. Testing only against `committed_polynomials` would let
563 // two entries sharing a polynomial index both see a vacant slot: the second insert would
564 // silently rebind the first — the single-assignment invariant this two-phase check exists to
565 // enforce — and `total_committed` would count two occupants of one slot, so a batch of
566 // `num_polys` entries containing a repeat could never complete the set and every retry would
567 // be rejected as a duplicate against the slots it did fill. The wire decoder rejects
568 // intra-message duplicates today, but this builder is not meant to depend on that.
569 let mut seen = std::collections::HashSet::with_capacity(validated.len());
570 for (polynomial_index, _) in &validated {
571 if self.committed_polynomials.contains_key(polynomial_index) || !seen.insert(*polynomial_index) {
572 return Err(errors::PixError::DuplicateCommitment);
573 }
574 }
575
576 // Second phase: insert into confirmed-vacant slots, maintaining the progress counter.
577 for (polynomial_index, polynomial_coeff_commitment) in validated {
578 self.committed_polynomials
579 .insert(polynomial_index, polynomial_coeff_commitment);
580 self.total_committed += 1;
581 }
582
583 tracing::trace!(
584 id = %self.id,
585 "SSA commitment is {:.2}% complete",
586 self.total_committed as f64 * 100.0 / self.num_polys as f64
587 );
588
589 let mut progress = CommitmentProgress::empty();
590
591 // The one milestone: every constant term is in, so the SSA commitment — and with it the
592 // deposit address, the part accumulator and every polynomial's part builder — becomes
593 // known. Reading the map must happen before the drain below empties it.
594 if self.full_ssa_commitment.is_none() && self.committed_polynomials.len() == self.num_polys {
595 // Constant terms are already decoded; summing them needs no decompression.
596 let client_ssa_commitment = self.committed_polynomials.values().copied().sum::<PixGroup<S>>();
597 tracing::debug!(id = %self.id, commitment = const_hex::encode(client_ssa_commitment.to_bytes()), "SSA client commitment");
598
599 // The client commitment is now known, so its proof of knowledge can finally be checked.
600 //
601 // This gate must sit *before* `full_ssa_commitment` is recorded and before the
602 // `SsaBuilder` is handed out: those are what produce the deposit address and make the
603 // cycle live. Rejecting here means an unproven commitment never reaches the deposit
604 // path at all, which is the whole point — a peer that does not know the discrete
605 // logarithm of what it published may know the discrete logarithm of the *sum* with our
606 // own commitment, and could then sweep the deposit itself.
607 if !self
608 .commitment_proof
609 .as_ref()
610 .is_some_and(|proof| proof.verify(&self.id, &client_ssa_commitment))
611 {
612 tracing::error!(id = %self.id, "client ssa commitment has no valid proof of knowledge");
613 return Err(errors::PixError::UnprovenSsaCommitment);
614 }
615
616 let full_ssa_commitment = client_ssa_commitment + self.exit_commitment_public;
617
618 // Treat the failed conversion to deposit address as error
619 let deposit_addr =
620 S::group_to_deposit_address(full_ssa_commitment).ok_or(errors::PixError::InvalidInput)?;
621
622 // A zero threshold would make every part builder reconstruct from no shares at all.
623 // `new_exit_commitment` enforces `shares_per_poly >= 2`, so this is defensive — but it
624 // must be checked before the drain below, so a failure cannot strand the commitments
625 // it has already taken.
626 if self.poly_threshold == 0 {
627 return Err(errors::PixError::InvalidInput);
628 }
629
630 self.full_ssa_commitment = Some((full_ssa_commitment, deposit_addr));
631 progress.ssa_builder = Some(SsaBuilder::new(
632 full_ssa_commitment,
633 self.exit_commitment_secret,
634 self.num_polys,
635 ));
636
637 // Hand out every polynomial's part builder in this same call. Each commitment was
638 // decoded and subgroup-checked on arrival, so this costs no elliptic curve work, and
639 // the map is drained as it goes so the builder never holds both representations.
640 //
641 // They all become available at once because a polynomial's entire commitment *is* its
642 // constant term: there is no partially committed row to wait on. Shares that arrived
643 // before this point were deferred by the reconstructor and are redeemed by the caller
644 // right after these are installed.
645 progress.new_verifiers.reserve(self.committed_polynomials.len());
646 for (poly_index, constant_term) in self.committed_polynomials.drain() {
647 progress.new_verifiers.push(SsaPartBuilder::new(
648 SsaPartCommitment::from_decoded_commitment(
649 SsaPolynomialId::new(self.id, poly_index),
650 constant_term,
651 ),
652 self.poly_threshold,
653 ));
654 }
655
656 tracing::debug!(id = %self.id, "SSA is fully committed for verification");
657 self.complete = true;
658 progress.fully_committed = true;
659 }
660
661 progress.full_commitment = self.full_ssa_commitment.as_ref().map(|(c, _)| *c);
662
663 Ok(progress)
664 }
665}