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    /// Return Markdown documentation for this topic manager.
96    async fn get_documentation(&self) -> String;
97
98    /// Return metadata identifying this topic manager.
99    async fn get_metadata(&self) -> ServiceMetadata;
100}
101
102/// Errors from TopicManager operations.
103#[derive(Debug, thiserror::Error)]
104pub enum TopicManagerError {
105    /// The transaction BEEF could not be parsed.
106    #[error("invalid BEEF: {0}")]
107    InvalidBeef(String),
108
109    /// The transaction has no admissible outputs for this topic.
110    #[error("no admissible outputs: {0}")]
111    NoAdmissibleOutputs(String),
112
113    /// A script in the transaction is malformed.
114    #[error("invalid script: {0}")]
115    InvalidScript(String),
116
117    /// Signature verification failed.
118    #[error("signature verification failed: {0}")]
119    SignatureError(String),
120
121    /// Generic error.
122    #[error("{0}")]
123    Other(String),
124}
125
126// ============================================================================
127// Tests
128// ============================================================================
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    /// A trivial TopicManager that admits all outputs.
135    struct AdmitAllManager;
136
137    #[async_trait(?Send)]
138    impl TopicManager for AdmitAllManager {
139        async fn identify_admissible_outputs(
140            &self,
141            _tx: &Transaction,
142            _previous_coins: &[u8],
143            _off_chain_values: Option<&[u8]>,
144            _mode: SubmitMode,
145            _context: &TopicAdmittanceContext,
146        ) -> Result<AdmittanceInstructions, TopicManagerError> {
147            Ok(AdmittanceInstructions {
148                outputs_to_admit: vec![0, 1, 2],
149                coins_to_retain: vec![],
150                coins_removed: None,
151            })
152        }
153
154        async fn get_documentation(&self) -> String {
155            "Admits everything.".to_string()
156        }
157
158        async fn get_metadata(&self) -> ServiceMetadata {
159            ServiceMetadata {
160                name: "admit-all".to_string(),
161                description: Some("Test manager that admits all outputs".to_string()),
162                ..Default::default()
163            }
164        }
165    }
166
167    /// A TopicManager that rejects everything.
168    struct RejectAllManager;
169
170    #[async_trait(?Send)]
171    impl TopicManager for RejectAllManager {
172        async fn identify_admissible_outputs(
173            &self,
174            _tx: &Transaction,
175            _previous_coins: &[u8],
176            _off_chain_values: Option<&[u8]>,
177            _mode: SubmitMode,
178            _context: &TopicAdmittanceContext,
179        ) -> Result<AdmittanceInstructions, TopicManagerError> {
180            Ok(AdmittanceInstructions::default())
181        }
182
183        async fn get_documentation(&self) -> String {
184            "Rejects everything.".to_string()
185        }
186
187        async fn get_metadata(&self) -> ServiceMetadata {
188            ServiceMetadata {
189                name: "reject-all".to_string(),
190                ..Default::default()
191            }
192        }
193    }
194
195    #[tokio::test]
196    async fn test_admit_all_manager() {
197        let mgr = AdmitAllManager;
198        let result = mgr
199            .identify_admissible_outputs(
200                &Transaction::new(),
201                &[],
202                None,
203                SubmitMode::CurrentTx,
204                &TopicAdmittanceContext::default(),
205            )
206            .await
207            .unwrap();
208        assert_eq!(result.outputs_to_admit, vec![0, 1, 2]);
209        assert!(result.has_activity());
210    }
211
212    #[tokio::test]
213    async fn test_reject_all_manager() {
214        let mgr = RejectAllManager;
215        let result = mgr
216            .identify_admissible_outputs(
217                &Transaction::new(),
218                &[],
219                None,
220                SubmitMode::CurrentTx,
221                &TopicAdmittanceContext::default(),
222            )
223            .await
224            .unwrap();
225        assert!(result.outputs_to_admit.is_empty());
226        assert!(!result.has_activity());
227    }
228
229    #[tokio::test]
230    async fn test_default_identify_needed_inputs() {
231        let mgr = AdmitAllManager;
232        let inputs = mgr.identify_needed_inputs(&[], None).await.unwrap();
233        assert!(inputs.is_empty());
234    }
235
236    #[tokio::test]
237    async fn test_manager_metadata() {
238        let mgr = AdmitAllManager;
239        let meta = mgr.get_metadata().await;
240        assert_eq!(meta.name, "admit-all");
241        assert!(meta.description.is_some());
242
243        let docs = mgr.get_documentation().await;
244        assert!(!docs.is_empty());
245    }
246
247    #[tokio::test]
248    async fn test_topic_manager_is_object_safe() {
249        // Verify the trait can be used as dyn TopicManager (required for Engine)
250        let mgr: Box<dyn TopicManager> = Box::new(AdmitAllManager);
251        let result = mgr
252            .identify_admissible_outputs(
253                &Transaction::new(),
254                &[],
255                None,
256                SubmitMode::HistoricalTx,
257                &TopicAdmittanceContext::default(),
258            )
259            .await
260            .unwrap();
261        assert_eq!(result.outputs_to_admit.len(), 3);
262    }
263}