Skip to main content

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}