1use 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#[async_trait(?Send)]
27pub trait LookupService {
28 fn admission_mode(&self) -> AdmissionMode;
32
33 fn spend_notification_mode(&self) -> SpendNotificationMode;
39
40 fn reads_off_chain_values(&self, _topic: &str) -> bool {
51 true
52 }
53
54 async fn output_admitted_by_topic(
63 &self,
64 payload: &OutputAdmittedByTopic,
65 ) -> Result<(), LookupServiceError>;
66
67 async fn output_spent(&self, _payload: &OutputSpent) -> Result<(), LookupServiceError> {
72 Ok(())
73 }
74
75 async fn output_evicted(&self, txid: &str, output_index: u32)
80 -> Result<(), LookupServiceError>;
81
82 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 async fn lookup(&self, question: &LookupQuestion) -> Result<LookupResult, LookupServiceError>;
116
117 async fn get_documentation(&self) -> String;
123
124 async fn get_metadata(&self) -> ServiceMetadata;
126}
127
128#[derive(Debug, thiserror::Error)]
130pub enum LookupServiceError {
131 #[error("invalid query: {0}")]
133 InvalidQuery(String),
134
135 #[error("storage error: {0}")]
137 StorageError(String),
138
139 #[error("unsupported query: {0}")]
141 Unsupported(String),
142
143 #[error("{0}")]
145 Other(String),
146}
147
148#[cfg(test)]
153mod tests {
154 use super::*;
155
156 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 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 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 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}