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}