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}