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