Skip to main content

hopr_protocol_pix/
lib.rs

1// PIX (Protocol for Incentivization of eXits): SSA commitment generation and reconstruction.
2#![deny(clippy::debug_assert_with_mut_call)]
3
4use std::ops::{Add, Mul};
5
6use hopr_types::{
7    crypto::{
8        crypto_traits::{
9            BlockSizeUser, FixedOutput, HashMarker, KeyIvInit, OutputSizeUser, StreamCipher,
10            elliptic_curve::ops::Reduce,
11            hash2curve::{ExpandMsgXmd, GroupDigest, MapToCurve, hash_to_scalar},
12        },
13        prelude::Pseudonym,
14    },
15    primitive::hybrid_array::{
16        Array, ArraySize,
17        typenum::{IsGreaterOrEqual, IsLess, IsLessOrEqual, NonZero, Prod, True, U2},
18    },
19};
20use vsss_rs::{
21    DefaultShare, IdentifierPrimeField,
22    elliptic_curve::{Curve, CurveArithmetic, PrimeCurve, PrimeField, consts::U256},
23};
24
25pub mod ack_verify;
26pub mod errors;
27mod generator;
28mod params;
29mod reconstructor;
30mod traits;
31mod types;
32
33pub use generator::{
34    EmissionProgress, SHARE_EMISSION_WINDOW, SsaGeneratorConfig, SsaShareGenerator, min_emission_for_early_recovery,
35};
36pub use params::{InvalidPixParams, PixParams, PixSuite};
37pub use reconstructor::{
38    AWAITING_ACK_ENTRY_BYTES, MAX_DEFERRED_ACKS_PER_CYCLE, MAX_DEFERRED_ACKS_PER_POLYNOMIAL,
39    PART_BUILDER_OVERHEAD_BYTES, SsaCommitmentGuard, SsaReconstructor, SsaReconstructorConfig, SsaRetirementScope,
40    peak_cycle_bytes, peak_share_buffer_bytes,
41};
42pub use traits::{EntryShareGenerator, ExitAcknowledgementShareProcessor, ShareResolution};
43pub use types::{
44    CoefficientIndex, EncryptedPartialSsaShare, GeneratedShare, PartialSsaShare, PolynomialIndex, RawSsaIndex,
45    RecoveredSsa, SsaCommitment, SsaCommitmentProof, SsaCommitmentState, SsaId, SsaIndex, SsaPolyIndexPrefixSize,
46    SsaPolynomialId, SsaRecoveryProgress, TaggedEncryptedPartialSsaShare,
47};
48pub use vsss_rs::elliptic_curve::{
49    Field, Group,
50    group::{GroupEncoding, cofactor::CofactorGroup},
51};
52
53#[doc(hidden)]
54pub mod prelude {
55    pub use super::*;
56}
57
58/// Coefficient index of the polynomial constant term.
59///
60/// The only coefficient PIX commits to: see [`SsaPartCommitment`] for why the rest are not sent.
61/// The wire format still carries a `coefficient_index`, so a peer *may* send others — the Exit
62/// ignores them.
63pub const CONSTANT_TERM_COEFFICIENT: CoefficientIndex = 0;
64
65/// Number of polynomials per SSA.
66///
67/// This and [`DEFAULT_POLY_THRESHOLD`] are the **deployed** split, not merely a library
68/// convenience: `hopr-transport-session` aliases them as `DEFAULT_PIX_POLYS_PER_SSA` and
69/// `DEFAULT_PIX_SHARES_PER_POLY`, and the Exit's accepted quota range is derived from their
70/// product. Changing either changes what nodes negotiate, and a product that no longer matches
71/// the Exit's range makes every PIX Session fail to establish — so move them together.
72pub const DEFAULT_POLYS_PER_SSA: u16 = 8192;
73
74/// Minimum number of shares needed to recover one polynomial of an SSA.
75///
76/// 64 rather than the 128 this was before the split was re-tuned. Dropping the non-constant
77/// coefficient commitments (see [`SsaPartCommitment`]) put every commitment-side cost — wire
78/// volume, ingest, reconstructor memory, share verification — on `polys` alone. What still grows
79/// with the threshold is Exit interpolation (`O(threshold²)` per polynomial), fault-detection
80/// latency (`threshold` return packets) and, on the Entry, the per-packet
81/// [`SsaShareGenerator::next_share`], which evaluates a `threshold`-wide polynomial by Horner for
82/// every share it emits. The full cost model is documented on `DEFAULT_PIX_POLYS_PER_SSA` in
83/// `hopr-transport-session`.
84///
85/// # What 64 optimises
86///
87/// **Exit reconstruction capacity, deliberately, and not total network CPU.** The two sides do not
88/// deserve equal weight: an Exit is a shared resource serving 10–30 concurrent clients while each
89/// Entry generates only its own shares, so the Exit is the side that saturates first and the one
90/// whose per-share cost sets what the network can carry. Both were measured (48 cores,
91/// `acknowledge_shares/interpolation` and `SsaShareGenerator::next_share`, µs/share):
92///
93/// | threshold | Exit  | Entry | total |
94/// | --------- | ----- | ----- | ----- |
95/// | 16        | 13.15 | 0.90  | 14.05 |
96/// | 32        | 11.12 | 1.20  | 12.32 |
97/// | 48        | 10.62 | 1.51  | 12.13 |
98/// | 64        | 10.68 | 1.82  | 12.50 |
99///
100/// The Exit curve *falls* and then flattens: fitting µs/polynomial = `A + B·t + C·t²` gives
101/// `A ≈ 81 µs`, `B ≈ 7.61`, `C ≈ 0.028`, so per share it is `A/t + B + C·t` — the fixed
102/// per-polynomial commitment opening is amortised over `threshold` shares and punishes *low*
103/// thresholds harder than interpolation punishes high ones. The Exit-only optimum is
104/// `t = √(A/C) ≈ 54`, flat within 0.5 % from 48 to 64, and 64 sits within 0.4 % of it.
105///
106/// Under the total column 48 is about 3 % cheaper. That reading is recorded rather than acted on:
107/// it weights one Entry's share generation equally with the Exit work of the 10–30 clients that
108/// Exit serves, which is not where the capacity limit is. Moving the split also moves the
109/// negotiated per-SSA quota and everything derived from it, which is not worth 3 % of a model that
110/// does not describe the bottleneck.
111pub const DEFAULT_POLY_THRESHOLD: u8 = 64;
112
113/// Share loss a polynomial's surplus is sized to absorb, as a reciprocal: `1/5` is 20 %.
114///
115/// The surplus is insurance against *lost* shares, and this is the loss rate it covers. A
116/// polynomial reconstructs from the first `threshold` distinct shares to arrive out of
117/// `threshold + surplus` emitted, so surviving a per-packet loss rate of `p` needs
118/// `surplus >= threshold · p/(1−p)` — which makes the surplus inherently a **ratio** of the
119/// threshold, and `surplus/(threshold + surplus)` the loss rate it tolerates.
120///
121/// At `threshold/4` that is 20 %, and [`default_surplus_for`] rounds *up* so it is a floor rather
122/// than an approximation — see there for why the direction matters.
123const SURPLUS_LOSS_TOLERANCE_DIVISOR: u8 = 4;
124
125/// Shares to emit per polynomial beyond `threshold`, to absorb losses.
126///
127/// **A ratio, evaluated at the threshold actually configured** — see
128// `SURPLUS_LOSS_TOLERANCE_DIVISOR` is deliberately unlinked here and below: it is crate-private, so
129// an intra-doc link from this public function trips `rustdoc::private_intra_doc_links`, which the
130// `nix build .#docs` job builds as an error.
131/// `SURPLUS_LOSS_TOLERANCE_DIVISOR` for why a ratio is the physically meaningful shape.
132///
133/// This used to be a bare constant, `DEFAULT_POLY_THRESHOLD / 2`, evaluated once at the *default*
134/// threshold and then applied whatever the configured one was. Deployments run thresholds from 16
135/// to 64, so a single absolute surplus means wildly different insurance across that range: a flat
136/// 20 covers 24 % loss at threshold 64 but 56 % at threshold 16, where it exceeds the shares it
137/// insures. Since H5 the surplus is billed on purchase rather than on claim, so that over-insurance
138/// is quota an Entry pays for in every deposit.
139///
140/// **Rounds up.** The result is a guarantee of *at least* the documented tolerance, not an
141/// approximation of it: shares are indivisible, so a threshold that is not a multiple of
142/// `SURPLUS_LOSS_TOLERANCE_DIVISOR` has to land on one side of 20 % or the other, and covering
143/// less than the documented rate is the failure this function exists to prevent. Rounding down
144/// undershot for every such threshold — 33 received 8 surplus shares and covered 19.51 % — and gave
145/// thresholds 2 and 3 a surplus of **zero**, i.e. no loss tolerance at all, at and just above
146/// [`MIN_POLY_THRESHOLD`]. Rounding up over-covers by less than one share, which is most visible at
147/// the smallest thresholds (2 → 1 surplus → 33 %) and vanishes as the threshold grows (63 → 16 →
148/// 20.3 %).
149///
150/// The deployed value is unaffected: [`DEFAULT_POLY_THRESHOLD`] is 64, a multiple of the divisor, so
151/// this returns 16 either way and no negotiated quota moves.
152pub const fn default_surplus_for(threshold: u8) -> u8 {
153    // Not `(threshold + DIVISOR - 1) / DIVISOR`: that addition overflows a `u8` for thresholds above
154    // 252, which `MAX_POLY_THRESHOLD` (255) admits, and would fail const evaluation rather than
155    // merely misbehave.
156    threshold.div_ceil(SURPLUS_LOSS_TOLERANCE_DIVISOR)
157}
158
159/// Shares emitted per polynomial beyond [`DEFAULT_POLY_THRESHOLD`], to absorb losses.
160///
161/// The third leg of the deployed split, and like the other two a single value rather than one per
162/// crate: it used to be a literal `20` here and a separately-derived `32` in
163/// `PixGlobalConfig::additional_shares`, which meant [`SsaGeneratorConfig::default`] modelled a
164/// cycle no deployed node ever runs. That mattered little while the surplus was unpriced; it stopped
165/// being harmless once the per-SSA quota started counting it, because the quota is a `const` and had
166/// to pick one of the two.
167///
168/// Only the value of [`default_surplus_for`] at the default threshold. Anything that knows its own
169/// threshold should call that instead — this constant cannot, which is exactly the defect above.
170pub const DEFAULT_SURPLUS_SHARES: u8 = default_surplus_for(DEFAULT_POLY_THRESHOLD);
171
172/// Maximum number of polynomials per SSA supported by the [`SsaReconstructor`].
173pub const MAX_POLYS_PER_SSA: u16 = 16192;
174
175/// Retransmission requests the Entry answers for one SSA cycle.
176///
177/// A cycle's commitment ships as hundreds of packets, and a single lost one strands the whole cycle
178/// (see [`SsaShareGenerator::recommit`]), so the Exit must be able to ask again for the pieces it
179/// never received. This bounds how often it may: one request can legally name every polynomial of
180/// the cycle, and an answer is the entire burst again, so an unbounded count is a packet
181/// amplification lever available to a peer that has already been served.
182///
183/// Eight, because the Exit re-asks on an idle timer rather than once. At the shipped Exit settings
184/// — a 3 s re-request interval inside a 20 s commitment deadline — a cycle can produce about six
185/// attempts, and a peer batching several SSAs per request produces proportionally more against a
186/// scaled deadline. The excess is simply refused: an unanswered request costs the Exit one packet
187/// and this node nothing, which is why the two sides are not required to agree on the figure.
188pub const MAX_COMMITMENT_RETRANSMISSIONS: u8 = 8;
189
190/// Cycles per pseudonym whose commitment material the [`SsaShareGenerator`] retains for
191/// retransmission.
192///
193/// The newest this many are kept and the oldest is evicted, and within a live cache entry that is
194/// the only thing that releases them — emission deliberately does not enter into it. (The entry as a
195/// whole goes on idle eviction, which is a separate lifetime; see below.) Whether a re-sent commitment is
196/// still worth anything is a question about what the peer holds, and none of that is visible from
197/// this side: it completes a cycle out of the shares it has already buffered plus the commitment it
198/// is still missing, on its own delivery deadline. Releasing on drain instead would give up the
199/// repair exactly where it is worth most, a cycle whose every share has gone out and whose only
200/// missing piece is the one a lost packet took.
201///
202/// Eighteen is `MAX_OVERLAPPING_BATCHES` (2) generations of `MAX_SSA_BATCH_SIZE` (9) cycles, the
203/// figures `hopr-transport-session` is built to; they are not visible from here, so the derivation is
204/// written down rather than imported. It leaves a cycle covered for the whole of its own batch plus
205/// one further generation, and the successor gate makes that second generation expensive — a peer
206/// cannot reach it without draining nearly every cycle of the first, by which point it has been
207/// asking for far longer than [`MAX_COMMITMENT_RETRANSMISSIONS`] attempts on any sane re-request
208/// interval allow.
209///
210/// Costs `polynomials_per_ssa × 33 B` per cycle — 270 kB at the deployed dimensions, 4.8 MB per
211/// pseudonym with the cap full. Entry-side state, which is why it is not charged against an Exit's
212/// incoming-Session memory budget: that reserves reconstructor state for Sessions this node *serves*,
213/// and nothing else the generator holds is charged there either.
214///
215/// It lives as long as the pseudonym's entry in the generator's cache, which idle eviction alone
216/// releases — closing a Session clears nothing here. So a node holds this for every pseudonym it has
217/// committed a cycle for within that window, Sessions long closed included, and churn accumulates it
218/// rather than it tracking the live Session count.
219/// What bounds the consequence is that the same entry keeps its polynomial state under exactly the
220/// same rule, and that term is larger by two orders of magnitude: 270 kB here against ~33 MB there,
221/// per cycle. A Session whose cycles are still queued when it closes — the ordinary case, since
222/// draining one takes `polynomials_per_ssa × (threshold + surplus)` shares, 655 k at the deployed
223/// dimensions — leaves both, and this is under 1% of it. Reaching the cap instead means having
224/// drained eighteen cycles, some twelve million shares, by which point the polynomials are gone and
225/// 4.8 MB is the entire residue.
226///
227/// Clearing the entry outright at Session teardown would release both sooner, and is worth doing for
228/// the polynomial term; [`SsaShareGenerator::forget`] exists for it, but no production path calls it
229/// today.
230pub const MAX_RETAINED_COMMITMENT_CYCLES: usize = 18;
231
232/// Minimum SSA polynomial threshold.
233///
234/// A threshold of 1 would make every single share reconstruct its polynomial on its own, so the
235/// secret sharing would hide nothing.
236pub const MIN_POLY_THRESHOLD: u8 = 2;
237
238/// Maximum SSA polynomial threshold supported by the [`SsaReconstructor`].
239///
240/// A byte, because the threshold shares the lower half of the negotiated [`PixParams`] word with
241/// [`SsaGeneratorConfig::surplus_shares`] — see [`PixParams::to_u32`]. The bound is therefore
242/// structural rather than merely checked; the constant exists to name it.
243pub const MAX_POLY_THRESHOLD: u8 = u8::MAX;
244
245/// Protocol-wide floor on [`SsaReconstructorConfig::early_recovery_threshold`].
246///
247/// The Entry refuses a successor `SsaRequest` that arrives before the current batch can plausibly
248/// have been recovered — see [`min_emission_for_early_recovery`]. That boundary is a function of the
249/// *Exit's* `early_recovery_threshold`, and the two sides negotiate [`PixParams`] but not this: it is
250/// a local reconstructor setting on the Exit, and the Entry computing the gate has no way to read it.
251///
252/// So the Entry computes the gate at this floor instead of at its own value, and every Exit is
253/// required to sit at or above it. That turns an unshared value into a shared one without a wire
254/// change, and it is sound in the only direction that matters — an Exit at or above the floor asks at
255/// or after the point the Entry admits, so no conforming pair can fail to communicate. Deriving the
256/// gate from the Entry's own setting instead left two individually valid configurations that could
257/// not talk: an Exit configured lower sent its one-shot request early, the Entry silently dropped it,
258/// and the Session died waiting for a commitment that was deliberately refused.
259///
260/// Equal to the shipped default today, but a separate constant on purpose. The default is a policy
261/// choice an operator may raise; this is a compatibility bound, and *lowering* it is a protocol
262/// break — every Entry still running the old value would refuse the earlier request. Raising it is
263/// equally breaking in the other direction, since Exits at the old floor become unservable.
264///
265/// Enforced where the two halves meet rather than in [`SsaReconstructorConfig`]'s own validator: a
266/// reconstructor exercised on its own has no peer to be incompatible with, and pinning the
267/// early-recovery logic in a unit test needs thresholds well below anything deployable.
268pub const MIN_EARLY_RECOVERY_THRESHOLD: f64 = 0.85;
269
270// The shipped default must itself be admissible under the floor it is checked against. The two are
271// defined independently and only compared at configuration time, so a default lowered below this
272// would ship, validate every hand-written config, and then be rejected the moment an operator wrote
273// the default out explicitly — or worse, be accepted locally and refused by every conforming Entry.
274// Asserted here rather than only in a test because it costs nothing and cannot be skipped.
275const _: () = assert!(
276    SsaReconstructorConfig::DEFAULT_EARLY_RECOVERY_THRESHOLD >= MIN_EARLY_RECOVERY_THRESHOLD,
277    "SsaReconstructorConfig::DEFAULT_EARLY_RECOVERY_THRESHOLD must not sit below MIN_EARLY_RECOVERY_THRESHOLD — a \
278     stock Exit would ask for its next SSA batch before any conforming Entry admits the request"
279);
280
281/// Specification of the Protocol for Incentivization of eXits (PIX) instantiation.
282pub trait PixSpec: Send + Sync + 'static
283where
284    PixScalar<Self>: PrimeField,
285    PixGroup<Self>: Group<Scalar = PixScalar<Self>> + GroupEncoding + Default + CofactorGroup,
286    PixGroupRepr<Self>: std::fmt::Debug + PartialEq + Eq,
287    <PixDigest<Self> as OutputSizeUser>::OutputSize: IsLess<U256>,
288    <PixDigest<Self> as OutputSizeUser>::OutputSize:
289        IsLessOrEqual<<PixDigest<Self> as BlockSizeUser>::BlockSize, Output = True>,
290    <Self::Curve as Curve>::FieldBytesSize: Add<SsaPolyIndexPrefixSize>,
291    <<Self::Curve as Curve>::FieldBytesSize as Add<SsaPolyIndexPrefixSize>>::Output: ArraySize,
292    // hash2curve `hash_to_scalar` bounds for `msg_to_scalar`
293    <<Self::Curve as MapToCurve>::SecurityLevel as Mul<U2>>::Output: Sized,
294    <Self::Curve as MapToCurve>::SecurityLevel: Mul<U2>,
295    <PixDigest<Self> as OutputSizeUser>::OutputSize:
296        IsGreaterOrEqual<Prod<<Self::Curve as MapToCurve>::SecurityLevel, U2>, Output = True>,
297    <Self::Curve as Curve>::FieldBytesSize: NonZero,
298    PixScalar<Self>: Reduce<Array<u8, <Self::Curve as Curve>::FieldBytesSize>>,
299{
300    /// Prime order elliptic curve use for commitments.
301    type Curve: PrimeCurve + CurveArithmetic + GroupDigest;
302    /// Digest used for hashing operations.
303    type Digest: BlockSizeUser + FixedOutput + std::fmt::Debug + Default + HashMarker;
304    /// Pseudonym used to identify groups of SURBs.
305    type Pseudonym: Pseudonym + std::fmt::Debug + Copy + Send + Sync + 'static;
306    /// Stream cipher used to encrypt the SSA shares.
307    type Cipher: StreamCipher + KeyIvInit;
308    /// Deposit address type.
309    type DepositAddress: Copy + for<'a> From<&'a Self::AddressPrivateKey> + Send + Sync + 'static;
310    /// Private key type.
311    type AddressPrivateKey: Clone + Send + Sync + 'static;
312
313    /// Context data used to derive the SSA encryption key.
314    const KEY_DERIVATION_CONTEXT: &'static str = "HASH_SSA_POLY_SHARE";
315    /// Domain separator used to derive the X value of a share.
316    const HASH_SCALAR_DERIVATION_CONTEXT: &'static str = "HASH_SSA_POLY_SHARE_SCALAR";
317    /// Domain separator used to derive the Fiat–Shamir challenge of an [`SsaCommitmentProof`].
318    const HASH_COMMITMENT_PROOF_CONTEXT: &'static str = "HASH_SSA_COMMITMENT_PROOF";
319
320    /// Stable, protocol-versioned hash-to-scalar suite identifier used for
321    /// domain separation. This must be a fixed string — deriving it dynamically
322    /// from Debug output would break wire compatibility when dependency versions
323    /// change formatting.
324    const HASH_TO_SCALAR_SUITE_ID: &'static [u8];
325
326    /// Which curve this spec instantiates, as announced to the peer in [`PixParams`].
327    ///
328    /// Deliberately has no default. It must name the same curve as [`Curve`](Self::Curve), and a
329    /// default would let a new spec inherit a wrong answer silently — the failure it exists to
330    /// prevent is precisely two peers disagreeing about a curve neither of them states.
331    const PIX_SUITE: PixSuite;
332
333    /// Performs conversion of the given `spi` and `msg` into [`PixScalar`] of this spec.
334    fn msg_to_scalar(
335        spi: &SsaPolynomialId<Self::Pseudonym>,
336        msg: impl AsRef<[u8]>,
337    ) -> errors::Result<PixScalar<Self>, Self::Pseudonym>
338    where
339        Self: Sized,
340    {
341        hash_to_scalar::<Self::Curve, ExpandMsgXmd<Self::Digest>, <Self::Curve as Curve>::FieldBytesSize>(
342            &[
343                msg.as_ref(),
344                spi.pseudonym().as_ref(),
345                spi.ssa_index().get().to_be_bytes().as_ref(),
346                spi.poly_index().to_be_bytes().as_ref(),
347            ],
348            &[
349                Self::HASH_TO_SCALAR_SUITE_ID,
350                Self::HASH_SCALAR_DERIVATION_CONTEXT.as_bytes(),
351            ],
352        )
353        .map_err(|_| errors::PixError::InvalidInput)
354    }
355
356    /// Derives the Fiat–Shamir challenge of an [`SsaCommitmentProof`] over the client's
357    /// `ssa_commitment` and the prover's `nonce_commitment`.
358    ///
359    /// `ssa_id` is bound in so that a proof cannot be replayed onto a different SSA index or a
360    /// different Session's pseudonym.
361    ///
362    /// The Exit's own commitment is deliberately **not** bound in. The statement being proven is
363    /// knowledge of `dlog(ssa_commitment)` alone, and the deposit is protected because the Exit's
364    /// secret is what separates that from `dlog(ssa_commitment + exit_commitment)` — which holds
365    /// regardless of what the challenge hashes. Binding it would only prevent reusing one proof for
366    /// the same `ssa_commitment` against two different Exits, and an Entry that reuses its
367    /// commitment does know its discrete log, so that case is honest (reuse is a linkability
368    /// concern, not an exploit).
369    fn commitment_proof_challenge(
370        ssa_id: &SsaId<Self::Pseudonym>,
371        ssa_commitment: &PixGroup<Self>,
372        nonce_commitment: &PixGroup<Self>,
373    ) -> errors::Result<PixScalar<Self>, Self::Pseudonym>
374    where
375        Self: Sized,
376    {
377        let ssa_index = ssa_id.ssa_index().get().to_be_bytes();
378        let ssa_commitment = ssa_commitment.to_bytes();
379        let nonce_commitment = nonce_commitment.to_bytes();
380
381        hash_to_scalar::<Self::Curve, ExpandMsgXmd<Self::Digest>, <Self::Curve as Curve>::FieldBytesSize>(
382            &[
383                ssa_id.pseudonym().as_ref(),
384                ssa_index.as_ref(),
385                ssa_commitment.as_ref(),
386                nonce_commitment.as_ref(),
387            ],
388            &[
389                Self::HASH_TO_SCALAR_SUITE_ID,
390                Self::HASH_COMMITMENT_PROOF_CONTEXT.as_bytes(),
391            ],
392        )
393        .map_err(|_| errors::PixError::InvalidInput)
394    }
395
396    /// Converts `PixGroup` to an address that can be deposited to.
397    ///
398    /// Returns `None` if the conversion is not possible.
399    fn group_to_deposit_address(group: PixGroup<Self>) -> Option<Self::DepositAddress>;
400    /// Convert `PixScalar` to a private key of a deposit address.
401    ///
402    /// Returns `None` if the conversion is not possible.
403    fn scalar_to_private_key(scalar: PixScalar<Self>) -> Option<Self::AddressPrivateKey>;
404}
405
406/// Finite field used to represent the polynomial coefficients.
407pub type PixScalar<S> = <<S as PixSpec>::Curve as CurveArithmetic>::Scalar;
408/// Elliptic curve point used to represent the polynomial coefficient commitments.
409pub type PixGroup<S> = <<S as PixSpec>::Curve as CurveArithmetic>::ProjectivePoint;
410/// Serializable representation of the polynomial coefficient commitments.
411pub type PixGroupRepr<S> = <PixGroup<S> as GroupEncoding>::Repr; // This internally converts to affine
412/// Digest used for hashing operations.
413pub type PixDigest<S> = <S as PixSpec>::Digest;
414
415pub(crate) type CompletedShare<S> =
416    DefaultShare<IdentifierPrimeField<PixScalar<S>>, IdentifierPrimeField<PixScalar<S>>>;
417
418#[inline]
419pub(crate) fn into_completed_share<S: PixSpec>(
420    identifier: PixScalar<S>,
421    share: &PartialSsaShare<S>,
422) -> errors::Result<CompletedShare<S>, S::Pseudonym> {
423    Ok(DefaultShare {
424        identifier: identifier.into(),
425        value: Option::from(PixScalar::<S>::from_repr(share.0))
426            .map(|s: PixScalar<S>| s.into())
427            .ok_or(vsss_rs::Error::InvalidShare)?,
428    })
429}
430
431/// Commitment to the constant term of the polynomial with the given [`SsaPolynomialId`].
432///
433/// ## Why only the constant term
434///
435/// This used to be a full Feldman verifier — a commitment to *every* coefficient — so that each
436/// individual share could be checked the moment it arrived. Classic VSS needs that, because its
437/// shares sit with mutually distrusting parties that reconstruct later. PIX has exactly **one**
438/// shareholder: the Exit holds every share, reconstructs locally, is the whole quorum, and
439/// consumes only the recovered constant term. Checking `a₀·G == C₀` once, on the reconstructed
440/// part, is therefore deterministic and exact for the property actually relied upon — and costs
441/// one scalar multiplication per polynomial instead of `threshold` per share.
442///
443/// What the per-coefficient commitments did buy was fault *isolation*: one bad share could be
444/// rejected on arrival and its slot refilled from the surplus. That is given up. A share that
445/// fails to reconstruct implies a dishonest or broken Entry — it travels inside a
446/// Sphinx-authenticated SURB, and its decryption key is fixed by the very acknowledgement
447/// challenge it is filed under, so there is no benign path to a corrupt one — and such an Entry
448/// has already funded the deposit it thereby forfeits. The price paid is detection latency:
449/// a dishonest Entry is caught on the `threshold`-th share of a polynomial rather than the first.
450///
451/// [`surplus_shares`](SsaGeneratorConfig::surplus_shares) still absorbs *lost* shares, since
452/// reconstruction starts at the first `threshold` distinct shares that arrive.
453#[derive(Debug, Clone, PartialEq, Eq)]
454#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
455pub struct SsaPartCommitment<S: PixSpec, P = <S as PixSpec>::Pseudonym> {
456    pub(crate) spi: SsaPolynomialId<P>,
457    #[cfg_attr(feature = "serde", serde(with = "elliptic_curve_tools::group"))]
458    pub(crate) constant_term: PixGroup<S>,
459}
460
461impl<S: PixSpec> SsaPartCommitment<S, S::Pseudonym> {
462    /// Creates a commitment from an **already decoded and subgroup-checked** group element.
463    ///
464    /// The only decode is [`decode_commitment`](Self::decode_commitment), performed once when the
465    /// commitment arrives on the wire. Decompression requires a modular square root and is the
466    /// dominant per-commitment cost, so nothing here decodes a second time.
467    #[inline]
468    pub fn from_decoded_commitment(spi: SsaPolynomialId<S::Pseudonym>, constant_term: PixGroup<S>) -> Self {
469        Self { spi, constant_term }
470    }
471
472    /// Returns the [`SsaPolynomialId`] of the polynomial this commitment belongs to.
473    #[inline]
474    pub fn spi(&self) -> &SsaPolynomialId<S::Pseudonym> {
475        &self.spi
476    }
477
478    /// Returns the commitment to the constant term of the polynomial.
479    #[inline]
480    pub fn constant_term(&self) -> &PixGroup<S> {
481        &self.constant_term
482    }
483
484    /// Checks a reconstructed constant term against this commitment.
485    ///
486    /// This is the *entire* verification the Exit performs on a polynomial, and it happens once,
487    /// after `threshold` shares have been interpolated. A mismatch means at least one of those
488    /// shares did not come from the committed polynomial; it does not say which.
489    #[inline]
490    pub fn verify_reconstructed(&self, secret: &PixScalar<S>) -> bool {
491        PixGroup::<S>::mul_by_generator(secret) == self.constant_term
492    }
493
494    /// Decodes a single serialized coefficient commitment into a group element.
495    ///
496    /// Rejects bytes that do not decode, and points outside the prime-order subgroup: Baby JubJub
497    /// has cofactor 8, so small-order points can pass the plain on-curve check.
498    ///
499    /// No value-based filtering is applied — a coefficient commitment equal to the generator
500    /// validly represents scalar coefficient 1 and must be preserved.
501    pub fn decode_commitment(commitment: &PixGroupRepr<S>) -> errors::Result<PixGroup<S>, S::Pseudonym> {
502        Option::<PixGroup<S>>::from(PixGroup::<S>::from_bytes(commitment))
503            .filter(|pt| bool::from(pt.is_torsion_free()))
504            .ok_or(errors::PixError::InvalidInput)
505    }
506}
507
508#[cfg(test)]
509pub(crate) mod tests {
510    use hopr_types::{
511        crypto::{
512            crypto_traits,
513            prelude::{ChainKeypair, Keypair, PublicKey, Secp256k1, SimplePseudonym},
514        },
515        primitive::prelude::Address,
516    };
517    use vsss_rs::{
518        ParticipantIdGenerator, ReadableShareSet, ShareVerifierGroup,
519        elliptic_curve::{Field, rand_core::CryptoRng},
520        feldman,
521    };
522
523    use super::*;
524    use crate::types::SsaId;
525
526    #[derive(Debug, Copy, Clone, PartialEq, Eq, Default, Hash, Ord, PartialOrd)]
527    #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
528    pub struct TestSpec;
529
530    impl PixSpec for TestSpec {
531        type AddressPrivateKey = ChainKeypair;
532        type Cipher = hopr_types::crypto::primitives::ChaCha20;
533        type Curve = hopr_types::crypto::primitives::Secp256k1;
534        type DepositAddress = Address;
535        type Digest = hopr_types::crypto::primitives::Blake3;
536        type Pseudonym = SimplePseudonym;
537
538        const HASH_TO_SCALAR_SUITE_ID: &'static [u8] = b"Secp256k1_XMD:BLAKE3_SSWU_RO_";
539        const PIX_SUITE: PixSuite = PixSuite::Secp256k1;
540
541        fn group_to_deposit_address(group: PixGroup<Self>) -> Option<Self::DepositAddress> {
542            PublicKey::try_from(group.to_affine()).ok().map(|pk| pk.to_address())
543        }
544
545        fn scalar_to_private_key(scalar: PixScalar<Self>) -> Option<Self::AddressPrivateKey> {
546            ChainKeypair::from_secret(scalar.to_bytes().as_ref()).ok()
547        }
548    }
549
550    type Share<S> = DefaultShare<IdentifierPrimeField<PixScalar<S>>, IdentifierPrimeField<PixScalar<S>>>;
551    type StandardShamirResult<S> = (Vec<Share<S>>, Vec<ShareVerifierGroup<PixGroup<S>>>);
552
553    fn standard_shamir_generate<S: PixSpec>(
554        secret: PixScalar<S>,
555        t: usize,
556        x: &[PixScalar<S>],
557        mut rng: impl CryptoRng,
558    ) -> anyhow::Result<StandardShamirResult<S>> {
559        anyhow::ensure!(t > 0, "t must be greater than 0");
560        anyhow::ensure!(x.len() >= t, "x must have at least t elements");
561
562        let (shares, verifier_set) =
563            feldman::split_secret_with_participant_generators::<Share<S>, ShareVerifierGroup<PixGroup<S>>>(
564                t,
565                x.len(),
566                &secret.into(),
567                None,
568                &mut rng,
569                &[ParticipantIdGenerator::list(
570                    &x.iter().map(|x| (*x).into()).collect::<Vec<_>>(),
571                )],
572            )
573            .map_err(anyhow::Error::msg)?;
574
575        Ok((shares, verifier_set))
576    }
577
578    fn test_spi() -> anyhow::Result<SsaPolynomialId<SimplePseudonym>> {
579        Ok(SsaPolynomialId::new(
580            SsaId::new(SimplePseudonym::try_from([0u8; 10].as_ref())?, 1.try_into()?),
581            1,
582        ))
583    }
584
585    /// The commitment PIX keeps must be exactly the constant-term entry of a standard Feldman
586    /// verifier set, and interpolating `threshold` of the standard shares must open it.
587    ///
588    /// This is the replacement for the old per-share verification test: the property the Exit now
589    /// relies on is not "every share lies on the committed polynomial" but "the reconstructed
590    /// constant term is the one that was committed to".
591    #[test]
592    fn ssa_part_commitment_must_correspond_to_standard() -> anyhow::Result<()> {
593        const THRESHOLD: usize = 10;
594
595        let mut rng = rand::rng();
596        let secret = crypto_traits::elliptic_curve::Scalar::<Secp256k1>::random(&mut rng);
597        let spi = test_spi()?;
598
599        let x = (0..=20_u32)
600            .map(|i| TestSpec::msg_to_scalar(&spi, i.to_be_bytes()).unwrap())
601            .collect::<Vec<_>>();
602
603        let (shares, verifier) = standard_shamir_generate::<TestSpec>(secret, THRESHOLD, &x, &mut rng)?;
604        assert_eq!(shares.len(), x.len());
605        // [generator, C₀, C₁ … C_{t-1}] — PIX now keeps only the second entry.
606        assert_eq!(verifier.len(), THRESHOLD + 1);
607
608        let commitment = SsaPartCommitment::<TestSpec>::from_decoded_commitment(spi, verifier[1].0);
609        assert_eq!(&verifier[1].0, commitment.constant_term());
610        assert_eq!(&spi, commitment.spi());
611
612        // Exactly `threshold` shares suffice, and they open the commitment.
613        let reconstructed = shares[..THRESHOLD].to_vec().combine().map_err(anyhow::Error::msg)?.0;
614        assert_eq!(secret, reconstructed, "threshold shares must recover the secret");
615        assert!(commitment.verify_reconstructed(&reconstructed));
616
617        // Any other scalar must not.
618        assert!(!commitment.verify_reconstructed(&(reconstructed + PixScalar::<TestSpec>::ONE)));
619
620        Ok(())
621    }
622
623    /// A single corrupted share is not detected on arrival — that is the cost of dropping the
624    /// per-coefficient commitments — but it does surface at reconstruction.
625    #[test]
626    fn ssa_part_commitment_must_reject_a_corrupted_share_set() -> anyhow::Result<()> {
627        const THRESHOLD: usize = 10;
628
629        let mut rng = rand::rng();
630        let secret = crypto_traits::elliptic_curve::Scalar::<Secp256k1>::random(&mut rng);
631        let spi = test_spi()?;
632
633        let x = (0..=20_u32)
634            .map(|i| TestSpec::msg_to_scalar(&spi, i.to_be_bytes()).unwrap())
635            .collect::<Vec<_>>();
636
637        let (mut shares, verifier) = standard_shamir_generate::<TestSpec>(secret, THRESHOLD, &x, &mut rng)?;
638        let commitment = SsaPartCommitment::<TestSpec>::from_decoded_commitment(spi, verifier[1].0);
639
640        *shares[3].value.as_mut() += PixScalar::<TestSpec>::ONE;
641
642        let reconstructed = shares[..THRESHOLD].to_vec().combine().map_err(anyhow::Error::msg)?.0;
643        assert_ne!(secret, reconstructed);
644        assert!(
645            !commitment.verify_reconstructed(&reconstructed),
646            "a corrupted share must make the reconstructed part fail its commitment"
647        );
648
649        Ok(())
650    }
651
652    /// A commitment equal to the generator represents constant term 1 and must survive decoding —
653    /// no value-based filtering is applied.
654    #[test]
655    fn decode_commitment_accepts_a_generator_valued_commitment() -> errors::Result<(), SimplePseudonym> {
656        let generator = PixGroup::<TestSpec>::generator();
657        let decoded = SsaPartCommitment::<TestSpec>::decode_commitment(&generator.to_bytes())?;
658        assert_eq!(generator, decoded);
659        Ok(())
660    }
661}