Skip to main content

hopr_protocol_pix/
ack_verify.rs

1//! Common acknowledgement verification helper shared by acknowledgement processors.
2//!
3//! Both the `acknowledge_shares` method on [`crate::ExitAcknowledgementShareProcessor`] and the
4//! `HoprUnacknowledgedTicketProcessor::acknowledge_tickets` over in `hopr-protocol-hopr`
5//! need to perform the very same steps when an incoming batch of [`Acknowledgement`]s
6//! arrives from a peer:
7//!
8//! 1. Check that *some* state is awaited from the given peer (i.e. that the local awaiting-acks cache contains the peer
9//!    entry) without bumping its popularity estimator so that the entry can be evicted normally on inactivity.
10//! 2. Verify all acknowledgements (using either per-acknowledgement verification or the more efficient batch
11//!    verification algorithm) and translate the resulting half-keys to their challenges.
12//!
13//! The post-processing of the verified acknowledgements is specific to each caller
14//! and is therefore left out of this helper.
15use hopr_types::{
16    crypto::prelude::{HalfKey, HalfKeyChallenge, OffchainPublicKey},
17    internal::prelude::Acknowledgement,
18};
19#[cfg(feature = "rayon")]
20use hopr_utils::parallelize::cpu::rayon::prelude::*;
21
22/// Checks whether an acknowledgement from `peer` is currently expected (by looking at the
23/// `awaiting_acks` cache) and, if so, verifies all incoming `acks`.
24///
25/// The lookup is performed in two steps so that the popularity estimator of the
26/// `peer` entry inside `awaiting_acks` is not bumped on the initial existence check,
27/// allowing the entry to time out naturally on inactivity.
28///
29/// When `use_batch_verification` is `true`, [`Acknowledgement::verify_batch`] is used,
30/// which transparently falls back to per-acknowledgement verification for small batches
31/// and switches to the more effective batch verification algorithm for larger ones.
32/// Otherwise, each acknowledgement is verified individually.
33///
34/// Returns the cloned per-peer cache entry alongside the verified `(half-key, challenge)`
35/// pairs, or [`None`] if no acknowledgement is expected from `peer`.
36/// Invalid acknowledgements are logged and silently dropped.
37pub fn verify_expected_acknowledgements<V>(
38    peer: OffchainPublicKey,
39    acks: Vec<Acknowledgement>,
40    awaiting_acks: &moka::sync::Cache<OffchainPublicKey, V>,
41    use_batch_verification: bool,
42) -> Option<(V, Vec<(HalfKey, HalfKeyChallenge)>)>
43where
44    V: Clone + Send + Sync + 'static,
45{
46    // Check if we're even expecting an acknowledgement from this peer:
47    // We need to first do a check that does not update the popularity estimator of `peer` in this cache,
48    // so we actually allow the entry to time out eventually. However, this comes at the cost
49    // of a double-lookup.
50    if !awaiting_acks.contains_key(&peer) {
51        tracing::trace!("not awaiting any acknowledgement from peer");
52        return None;
53    }
54    let Some(awaiting_ack_from_peer) = awaiting_acks.get(&peer) else {
55        tracing::trace!("not awaiting any acknowledgement from peer");
56        return None;
57    };
58
59    // Verify all the acknowledgements and compute challenges from half-keys
60    let half_keys_challenges = if use_batch_verification {
61        // Uses regular verifications for small batches but switches to a more effective
62        // batch verification algorithm for larger ones.
63        let acks = Acknowledgement::verify_batch(acks.into_iter().map(|ack| (peer, ack)));
64
65        #[cfg(feature = "rayon")]
66        let iter = acks.into_par_iter();
67
68        #[cfg(not(feature = "rayon"))]
69        let iter = acks.into_iter();
70
71        iter.map(|verified| {
72            verified.and_then(|verified| Ok((*verified.ack_key_share(), verified.ack_key_share().to_challenge()?)))
73        })
74        .filter_map(|res| {
75            res.inspect_err(|error| tracing::error!(%error, "failed to process acknowledgement"))
76                .ok()
77        })
78        .collect::<Vec<_>>()
79    } else {
80        #[cfg(feature = "rayon")]
81        let iter = acks.into_par_iter();
82
83        #[cfg(not(feature = "rayon"))]
84        let iter = acks.into_iter();
85
86        iter.map(|ack| {
87            ack.verify(&peer)
88                .and_then(|verified| Ok((*verified.ack_key_share(), verified.ack_key_share().to_challenge()?)))
89        })
90        .filter_map(|res| {
91            res.inspect_err(|error| tracing::error!(%error, "failed to process acknowledgement"))
92                .ok()
93        })
94        .collect::<Vec<_>>()
95    };
96
97    Some((awaiting_ack_from_peer, half_keys_challenges))
98}
99
100#[cfg(test)]
101mod tests {
102    use hopr_types::{
103        crypto::prelude::{HalfKey, Keypair, OffchainKeypair},
104        crypto_random::Randomizable,
105        internal::prelude::VerifiedAcknowledgement,
106    };
107
108    use super::*;
109
110    /// Marker cache value type to assert that the helper returns the entry
111    /// associated with the peer (and not some default-constructed one).
112    type Marker = u32;
113
114    fn cache() -> moka::sync::Cache<OffchainPublicKey, Marker> {
115        moka::sync::CacheBuilder::new(16).build()
116    }
117
118    fn make_acks(count: usize, signer: &OffchainKeypair) -> Vec<(HalfKey, Acknowledgement)> {
119        (0..count)
120            .map(|_| {
121                let hk = HalfKey::random();
122                let ack = VerifiedAcknowledgement::new(hk, signer).leak();
123                (hk, ack)
124            })
125            .collect()
126    }
127
128    #[test]
129    fn returns_none_when_peer_is_not_in_cache() {
130        let cache = cache();
131        let peer = OffchainKeypair::random();
132
133        // Empty cache.
134        assert!(verify_expected_acknowledgements(*peer.public(), vec![], &cache, false).is_none());
135        assert!(verify_expected_acknowledgements(*peer.public(), vec![], &cache, true).is_none());
136
137        // Cache has a different peer.
138        let other = OffchainKeypair::random();
139        cache.insert(*other.public(), 1);
140        assert!(verify_expected_acknowledgements(*peer.public(), vec![], &cache, false).is_none());
141        assert!(verify_expected_acknowledgements(*peer.public(), vec![], &cache, true).is_none());
142    }
143
144    #[test]
145    fn returns_empty_pairs_for_empty_acks() -> anyhow::Result<()> {
146        for use_batch in [false, true] {
147            let cache = cache();
148            let peer = OffchainKeypair::random();
149            cache.insert(*peer.public(), 42);
150
151            let (value, pairs) = verify_expected_acknowledgements(*peer.public(), vec![], &cache, use_batch)
152                .ok_or_else(|| anyhow::anyhow!("expected Some for peer in cache"))?;
153            assert_eq!(42, value, "must return the cached value for the peer");
154            assert!(pairs.is_empty());
155        }
156        Ok(())
157    }
158
159    #[test]
160    fn verifies_all_valid_acknowledgements() -> anyhow::Result<()> {
161        for use_batch in [false, true] {
162            let cache = cache();
163            let peer = OffchainKeypair::random();
164            cache.insert(*peer.public(), 7);
165
166            const N: usize = 5;
167            let prepared = make_acks(N, &peer);
168            let mut expected: Vec<(HalfKey, HalfKeyChallenge)> = prepared
169                .iter()
170                .map(|(hk, _)| Ok::<_, anyhow::Error>((*hk, hk.to_challenge()?)))
171                .collect::<Result<_, _>>()?;
172            let acks = prepared.into_iter().map(|(_, a)| a).collect();
173
174            let (value, mut pairs) = verify_expected_acknowledgements(*peer.public(), acks, &cache, use_batch)
175                .ok_or_else(|| anyhow::anyhow!("expected Some"))?;
176
177            assert_eq!(7, value);
178            assert_eq!(N, pairs.len());
179
180            // Order is not guaranteed (rayon iteration), so sort both sides
181            // by half-key challenge bytes before comparing.
182            let key = |(_, ch): &(HalfKey, HalfKeyChallenge)| ch.as_ref().to_vec();
183            pairs.sort_by_key(key);
184            expected.sort_by_key(key);
185            assert_eq!(expected, pairs);
186        }
187        Ok(())
188    }
189
190    #[test]
191    fn drops_acknowledgements_signed_by_wrong_peer() -> anyhow::Result<()> {
192        for use_batch in [false, true] {
193            let cache = cache();
194            let peer = OffchainKeypair::random();
195            let imposter = OffchainKeypair::random();
196            cache.insert(*peer.public(), 0);
197
198            // 3 valid acks from `peer`, 2 bogus acks signed by `imposter`.
199            let valid = make_acks(3, &peer);
200            let bogus = make_acks(2, &imposter);
201
202            let expected_challenges: std::collections::HashSet<_> = valid
203                .iter()
204                .map(|(hk, _)| hk.to_challenge())
205                .collect::<Result<_, _>>()?;
206
207            let mut acks: Vec<Acknowledgement> = valid.into_iter().map(|(_, a)| a).collect();
208            acks.extend(bogus.into_iter().map(|(_, a)| a));
209
210            let (_, pairs) = verify_expected_acknowledgements(*peer.public(), acks, &cache, use_batch)
211                .ok_or_else(|| anyhow::anyhow!("expected Some"))?;
212
213            assert_eq!(3, pairs.len(), "only valid acks must survive (use_batch={use_batch})");
214            let got: std::collections::HashSet<_> = pairs.into_iter().map(|(_, ch)| ch).collect();
215            assert_eq!(expected_challenges, got);
216        }
217        Ok(())
218    }
219
220    #[test]
221    fn existence_check_does_not_load_default_value() {
222        // The cache uses `get`/`contains_key` — never `get_with` — so a peer absent
223        // from the cache must not cause an entry to be created as a side effect.
224        let cache = cache();
225        let peer = OffchainKeypair::random();
226
227        assert!(verify_expected_acknowledgements(*peer.public(), vec![], &cache, false).is_none());
228        assert_eq!(0, cache.entry_count(), "no entry must be inserted on miss");
229    }
230}