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}