Skip to main content

hopr_protocol_pix/
types.rs

1use std::{
2    collections::{BTreeMap, HashMap},
3    num::NonZero,
4    ops::Add,
5};
6
7use hopr_types::{
8    crypto::{
9        crypto_traits::{
10            self, KeyIvInit, StreamCipher,
11            elliptic_curve::{Curve, PrimeField},
12        },
13        prelude::HalfKey,
14        primitives::Blake3,
15    },
16    primitive::{
17        hybrid_array::{
18            Array, ArraySize,
19            typenum::{Sum, U, Unsigned},
20        },
21        prelude::{BytesRepresentable, GeneralError},
22    },
23};
24
25use crate::{
26    ExitAcknowledgementShareProcessor, Group, GroupEncoding, PixGroup, PixGroupRepr, PixScalar, PixSpec,
27    SsaPartCommitment, errors, errors::PixError,
28};
29
30/// Raw zeroable SSA Index.
31pub type RawSsaIndex = u32;
32
33/// Type used to index Session Stealth Addresses (SSA).
34///
35/// Note that SSA Index starts with 1.
36pub type SsaIndex = NonZero<RawSsaIndex>;
37
38/// Type used to index polynomials that reconstruct parts of a Session Stealth Addresses (SSA).
39///
40/// The index is 0-based.
41pub type PolynomialIndex = u16;
42
43/// Type used to index coefficients in a polynomial.
44///
45/// The index is 0-based.
46pub type CoefficientIndex = u16;
47
48/// Byte size of the [`SsaIndex`] when serialized as a big-endian prefix.
49const SSA_INDEX_SIZE: usize = size_of::<SsaIndex>();
50
51/// Byte size of the [`PolynomialIndex`] when serialized as a big-endian prefix.
52const POLY_INDEX_SIZE: usize = size_of::<PolynomialIndex>();
53
54/// [`typenum`]: hopr_types::primitive::hybrid_array::typenum
55/// Size of the [`SsaIndex`] and [`PolynomialIndex`] prefix prepended to the encrypted share.
56///
57/// Derived at compile time from `size_of::<SsaIndex>() + size_of::<PolynomialIndex>()` via
58/// `typenum`'s `Const`/`ToUInt` machinery. The matching runtime invariant is asserted by
59/// the `size_of_indices_must_match` unit test below.
60pub type SsaPolyIndexPrefixSize = Sum<U<SSA_INDEX_SIZE>, U<POLY_INDEX_SIZE>>;
61
62/// Uniquely identifies a Session Stealth Address (SSA).
63///
64/// This consists of a pseudonym and [`SsaIndex`].
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
66#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
67pub struct SsaId<P> {
68    pseudonym: P,
69    ssa_index: SsaIndex,
70}
71
72impl<P> SsaId<P> {
73    /// Creates a new `SsaId` with the given pseudonym and SSA index.
74    pub fn new(pseudonym: P, ssa_index: SsaIndex) -> Self {
75        Self { pseudonym, ssa_index }
76    }
77
78    /// Pseudonym part of the `HoprSenderId`.
79    #[inline]
80    pub fn pseudonym(&self) -> &P {
81        &self.pseudonym
82    }
83
84    /// Index (i-value) of the Session Stealth Address (SSA).
85    #[inline]
86    pub fn ssa_index(&self) -> SsaIndex {
87        self.ssa_index
88    }
89}
90
91impl<P: std::fmt::Display> std::fmt::Display for SsaId<P> {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        write!(f, "{}-ssa#{}", self.pseudonym, self.ssa_index)
94    }
95}
96
97/// Uniquely identifies a polynomial that allows forming a Session Stealth Address (SSA) corresponding
98/// to a specific Session.
99///
100/// The index consists of the following parts:
101/// 1. The Pseudonym part of the `HoprSenderId` - fixed for the given Session.
102/// 2. Index (i) of the Session Stealth Address (SSA)
103/// 3. Index (j) of the polynomial used to reconstruct the portion of the SSA.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
106pub struct SsaPolynomialId<P> {
107    id: SsaId<P>,
108    poly_index: PolynomialIndex,
109}
110
111/// Polynomial coefficient commitments laid out coefficient-major, which is how the wire messages
112/// chunk them.
113///
114/// PIX only ever populates [`CONSTANT_TERM_COEFFICIENT`](crate::CONSTANT_TERM_COEFFICIENT), so in
115/// practice this holds exactly one key. The map shape is retained because the wire format still
116/// admits higher coefficient indices — see [`SsaPartCommitment`] for why
117/// none are produced.
118pub type TransposedVerifiers<S> = HashMap<CoefficientIndex, Vec<(PolynomialIndex, PixGroupRepr<S>)>>;
119
120impl<P> SsaPolynomialId<P> {
121    /// Creates a new `SsaPolynomialId` with the given `SsaId` and polynomial index.
122    pub fn new(id: SsaId<P>, poly_index: PolynomialIndex) -> Self {
123        Self { id, poly_index }
124    }
125
126    /// Pseudonym part of the `HoprSenderId`.
127    #[inline]
128    pub fn pseudonym(&self) -> &P {
129        &self.id.pseudonym
130    }
131
132    /// Index (i-value) of the Session Stealth Address (SSA).
133    #[inline]
134    pub fn ssa_index(&self) -> SsaIndex {
135        self.id.ssa_index
136    }
137
138    /// Index (j-value) of the polynomial used to reconstruct the portion of the SSA.
139    #[inline]
140    pub fn poly_index(&self) -> PolynomialIndex {
141        self.poly_index
142    }
143}
144
145impl<P> AsRef<SsaId<P>> for SsaPolynomialId<P> {
146    fn as_ref(&self) -> &SsaId<P> {
147        &self.id
148    }
149}
150
151impl<P: std::fmt::Display> std::fmt::Display for SsaPolynomialId<P> {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        write!(f, "{}:{}", self.id, self.poly_index)
154    }
155}
156
157/// Share of a polynomial used to reconstruct a portion of the Session Stealth Address (SSA).
158///
159/// This corresponds to the `P_ij(X)` of the polynomial used to reconstruct the j-th portion of i-th SSA
160/// at some value `X`.
161///
162/// The struct does not hold the `X` value, as it is usually computed from the
163/// [`nonce`](TaggedEncryptedPartialSsaShare).
164///
165/// See [`TaggedEncryptedPartialSsaShare`] and [`EncryptedPartialSsaShare`] for more details.
166#[derive(Clone, Default, Hash, PartialEq, Eq)]
167pub struct PartialSsaShare<S: PixSpec>(pub(crate) <PixScalar<S> as PrimeField>::Repr);
168
169impl<S: PixSpec> std::fmt::Debug for PartialSsaShare<S> {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.debug_struct("PartialSsaShare").finish_non_exhaustive()
172    }
173}
174
175impl<S: PixSpec> PartialSsaShare<S> {
176    /// Encrypts this partial SSA share using the given acknowledgement [`HalfKey`].
177    pub fn encrypt(
178        mut self,
179        spi: &SsaPolynomialId<S::Pseudonym>,
180        ack_key: &HalfKey,
181    ) -> errors::Result<EncryptedPartialSsaShare<S>, S::Pseudonym>
182    where
183        FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
184        EncShareSize<S>: ArraySize,
185    {
186        let mut cipher = derive_ssa_encryption_key::<S>(spi, ack_key)?;
187        cipher.apply_keystream(self.0.as_mut());
188
189        let mut out = Array::<u8, EncShareSize<S>>::default();
190        out[0..size_of::<SsaIndex>()].copy_from_slice(&spi.ssa_index().get().to_be_bytes());
191        out[size_of::<SsaIndex>()..size_of::<SsaIndex>() + size_of::<PolynomialIndex>()]
192            .copy_from_slice(&spi.poly_index().to_be_bytes());
193        out[size_of::<SsaIndex>() + size_of::<PolynomialIndex>()..].copy_from_slice(self.0.as_ref());
194        Ok(EncryptedPartialSsaShare(out))
195    }
196}
197
198impl<S: PixSpec> AsRef<<PixScalar<S> as PrimeField>::Repr> for PartialSsaShare<S> {
199    fn as_ref(&self) -> &<PixScalar<S> as PrimeField>::Repr {
200        &self.0
201    }
202}
203
204fn derive_ssa_encryption_key<S: PixSpec>(
205    spi: &SsaPolynomialId<S::Pseudonym>,
206    ack: &HalfKey,
207) -> errors::Result<S::Cipher, S::Pseudonym> {
208    let mut output = Blake3::new_derive_key(S::KEY_DERIVATION_CONTEXT)
209        .update_reader(ack.as_ref())
210        .and_then(|h| h.update_reader(spi.id.pseudonym.as_ref()))
211        .and_then(|h| h.update_reader(spi.id.ssa_index.get().to_be_bytes().as_ref()))
212        .and_then(|h| h.update_reader(spi.poly_index.to_be_bytes().as_ref()))
213        .map_err(|_| hopr_types::crypto::errors::CryptoError::InvalidInputValue("invalid ssa encryption key"))?
214        .finalize_xof();
215
216    let mut key = crypto_traits::Key::<S::Cipher>::default();
217    let mut iv = crypto_traits::Iv::<S::Cipher>::default();
218
219    let mut out = vec![0u8; key.len() + iv.len()];
220    output.fill(&mut out);
221
222    let (v_iv, v_key) = out.split_at(iv.len());
223    iv.copy_from_slice(v_iv);
224    key.copy_from_slice(v_key);
225
226    Ok(S::Cipher::new(&key, &iv))
227}
228
229/// Size of the field-bytes portion of the encrypted share.
230pub type FieldBytesSize<S> = <<S as PixSpec>::Curve as Curve>::FieldBytesSize;
231
232/// Total size of the [`EncryptedPartialSsaShare`] internal representation:
233/// `SsaPolyIndexPrefixSize` + FieldBytesSize.
234pub type EncShareSize<S> = Sum<FieldBytesSize<S>, SsaPolyIndexPrefixSize>;
235
236/// Contains an encrypted partial Session Stealth Address (SSA) share.
237///
238/// The internal byte layout is:
239/// 1. [`SsaIndex`] (big-endian, 4 bytes)
240/// 2. [`PolynomialIndex`] (big-endian, 2 bytes)
241/// 3. The encrypted scalar share (FieldBytesSize bytes)
242///
243/// This share can be decrypted to [`PartialSsaShare`]
244/// to be verified and used for reconstruction.
245#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
246pub struct EncryptedPartialSsaShare<S: PixSpec>(Array<u8, EncShareSize<S>>)
247where
248    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
249    EncShareSize<S>: ArraySize;
250
251#[cfg(feature = "serde")]
252impl<S: PixSpec> serde::Serialize for EncryptedPartialSsaShare<S>
253where
254    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
255    EncShareSize<S>: ArraySize,
256{
257    fn serialize<Ser: serde::Serializer>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error> {
258        serde_bytes::Bytes::new(self.0.as_ref()).serialize(serializer)
259    }
260}
261
262#[cfg(feature = "serde")]
263impl<'de, S: PixSpec> serde::Deserialize<'de> for EncryptedPartialSsaShare<S>
264where
265    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
266    EncShareSize<S>: ArraySize,
267{
268    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
269        let bytes = <&serde_bytes::Bytes as serde::Deserialize>::deserialize(deserializer)?;
270        Self::try_from(bytes.as_ref()).map_err(serde::de::Error::custom)
271    }
272}
273
274impl<S: PixSpec> EncryptedPartialSsaShare<S>
275where
276    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
277    EncShareSize<S>: ArraySize,
278{
279    /// [`SsaIndex`] and [`PolynomialIndex`] embedded in this encrypted share.
280    ///
281    /// Returns `None` if the share is empty.
282    pub fn indices(&self) -> Option<(SsaIndex, PolynomialIndex)> {
283        let ssa_index: Option<SsaIndex> =
284            RawSsaIndex::from_be_bytes(self.0[0..size_of::<RawSsaIndex>()].try_into().ok()?)
285                .try_into()
286                .ok();
287
288        let poly_index = PolynomialIndex::from_be_bytes(
289            self.0[size_of::<RawSsaIndex>()..size_of::<RawSsaIndex>() + size_of::<PolynomialIndex>()]
290                .try_into()
291                .ok()?,
292        );
293
294        ssa_index.map(|ssa_index| (ssa_index, poly_index))
295    }
296
297    /// Tries to decrypt the encrypted partial SSA share using the provided pseudonym and
298    /// acknowledgement [`HalfKey`].
299    ///
300    /// NOTE: that the share must be verified by the reconstructor.
301    pub(crate) fn decrypt(
302        self,
303        pseudonym: &S::Pseudonym,
304        ack_key: &HalfKey,
305    ) -> errors::Result<PartialSsaShare<S>, S::Pseudonym> {
306        if let Some((ssa_index, poly_index)) = self.indices() {
307            let spi = SsaPolynomialId::new(SsaId::new(*pseudonym, ssa_index), poly_index);
308            let mut cipher = derive_ssa_encryption_key::<S>(&spi, ack_key)?;
309            let mut share = <PixScalar<S> as PrimeField>::Repr::default();
310            share.copy_from_slice(&self.0[SsaPolyIndexPrefixSize::USIZE..]);
311            cipher.apply_keystream(share.as_mut());
312            Ok(PartialSsaShare(share))
313        } else {
314            Err(PixError::ShareIsEmpty)
315        }
316    }
317
318    /// Returns true if the encrypted share is empty (all zeroes or zero SSA index).
319    #[inline]
320    pub fn is_empty(&self) -> bool {
321        self.0 == Array::<u8, EncShareSize<S>>::default() || self.indices().is_none()
322    }
323}
324
325impl<S: PixSpec + Copy> Copy for EncryptedPartialSsaShare<S>
326where
327    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
328    EncShareSize<S>: ArraySize,
329    Array<u8, EncShareSize<S>>: Copy,
330{
331}
332
333impl<S: PixSpec> AsRef<[u8]> for EncryptedPartialSsaShare<S>
334where
335    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
336    EncShareSize<S>: ArraySize,
337{
338    fn as_ref(&self) -> &[u8] {
339        &self.0
340    }
341}
342
343impl<'a, S: PixSpec> TryFrom<&'a [u8]> for EncryptedPartialSsaShare<S>
344where
345    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
346    EncShareSize<S>: ArraySize,
347{
348    type Error = GeneralError;
349
350    fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
351        Array::try_from(value)
352            .map(Self)
353            .map_err(|_| GeneralError::ParseError("EncryptedPartialSsaShare.size".into()))
354    }
355}
356
357impl<S: PixSpec> BytesRepresentable for EncryptedPartialSsaShare<S>
358where
359    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
360    EncShareSize<S>: ArraySize,
361{
362    const SIZE: usize = EncShareSize::<S>::USIZE;
363}
364
365/// This is a wrapped [`EncryptedPartialSsaShare`] extracted from a specific SURB (presumably along with the associated
366/// `AcknowledgementChallenge`).
367#[derive(Clone, Debug, PartialEq, Eq)]
368#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
369#[cfg_attr(
370    feature = "serde",
371    serde(bound(
372        serialize = "P: serde::Serialize, T: serde::Serialize, EncryptedPartialSsaShare<S>: serde::Serialize",
373        deserialize = "P: serde::Deserialize<'de>, T: serde::Deserialize<'de>, EncryptedPartialSsaShare<S>: \
374                       serde::Deserialize<'de>"
375    ))
376)]
377pub struct TaggedEncryptedPartialSsaShare<S: PixSpec, P = <S as PixSpec>::Pseudonym, T = PixScalar<S>> {
378    /// Pseudonym of the sender that created the encrypted partial SSA share.
379    pub pseudonym: P,
380    /// Nonce used to generate the encrypted partial SSA share.
381    ///
382    /// This must typically be convertible to [`PixScalar`] of `S`.
383    pub nonce: T,
384    /// Encrypted partial SSA share.
385    pub partial_share: EncryptedPartialSsaShare<S>,
386}
387
388impl<S: PixSpec> TaggedEncryptedPartialSsaShare<S, S::Pseudonym, PixScalar<S>> {
389    /// Creates a new tagged encrypted partial SSA share from the
390    /// [encrypted partial SSA share](TaggedEncryptedPartialSsaShare::partial_share) that must correspond
391    /// to the given `pseudonym` and `nonce`.
392    pub fn new(
393        pseudonym: S::Pseudonym,
394        nonce: &impl AsRef<[u8]>,
395        partial_share: EncryptedPartialSsaShare<S>,
396    ) -> errors::Result<Self, S::Pseudonym> {
397        if let Some((ssa_index, poly_index)) = partial_share.indices() {
398            Ok(Self {
399                pseudonym,
400                nonce: S::msg_to_scalar(
401                    &SsaPolynomialId::new(SsaId::new(pseudonym, ssa_index), poly_index),
402                    nonce.as_ref(),
403                )?,
404                partial_share,
405            })
406        } else {
407            Err(PixError::ShareIsEmpty)
408        }
409    }
410}
411
412impl<S: PixSpec> TaggedEncryptedPartialSsaShare<S, S::Pseudonym, PixScalar<S>> {
413    /// SSA ID this share corresponds to.
414    ///
415    /// Returns `None` if the share is empty.
416    #[inline]
417    pub fn ssa_id(&self) -> Option<SsaId<S::Pseudonym>> {
418        self.partial_share
419            .indices()
420            .map(|(ssa_index, _)| SsaId::new(self.pseudonym, ssa_index))
421    }
422
423    /// SSA polynomial ID this share corresponds to.
424    ///
425    /// Returns `None` if the share is empty.
426    #[inline]
427    pub fn ssa_polynomial_id(&self) -> Option<SsaPolynomialId<S::Pseudonym>> {
428        self.partial_share
429            .indices()
430            .map(|(ssa_index, poly_index)| SsaPolynomialId::new(SsaId::new(self.pseudonym, ssa_index), poly_index))
431    }
432}
433
434impl<S: PixSpec + Copy, P: Copy, T: Copy> Copy for TaggedEncryptedPartialSsaShare<S, P, T> where
435    EncryptedPartialSsaShare<S>: Copy
436{
437}
438
439/// Contains a generated share from a specific previously committed SSA.
440#[derive(Clone, PartialEq, Eq)]
441pub struct GeneratedShare<S: PixSpec, P = <S as PixSpec>::Pseudonym> {
442    /// ID of the polynomial corresponding to the partial SSA share.
443    pub id: SsaPolynomialId<P>,
444    /// Generated partial SSA share.
445    pub share: PartialSsaShare<S>,
446}
447
448impl<S: PixSpec, P: std::fmt::Debug> std::fmt::Debug for GeneratedShare<S, P> {
449    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450        f.debug_struct("GeneratedShare")
451            .field("id", &self.id)
452            .finish_non_exhaustive()
453    }
454}
455
456impl<S: PixSpec> GeneratedShare<S, S::Pseudonym> {
457    /// Convenience method to [encrypt](PartialSsaShare::encrypt) the share using an acknowledgement [`HalfKey`].
458    #[inline]
459    pub fn encrypt(self, ack: &HalfKey) -> errors::Result<EncryptedPartialSsaShare<S>, S::Pseudonym> {
460        self.share.encrypt(&self.id, ack)
461    }
462}
463
464/// Wire form of a [`PixScalar`].
465type ScalarRepr<S> = <PixScalar<S> as PrimeField>::Repr;
466
467/// Proof that whoever published an [`SsaCommitment`] knows its discrete logarithm.
468///
469/// ## Why this exists
470///
471/// The SSA deposit key is `s + e`, where `s` is the sum of the Entry's polynomial constant terms and
472/// `e` is the Exit's commitment secret. The deposit is safe precisely because neither party knows
473/// the sum. But the Exit publishes `e·G` *first* — the `SsaRequest` message carries it so the Entry
474/// can derive the address it has to fund — and without this proof nothing stops a malicious Entry
475/// from picking a `w` it knows, publishing constant terms that sum to `w·G − e·G`, and ending up
476/// with a deposit address whose key is `w`. It could then sweep its own deposit while the
477/// polynomial whose constant term it does *not* know never yields a valid share, so the Exit is
478/// never paid — and because the Entry chooses the order in which polynomials are drained, it can
479/// place that one last and be served nearly the whole cycle first.
480///
481/// Requiring proof of knowledge of `s` closes this, and the case analysis is exhaustive:
482///
483/// * if the Entry can produce the proof it knows `s`, and then `s + e` is out of reach because `e` is not;
484/// * if it cannot, the Exit rejects the SSA before it ever publishes a deposit address.
485///
486/// The proof is over the *sum* rather than per polynomial on purpose: an individual constant-term
487/// commitment whose discrete log the Entry does not know is harmless, as long as the sum's is known,
488/// because the deposit key is then still unreachable.
489///
490/// The Exit needs no matching proof as long as it keeps committing first — it cannot adapt `e·G` to
491/// the Entry's commitment, so the symmetric attack is unavailable to it. Reversing the message order
492/// would move the exploit to the Exit and oblige *it* to prove instead.
493///
494/// ## Construction
495///
496/// A standard non-interactive Schnorr proof of knowledge: `R = r·G`,
497/// `c = H(ssa_id ‖ commitment ‖ R)`, `z = r + c·s`, verified as `z·G == R + c·commitment`. Neither
498/// component is secret, so both travel and print in the clear.
499#[derive(Debug)]
500#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
501#[cfg_attr(
502    feature = "serde",
503    serde(bound(
504        serialize = "PixGroupRepr<S>: serde::Serialize, ScalarRepr<S>: serde::Serialize",
505        deserialize = "PixGroupRepr<S>: serde::Deserialize<'de>, ScalarRepr<S>: serde::Deserialize<'de>"
506    ))
507)]
508pub struct SsaCommitmentProof<S: PixSpec> {
509    /// Commitment to the proof nonce, `R = r·G`.
510    nonce_commitment: PixGroupRepr<S>,
511    /// Response, `z = r + c·s`.
512    response: ScalarRepr<S>,
513}
514
515// Both components are plain byte arrays, so these are unconditional in `S`. Deriving them instead
516// would demand `S: Clone + PartialEq + Eq`, which callers generic only over `PixSpec` cannot supply.
517impl<S: PixSpec> Clone for SsaCommitmentProof<S> {
518    fn clone(&self) -> Self {
519        *self
520    }
521}
522
523impl<S: PixSpec> Copy for SsaCommitmentProof<S> {}
524
525impl<S: PixSpec> PartialEq for SsaCommitmentProof<S> {
526    fn eq(&self, other: &Self) -> bool {
527        self.nonce_commitment.as_ref() == other.nonce_commitment.as_ref()
528            && AsRef::<[u8]>::as_ref(&self.response) == AsRef::<[u8]>::as_ref(&other.response)
529    }
530}
531
532impl<S: PixSpec> Eq for SsaCommitmentProof<S> {}
533
534impl<S: PixSpec> SsaCommitmentProof<S> {
535    /// Byte size of the serialized proof: the nonce commitment followed by the response.
536    pub const SIZE: usize = size_of::<PixGroupRepr<S>>() + size_of::<ScalarRepr<S>>();
537
538    /// Proves knowledge of `secret`, the discrete logarithm of `ssa_commitment`.
539    pub fn prove(
540        ssa_id: &SsaId<S::Pseudonym>,
541        secret: &PixScalar<S>,
542        ssa_commitment: &PixGroup<S>,
543    ) -> errors::Result<Self, S::Pseudonym> {
544        // The nonce must be freshly random for every proof. Two proofs sharing an `r` but having
545        // different challenges expose `secret` as `(z₁ − z₂)/(c₁ − c₂)`, so this must never be
546        // derived from the SSA id, the commitment, or anything else that repeats.
547        let nonce =
548            <PixScalar<S> as crypto_traits::elliptic_curve::Field>::random(&mut hopr_types::crypto_random::rng());
549        let nonce_commitment = PixGroup::<S>::mul_by_generator(&nonce);
550        let challenge = S::commitment_proof_challenge(ssa_id, ssa_commitment, &nonce_commitment)?;
551
552        Ok(Self {
553            nonce_commitment: nonce_commitment.to_bytes(),
554            response: (nonce + challenge * *secret).to_repr(),
555        })
556    }
557
558    /// Checks the proof against the `ssa_commitment` it is supposed to open.
559    ///
560    /// Returns `false` for anything malformed as well as for a genuine verification failure — a
561    /// caller cannot act differently on the two, since both mean the commitment is unusable.
562    pub fn verify(&self, ssa_id: &SsaId<S::Pseudonym>, ssa_commitment: &PixGroup<S>) -> bool {
563        let Some(nonce_commitment) = Option::<PixGroup<S>>::from(PixGroup::<S>::from_bytes(&self.nonce_commitment))
564        else {
565            return false;
566        };
567        // Same subgroup check the coefficient commitments get: on a curve with a cofactor a
568        // small-order `R` would let the verification equation hold for a wrong response.
569        if !bool::from(crate::CofactorGroup::is_torsion_free(&nonce_commitment)) {
570            return false;
571        }
572        let Some(response) = Option::<PixScalar<S>>::from(PixScalar::<S>::from_repr(self.response)) else {
573            return false;
574        };
575        let Ok(challenge) = S::commitment_proof_challenge(ssa_id, ssa_commitment, &nonce_commitment) else {
576            return false;
577        };
578
579        PixGroup::<S>::mul_by_generator(&response) == nonce_commitment + *ssa_commitment * challenge
580    }
581
582    /// Serializes the proof as `nonce_commitment ‖ response`, exactly [`Self::SIZE`] bytes.
583    pub fn to_bytes(&self) -> Vec<u8> {
584        let mut out = Vec::with_capacity(Self::SIZE);
585        out.extend_from_slice(self.nonce_commitment.as_ref());
586        out.extend_from_slice(self.response.as_ref());
587        out
588    }
589
590    /// Parses a proof from the layout produced by [`Self::to_bytes`].
591    ///
592    /// Only the length is checked here; whether the components are meaningful is decided by
593    /// [`Self::verify`], so that a malformed proof and an invalid one take the same path.
594    pub fn try_from_bytes(bytes: &[u8]) -> errors::Result<Self, S::Pseudonym> {
595        if bytes.len() != Self::SIZE {
596            return Err(PixError::InvalidInput);
597        }
598
599        let (nonce_bytes, response_bytes) = bytes.split_at(size_of::<PixGroupRepr<S>>());
600        let mut nonce_commitment = PixGroupRepr::<S>::default();
601        AsMut::<[u8]>::as_mut(&mut nonce_commitment).copy_from_slice(nonce_bytes);
602        let mut response = ScalarRepr::<S>::default();
603        AsMut::<[u8]>::as_mut(&mut response).copy_from_slice(response_bytes);
604
605        Ok(Self {
606            nonce_commitment,
607            response,
608        })
609    }
610}
611
612/// Contains commitment to a specific SSA and corresponding verifier.
613#[derive(Debug, Clone, PartialEq, Eq)]
614#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
615pub struct SsaCommitment<S: PixSpec, P = <S as PixSpec>::Pseudonym> {
616    /// ID of the SSA that is being committed to.
617    pub ssa_id: SsaId<P>,
618    /// Commitment to the SSA.
619    #[cfg_attr(feature = "serde", serde(with = "elliptic_curve_tools::group"))]
620    pub ssa_commitment: PixGroup<S>,
621    /// Proof that the sender knows the discrete logarithm of [`Self::ssa_commitment`].
622    ///
623    /// Without it the recipient cannot tell a genuine commitment from one crafted so that the
624    /// *combined* deposit key is known to the sender alone — see [`SsaCommitmentProof`].
625    pub commitment_proof: SsaCommitmentProof<S>,
626    /// Commitments to the polynomials' constant terms, keyed by coefficient index.
627    ///
628    /// Always exactly one entry, at [`CONSTANT_TERM_COEFFICIENT`](crate::CONSTANT_TERM_COEFFICIENT).
629    #[cfg_attr(
630        feature = "serde",
631        serde(bound(
632            serialize = "PixGroupRepr<S>: serde::Serialize",
633            deserialize = "PixGroupRepr<S>: serde::Deserialize<'de>"
634        ))
635    )]
636    pub verifiers: TransposedVerifiers<S>,
637}
638
639impl<S: PixSpec, P> IntoIterator for SsaCommitment<S, P> {
640    type IntoIter = std::collections::hash_map::IntoIter<CoefficientIndex, Vec<(PolynomialIndex, PixGroupRepr<S>)>>;
641    type Item = (CoefficientIndex, Vec<(PolynomialIndex, PixGroupRepr<S>)>);
642
643    fn into_iter(self) -> Self::IntoIter {
644        self.verifiers.into_iter()
645    }
646}
647
648impl<S: PixSpec> SsaCommitment<S, S::Pseudonym> {
649    /// Reconstructs the per-polynomial commitments from the transposed representation, ordered by
650    /// polynomial index.
651    ///
652    /// Only the [`crate::CONSTANT_TERM_COEFFICIENT`] row is read; any other coefficient index is
653    /// ignored, matching what the Exit does with one on the wire.
654    pub fn reconstruct_part_commitments(self) -> errors::Result<Vec<SsaPartCommitment<S>>, S::Pseudonym> {
655        let constant_terms: BTreeMap<PolynomialIndex, PixGroupRepr<S>> = self
656            .verifiers
657            .get(&crate::CONSTANT_TERM_COEFFICIENT)
658            .into_iter()
659            .flatten()
660            .copied()
661            .collect();
662
663        constant_terms
664            .into_iter()
665            .map(|(poly_idx, commitment)| {
666                Ok(SsaPartCommitment::from_decoded_commitment(
667                    SsaPolynomialId::new(self.ssa_id, poly_idx),
668                    SsaPartCommitment::<S>::decode_commitment(&commitment)?,
669                ))
670            })
671            .collect()
672    }
673
674    /// Shorthand to pass all the coefficient commitments into the [reconstructor](ExitAcknowledgementShareProcessor).
675    ///
676    /// The proof accompanies the constant-term batch, mirroring how the wire messages carry it.
677    /// In practice that is the only batch there is — see [`SsaPartCommitment`] — but the loop is
678    /// kept over the whole map because the type still admits higher coefficient indices.
679    pub fn process_into_reconstructor<R: ExitAcknowledgementShareProcessor<S>>(
680        self,
681        reconstructor: &R,
682    ) -> Result<(), R::Error> {
683        let ssa_id = self.ssa_id;
684        let proof = self.commitment_proof;
685        for (coeff_idx, coeffs) in self.verifiers {
686            let batch_proof = (coeff_idx == crate::CONSTANT_TERM_COEFFICIENT).then_some(proof);
687            reconstructor.insert_coefficient_commitments(ssa_id, coeff_idx, batch_proof, coeffs.into_iter())?;
688        }
689        Ok(())
690    }
691}
692
693/// Represents the current state of a specific SSA commitment on an Exit node.
694#[derive(Debug, Clone, Copy, PartialEq, Eq)]
695#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
696pub struct SsaCommitmentState<P, A> {
697    /// ID of the SSA that is being committed to.
698    pub ssa_id: SsaId<P>,
699    /// Commitment to the SSA, if it's already known.
700    pub ssa_deposit_address: Option<A>,
701    /// Whether the commitment is fully committed and therefore its partial shares are verifiable.
702    pub is_verifiable: bool,
703    /// Whether this SSA was encountered for the first time.
704    pub is_first_encountered: bool,
705    /// Whether the SSA deposit address has been discovered.
706    pub deposit_address_first_encountered: bool,
707}
708
709impl<P, A> SsaCommitmentState<P, A> {
710    /// Creates a new SsaCommitmentState for the given SSA ID.
711    ///
712    /// It has no associated commitment and is not verifiable initially.
713    pub fn new(ssa_id: SsaId<P>) -> Self {
714        Self {
715            ssa_id,
716            ssa_deposit_address: None,
717            is_verifiable: false,
718            is_first_encountered: true,
719            deposit_address_first_encountered: false,
720        }
721    }
722}
723
724/// Absolute recovery progress for a single SSA cycle.
725///
726/// Carries running totals rather than per-batch deltas, so a consumer can detect a stall, a
727/// completion, or a protocol violation without having received every snapshot. That matters because
728/// acknowledgement batches are processed concurrently: snapshots can arrive out of order, and one
729/// may be dropped under load. A consumer should keep the maximum it has seen and treat a lower or
730/// repeated `useful_shares` as benign noise.
731///
732/// `P` is the pseudonym type.
733#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
734pub struct SsaRecoveryProgress<P> {
735    /// The SSA this snapshot describes.
736    pub ssa_id: SsaId<P>,
737    /// Shares that advanced reconstruction — verified, distinct, and below their polynomial's
738    /// threshold when they arrived. Excludes duplicates and the surplus a conforming Entry sends.
739    pub useful_shares: u64,
740    /// Useful shares that constitute full recovery: `polynomials × threshold`.
741    ///
742    /// A consumer that negotiated the dimensions can compare this against its own expectation; a
743    /// mismatch is a protocol violation rather than drift.
744    pub target_useful_shares: u64,
745    /// Polynomial parts that have reconstructed and opened their commitment.
746    pub recovered_polynomials: u16,
747}
748
749/// Contains the already recovered secret scalar corresponding to a specific SSA.
750///
751/// `P` is the pseudonym type, `A` is the private key type for SSA.
752#[derive(Clone, Copy)]
753pub struct RecoveredSsa<P, A> {
754    /// ID of the SSA that was recovered.
755    pub ssa_id: SsaId<P>,
756    /// Recovered secret scalar (private key corresponding to the SSA deposit address).
757    pub ssa: A,
758}
759
760impl<P: std::fmt::Debug, A> std::fmt::Debug for RecoveredSsa<P, A> {
761    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
762        f.debug_struct("RecoveredSsa")
763            .field("ssa_id", &self.ssa_id)
764            .finish_non_exhaustive()
765    }
766}
767
768impl<P: PartialEq, A> PartialEq for RecoveredSsa<P, A> {
769    fn eq(&self, other: &Self) -> bool {
770        self.ssa_id == other.ssa_id
771    }
772}
773
774impl<P: Eq, A> Eq for RecoveredSsa<P, A> {}
775
776impl<P: std::hash::Hash, A> std::hash::Hash for RecoveredSsa<P, A> {
777    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
778        self.ssa_id.hash(state);
779    }
780}
781
782#[cfg(test)]
783mod tests {
784    use hopr_types::{crypto::types::SimplePseudonym, crypto_random::Randomizable};
785    use vsss_rs::elliptic_curve::Field;
786
787    use super::*;
788    use crate::tests::TestSpec;
789
790    #[test]
791    fn test_tagged_encrypted_partial_ssa_share_traits() {
792        let pseudonym = SimplePseudonym::random();
793        let nonce = 42u64;
794        let partial_share = EncryptedPartialSsaShare::<TestSpec>::default();
795        let tagged = TaggedEncryptedPartialSsaShare {
796            pseudonym,
797            nonce,
798            partial_share,
799        };
800
801        // Test Clone
802        #[allow(clippy::clone_on_copy)]
803        let cloned = tagged.clone();
804        assert_eq!(tagged, cloned);
805
806        // Test Copy
807        let copied = tagged;
808        assert_eq!(tagged, copied);
809
810        // Test Debug
811        let debug_str = format!("{:?}", tagged);
812        assert!(debug_str.contains("TaggedEncryptedPartialSsaShare"));
813        assert!(debug_str.contains("pseudonym"));
814        assert!(debug_str.contains("nonce"));
815        assert!(debug_str.contains("partial_share"));
816    }
817
818    #[cfg(feature = "serde")]
819    #[test]
820    fn test_tagged_encrypted_partial_ssa_share_serde() {
821        let pseudonym = SimplePseudonym::random();
822        let nonce = 42u64;
823        let partial_share = EncryptedPartialSsaShare::<TestSpec>::default();
824        let _tagged = TaggedEncryptedPartialSsaShare {
825            pseudonym,
826            nonce,
827            partial_share,
828        };
829        // Compile-time check only for now as no serde_json is available in this crate
830    }
831
832    #[test]
833    fn size_of_indices_must_match() {
834        let actual = size_of::<SsaIndex>() + size_of::<PolynomialIndex>();
835        let expected = SsaPolyIndexPrefixSize::USIZE;
836        assert_eq!(actual, expected);
837    }
838
839    #[test]
840    fn default_enc_share_is_empty() {
841        assert!(EncryptedPartialSsaShare::<TestSpec>::default().is_empty());
842        assert!(EncryptedPartialSsaShare::<TestSpec>::default().indices().is_none());
843    }
844
845    #[test]
846    fn ssa_part_shares_should_encrypt_and_decrypt() -> anyhow::Result<()> {
847        let key = HalfKey::random();
848        let spi = SsaPolynomialId::<SimplePseudonym>::new(SsaId::new(SimplePseudonym::random(), 1.try_into()?), 0);
849        let scalar = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
850
851        let share_1 = PartialSsaShare::<TestSpec>(scalar.to_repr());
852        let share_2 = share_1.clone().encrypt(&spi, &key)?.decrypt(spi.pseudonym(), &key)?;
853
854        assert_eq!(share_1, share_2);
855        Ok(())
856    }
857
858    #[test]
859    fn debug_redaction_partial_ssa_share() {
860        let scalar = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
861        let share = PartialSsaShare::<TestSpec>(scalar.to_repr());
862        let debug = format!("{:?}", share);
863        assert!(debug.contains("PartialSsaShare"));
864        // The scalar repr should not appear in Debug output
865        assert_eq!(
866            debug, "PartialSsaShare { .. }",
867            "PartialSsaShare Debug must exactly match the redacted format"
868        );
869    }
870
871    #[test]
872    fn debug_redaction_generated_share() {
873        use crate::tests::TestSpec;
874
875        let scalar = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
876        let share = PartialSsaShare::<TestSpec>(scalar.to_repr());
877        let id = SsaPolynomialId::new(
878            SsaId::new(
879                SimplePseudonym::try_from([0u8; 10].as_ref()).unwrap(),
880                1.try_into().unwrap(),
881            ),
882            0,
883        );
884        let generated = GeneratedShare { id, share };
885        let debug = format!("{:?}", generated);
886
887        // Must include the public ID field
888        assert!(debug.contains("GeneratedShare"));
889        assert!(debug.contains("id"));
890
891        // Must NOT include the secret share field — only the public id
892        assert_eq!(
893            debug,
894            format!("GeneratedShare {{ id: {:?}, .. }}", id),
895            "GeneratedShare Debug must expose only the id field"
896        );
897    }
898
899    #[test]
900    fn debug_redaction_recovered_ssa() {
901        use hopr_types::crypto::{keypairs::Keypair, prelude::ChainKeypair};
902
903        let pseudonym = SimplePseudonym::random();
904        let ssa_id = SsaId::new(pseudonym, 1.try_into().unwrap());
905        let dummy_key = ChainKeypair::random();
906        let recovered = RecoveredSsa { ssa_id, ssa: dummy_key };
907        let debug = format!("{:?}", recovered);
908
909        // Must include the public ssa_id field
910        assert!(debug.contains("RecoveredSsa"));
911        assert!(debug.contains("ssa_id"));
912
913        // Must NOT include the secret ssa field — only the public ssa_id
914        assert_eq!(
915            debug,
916            format!("RecoveredSsa {{ ssa_id: {:?}, .. }}", ssa_id),
917            "RecoveredSsa Debug must expose only the ssa_id field"
918        );
919    }
920
921    /// `RecoveredSsa`'s `PartialEq` and `Hash` are hand-written to ignore the key, so that `A` need
922    /// not be comparable or hashable. Two recoveries of the same SSA are the same event.
923    #[test]
924    fn recovered_ssa_equality_and_hash_ignore_the_key() {
925        use std::hash::{Hash as _, Hasher as _};
926
927        use hopr_types::crypto::{keypairs::Keypair, prelude::ChainKeypair};
928
929        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into().unwrap());
930        let other_id = SsaId::new(SimplePseudonym::random(), 2.try_into().unwrap());
931
932        let hash_of = |r: &RecoveredSsa<SimplePseudonym, ChainKeypair>| {
933            let mut h = std::collections::hash_map::DefaultHasher::new();
934            r.hash(&mut h);
935            h.finish()
936        };
937
938        let a = RecoveredSsa {
939            ssa_id,
940            ssa: ChainKeypair::random(),
941        };
942        let b = RecoveredSsa {
943            ssa_id,
944            ssa: ChainKeypair::random(),
945        };
946        let c = RecoveredSsa {
947            ssa_id: other_id,
948            ssa: ChainKeypair::random(),
949        };
950
951        assert_eq!(a, b, "two different keys for the same SSA must compare equal");
952        assert_eq!(hash_of(&a), hash_of(&b), "and must hash equal");
953        assert_ne!(a, c);
954        assert_ne!(hash_of(&a), hash_of(&c));
955    }
956
957    #[test]
958    fn ssa_ids_expose_their_parts_and_display_hierarchically() {
959        let pseudonym = SimplePseudonym::random();
960        let ssa_index: SsaIndex = 7.try_into().unwrap();
961        let ssa_id = SsaId::new(pseudonym, ssa_index);
962
963        assert_eq!(ssa_id.pseudonym(), &pseudonym);
964        assert_eq!(ssa_id.ssa_index(), ssa_index);
965        assert_eq!(ssa_id.to_string(), format!("{pseudonym}-ssa#7"));
966
967        let spi = SsaPolynomialId::new(ssa_id, 3);
968        assert_eq!(spi.pseudonym(), &pseudonym);
969        assert_eq!(spi.ssa_index(), ssa_index);
970        assert_eq!(spi.poly_index(), 3);
971        // The polynomial id borrows the SSA id it was built from, so cycle-scoped lookups can key
972        // off a polynomial-scoped value without rebuilding one.
973        assert_eq!(AsRef::<SsaId<_>>::as_ref(&spi), &ssa_id);
974        assert_eq!(spi.to_string(), format!("{ssa_id}:3"));
975    }
976
977    #[test]
978    fn encrypted_share_round_trips_through_bytes_and_rejects_the_wrong_length() {
979        let key = HalfKey::random();
980        let spi =
981            SsaPolynomialId::<SimplePseudonym>::new(SsaId::new(SimplePseudonym::random(), 5.try_into().unwrap()), 9);
982        let scalar = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
983        let encrypted = PartialSsaShare::<TestSpec>(scalar.to_repr())
984            .encrypt(&spi, &key)
985            .expect("encryption must succeed");
986
987        // The indices are carried in the clear as a prefix, so the Exit can route a share before it
988        // has the key to decrypt it.
989        assert_eq!(encrypted.indices(), Some((spi.ssa_index(), spi.poly_index())));
990        assert!(!encrypted.is_empty());
991
992        let bytes: &[u8] = encrypted.as_ref();
993        assert_eq!(bytes.len(), EncryptedPartialSsaShare::<TestSpec>::SIZE);
994        assert_eq!(
995            EncryptedPartialSsaShare::<TestSpec>::try_from(bytes).expect("exact length must parse"),
996            encrypted
997        );
998
999        for wrong in [&bytes[..bytes.len() - 1], &[][..]] {
1000            assert!(
1001                EncryptedPartialSsaShare::<TestSpec>::try_from(wrong).is_err(),
1002                "a {}-byte buffer must not parse as a {}-byte share",
1003                wrong.len(),
1004                EncryptedPartialSsaShare::<TestSpec>::SIZE
1005            );
1006        }
1007    }
1008
1009    #[cfg(feature = "serde")]
1010    #[test]
1011    fn encrypted_share_serde_round_trips_as_opaque_bytes() {
1012        let key = HalfKey::random();
1013        let spi =
1014            SsaPolynomialId::<SimplePseudonym>::new(SsaId::new(SimplePseudonym::random(), 2.try_into().unwrap()), 1);
1015        let scalar = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
1016        let encrypted = PartialSsaShare::<TestSpec>(scalar.to_repr())
1017            .encrypt(&spi, &key)
1018            .expect("encryption must succeed");
1019
1020        // Serialized as a byte string rather than a sequence of integers, so the encoding stays the
1021        // same size on the wire as the share itself.
1022        let encoded = serde_cbor_2::to_vec(&encrypted).expect("serialization must succeed");
1023        let decoded: EncryptedPartialSsaShare<TestSpec> =
1024            serde_cbor_2::from_slice(&encoded).expect("deserialization must succeed");
1025        assert_eq!(decoded, encrypted);
1026
1027        // A short byte string must be rejected by the same length check `TryFrom` applies.
1028        // `0x43` is a CBOR byte string of length 3.
1029        let truncated = [0x43u8, 0x00, 0x00, 0x00];
1030        assert!(serde_cbor_2::from_slice::<EncryptedPartialSsaShare<TestSpec>>(&truncated).is_err());
1031    }
1032
1033    #[test]
1034    fn commitment_proof_verifies_only_against_the_commitment_it_opens() -> anyhow::Result<()> {
1035        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1036        let secret = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
1037        let commitment = PixGroup::<TestSpec>::mul_by_generator(&secret);
1038
1039        let proof = SsaCommitmentProof::<TestSpec>::prove(&ssa_id, &secret, &commitment)?;
1040        assert!(proof.verify(&ssa_id, &commitment));
1041
1042        // The challenge binds the SSA id, so a proof cannot be replayed onto another cycle.
1043        let other_id = SsaId::new(*ssa_id.pseudonym(), 2.try_into()?);
1044        assert!(!proof.verify(&other_id, &commitment), "the proof must bind the SSA id");
1045
1046        // ...and onto another commitment, which is the attack it exists to stop.
1047        let other_secret = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
1048        let other_commitment = PixGroup::<TestSpec>::mul_by_generator(&other_secret);
1049        assert!(
1050            !proof.verify(&ssa_id, &other_commitment),
1051            "the proof must bind the commitment"
1052        );
1053
1054        // Two proofs of the same statement use fresh nonces, so they must differ. Reusing a nonce
1055        // across differing challenges leaks the secret.
1056        let again = SsaCommitmentProof::<TestSpec>::prove(&ssa_id, &secret, &commitment)?;
1057        assert_ne!(proof, again, "the proof nonce must be fresh for every proof");
1058        assert!(again.verify(&ssa_id, &commitment));
1059
1060        // Clone is a copy of two byte arrays; equality compares both components.
1061        #[allow(clippy::clone_on_copy)]
1062        let cloned = proof.clone();
1063        assert_eq!(proof, cloned);
1064
1065        Ok(())
1066    }
1067
1068    #[test]
1069    fn commitment_proof_round_trips_through_bytes() -> anyhow::Result<()> {
1070        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1071        let secret = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
1072        let commitment = PixGroup::<TestSpec>::mul_by_generator(&secret);
1073        let proof = SsaCommitmentProof::<TestSpec>::prove(&ssa_id, &secret, &commitment)?;
1074
1075        let bytes = proof.to_bytes();
1076        assert_eq!(bytes.len(), SsaCommitmentProof::<TestSpec>::SIZE);
1077
1078        let parsed = SsaCommitmentProof::<TestSpec>::try_from_bytes(&bytes)?;
1079        assert_eq!(parsed, proof);
1080        assert!(parsed.verify(&ssa_id, &commitment));
1081
1082        for wrong in [&bytes[..bytes.len() - 1], &[][..]] {
1083            assert!(
1084                SsaCommitmentProof::<TestSpec>::try_from_bytes(wrong).is_err(),
1085                "only the exact length may parse"
1086            );
1087        }
1088
1089        // Garbage of the right length parses but must not verify: `try_from_bytes` checks the
1090        // length only, deliberately routing a malformed proof and an invalid one down one path.
1091        let garbage = SsaCommitmentProof::<TestSpec>::try_from_bytes(&[0xAA; SsaCommitmentProof::<TestSpec>::SIZE])?;
1092        assert!(!garbage.verify(&ssa_id, &commitment));
1093
1094        Ok(())
1095    }
1096
1097    #[test]
1098    fn ssa_commitment_iterates_its_transposed_verifiers() -> anyhow::Result<()> {
1099        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1100        let secret = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
1101        let ssa_commitment = PixGroup::<TestSpec>::mul_by_generator(&secret);
1102        let commitment_proof = SsaCommitmentProof::<TestSpec>::prove(&ssa_id, &secret, &ssa_commitment)?;
1103
1104        let entries: Vec<(PolynomialIndex, PixGroupRepr<TestSpec>)> = (0..3u16)
1105            .map(|poly_index| {
1106                let s = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
1107                (poly_index, PixGroup::<TestSpec>::mul_by_generator(&s).to_bytes())
1108            })
1109            .collect();
1110
1111        let commitment = SsaCommitment::<TestSpec> {
1112            ssa_id,
1113            ssa_commitment,
1114            commitment_proof,
1115            verifiers: HashMap::from([(crate::CONSTANT_TERM_COEFFICIENT, entries.clone())]),
1116        };
1117
1118        let collected: Vec<_> = commitment.into_iter().collect();
1119        assert_eq!(collected.len(), 1, "PIX commits to the constant term and nothing else");
1120        assert_eq!(collected[0].0, crate::CONSTANT_TERM_COEFFICIENT);
1121        assert_eq!(collected[0].1, entries);
1122
1123        Ok(())
1124    }
1125
1126    #[test]
1127    fn ssa_commitment_state_starts_unverifiable_and_unaddressed() {
1128        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into().unwrap());
1129        let state = SsaCommitmentState::<SimplePseudonym, hopr_types::primitive::prelude::Address>::new(ssa_id);
1130
1131        assert_eq!(state.ssa_id, ssa_id);
1132        assert!(
1133            state.ssa_deposit_address.is_none(),
1134            "the address is the sum of constant terms, none of which have arrived yet"
1135        );
1136        assert!(!state.is_verifiable, "shares cannot verify before the commitment does");
1137        assert!(state.is_first_encountered);
1138        assert!(!state.deposit_address_first_encountered);
1139    }
1140}