Skip to main content

bsv_overlay_engine/
lookup_service.rs

1//! LookupService trait — indexes admitted outputs and answers lookup queries.
2//!
3//! Each lookup service in the overlay is notified when outputs are admitted or
4//! spent, and responds to queries from clients. The Engine calls lifecycle hooks
5//! during submit() and delegates query handling to lookup().
6//!
7//! Ported from `~/bsv/overlay-services/src/LookupService.ts`.
8
9use async_trait::async_trait;
10
11use crate::types::{
12    AdmissionMode, LookupQuestion, LookupResult, OutputAdmittedByTopic, OutputSpent,
13    ServiceMetadata, SpendNotificationMode,
14};
15
16#[cfg(test)]
17use crate::types::UTXOReference;
18
19/// Indexes overlay outputs and answers lookup queries.
20///
21/// Implementations handle specific types of data — e.g., SHIPLookupService
22/// indexes SHIP advertisements and answers "which nodes host topic X?".
23///
24/// The Engine calls lifecycle hooks (`output_admitted_by_topic`, `output_spent`,
25/// etc.) during transaction processing, and `lookup()` when clients query.
26#[async_trait(?Send)]
27pub trait LookupService {
28    /// How the Engine should deliver data when an output is admitted.
29    /// - `LockingScript`: provides txid, outputIndex, topic, satoshis, locking script
30    /// - `WholeTx`: provides the entire Atomic BEEF
31    fn admission_mode(&self) -> AdmissionMode;
32
33    /// How the Engine should notify when an output is spent.
34    /// - `None`: no notification
35    /// - `Txid`: just the spending txid
36    /// - `Script`: spending txid + unlocking script + input details
37    /// - `WholeTx`: entire spending transaction as Atomic BEEF
38    fn spend_notification_mode(&self) -> SpendNotificationMode;
39
40    /// Whether this service READS the off-chain values of what it is told
41    /// of in `topic` (`output_admitted_by_topic`, `output_spent`; lane E1D's
42    /// delta fold, L1). The door lands a carried predecessor first with NO
43    /// off-chain values (the submit carries only the successor's), and the
44    /// predecessor's own submit is a dupe after that, so the values are
45    /// never told: the engine lands nothing first into a topic for which
46    /// any lookup service answers `true` (the successor waits for the
47    /// predecessor's own submit). Default `true`, so a service that does
48    /// not say never loses a value; one that never reads them answers
49    /// `false`.
50    fn reads_off_chain_values(&self, _topic: &str) -> bool {
51        true
52    }
53
54    // ========================================================================
55    // Lifecycle hooks (called by Engine during submit)
56    // ========================================================================
57
58    /// Called when a Topic Manager admits a new UTXO.
59    ///
60    /// The lookup service should index this output so it can be found via `lookup()`.
61    /// Payload shape depends on `admission_mode()`.
62    async fn output_admitted_by_topic(
63        &self,
64        payload: &OutputAdmittedByTopic,
65    ) -> Result<(), LookupServiceError>;
66
67    /// Called when a previously-admitted UTXO is spent.
68    ///
69    /// Payload shape depends on `spend_notification_mode()`.
70    /// Default: no-op (for services with SpendNotificationMode::None).
71    async fn output_spent(&self, _payload: &OutputSpent) -> Result<(), LookupServiceError> {
72        Ok(())
73    }
74
75    /// Called when an output is permanently evicted from the overlay.
76    ///
77    /// The lookup service MUST remove this output from all indices.
78    /// After eviction, the output MUST NOT appear in any future lookup answers.
79    async fn output_evicted(&self, txid: &str, output_index: u32)
80        -> Result<(), LookupServiceError>;
81
82    /// Called when historical retention of an output is no longer needed.
83    ///
84    /// Default: no-op.
85    async fn output_no_longer_retained_in_history(
86        &self,
87        _txid: &str,
88        _output_index: u32,
89        _topic: &str,
90    ) -> Result<(), LookupServiceError> {
91        Ok(())
92    }
93
94    // ========================================================================
95    // Query API
96    // ========================================================================
97
98    /// Answer a lookup query.
99    ///
100    /// Returns a [`LookupResult`] in one of two shapes:
101    ///
102    /// - `LookupResult::OutputList(refs)` — common case. The Engine
103    ///   hydrates each `UTXOReference` with the BEEF from storage and
104    ///   assembles a `LookupAnswer::OutputList`. This is the cheap path:
105    ///   services don't need direct access to BEEF storage.
106    ///
107    /// - `LookupResult::Answer(answer)` — escape hatch. Services that
108    ///   return aggregate stats (`Freeform`) or formula chains
109    ///   (`Formula`) emit the full answer themselves; the Engine passes
110    ///   it through verbatim.
111    ///
112    /// Existing impls that returned `Vec<UTXOReference>` migrate
113    /// mechanically by wrapping in `LookupResult::OutputList(refs)` (or
114    /// using the `From<Vec<UTXOReference>>` impl).
115    async fn lookup(&self, question: &LookupQuestion) -> Result<LookupResult, LookupServiceError>;
116
117    // ========================================================================
118    // Documentation
119    // ========================================================================
120
121    /// Return Markdown documentation for this lookup service.
122    async fn get_documentation(&self) -> String;
123
124    /// Return metadata identifying this lookup service.
125    async fn get_metadata(&self) -> ServiceMetadata;
126}
127
128/// Errors from LookupService operations.
129#[derive(Debug, thiserror::Error)]
130pub enum LookupServiceError {
131    /// The query is malformed or missing required fields.
132    #[error("invalid query: {0}")]
133    InvalidQuery(String),
134
135    /// Storage operation failed.
136    #[error("storage error: {0}")]
137    StorageError(String),
138
139    /// The service does not support this query type.
140    #[error("unsupported query: {0}")]
141    Unsupported(String),
142
143    /// Generic error.
144    #[error("{0}")]
145    Other(String),
146}
147
148// ============================================================================
149// Tests
150// ============================================================================
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    /// Mock lookup service that stores references in memory.
157    struct MockLookupService {
158        records: std::sync::Mutex<Vec<UTXOReference>>,
159    }
160
161    impl MockLookupService {
162        fn new() -> Self {
163            Self {
164                records: std::sync::Mutex::new(Vec::new()),
165            }
166        }
167
168        fn record_count(&self) -> usize {
169            self.records.lock().unwrap().len()
170        }
171    }
172
173    #[async_trait(?Send)]
174    impl LookupService for MockLookupService {
175        fn admission_mode(&self) -> AdmissionMode {
176            AdmissionMode::LockingScript
177        }
178
179        fn spend_notification_mode(&self) -> SpendNotificationMode {
180            SpendNotificationMode::None
181        }
182
183        async fn output_admitted_by_topic(
184            &self,
185            payload: &OutputAdmittedByTopic,
186        ) -> Result<(), LookupServiceError> {
187            let (txid, output_index) = match payload {
188                OutputAdmittedByTopic::LockingScript {
189                    txid, output_index, ..
190                } => (txid.clone(), *output_index),
191                OutputAdmittedByTopic::WholeTx { output_index, .. } => {
192                    ("whole-tx".to_string(), *output_index)
193                }
194            };
195            self.records
196                .lock()
197                .unwrap()
198                .push(UTXOReference { txid, output_index });
199            Ok(())
200        }
201
202        async fn output_evicted(
203            &self,
204            txid: &str,
205            output_index: u32,
206        ) -> Result<(), LookupServiceError> {
207            self.records
208                .lock()
209                .unwrap()
210                .retain(|r| !(r.txid == txid && r.output_index == output_index));
211            Ok(())
212        }
213
214        async fn lookup(
215            &self,
216            _question: &LookupQuestion,
217        ) -> Result<LookupResult, LookupServiceError> {
218            Ok(LookupResult::OutputList(
219                self.records.lock().unwrap().clone(),
220            ))
221        }
222
223        async fn get_documentation(&self) -> String {
224            "Mock lookup service for testing.".to_string()
225        }
226
227        async fn get_metadata(&self) -> ServiceMetadata {
228            ServiceMetadata {
229                name: "mock-lookup".to_string(),
230                description: Some("Test lookup service".to_string()),
231                ..Default::default()
232            }
233        }
234    }
235
236    #[tokio::test]
237    async fn test_output_admitted_and_lookup() {
238        let svc = MockLookupService::new();
239
240        let payload = OutputAdmittedByTopic::LockingScript {
241            txid: "abc".to_string(),
242            output_index: 0,
243            topic: "tm_test".to_string(),
244            satoshis: 1000,
245            locking_script: vec![0x76],
246            off_chain_values: None,
247        };
248        svc.output_admitted_by_topic(&payload).await.unwrap();
249        assert_eq!(svc.record_count(), 1);
250
251        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
252        let result = svc.lookup(&question).await.unwrap();
253        let LookupResult::OutputList(refs) = result else {
254            panic!("expected OutputList");
255        };
256        assert_eq!(refs.len(), 1);
257        assert_eq!(refs[0].txid, "abc");
258        assert_eq!(refs[0].output_index, 0);
259    }
260
261    #[tokio::test]
262    async fn test_output_evicted() {
263        let svc = MockLookupService::new();
264
265        let payload = OutputAdmittedByTopic::LockingScript {
266            txid: "abc".to_string(),
267            output_index: 0,
268            topic: "tm_test".to_string(),
269            satoshis: 1000,
270            locking_script: vec![],
271            off_chain_values: None,
272        };
273        svc.output_admitted_by_topic(&payload).await.unwrap();
274        assert_eq!(svc.record_count(), 1);
275
276        svc.output_evicted("abc", 0).await.unwrap();
277        assert_eq!(svc.record_count(), 0);
278
279        // Lookup should return empty
280        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
281        let result = svc.lookup(&question).await.unwrap();
282        let LookupResult::OutputList(refs) = result else {
283            panic!("expected OutputList");
284        };
285        assert!(refs.is_empty());
286    }
287
288    #[tokio::test]
289    async fn test_default_output_spent_is_noop() {
290        let svc = MockLookupService::new();
291        let payload = OutputSpent::None {
292            txid: "abc".to_string(),
293            output_index: 0,
294            topic: "tm_test".to_string(),
295        };
296        // Default impl should not error
297        svc.output_spent(&payload).await.unwrap();
298    }
299
300    #[tokio::test]
301    async fn test_default_output_no_longer_retained() {
302        let svc = MockLookupService::new();
303        svc.output_no_longer_retained_in_history("abc", 0, "tm_test")
304            .await
305            .unwrap();
306    }
307
308    #[tokio::test]
309    async fn test_lookup_service_is_object_safe() {
310        let svc: Box<dyn LookupService> = Box::new(MockLookupService::new());
311        assert_eq!(svc.admission_mode(), AdmissionMode::LockingScript);
312        assert_eq!(svc.spend_notification_mode(), SpendNotificationMode::None);
313
314        let meta = svc.get_metadata().await;
315        assert_eq!(meta.name, "mock-lookup");
316    }
317
318    #[tokio::test]
319    async fn test_multiple_admissions_and_selective_eviction() {
320        let svc = MockLookupService::new();
321
322        for i in 0..5 {
323            let payload = OutputAdmittedByTopic::LockingScript {
324                txid: format!("tx{i}"),
325                output_index: 0,
326                topic: "tm_test".to_string(),
327                satoshis: 1000,
328                locking_script: vec![],
329                off_chain_values: None,
330            };
331            svc.output_admitted_by_topic(&payload).await.unwrap();
332        }
333        assert_eq!(svc.record_count(), 5);
334
335        // Evict only tx2
336        svc.output_evicted("tx2", 0).await.unwrap();
337        assert_eq!(svc.record_count(), 4);
338
339        let result = svc
340            .lookup(&LookupQuestion::new("ls_test", serde_json::json!({})))
341            .await
342            .unwrap();
343        let LookupResult::OutputList(refs) = result else {
344            panic!("expected OutputList");
345        };
346        assert!(!refs.iter().any(|r| r.txid == "tx2"));
347    }
348}