Skip to main content

hopr_protocol_pix/
traits.rs

1use std::hash::Hash;
2
3use hopr_types::{
4    crypto::prelude::{HalfKeyChallenge, OffchainPublicKey},
5    internal::prelude::Acknowledgement,
6};
7
8use crate::{
9    CoefficientIndex, GeneratedShare, PixGroup, PixGroupRepr, PixParams, PixSpec, PolynomialIndex, RecoveredSsa,
10    SsaCommitment, SsaCommitmentProof, SsaCommitmentState, SsaId, SsaIndex, SsaRecoveryProgress,
11    TaggedEncryptedPartialSsaShare,
12};
13
14/// Possible resolutions of a received acknowledgement that might be bound to decrypt
15/// an encrypted PIX share.
16///
17/// `P` is the pseudonym type, `A` is the private key type for SSA.
18///
19/// ## Ordering and multiplicity within one batch
20///
21/// [`acknowledge_shares`](ExitAcknowledgementShareProcessor::acknowledge_shares) emits at most one
22/// [`Progress`](Self::Progress) and at most one [`InvalidShares`](Self::InvalidShares) per SSA per
23/// call, and orders them ahead of the terminal
24/// [`AlmostRecoveredSsa`](Self::AlmostRecoveredSsa)/[`RecoveredSsa`](Self::RecoveredSsa) for the
25/// same SSA. A consumer can therefore act on a terminal event knowing the counters it would have
26/// wanted first have already been delivered.
27#[derive(Clone, strum::EnumTryAs)]
28pub enum ShareResolution<P, A> {
29    /// Full SSA was recovered.
30    RecoveredSsa(RecoveredSsa<P, A>),
31    /// The early recovery threshold was reached (SSA almost complete).
32    AlmostRecoveredSsa(SsaId<P>),
33    /// Absolute recovery progress for one SSA after this batch.
34    Progress(SsaRecoveryProgress<P>),
35    /// Invalid (unverifiable) shares were encountered for an SSA.
36    ///
37    /// `observed_total` is the **cross-peer aggregate** for the SSA, not a delta and not this peer's
38    /// share of it: the cycle is a single unit of accounting and any relayer can carry shares for it.
39    /// `peer` identifies who relayed the share that triggered this emission, for attribution only.
40    InvalidShares {
41        /// Relayer whose acknowledgement carried the offending share.
42        peer: Box<OffchainPublicKey>,
43        /// SSA the offending share belongs to.
44        ssa_id: SsaId<P>,
45        /// Total invalid shares seen for this SSA across all peers.
46        observed_total: u64,
47    },
48}
49
50impl<P: PartialEq, A> PartialEq for ShareResolution<P, A> {
51    fn eq(&self, other: &Self) -> bool {
52        match (self, other) {
53            (Self::RecoveredSsa(a), Self::RecoveredSsa(b)) => a == b,
54            (Self::AlmostRecoveredSsa(a), Self::AlmostRecoveredSsa(b)) => a == b,
55            (Self::Progress(a), Self::Progress(b)) => a == b,
56            (
57                Self::InvalidShares {
58                    peer: p1,
59                    ssa_id: id1,
60                    observed_total: t1,
61                },
62                Self::InvalidShares {
63                    peer: p2,
64                    ssa_id: id2,
65                    observed_total: t2,
66                },
67            ) => p1 == p2 && id1 == id2 && t1 == t2,
68            _ => false,
69        }
70    }
71}
72
73impl<P: Eq, A> Eq for ShareResolution<P, A> {}
74
75impl<P: std::fmt::Debug, A> std::fmt::Debug for ShareResolution<P, A> {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        match self {
78            Self::RecoveredSsa(ssa) => f.debug_tuple("RecoveredSsa").field(ssa).finish(),
79            Self::AlmostRecoveredSsa(id) => f.debug_tuple("AlmostRecoveredSsa").field(id).finish(),
80            Self::Progress(progress) => f.debug_tuple("Progress").field(progress).finish(),
81            Self::InvalidShares {
82                peer,
83                ssa_id,
84                observed_total,
85            } => f
86                .debug_struct("InvalidShares")
87                .field("peer", peer)
88                .field("ssa_id", ssa_id)
89                .field("observed_total", observed_total)
90                .finish(),
91        }
92    }
93}
94
95impl<P: std::hash::Hash, A> Hash for ShareResolution<P, A> {
96    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
97        std::mem::discriminant(self).hash(state);
98        match self {
99            Self::RecoveredSsa(recovered) => recovered.hash(state),
100            Self::AlmostRecoveredSsa(id) => id.hash(state),
101            Self::Progress(progress) => progress.hash(state),
102            Self::InvalidShares {
103                peer,
104                ssa_id,
105                observed_total,
106            } => {
107                peer.hash(state);
108                ssa_id.hash(state);
109                observed_total.hash(state);
110            }
111        }
112    }
113}
114
115/// Type alias for a collection of [`ShareResolution`]s.
116pub type ShareResolutions<S> = Vec<ShareResolution<<S as PixSpec>::Pseudonym, <S as PixSpec>::AddressPrivateKey>>;
117
118/// Allows reconstruction of SSAs at the Exit node.
119///
120/// There are 3 inputs that the implementor is dependent on (in order):
121/// 1. SSA commitments from the Client (delivered via
122///    [`insert_coefficient_commitments`](ExitAcknowledgementShareProcessor::insert_coefficient_commitments))
123/// 2. Extraction of pending encrypted shares (added via
124///    [`insert_encrypted_share`](ExitAcknowledgementShareProcessor::insert_encrypted_share)
125/// 3. Decryption of pending encrypted shares via [`Acknowledgement`]s (via
126///    [`acknowledge_shares`](ExitAcknowledgementShareProcessor::acknowledge_shares))
127#[auto_impl::auto_impl(&, Arc, Box)]
128pub trait ExitAcknowledgementShareProcessor<S: PixSpec> {
129    type Error: std::error::Error + Send + Sync + 'static;
130
131    /// Returns `true` if the peer has pending encrypted shares awaiting an acknowledgement.
132    ///
133    /// This is a cheap, non-blocking check used by the pipeline to avoid spawning a
134    /// blocking thread-pool task for `acknowledge_shares` when there are no pending shares
135    /// for the peer. Implementations should return `false` when no shares are pending.
136    ///
137    /// The default implementation returns `true` for safety — callers must still handle
138    /// the case where `acknowledge_shares` returns no results.
139    fn has_pending_shares(&self, _peer: &OffchainPublicKey) -> bool {
140        true
141    }
142
143    /// Returns `true` if the given error is an expected "not for us" skip (e.g. no
144    /// acknowledgements from the peer were expected), so the caller can log it at a
145    /// lower severity.
146    ///
147    /// The default implementation returns `false`. Implementations with a concrete
148    /// error type should override this to identify expected error variants.
149    fn is_expected_error(&self, _error: &Self::Error) -> bool {
150        false
151    }
152
153    /// Releases heavyweight reconstructor state for a finished or torn-down SSA cycle.
154    ///
155    /// An implementation may retain bounded, non-secret post-recovery accounting for SURBs minted
156    /// before recovery and still draining through the transport. Such state must be constant-bounded
157    /// per Session and must never restore a retired verifier or commitment. Implementations must be
158    /// idempotent — retiring an unknown or already-retired cycle is a no-op.
159    fn retire_ssa(&self, ssa_id: SsaId<S::Pseudonym>);
160
161    /// Generates a new random Exit SSA commitment and registers it internally under the given `id`.
162    ///
163    /// Takes the negotiated triple whole. The surplus is not needed to *reconstruct* anything, but
164    /// it bounds how much post-reconstruction traffic the cycle will credit as evidence that the
165    /// peer is still working — so dropping it here is what previously left that unbounded.
166    fn new_exit_commitment(&self, id: SsaId<S::Pseudonym>, params: PixParams) -> Result<PixGroup<S>, Self::Error>;
167
168    /// Adds the client commitment data.
169    ///
170    /// Each "data packet" should contain an `ssa_id` of the corresponding SSA. The `index` is
171    /// the polynomial coefficient index that is common to all the polynomial coefficient commitments included in
172    /// `commitments`. In other words, the `commitments` argument contains commitments to
173    /// the same polynomial coefficients across multiple polynomials (each one with its own polynomial index).
174    ///
175    /// `proof` carries the sender's [`SsaCommitmentProof`] and is expected on messages delivering
176    /// constant terms (`index == 0`), since those are what determine the commitment it opens. The
177    /// first one supplied for an SSA is kept and the rest ignored — any single valid proof suffices.
178    /// A cycle whose constant terms are all present but which never carried a valid proof is
179    /// rejected, and no deposit address is published for it.
180    fn insert_coefficient_commitments(
181        &self,
182        ssa_id: SsaId<S::Pseudonym>,
183        index: CoefficientIndex,
184        proof: Option<SsaCommitmentProof<S>>,
185        commitments: impl Iterator<Item = (PolynomialIndex, PixGroupRepr<S>)>,
186    ) -> Result<SsaCommitmentState<S::Pseudonym, S::DepositAddress>, Self::Error>;
187
188    /// Adds an encrypted partial SSA share awaiting acknowledgement from `peer` to be decrypted.
189    ///
190    /// The `challenge` is the acknowledgement challenge that must correspond to the
191    /// acknowledgement that will be awaited.
192    fn insert_encrypted_share(
193        &self,
194        peer: &OffchainPublicKey,
195        challenge: HalfKeyChallenge,
196        tagged_enc_share: TaggedEncryptedPartialSsaShare<S>,
197    ) -> Result<(), Self::Error>;
198
199    /// Finds and acknowledges previously inserted encrypted share, using incoming [`Acknowledgement`]s
200    /// from the upstream [`peer`](OffchainPublicKey).
201    ///
202    /// Function should first check if any acknowledgements are expected from the given `peer`.
203    ///
204    /// Furthermore, the function must verify each given acknowledgement and find if it evaluates to any solutions
205    /// to challenges of previously
206    /// [inserted encrypted shares](ExitAcknowledgementShareProcessor::insert_encrypted_share).
207    ///
208    /// On success, the [resolutions](ShareResolution) contain any fully recovered SSA shares that were completed as
209    /// result of the given acknowledgements, or particular cases that lead to invalid (unverifiable) share. That
210    /// might indicate faulty behavior of the Entry, or a malicious attempt to disrupt the protocol.
211    ///
212    /// Challenges for which encrypted shares were not found are skipped.
213    ///
214    /// Must return an error if no acknowledgements from the given `peer` were expected.
215    ///
216    /// This operation is expected to be somewhat long-running and significantly blocking.
217    fn acknowledge_shares(
218        &self,
219        peer: OffchainPublicKey,
220        acks: Vec<Acknowledgement>,
221    ) -> Result<ShareResolutions<S>, Self::Error>;
222}
223
224#[auto_impl::auto_impl(&, Arc, Box)]
225pub trait EntryShareGenerator<S: PixSpec> {
226    type Error: std::error::Error + Send + Sync + 'static;
227
228    /// Generate the next [`crate::PartialSsaShare`] for the given pseudonym and message `msg`.
229    ///
230    /// IMPORTANT: Each `msg` MUST be unique for a given pseudonym.
231    ///
232    /// Returns `None` if all polynomials for the given pseudonym have been used up.
233    /// This signals that a new SSA must be committed.
234    fn next_share(
235        &self,
236        pseudonym: &S::Pseudonym,
237        msg: &impl AsRef<[u8]>,
238    ) -> Result<Option<GeneratedShare<S>>, Self::Error>;
239
240    /// Generates a new SSA commitment from the sender side, for the given `pseudonym`.
241    ///
242    /// Returns the new random SSA-commitment and the corresponding SSA share verifier.
243    fn new_ssa_commitment(
244        &self,
245        pseudonym: &S::Pseudonym,
246        ssa_index: SsaIndex,
247    ) -> Result<SsaCommitment<S>, Self::Error>;
248}
249
250#[cfg(test)]
251mod tests {
252    use std::{
253        collections::hash_map::DefaultHasher,
254        hash::{Hash as _, Hasher},
255    };
256
257    use hopr_types::{
258        crypto::{
259            keypairs::{Keypair, OffchainKeypair},
260            prelude::{ChainKeypair, SimplePseudonym},
261        },
262        crypto_random::Randomizable,
263    };
264
265    use super::*;
266    use crate::SsaRecoveryProgress;
267
268    /// `ShareResolution`'s `PartialEq`, `Debug` and `Hash` are hand-written rather than derived,
269    /// because a derive would demand `A: PartialEq + Hash + Debug` — and `A` is the recovered SSA
270    /// private key, which must stay both unconstrained and redacted. Hand-written means unchecked
271    /// by the compiler, so each arm is exercised below.
272    type Resolution = ShareResolution<SimplePseudonym, ChainKeypair>;
273
274    fn ssa_id(index: u32) -> SsaId<SimplePseudonym> {
275        SsaId::new(SimplePseudonym::random(), index.try_into().expect("non-zero index"))
276    }
277
278    fn progress(id: SsaId<SimplePseudonym>, useful: u64) -> SsaRecoveryProgress<SimplePseudonym> {
279        SsaRecoveryProgress {
280            ssa_id: id,
281            useful_shares: useful,
282            shares_seen: useful,
283            target_useful_shares: 128,
284            recovered_polynomials: 2,
285        }
286    }
287
288    fn invalid(peer: OffchainPublicKey, id: SsaId<SimplePseudonym>, total: u64) -> Resolution {
289        ShareResolution::InvalidShares {
290            peer: Box::new(peer),
291            ssa_id: id,
292            observed_total: total,
293        }
294    }
295
296    fn hash_of(value: &Resolution) -> u64 {
297        let mut hasher = DefaultHasher::new();
298        value.hash(&mut hasher);
299        hasher.finish()
300    }
301
302    #[test]
303    fn share_resolution_equality_compares_every_field_of_every_variant() {
304        let id = ssa_id(1);
305        let other_id = ssa_id(2);
306        let peer = *OffchainKeypair::random().public();
307        let other_peer = *OffchainKeypair::random().public();
308
309        // `RecoveredSsa` delegates to `RecoveredSsa`'s own `PartialEq`, which compares the id only —
310        // the key is not comparable and two recoveries of the same SSA are the same event.
311        let recovered = |id| {
312            Resolution::RecoveredSsa(RecoveredSsa {
313                ssa_id: id,
314                ssa: ChainKeypair::random(),
315            })
316        };
317        assert_eq!(recovered(id), recovered(id));
318        assert_ne!(recovered(id), recovered(other_id));
319
320        assert_eq!(Resolution::AlmostRecoveredSsa(id), Resolution::AlmostRecoveredSsa(id));
321        assert_ne!(
322            Resolution::AlmostRecoveredSsa(id),
323            Resolution::AlmostRecoveredSsa(other_id)
324        );
325
326        assert_eq!(
327            Resolution::Progress(progress(id, 10)),
328            Resolution::Progress(progress(id, 10))
329        );
330        assert_ne!(
331            Resolution::Progress(progress(id, 10)),
332            Resolution::Progress(progress(id, 11)),
333            "a progress snapshot differing only in useful_shares must not compare equal"
334        );
335
336        assert_eq!(invalid(peer, id, 3), invalid(peer, id, 3));
337        assert_ne!(
338            invalid(peer, id, 3),
339            invalid(other_peer, id, 3),
340            "peer must be compared"
341        );
342        assert_ne!(
343            invalid(peer, id, 3),
344            invalid(peer, other_id, 3),
345            "ssa_id must be compared"
346        );
347        assert_ne!(
348            invalid(peer, id, 3),
349            invalid(peer, id, 4),
350            "observed_total must be compared"
351        );
352    }
353
354    #[test]
355    fn share_resolution_of_different_variants_is_never_equal() {
356        let id = ssa_id(1);
357        let peer = *OffchainKeypair::random().public();
358
359        let all: [Resolution; 4] = [
360            Resolution::RecoveredSsa(RecoveredSsa {
361                ssa_id: id,
362                ssa: ChainKeypair::random(),
363            }),
364            Resolution::AlmostRecoveredSsa(id),
365            Resolution::Progress(progress(id, 10)),
366            invalid(peer, id, 3),
367        ];
368
369        // Every cross-variant pair must fall through to the catch-all arm, even though all four
370        // carry the same `SsaId`.
371        for (i, left) in all.iter().enumerate() {
372            for (j, right) in all.iter().enumerate() {
373                if i != j {
374                    assert_ne!(left, right, "variants {i} and {j} must not compare equal");
375                }
376            }
377        }
378    }
379
380    #[test]
381    fn share_resolution_debug_names_its_variant_and_fields() {
382        let id = ssa_id(1);
383        let peer = *OffchainKeypair::random().public();
384
385        let almost = format!("{:?}", Resolution::AlmostRecoveredSsa(id));
386        assert_eq!(almost, format!("AlmostRecoveredSsa({id:?})"));
387
388        let snapshot = progress(id, 10);
389        let progress_debug = format!("{:?}", Resolution::Progress(snapshot));
390        assert_eq!(progress_debug, format!("Progress({snapshot:?})"));
391
392        let invalid_debug = format!("{:?}", invalid(peer, id, 3));
393        assert!(invalid_debug.starts_with("InvalidShares {"), "got {invalid_debug}");
394        for field in ["peer", "ssa_id", "observed_total"] {
395            assert!(invalid_debug.contains(field), "{field} missing from {invalid_debug}");
396        }
397    }
398
399    #[test]
400    fn share_resolution_hash_agrees_with_equality() {
401        let id = ssa_id(1);
402        let other_id = ssa_id(2);
403        let peer = *OffchainKeypair::random().public();
404
405        // Equal values hash equal, for every variant.
406        assert_eq!(
407            hash_of(&Resolution::AlmostRecoveredSsa(id)),
408            hash_of(&Resolution::AlmostRecoveredSsa(id))
409        );
410        assert_eq!(
411            hash_of(&Resolution::Progress(progress(id, 10))),
412            hash_of(&Resolution::Progress(progress(id, 10)))
413        );
414        assert_eq!(hash_of(&invalid(peer, id, 3)), hash_of(&invalid(peer, id, 3)));
415        assert_eq!(
416            hash_of(&Resolution::RecoveredSsa(RecoveredSsa {
417                ssa_id: id,
418                ssa: ChainKeypair::random(),
419            })),
420            hash_of(&Resolution::RecoveredSsa(RecoveredSsa {
421                ssa_id: id,
422                ssa: ChainKeypair::random(),
423            })),
424            "RecoveredSsa hashes the id only, so the key must not perturb it"
425        );
426
427        // The discriminant is mixed in, so two variants carrying the same id do not collide.
428        assert_ne!(
429            hash_of(&Resolution::AlmostRecoveredSsa(id)),
430            hash_of(&Resolution::RecoveredSsa(RecoveredSsa {
431                ssa_id: id,
432                ssa: ChainKeypair::random(),
433            })),
434            "the discriminant must be hashed, or same-id variants collide"
435        );
436
437        // Differing payloads hash differently.
438        assert_ne!(
439            hash_of(&Resolution::AlmostRecoveredSsa(id)),
440            hash_of(&Resolution::AlmostRecoveredSsa(other_id))
441        );
442        assert_ne!(
443            hash_of(&Resolution::Progress(progress(id, 10))),
444            hash_of(&Resolution::Progress(progress(id, 11)))
445        );
446        assert_ne!(hash_of(&invalid(peer, id, 3)), hash_of(&invalid(peer, id, 4)));
447    }
448
449    /// The two provided methods exist so an implementation can stay minimal; their defaults are the
450    /// conservative choice (assume shares may be pending, assume no error is expected) and no
451    /// concrete implementation exercises them.
452    #[test]
453    fn exit_processor_defaults_are_conservative() {
454        struct Minimal;
455
456        #[derive(Debug, thiserror::Error)]
457        #[error("nope")]
458        struct MinimalError;
459
460        impl ExitAcknowledgementShareProcessor<crate::tests::TestSpec> for Minimal {
461            type Error = MinimalError;
462
463            fn retire_ssa(&self, _ssa_id: SsaId<SimplePseudonym>) {}
464
465            fn new_exit_commitment(
466                &self,
467                _id: SsaId<SimplePseudonym>,
468                _params: PixParams,
469            ) -> Result<PixGroup<crate::tests::TestSpec>, Self::Error> {
470                Err(MinimalError)
471            }
472
473            fn insert_coefficient_commitments(
474                &self,
475                _ssa_id: SsaId<SimplePseudonym>,
476                _index: CoefficientIndex,
477                _proof: Option<SsaCommitmentProof<crate::tests::TestSpec>>,
478                _commitments: impl Iterator<Item = (PolynomialIndex, PixGroupRepr<crate::tests::TestSpec>)>,
479            ) -> Result<SsaCommitmentState<SimplePseudonym, hopr_types::primitive::prelude::Address>, Self::Error>
480            {
481                Err(MinimalError)
482            }
483
484            fn insert_encrypted_share(
485                &self,
486                _peer: &OffchainPublicKey,
487                _challenge: HalfKeyChallenge,
488                _tagged_enc_share: TaggedEncryptedPartialSsaShare<crate::tests::TestSpec>,
489            ) -> Result<(), Self::Error> {
490                Err(MinimalError)
491            }
492
493            fn acknowledge_shares(
494                &self,
495                _peer: OffchainPublicKey,
496                _acks: Vec<Acknowledgement>,
497            ) -> Result<ShareResolutions<crate::tests::TestSpec>, Self::Error> {
498                Err(MinimalError)
499            }
500        }
501
502        let peer = *OffchainKeypair::random().public();
503        assert!(
504            Minimal.has_pending_shares(&peer),
505            "the default must assume shares may be pending, so the caller still calls in"
506        );
507        assert!(
508            !Minimal.is_expected_error(&MinimalError),
509            "the default must treat every error as unexpected, so nothing is silently downgraded"
510        );
511    }
512
513    #[test]
514    fn debug_redaction_share_resolution_recovered_ssa() {
515        // ShareResolution::RecoveredSsa wraps RecoveredSsa; the nested Debug
516        // must preserve the secret redaction.
517        let pseudonym = SimplePseudonym::random();
518        let ssa_id = SsaId::new(pseudonym, 1.try_into().unwrap());
519        let dummy_key = ChainKeypair::random();
520        let recovered = RecoveredSsa { ssa_id, ssa: dummy_key };
521        let recovered_debug = format!("{:?}", recovered);
522        let resolution = ShareResolution::RecoveredSsa(recovered);
523        let debug = format!("{:?}", resolution);
524
525        assert!(debug.contains("RecoveredSsa"));
526        // The outer tuple wraps the inner RecoveredSsa Debug, which redacts ssa
527        assert_eq!(
528            debug,
529            format!("RecoveredSsa({recovered_debug})"),
530            "ShareResolution::RecoveredSsa Debug must perfectly delegate to RecoveredSsa Debug"
531        );
532    }
533}