Skip to main content

bsv_overlay_engine/
topic_manager.rs

1//! TopicManager trait — decides which outputs from a transaction to admit into a topic.
2//!
3//! Each topic in the overlay has a TopicManager that validates transactions
4//! and returns admittance instructions. The Engine calls this for every
5//! submitted transaction, per topic.
6//!
7//! Ported from `~/bsv/overlay-services/src/TopicManager.ts`.
8
9use async_trait::async_trait;
10use bsv_rs::transaction::Transaction;
11
12use crate::types::{
13    AdmittanceInstructions, Outpoint, ServiceMetadata, SubmitMode, TopicAdmittanceContext,
14};
15
16/// Validates transactions and decides which outputs to admit into a topic.
17///
18/// Implementations are topic-specific — e.g., SHIPTopicManager validates SHIP
19/// advertisement PushDrop scripts, while a custom topic manager might validate
20/// token transfers.
21#[async_trait(?Send)]
22pub trait TopicManager {
23    /// Examine a transaction and return instructions indicating which
24    /// outputs should be admitted into this topic.
25    ///
26    /// # Arguments
27    /// - `tx` — The submitted transaction, parsed ONCE by the engine from
28    ///   the BEEF (`Transaction::from_beef(beef, None)`) and shared across
29    ///   every topic manager on the submit. Before bsv-low #289 each manager
30    ///   re-parsed the same bytes independently (T+1 parses per `/submit`).
31    ///   Admission stays byte-format-only: managers still validate the
32    ///   OUTPUT SCRIPTS themselves; only the redundant container parse
33    ///   moved.
34    /// - `previous_coins` — BEEF data for previously-admitted outputs that this
35    ///   transaction spends (inputs from the same topic). May be empty if no
36    ///   inputs come from this topic.
37    /// - `off_chain_values` — Optional off-chain context data associated with
38    ///   the transaction (not on-chain).
39    /// - `mode` — Submission mode: CurrentTx (broadcast + SPV), HistoricalTx
40    ///   (no broadcast), HistoricalTxNoSpv (GASP sync).
41    /// - `context`: the reference's fifth argument (`context?:
42    ///   TopicAdmittanceContext`, `dryRun`). `context.dry_run` is
43    ///   `true` when the engine only asks what WOULD be admitted and admits
44    ///   nothing on this call: the GASP walk asks it of every proven node a
45    ///   peer sends, and the anchor check replays a peer's whole graph that
46    ///   way before it is finalized. A dry run must leave NO durable trace:
47    ///   no storage write, no head advance, no counter an operator reads as
48    ///   an admission. The answer must still be the one a real call would
49    ///   give. A manager that writes on admission MUST read this flag; a
50    ///   manager that holds no state may ignore it. `mode` is not a
51    ///   substitute: the Cloudflare queue replays real submissions under
52    ///   `HistoricalTx`, the same mode both dry runs use, so only this flag
53    ///   tells them apart. Every submit passes `dry_run: false`.
54    ///
55    /// # Returns
56    /// `AdmittanceInstructions` with:
57    /// - `outputs_to_admit`: indices of outputs to keep in this topic
58    /// - `coins_to_retain`: indices of previous coins to keep (not garbage-collect)
59    async fn identify_admissible_outputs(
60        &self,
61        tx: &Transaction,
62        previous_coins: &[u8],
63        off_chain_values: Option<&[u8]>,
64        mode: SubmitMode,
65        context: &TopicAdmittanceContext,
66    ) -> Result<AdmittanceInstructions, TopicManagerError>;
67
68    /// Identify which inputs are needed to validate this transaction for GASP sync.
69    ///
70    /// Called by the GASP storage layer to determine which ancestor transactions
71    /// need to be fetched from the remote peer.
72    ///
73    /// Default: returns empty (no additional inputs needed beyond what BEEF provides).
74    async fn identify_needed_inputs(
75        &self,
76        _beef: &[u8],
77        _off_chain_values: Option<&[u8]>,
78    ) -> Result<Vec<Outpoint>, TopicManagerError> {
79        Ok(Vec::new())
80    }
81
82    /// Whether this manager's admission READS the transaction's off-chain
83    /// values (lane E1D's delta fold, L1). The door lands a carried
84    /// predecessor first with NO off-chain values (the submit carries only
85    /// the successor's), and the predecessor's own submit is a dupe after
86    /// that, so values its manager would have read are lost for good: the
87    /// engine lands nothing first into a topic whose manager answers `true`
88    /// (the successor waits for the predecessor's own submit). Default
89    /// `true`, so a manager that does not say is never judged without its
90    /// values; one that never reads them answers `false`.
91    fn reads_off_chain_values(&self) -> bool {
92        true
93    }
94
95    /// Optional advert expiry for a sync root, in Unix seconds. This does
96    /// not change submission admission or the treatment of ancestry.
97    fn sync_expiry(&self, _script: &bsv_rs::script::LockingScript) -> Option<u64> {
98        None
99    }
100
101    /// Return Markdown documentation for this topic manager.
102    async fn get_documentation(&self) -> String;
103
104    /// Return metadata identifying this topic manager.
105    async fn get_metadata(&self) -> ServiceMetadata;
106}
107
108/// Errors from TopicManager operations.
109#[derive(Debug, thiserror::Error)]
110pub enum TopicManagerError {
111    /// The transaction BEEF could not be parsed.
112    #[error("invalid BEEF: {0}")]
113    InvalidBeef(String),
114
115    /// The transaction has no admissible outputs for this topic.
116    #[error("no admissible outputs: {0}")]
117    NoAdmissibleOutputs(String),
118
119    /// A script in the transaction is malformed.
120    #[error("invalid script: {0}")]
121    InvalidScript(String),
122
123    /// Signature verification failed.
124    #[error("signature verification failed: {0}")]
125    SignatureError(String),
126
127    /// Generic error.
128    #[error("{0}")]
129    Other(String),
130}
131
132// ============================================================================
133// Tests
134// ============================================================================
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    /// A trivial TopicManager that admits all outputs.
141    struct AdmitAllManager;
142
143    #[async_trait(?Send)]
144    impl TopicManager for AdmitAllManager {
145        async fn identify_admissible_outputs(
146            &self,
147            _tx: &Transaction,
148            _previous_coins: &[u8],
149            _off_chain_values: Option<&[u8]>,
150            _mode: SubmitMode,
151            _context: &TopicAdmittanceContext,
152        ) -> Result<AdmittanceInstructions, TopicManagerError> {
153            Ok(AdmittanceInstructions {
154                outputs_to_admit: vec![0, 1, 2],
155                coins_to_retain: vec![],
156                coins_removed: None,
157            })
158        }
159
160        async fn get_documentation(&self) -> String {
161            "Admits everything.".to_string()
162        }
163
164        async fn get_metadata(&self) -> ServiceMetadata {
165            ServiceMetadata {
166                name: "admit-all".to_string(),
167                description: Some("Test manager that admits all outputs".to_string()),
168                ..Default::default()
169            }
170        }
171    }
172
173    /// A TopicManager that rejects everything.
174    struct RejectAllManager;
175
176    #[async_trait(?Send)]
177    impl TopicManager for RejectAllManager {
178        async fn identify_admissible_outputs(
179            &self,
180            _tx: &Transaction,
181            _previous_coins: &[u8],
182            _off_chain_values: Option<&[u8]>,
183            _mode: SubmitMode,
184            _context: &TopicAdmittanceContext,
185        ) -> Result<AdmittanceInstructions, TopicManagerError> {
186            Ok(AdmittanceInstructions::default())
187        }
188
189        async fn get_documentation(&self) -> String {
190            "Rejects everything.".to_string()
191        }
192
193        async fn get_metadata(&self) -> ServiceMetadata {
194            ServiceMetadata {
195                name: "reject-all".to_string(),
196                ..Default::default()
197            }
198        }
199    }
200
201    #[tokio::test]
202    async fn test_admit_all_manager() {
203        let mgr = AdmitAllManager;
204        let result = mgr
205            .identify_admissible_outputs(
206                &Transaction::new(),
207                &[],
208                None,
209                SubmitMode::CurrentTx,
210                &TopicAdmittanceContext::default(),
211            )
212            .await
213            .unwrap();
214        assert_eq!(result.outputs_to_admit, vec![0, 1, 2]);
215        assert!(result.has_activity());
216    }
217
218    #[tokio::test]
219    async fn test_reject_all_manager() {
220        let mgr = RejectAllManager;
221        let result = mgr
222            .identify_admissible_outputs(
223                &Transaction::new(),
224                &[],
225                None,
226                SubmitMode::CurrentTx,
227                &TopicAdmittanceContext::default(),
228            )
229            .await
230            .unwrap();
231        assert!(result.outputs_to_admit.is_empty());
232        assert!(!result.has_activity());
233    }
234
235    #[tokio::test]
236    async fn test_default_identify_needed_inputs() {
237        let mgr = AdmitAllManager;
238        let inputs = mgr.identify_needed_inputs(&[], None).await.unwrap();
239        assert!(inputs.is_empty());
240    }
241
242    #[tokio::test]
243    async fn test_manager_metadata() {
244        let mgr = AdmitAllManager;
245        let meta = mgr.get_metadata().await;
246        assert_eq!(meta.name, "admit-all");
247        assert!(meta.description.is_some());
248
249        let docs = mgr.get_documentation().await;
250        assert!(!docs.is_empty());
251    }
252
253    #[tokio::test]
254    async fn test_topic_manager_is_object_safe() {
255        // Verify the trait can be used as dyn TopicManager (required for Engine)
256        let mgr: Box<dyn TopicManager> = Box::new(AdmitAllManager);
257        let result = mgr
258            .identify_admissible_outputs(
259                &Transaction::new(),
260                &[],
261                None,
262                SubmitMode::HistoricalTx,
263                &TopicAdmittanceContext::default(),
264            )
265            .await
266            .unwrap();
267        assert_eq!(result.outputs_to_admit.len(), 3);
268    }
269}