smoldot_light/json_rpc_service/
statement.rs

1// Smoldot
2// Copyright (C) 2019-2022  Parity Technologies (UK) Ltd.
3// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0
4
5// This program is free software: you can redistribute it and/or modify
6// it under the terms of the GNU General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13// GNU General Public License for more details.
14
15// You should have received a copy of the GNU General Public License
16// along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
18use crate::network_service::{self, BroadcastStatementResult};
19use alloc::{string::String, vec::Vec};
20use core::{num::NonZero, time::Duration};
21use smoldot::json_rpc::methods::{
22    HexString, InternalError, InvalidReason, StatementSubmitResult, TopicFilter,
23};
24use smoldot::network::codec;
25
26/// Configuration for the Statement Store protocol.
27#[derive(Debug, Clone)]
28pub struct StatementProtocolConfig {
29    /// Per-subscription LRU cache size used for deduplicating delivered statements.
30    max_seen_statements: NonZero<usize>,
31    false_positive_rate: f64,
32    bloom_seed: u128,
33    affinity_update_interval: Duration,
34}
35
36impl StatementProtocolConfig {
37    pub fn new(
38        max_seen_statements: NonZero<usize>,
39        false_positive_rate: f64,
40        bloom_seed: u128,
41        affinity_update_interval: Duration,
42    ) -> Self {
43        assert!(
44            false_positive_rate.is_finite()
45                && false_positive_rate > 0.0
46                && false_positive_rate < 1.0
47        );
48        assert!(!affinity_update_interval.is_zero());
49        StatementProtocolConfig {
50            max_seen_statements,
51            false_positive_rate,
52            bloom_seed,
53            affinity_update_interval,
54        }
55    }
56
57    pub fn max_seen_statements(&self) -> NonZero<usize> {
58        self.max_seen_statements
59    }
60
61    pub fn false_positive_rate(&self) -> f64 {
62        self.false_positive_rate
63    }
64
65    pub fn bloom_seed(&self) -> u128 {
66        self.bloom_seed
67    }
68
69    pub fn affinity_update_interval(&self) -> Duration {
70        self.affinity_update_interval
71    }
72}
73
74/// Validates a SCALE-encoded statement and broadcasts it to the network.
75///
76/// Returns the appropriate [`StatementSubmitResult`] based on the decode and broadcast outcome.
77/// The `broadcast` closure is only called if the statement is valid.
78pub async fn validate_and_broadcast_statement<F, Fut>(
79    encoded: &[u8],
80    broadcast: F,
81) -> StatementSubmitResult
82where
83    F: FnOnce(Vec<u8>) -> Fut,
84    Fut: core::future::Future<Output = BroadcastStatementResult>,
85{
86    if codec::decode_statement(encoded).is_err() {
87        return StatementSubmitResult::Invalid {
88            reason: InvalidReason::Encoding,
89        };
90    }
91
92    let broadcasted = broadcast(encoded.to_vec()).await;
93    if broadcasted.total == 0 {
94        StatementSubmitResult::InternalError {
95            error: InternalError::NoConnectedPeers,
96        }
97    } else {
98        StatementSubmitResult::New
99    }
100}
101
102pub(super) struct StatementSubscription {
103    topic_filter: TopicFilter,
104    seen: Option<lru::LruCache<[u8; 32], (), fnv::FnvBuildHasher>>,
105}
106
107impl StatementSubscription {
108    pub(super) fn new(topic_filter: TopicFilter, max_seen: Option<NonZero<usize>>) -> Self {
109        Self {
110            topic_filter,
111            seen: max_seen
112                .map(|cap| lru::LruCache::with_hasher(cap, fnv::FnvBuildHasher::default())),
113        }
114    }
115
116    pub(super) fn accept(&mut self, hash: &[u8; 32], statement: &codec::Statement) -> bool {
117        if !self.topic_filter.matches(&statement.topics) {
118            return false;
119        }
120        if let Some(seen) = &mut self.seen {
121            if seen.put(*hash, ()).is_some() {
122                return false;
123            }
124        }
125        true
126    }
127}
128
129/// Set of active statement subscriptions together with a reverse index mapping each topic to the
130/// subscriptions that reference it.
131///
132/// The reverse index lets statement matching scale with the number of subscriptions that share a
133/// topic with the incoming statement, rather than with the total number of subscriptions.
134pub(super) struct StatementSubscriptions {
135    /// Maps subscription ID to its state.
136    subscriptions: hashbrown::HashMap<String, StatementSubscription, fnv::FnvBuildHasher>,
137
138    /// Reverse index: maps a topic to the IDs of all subscriptions whose filter references it.
139    /// Only populated for `MatchAny`/`MatchAll` filters with a non-empty topic list.
140    by_topic: hashbrown::HashMap<
141        [u8; 32],
142        hashbrown::HashSet<String, fnv::FnvBuildHasher>,
143        fnv::FnvBuildHasher,
144    >,
145
146    /// IDs of subscriptions that match every statement irrespective of its topics: either
147    /// `TopicFilter::Any`, or a `TopicFilter::MatchAll` whose topic list is empty.
148    wildcard: hashbrown::HashSet<String, fnv::FnvBuildHasher>,
149}
150
151impl StatementSubscriptions {
152    pub(super) fn with_capacity(capacity: usize) -> Self {
153        Self {
154            subscriptions: hashbrown::HashMap::with_capacity_and_hasher(
155                capacity,
156                Default::default(),
157            ),
158            by_topic: hashbrown::HashMap::with_hasher(Default::default()),
159            wildcard: hashbrown::HashSet::with_hasher(Default::default()),
160        }
161    }
162
163    pub(super) fn is_empty(&self) -> bool {
164        self.subscriptions.is_empty()
165    }
166
167    /// Inserts a new subscription and updates the reverse index.
168    pub(super) fn insert(
169        &mut self,
170        id: String,
171        topic_filter: TopicFilter,
172        max_seen: Option<NonZero<usize>>,
173    ) {
174        match &topic_filter {
175            TopicFilter::Any => {
176                self.wildcard.insert(id.clone());
177            }
178            // An empty `MatchAll` filter matches every statement.
179            TopicFilter::MatchAll(topics) if topics.is_empty() => {
180                self.wildcard.insert(id.clone());
181            }
182            TopicFilter::MatchAll(topics) | TopicFilter::MatchAny(topics) => {
183                for topic in topics {
184                    self.by_topic
185                        .entry(*topic)
186                        .or_insert_with(|| hashbrown::HashSet::with_hasher(Default::default()))
187                        .insert(id.clone());
188                }
189            }
190        }
191
192        self.subscriptions
193            .insert(id, StatementSubscription::new(topic_filter, max_seen));
194    }
195
196    /// Removes a subscription and cleans up the reverse index. Returns whether it existed.
197    pub(super) fn remove(&mut self, id: &str) -> bool {
198        let Some(sub) = self.subscriptions.remove(id) else {
199            return false;
200        };
201
202        match &sub.topic_filter {
203            TopicFilter::Any => {
204                self.wildcard.remove(id);
205            }
206            TopicFilter::MatchAll(topics) if topics.is_empty() => {
207                self.wildcard.remove(id);
208            }
209            TopicFilter::MatchAll(topics) | TopicFilter::MatchAny(topics) => {
210                for topic in topics {
211                    if let Some(ids) = self.by_topic.get_mut(topic) {
212                        ids.remove(id);
213                        if ids.is_empty() {
214                            self.by_topic.remove(topic);
215                        }
216                    }
217                }
218            }
219        }
220
221        true
222    }
223
224    pub(super) fn shrink_to_fit(&mut self) {
225        self.subscriptions.shrink_to_fit();
226        for ids in self.by_topic.values_mut() {
227            ids.shrink_to_fit();
228        }
229        self.by_topic.shrink_to_fit();
230        self.wildcard.shrink_to_fit();
231    }
232
233    /// Matches a batch of statements against the subscriptions.
234    ///
235    /// Returns, for every subscription that accepts at least one statement, the list of re-encoded
236    /// matching statements. Uses the reverse index to only consider subscriptions that either match
237    /// everything or share a topic with the statement; the precise per-subscription filter and
238    /// deduplication is then applied via [`StatementSubscription::accept`].
239    pub(super) fn matching(
240        &mut self,
241        statements: &[([u8; 32], codec::Statement)],
242    ) -> Vec<(String, Vec<HexString>)> {
243        // Disjoint borrows: `subscriptions` is mutated while `by_topic`/`wildcard` are only read.
244        let Self {
245            subscriptions,
246            by_topic,
247            wildcard,
248        } = self;
249
250        // Subscription ID -> its matching re-encoded statements.
251        let mut out: hashbrown::HashMap<&str, Vec<HexString>, fnv::FnvBuildHasher> =
252            hashbrown::HashMap::with_hasher(Default::default());
253        // Reused across statements to avoid reallocating.
254        let mut candidates: hashbrown::HashSet<&str, fnv::FnvBuildHasher> =
255            hashbrown::HashSet::with_hasher(Default::default());
256
257        for (hash, statement) in statements {
258            candidates.clear();
259            candidates.extend(wildcard.iter().map(String::as_str));
260            for topic in &statement.topics {
261                if let Some(ids) = by_topic.get(topic) {
262                    candidates.extend(ids.iter().map(String::as_str));
263                }
264            }
265
266            // Re-encoded lazily on first match and reused for every matching subscription.
267            let mut encoded: Option<HexString> = None;
268            for id in &candidates {
269                let sub = subscriptions
270                    .get_mut(*id)
271                    .expect("`candidates` is a subset of `subscriptions`; qed");
272                if sub.accept(hash, statement) {
273                    let encoded = encoded.get_or_insert_with(|| {
274                        HexString(
275                            codec::encode_statement(statement)
276                                .expect("re-encoding a decoded statement always succeeds; qed"),
277                        )
278                    });
279                    out.entry(*id).or_default().push(encoded.clone());
280                }
281            }
282        }
283
284        out.into_iter()
285            .map(|(id, matching)| (String::from(id), matching))
286            .collect()
287    }
288
289    pub(super) fn build_combined_affinity_filter(
290        &self,
291        config: &StatementProtocolConfig,
292    ) -> network_service::AffinityFilter {
293        let mut all_topics: Vec<&[u8; 32]> = Vec::new();
294
295        for sub in self.subscriptions.values() {
296            match &sub.topic_filter {
297                TopicFilter::Any => {
298                    return network_service::AffinityFilter::match_all(config.bloom_seed());
299                }
300                TopicFilter::MatchAll(topics) | TopicFilter::MatchAny(topics) => {
301                    all_topics.extend(topics.iter());
302                }
303            }
304        }
305
306        network_service::AffinityFilter::from_topics(
307            all_topics.into_iter(),
308            config.bloom_seed(),
309            config.false_positive_rate(),
310        )
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use alloc::string::ToString as _;
318    use core::time::Duration;
319    use futures_lite::future::block_on;
320
321    const SEED: u128 = 0x5EED_5EED_5EED_5EED_5EED_5EED_5EED_5EED;
322    const FPR: f64 = 0.01;
323
324    fn test_config() -> StatementProtocolConfig {
325        StatementProtocolConfig::new(
326            NonZero::new(128).unwrap(),
327            FPR,
328            SEED,
329            Duration::from_secs(1),
330        )
331    }
332
333    fn make_subscriptions(
334        entries: Vec<(&str, TopicFilter, Option<NonZero<usize>>)>,
335    ) -> StatementSubscriptions {
336        let mut subs = StatementSubscriptions::with_capacity(entries.len());
337        for (id, filter, max_seen) in entries {
338            subs.insert(id.to_string(), filter, max_seen);
339        }
340        subs
341    }
342
343    fn statement_with_topics(topics: Vec<[u8; 32]>) -> codec::Statement {
344        codec::Statement {
345            proof: None,
346            decryption_key: None,
347            expiry: 42,
348            channel: None,
349            topics,
350            data: None,
351        }
352    }
353
354    fn valid_statement() -> Vec<u8> {
355        codec::encode_statement(&codec::Statement {
356            proof: None,
357            decryption_key: None,
358            expiry: 42,
359            channel: None,
360            topics: Vec::new(),
361            data: None,
362        })
363        .unwrap()
364    }
365
366    #[test]
367    fn validate_and_broadcast_invalid_encoding() {
368        let result = block_on(validate_and_broadcast_statement(&[0xff, 0xff], |_| async {
369            unreachable!()
370        }));
371        assert_eq!(
372            result,
373            StatementSubmitResult::Invalid {
374                reason: InvalidReason::Encoding
375            }
376        );
377    }
378
379    #[test]
380    fn validate_and_broadcast_no_peers() {
381        let result = block_on(validate_and_broadcast_statement(
382            &valid_statement(),
383            |_| async { BroadcastStatementResult { sent: 0, total: 0 } },
384        ));
385        assert_eq!(
386            result,
387            StatementSubmitResult::InternalError {
388                error: InternalError::NoConnectedPeers
389            }
390        );
391    }
392
393    #[test]
394    fn validate_and_broadcast_new() {
395        let result = block_on(validate_and_broadcast_statement(
396            &valid_statement(),
397            |_| async { BroadcastStatementResult { sent: 3, total: 5 } },
398        ));
399        assert_eq!(result, StatementSubmitResult::New);
400    }
401
402    #[test]
403    fn build_combined_affinity_empty_subscriptions() {
404        let config = test_config();
405        let subs = make_subscriptions(vec![]);
406        let filter = subs.build_combined_affinity_filter(&config);
407
408        // Empty subscription set: no topics are ever in the filter.
409        assert!(!filter.contains(&[1u8; 32]));
410        // A statement with no topics (broadcast) still matches.
411        let broadcast: &[&[u8; 32]] = &[];
412        assert!(filter.matches_statement(broadcast));
413    }
414
415    #[test]
416    fn build_combined_affinity_any_filter_matches_everything() {
417        let config = test_config();
418        let subs = make_subscriptions(vec![("s", TopicFilter::Any, None)]);
419        let filter = subs.build_combined_affinity_filter(&config);
420
421        // TopicFilter::Any returns the broadcast `match_all` filter: every topic matches.
422        assert!(filter.contains(&[1u8; 32]));
423        assert!(filter.contains(&[99u8; 32]));
424        let t = [7u8; 32];
425        assert!(filter.matches_statement(&[&t]));
426    }
427
428    #[test]
429    fn build_combined_affinity_match_any_union() {
430        let config = test_config();
431        let t1 = [1u8; 32];
432        let t2 = [2u8; 32];
433        let subs = make_subscriptions(vec![
434            ("a", TopicFilter::match_any(vec![t1]).unwrap(), None),
435            ("b", TopicFilter::match_any(vec![t2]).unwrap(), None),
436        ]);
437        let filter = subs.build_combined_affinity_filter(&config);
438
439        assert!(filter.contains(&t1));
440        assert!(filter.contains(&t2));
441    }
442
443    #[test]
444    fn accept_fresh_statement_passes() {
445        let t1 = [1u8; 32];
446        let mut sub =
447            StatementSubscription::new(TopicFilter::match_any(vec![t1]).unwrap(), NonZero::new(8));
448        let stmt = statement_with_topics(vec![t1]);
449        assert!(sub.accept(&[0xbb; 32], &stmt));
450    }
451
452    #[test]
453    fn accept_duplicate_returns_false() {
454        let mut sub = StatementSubscription::new(TopicFilter::Any, NonZero::new(8));
455        let stmt = statement_with_topics(vec![]);
456        let hash = [0xcc; 32];
457        assert!(sub.accept(&hash, &stmt));
458        assert!(!sub.accept(&hash, &stmt));
459    }
460
461    #[test]
462    fn accept_lru_eviction_allows_resubmit() {
463        let mut sub = StatementSubscription::new(TopicFilter::Any, NonZero::new(2));
464        let stmt = statement_with_topics(vec![]);
465        let h_a = [0xa; 32];
466        let h_b = [0xb; 32];
467        let h_c = [0xc; 32];
468
469        assert!(sub.accept(&h_a, &stmt));
470        assert!(sub.accept(&h_b, &stmt));
471        // Inserting a third eviction-capacity 2 item evicts h_a (oldest).
472        assert!(sub.accept(&h_c, &stmt));
473        // h_a was evicted: it is accepted again as if fresh.
474        assert!(sub.accept(&h_a, &stmt));
475    }
476
477    #[test]
478    fn dedup_is_per_subscription() {
479        let mut sub_a = StatementSubscription::new(TopicFilter::Any, NonZero::new(8));
480        let mut sub_b = StatementSubscription::new(TopicFilter::Any, NonZero::new(8));
481        let stmt = statement_with_topics(vec![]);
482        let hash = [0xee; 32];
483
484        assert!(sub_a.accept(&hash, &stmt));
485        assert!(!sub_a.accept(&hash, &stmt));
486        // Same hash on a different subscription is still fresh: caches are independent.
487        assert!(sub_b.accept(&hash, &stmt));
488    }
489
490    /// Builds a `(hash, statement)` batch entry from a list of topics.
491    fn batch_entry(hash: u8, topics: Vec<[u8; 32]>) -> ([u8; 32], codec::Statement) {
492        ([hash; 32], statement_with_topics(topics))
493    }
494
495    /// Collects the IDs of all subscriptions that matched at least once.
496    fn matched_ids(matches: &[(String, Vec<HexString>)]) -> Vec<String> {
497        let mut ids: Vec<String> = matches.iter().map(|(id, _)| id.clone()).collect();
498        ids.sort();
499        ids
500    }
501
502    #[test]
503    fn matching_match_any_only_returns_relevant_subscriptions() {
504        let t1 = [1u8; 32];
505        let t2 = [2u8; 32];
506        let mut subs = make_subscriptions(vec![
507            ("a", TopicFilter::match_any(vec![t1]).unwrap(), None),
508            ("b", TopicFilter::match_any(vec![t2]).unwrap(), None),
509        ]);
510
511        // A statement carrying only `t1` must match `a` and not `b`.
512        let matches = subs.matching(&[batch_entry(0xaa, vec![t1])]);
513        assert_eq!(matched_ids(&matches), vec!["a".to_string()]);
514
515        // A statement with an unrelated topic matches nothing.
516        let matches = subs.matching(&[batch_entry(0xbb, vec![[9u8; 32]])]);
517        assert!(matches.is_empty());
518    }
519
520    #[test]
521    fn matching_wildcard_filters_match_every_statement() {
522        // `Any` and an empty `MatchAll` both match every statement, with or without topics.
523        let mut subs = make_subscriptions(vec![
524            ("any", TopicFilter::Any, None),
525            ("all", TopicFilter::match_all(vec![]).unwrap(), None),
526        ]);
527
528        let matches = subs.matching(&[batch_entry(0x01, vec![[7u8; 32]])]);
529        assert_eq!(
530            matched_ids(&matches),
531            vec!["all".to_string(), "any".to_string()]
532        );
533
534        let matches = subs.matching(&[batch_entry(0x02, vec![])]);
535        assert_eq!(
536            matched_ids(&matches),
537            vec!["all".to_string(), "any".to_string()]
538        );
539    }
540
541    #[test]
542    fn matching_match_all_requires_every_topic() {
543        let t1 = [1u8; 32];
544        let t2 = [2u8; 32];
545        let mut subs = make_subscriptions(vec![(
546            "all",
547            TopicFilter::match_all(vec![t1, t2]).unwrap(),
548            None,
549        )]);
550
551        // A statement carrying only one of the required topics is a candidate via the reverse
552        // index but must be rejected by the precise re-check.
553        let matches = subs.matching(&[batch_entry(0xaa, vec![t1])]);
554        assert!(matches.is_empty());
555
556        // A statement carrying both topics matches.
557        let matches = subs.matching(&[batch_entry(0xbb, vec![t1, t2])]);
558        assert_eq!(matched_ids(&matches), vec!["all".to_string()]);
559    }
560
561    #[test]
562    fn matching_empty_match_any_never_matches() {
563        let mut subs = make_subscriptions(vec![(
564            "none",
565            TopicFilter::match_any(vec![]).unwrap(),
566            None,
567        )]);
568
569        let matches = subs.matching(&[batch_entry(0x01, vec![[1u8; 32]])]);
570        assert!(matches.is_empty());
571        let matches = subs.matching(&[batch_entry(0x02, vec![])]);
572        assert!(matches.is_empty());
573    }
574
575    #[test]
576    fn matching_dedup_applies_across_batches() {
577        let t1 = [1u8; 32];
578        let mut subs = make_subscriptions(vec![(
579            "a",
580            TopicFilter::match_any(vec![t1]).unwrap(),
581            NonZero::new(8),
582        )]);
583
584        let entry = batch_entry(0xaa, vec![t1]);
585        let matches = subs.matching(&[entry.clone()]);
586        assert_eq!(matches.len(), 1);
587        assert_eq!(matches[0].1.len(), 1);
588
589        // The same statement hash is deduplicated and produces no further notification.
590        let matches = subs.matching(&[entry]);
591        assert!(matches.is_empty());
592    }
593
594    #[test]
595    fn matching_groups_multiple_statements_per_subscription() {
596        let t1 = [1u8; 32];
597        let mut subs =
598            make_subscriptions(vec![("a", TopicFilter::match_any(vec![t1]).unwrap(), None)]);
599
600        let matches = subs.matching(&[batch_entry(0x01, vec![t1]), batch_entry(0x02, vec![t1])]);
601        assert_eq!(matches.len(), 1);
602        assert_eq!(matches[0].0, "a");
603        assert_eq!(matches[0].1.len(), 2);
604    }
605
606    #[test]
607    fn remove_cleans_reverse_index() {
608        let t1 = [1u8; 32];
609        let mut subs =
610            make_subscriptions(vec![("a", TopicFilter::match_any(vec![t1]).unwrap(), None)]);
611
612        assert!(subs.remove("a"));
613        assert!(!subs.remove("a"));
614        assert!(subs.is_empty());
615        // The topic entry must have been cleaned up, so a matching statement finds nothing.
616        let matches = subs.matching(&[batch_entry(0xaa, vec![t1])]);
617        assert!(matches.is_empty());
618    }
619}