Skip to main content

bsv_overlay_engine/
builder.rs

1//! EngineBuilder — composable configuration for the Overlay Services Engine.
2//!
3//! Provides a builder pattern for registering topic managers, lookup services,
4//! and configuration before constructing the Engine.
5
6use crate::advertiser::Advertiser;
7use crate::broadcaster::{ArcBroadcaster, Broadcaster};
8use crate::engine::{Engine, EngineConfig};
9use crate::lookup_service::LookupService;
10use crate::storage::Storage;
11use crate::topic_manager::TopicManager;
12use std::collections::HashMap;
13
14/// Builder for constructing an [`Engine`] with composable configuration.
15///
16/// # Example
17/// ```rust,ignore
18/// let engine = EngineBuilder::new(Box::new(MemoryStorage::new()))
19///     .with_topic("tm_ship", Box::new(SHIPTopicManager::new()))
20///     .with_lookup("ls_ship", Box::new(SHIPLookupService::new(storage)))
21///     .with_hosting_url("https://my-overlay.example.com")
22///     .with_ship_trackers(vec!["https://overlay-us-1.bsvb.tech".into()])
23///     .build();
24/// ```
25pub struct EngineBuilder {
26    storage: Box<dyn Storage>,
27    managers: HashMap<String, Box<dyn TopicManager>>,
28    lookup_services: HashMap<String, Box<dyn LookupService>>,
29    advertiser: Option<Box<dyn Advertiser>>,
30    broadcaster: Option<Box<dyn Broadcaster>>,
31    arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
32    chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
33    hosting_url: Option<String>,
34    ship_trackers: Vec<String>,
35    slap_trackers: Vec<String>,
36    suppress_default_sync_ads: bool,
37    verify_scripts: bool,
38    walk_budget: crate::engine::DoorBudget,
39}
40
41impl EngineBuilder {
42    /// Create a new builder with the given storage backend.
43    pub fn new(storage: Box<dyn Storage>) -> Self {
44        Self {
45            storage,
46            managers: HashMap::new(),
47            lookup_services: HashMap::new(),
48            advertiser: None,
49            broadcaster: None,
50            arc_broadcaster: None,
51            chain_tracker: None,
52            hosting_url: None,
53            ship_trackers: Vec::new(),
54            slap_trackers: Vec::new(),
55            suppress_default_sync_ads: true,
56            verify_scripts: true,
57            walk_budget: crate::engine::DoorBudget::DEFAULT,
58        }
59    }
60
61    /// Register a topic manager.
62    pub fn with_topic(mut self, name: impl Into<String>, manager: Box<dyn TopicManager>) -> Self {
63        self.managers.insert(name.into(), manager);
64        self
65    }
66
67    /// Register a lookup service.
68    pub fn with_lookup(mut self, name: impl Into<String>, service: Box<dyn LookupService>) -> Self {
69        self.lookup_services.insert(name.into(), service);
70        self
71    }
72
73    /// Set the advertiser for SHIP/SLAP advertisement management.
74    pub fn with_advertiser(mut self, advertiser: Box<dyn Advertiser>) -> Self {
75        self.advertiser = Some(advertiser);
76        self
77    }
78
79    /// Set the broadcaster for SHIP propagation to peer nodes.
80    pub fn with_broadcaster(mut self, broadcaster: Box<dyn Broadcaster>) -> Self {
81        self.broadcaster = Some(broadcaster);
82        self
83    }
84
85    /// Set the ARC broadcaster for network broadcast to miners.
86    pub fn with_arc_broadcaster(mut self, arc: Box<dyn ArcBroadcaster>) -> Self {
87        self.arc_broadcaster = Some(arc);
88        self
89    }
90
91    /// Set the chain tracker for SPV verification.
92    pub fn with_chain_tracker(
93        mut self,
94        tracker: Box<dyn bsv_rs::transaction::ChainTracker>,
95    ) -> Self {
96        self.chain_tracker = Some(tracker);
97        self
98    }
99
100    /// Set the hosting URL for this overlay node.
101    pub fn with_hosting_url(mut self, url: impl Into<String>) -> Self {
102        self.hosting_url = Some(url.into());
103        self
104    }
105
106    /// Add SHIP tracker URLs for bootstrapping peer discovery.
107    pub fn with_ship_trackers(mut self, trackers: Vec<String>) -> Self {
108        self.ship_trackers = trackers;
109        self
110    }
111
112    /// Add SLAP tracker URLs for bootstrapping service discovery.
113    pub fn with_slap_trackers(mut self, trackers: Vec<String>) -> Self {
114        self.slap_trackers = trackers;
115        self
116    }
117
118    /// Control whether default SHIP/SLAP sync advertisements are suppressed.
119    pub fn suppress_default_sync_advertisements(mut self, suppress: bool) -> Self {
120        self.suppress_default_sync_ads = suppress;
121        self
122    }
123
124    /// Reference-parity script verification on submit, **DEFAULT ON**.
125    ///
126    /// On, every submit outside `HistoricalTxNoSpv` runs the reference's
127    /// `tx.verify(chainTracker)` walk: merkle paths against the chain tracker
128    /// AND every unproven input's unlocking script EXECUTED against its
129    /// source output, recursively over the ancestry in the BEEF; with no chain
130    /// tracker the walk is the ts-sdk's `'scripts only'`. A spend the
131    /// interpreter refuses is [`crate::engine::EngineError::ScriptVerificationFailed`].
132    ///
133    /// `false` is an ESCAPE HATCH, not a mode: for an operator who must admit
134    /// a body the interpreter wrongly refuses while the defect is fixed. It
135    /// restores the pre-2026-09-08 structural check (BEEF validity + roots,
136    /// tracker permitting; nothing without a tracker), under which an invalid
137    /// spend no broadcaster had yet refused was admitted on structure alone.
138    pub fn with_script_verification(mut self, enabled: bool) -> Self {
139        self.verify_scripts = enabled;
140        self
141    }
142
143    /// The budget of the submit's own script walk (bsv-low #592): the door's
144    /// two limbs, [`crate::engine::DoorBudget::DEFAULT`] unless set. See
145    /// [`Engine::set_walk_budget`].
146    pub fn with_walk_budget(mut self, budget: crate::engine::DoorBudget) -> Self {
147        self.walk_budget = budget;
148        self
149    }
150
151    /// Build the Engine.
152    pub fn build(self) -> Engine {
153        let mut engine = Engine::with_all(
154            self.managers,
155            self.lookup_services,
156            self.storage,
157            self.advertiser,
158            self.broadcaster,
159            self.arc_broadcaster,
160            self.chain_tracker,
161            EngineConfig {
162                hosting_url: self.hosting_url,
163                ship_trackers: self.ship_trackers,
164                slap_trackers: self.slap_trackers,
165                sync_configuration: HashMap::new(),
166                suppress_default_sync_advertisements: self.suppress_default_sync_ads,
167            },
168        );
169        engine.set_script_verification(self.verify_scripts);
170        engine.set_walk_budget(self.walk_budget);
171        engine
172    }
173}
174
175// ============================================================================
176// Tests
177// ============================================================================
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::storage::memory::MemoryStorage;
183    use crate::topic_manager::TopicManagerError;
184    use crate::types::*;
185    use async_trait::async_trait;
186
187    struct DummyTM;
188    #[async_trait(?Send)]
189    impl TopicManager for DummyTM {
190        async fn identify_admissible_outputs(
191            &self,
192            _: &bsv_rs::transaction::Transaction,
193            _: &[u8],
194            _: Option<&[u8]>,
195            _: SubmitMode,
196            _context: &TopicAdmittanceContext,
197        ) -> Result<AdmittanceInstructions, TopicManagerError> {
198            Ok(AdmittanceInstructions::default())
199        }
200        async fn get_documentation(&self) -> String {
201            "Dummy".into()
202        }
203        async fn get_metadata(&self) -> ServiceMetadata {
204            ServiceMetadata {
205                name: "dummy".into(),
206                ..Default::default()
207            }
208        }
209    }
210
211    struct DummyLS;
212    #[async_trait(?Send)]
213    impl LookupService for DummyLS {
214        fn admission_mode(&self) -> AdmissionMode {
215            AdmissionMode::LockingScript
216        }
217        fn spend_notification_mode(&self) -> SpendNotificationMode {
218            SpendNotificationMode::None
219        }
220        async fn output_admitted_by_topic(
221            &self,
222            _: &OutputAdmittedByTopic,
223        ) -> Result<(), crate::lookup_service::LookupServiceError> {
224            Ok(())
225        }
226        async fn output_evicted(
227            &self,
228            _: &str,
229            _: u32,
230        ) -> Result<(), crate::lookup_service::LookupServiceError> {
231            Ok(())
232        }
233        async fn lookup(
234            &self,
235            _: &LookupQuestion,
236        ) -> Result<LookupResult, crate::lookup_service::LookupServiceError> {
237            Ok(LookupResult::OutputList(vec![]))
238        }
239        async fn get_documentation(&self) -> String {
240            "Dummy".into()
241        }
242        async fn get_metadata(&self) -> ServiceMetadata {
243            ServiceMetadata {
244                name: "dummy-ls".into(),
245                ..Default::default()
246            }
247        }
248    }
249
250    #[tokio::test]
251    async fn builder_creates_engine_with_topics() {
252        let engine = EngineBuilder::new(Box::new(MemoryStorage::new()))
253            .with_topic("tm_test", Box::new(DummyTM))
254            .with_lookup("ls_test", Box::new(DummyLS))
255            .build();
256
257        let managers = engine.list_topic_managers().await;
258        assert!(managers.contains_key("tm_test"));
259        assert_eq!(managers["tm_test"].name, "dummy");
260
261        let services = engine.list_lookup_service_providers().await;
262        assert!(services.contains_key("ls_test"));
263    }
264
265    #[tokio::test]
266    async fn builder_with_multiple_topics() {
267        let engine = EngineBuilder::new(Box::new(MemoryStorage::new()))
268            .with_topic("tm_alpha", Box::new(DummyTM))
269            .with_topic("tm_beta", Box::new(DummyTM))
270            .with_lookup("ls_alpha", Box::new(DummyLS))
271            .with_lookup("ls_beta", Box::new(DummyLS))
272            .build();
273
274        let managers = engine.list_topic_managers().await;
275        assert_eq!(managers.len(), 2);
276    }
277
278    #[test]
279    fn builder_with_hosting_url() {
280        let engine = EngineBuilder::new(Box::new(MemoryStorage::new()))
281            .with_hosting_url("https://overlay.example.com")
282            .build();
283
284        assert_eq!(
285            engine.config().hosting_url.as_deref(),
286            Some("https://overlay.example.com")
287        );
288    }
289
290    #[test]
291    fn builder_with_trackers() {
292        let engine = EngineBuilder::new(Box::new(MemoryStorage::new()))
293            .with_ship_trackers(vec!["https://ship1.example.com".into()])
294            .with_slap_trackers(vec!["https://slap1.example.com".into()])
295            .build();
296
297        assert_eq!(engine.config().ship_trackers.len(), 1);
298        assert_eq!(engine.config().slap_trackers.len(), 1);
299    }
300
301    #[test]
302    fn builder_empty_is_valid() {
303        let engine = EngineBuilder::new(Box::new(MemoryStorage::new())).build();
304        assert!(engine.config().hosting_url.is_none());
305    }
306
307    #[test]
308    fn builder_script_verification_defaults_on_and_can_be_switched_off() {
309        let engine = EngineBuilder::new(Box::new(MemoryStorage::new())).build();
310        assert!(
311            engine.script_verification(),
312            "reference parity: scripts are verified on submit by default"
313        );
314
315        let engine = EngineBuilder::new(Box::new(MemoryStorage::new()))
316            .with_script_verification(false)
317            .build();
318        assert!(
319            !engine.script_verification(),
320            "the escape hatch turns it off"
321        );
322    }
323
324    #[test]
325    fn builder_sync_config_defaults() {
326        let engine = EngineBuilder::new(Box::new(MemoryStorage::new()))
327            .with_topic("tm_custom", Box::new(DummyTM))
328            .build();
329
330        // Custom topics default to SHIP sync
331        assert!(engine.config().sync_configuration.contains_key("tm_custom"));
332    }
333}