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