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, 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}