Skip to main content

hopr_protocol_pix/
lib.rs

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