bsv_overlay_engine/types.rs
1//! Core types for the BSV Overlay Services Engine.
2//!
3//! Types that already exist in `bsv::overlay::types` (Protocol, LookupQuestion, LookupAnswer,
4//! TaggedBEEF, AdmittanceInstructions, Steak, ServiceMetadata, etc.) are re-exported.
5//! This module defines Engine-specific types not present in the SDK.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10// Re-export SDK overlay types so consumers only need `overlay_engine::types::*`
11pub use bsv_rs::overlay::types::{
12 AdmittanceInstructions, HostResponse, LookupAnswer, LookupAnswerType, LookupFormula,
13 LookupQuestion, NetworkPreset, OutputListItem, Protocol, ServiceMetadata, Steak, TaggedBEEF,
14};
15
16// ============================================================================
17// Outpoint
18// ============================================================================
19
20/// A transaction outpoint — txid + output index.
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub struct Outpoint {
23 pub txid: String,
24 #[serde(rename = "outputIndex")]
25 pub output_index: u32,
26}
27
28impl Outpoint {
29 pub fn new(txid: impl Into<String>, output_index: u32) -> Self {
30 Self {
31 txid: txid.into(),
32 output_index,
33 }
34 }
35
36 /// Parse from "txid.outputIndex" format used by GASP graphIDs.
37 pub fn from_graph_id(graph_id: &str) -> Option<Self> {
38 let parts: Vec<&str> = graph_id.splitn(2, '.').collect();
39 if parts.len() != 2 {
40 return None;
41 }
42 let output_index = parts[1].parse::<u32>().ok()?;
43 Some(Self {
44 txid: parts[0].to_string(),
45 output_index,
46 })
47 }
48
49 /// Format as "txid.outputIndex" for GASP graphIDs.
50 pub fn to_graph_id(&self) -> String {
51 format!("{}.{}", self.txid, self.output_index)
52 }
53}
54
55impl std::fmt::Display for Outpoint {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 write!(f, "{}.{}", self.txid, self.output_index)
58 }
59}
60
61// ============================================================================
62// Output — tracked UTXO in the overlay
63// ============================================================================
64
65/// A UTXO tracked by the Overlay Services Engine.
66///
67/// Ported from `@bsv/overlay` Output interface.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct Output {
70 /// Transaction ID containing this output.
71 pub txid: String,
72
73 /// Index of this output within the transaction.
74 #[serde(rename = "outputIndex")]
75 pub output_index: u32,
76
77 /// Locking script bytes.
78 #[serde(rename = "outputScript")]
79 pub output_script: Vec<u8>,
80
81 /// Satoshi value.
82 pub satoshis: u64,
83
84 /// Topic this output belongs to.
85 pub topic: String,
86
87 /// Whether this output has been spent.
88 pub spent: bool,
89
90 /// Outpoints consumed by the transaction that created this output (its inputs).
91 #[serde(rename = "outputsConsumed", default)]
92 pub outputs_consumed: Vec<Outpoint>,
93
94 /// Outpoints of transactions that later spent this output.
95 #[serde(rename = "consumedBy", default)]
96 pub consumed_by: Vec<Outpoint>,
97
98 /// BEEF-encoded transaction data (optional, loaded on demand).
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub beef: Option<Vec<u8>>,
101
102 /// Block height if the transaction is mined.
103 #[serde(rename = "blockHeight", skip_serializing_if = "Option::is_none")]
104 pub block_height: Option<u32>,
105
106 /// Score for GASP pagination (typically a timestamp or sequence number).
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub score: Option<f64>,
109}
110
111impl Output {
112 /// Get this output's outpoint.
113 pub fn outpoint(&self) -> Outpoint {
114 Outpoint::new(&self.txid, self.output_index)
115 }
116}
117
118// ============================================================================
119// Advertisement
120// ============================================================================
121
122/// A SHIP or SLAP overlay advertisement.
123///
124/// Represents a service node's announcement that it hosts a particular topic (SHIP)
125/// or provides a particular lookup service (SLAP).
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct Advertisement {
128 /// Protocol type (SHIP or SLAP).
129 pub protocol: Protocol,
130
131 /// Identity key of the advertising node (66-char hex compressed pubkey).
132 #[serde(rename = "identityKey")]
133 pub identity_key: String,
134
135 /// Domain/URL where the service is hosted.
136 pub domain: String,
137
138 /// Topic name (for SHIP, e.g. "tm_mycoin") or service name (for SLAP, e.g. "ls_myservice").
139 #[serde(rename = "topicOrService")]
140 pub topic_or_service: String,
141
142 /// BEEF transaction containing the advertisement output (optional).
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub beef: Option<Vec<u8>>,
145
146 /// Output index within the advertisement transaction (optional).
147 #[serde(rename = "outputIndex", skip_serializing_if = "Option::is_none")]
148 pub output_index: Option<u32>,
149}
150
151/// Data needed to create a new advertisement.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct AdvertisementData {
154 /// Protocol type (SHIP or SLAP).
155 pub protocol: Protocol,
156
157 /// Topic or service name to advertise.
158 #[serde(rename = "topicOrServiceName")]
159 pub topic_or_service_name: String,
160}
161
162// ============================================================================
163// SyncConfiguration
164// ============================================================================
165
166/// Configuration for topic synchronization via GASP.
167///
168/// Maps topic names to their sync targets:
169/// - `SyncTarget::Ship` — discover peers dynamically via SHIP protocol
170/// - `SyncTarget::Peers(urls)` — sync with specific hardcoded peer URLs
171/// - `SyncTarget::Disabled` — no sync for this topic
172pub type SyncConfiguration = HashMap<String, SyncTarget>;
173
174/// Sync target for a topic.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[serde(untagged)]
177pub enum SyncTarget {
178 /// Use SHIP protocol to discover peers dynamically.
179 Ship,
180 /// Sync with specific peer URLs.
181 Peers(Vec<String>),
182 /// Sync disabled for this topic.
183 Disabled,
184}
185
186// ============================================================================
187// Submit mode
188// ============================================================================
189
190/// The context of one `TopicManager::identify_admissible_outputs` call: the
191/// reference's fifth argument, under its name (`context?:
192/// TopicAdmittanceContext`, ts-stack
193/// `packages/overlays/overlay/src/TopicManager.ts` at `f999e0c1a`).
194///
195/// `#[non_exhaustive]`: build it from `TopicAdmittanceContext::default()` (a
196/// real admission) or `TopicAdmittanceContext::DRY_RUN`, so a later field is
197/// not a breaking change for a manager.
198#[non_exhaustive]
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
200pub struct TopicAdmittanceContext {
201 /// `true`: the engine is only ASKING what the manager would admit (the
202 /// GASP walk over a peer's node, the anchor replay of a peer's graph) and
203 /// nothing is admitted on this call. The manager must leave NO durable
204 /// trace: no storage write, no head advance, no counter an operator reads
205 /// as an admission. The reference's `dryRun`.
206 ///
207 /// `false` (the default): a submit. What the manager admits here the
208 /// engine goes on to store.
209 pub dry_run: bool,
210}
211
212impl TopicAdmittanceContext {
213 /// The context of a dry run (`dry_run: true`).
214 pub const DRY_RUN: Self = Self { dry_run: true };
215}
216
217/// Mode for transaction submission to the Engine.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
219pub enum SubmitMode {
220 /// Current transaction — broadcast to network + SPV verification.
221 #[default]
222 #[serde(rename = "current-tx")]
223 CurrentTx,
224 /// Historical transaction — no broadcast, SPV verified.
225 #[serde(rename = "historical-tx")]
226 HistoricalTx,
227 /// Historical transaction — no broadcast, no SPV (for GASP sync).
228 #[serde(rename = "historical-tx-no-spv")]
229 HistoricalTxNoSpv,
230}
231
232// ============================================================================
233// Lookup Service payload types
234// ============================================================================
235
236/// How the Engine delivers data to a LookupService on output admission.
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
238pub enum AdmissionMode {
239 /// Provide only the locking script, txid, outputIndex, satoshis, topic.
240 #[serde(rename = "locking-script")]
241 LockingScript,
242 /// Provide the entire Atomic BEEF transaction.
243 #[serde(rename = "whole-tx")]
244 WholeTx,
245}
246
247/// How the Engine notifies a LookupService when an output is spent.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249pub enum SpendNotificationMode {
250 /// No spend notification.
251 #[serde(rename = "none")]
252 None,
253 /// Provide only the spending txid.
254 #[serde(rename = "txid")]
255 Txid,
256 /// Provide the spending txid + unlocking script + input details.
257 #[serde(rename = "script")]
258 Script,
259 /// Provide the entire spending transaction as Atomic BEEF.
260 #[serde(rename = "whole-tx")]
261 WholeTx,
262}
263
264/// Payload delivered to LookupService::output_admitted_by_topic().
265#[derive(Debug, Clone)]
266pub enum OutputAdmittedByTopic {
267 /// Locking-script mode — provides parsed output fields.
268 LockingScript {
269 txid: String,
270 output_index: u32,
271 topic: String,
272 satoshis: u64,
273 locking_script: Vec<u8>,
274 off_chain_values: Option<Vec<u8>>,
275 },
276 /// Whole-tx mode — provides the entire Atomic BEEF.
277 WholeTx {
278 atomic_beef: Vec<u8>,
279 output_index: u32,
280 topic: String,
281 off_chain_values: Option<Vec<u8>>,
282 },
283}
284
285/// Payload delivered to LookupService::output_spent().
286#[derive(Debug, Clone)]
287pub enum OutputSpent {
288 /// No details — just that it was spent.
289 None {
290 txid: String,
291 output_index: u32,
292 topic: String,
293 },
294 /// Spending txid provided.
295 Txid {
296 txid: String,
297 output_index: u32,
298 topic: String,
299 spending_txid: String,
300 },
301 /// Spending txid + unlocking script + input details.
302 Script {
303 txid: String,
304 output_index: u32,
305 topic: String,
306 spending_txid: String,
307 input_index: u32,
308 unlocking_script: Vec<u8>,
309 sequence_number: u32,
310 off_chain_values: Option<Vec<u8>>,
311 },
312 /// Full spending transaction as Atomic BEEF.
313 WholeTx {
314 txid: String,
315 output_index: u32,
316 topic: String,
317 spending_atomic_beef: Vec<u8>,
318 off_chain_values: Option<Vec<u8>>,
319 },
320}
321
322// ============================================================================
323// GASP types
324// ============================================================================
325
326/// Initial request in the GASP (Graph Aware Sync Protocol).
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct GASPInitialRequest {
329 /// GASP protocol version. Currently 1.
330 pub version: u32,
331 /// Unix timestamp (seconds) of last sync between these two parties.
332 pub since: u64,
333 /// Maximum number of UTXOs to return.
334 #[serde(skip_serializing_if = "Option::is_none")]
335 pub limit: Option<u64>,
336}
337
338/// A single output in a GASP sync response.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct GASPOutput {
341 /// Transaction ID.
342 pub txid: String,
343 /// Output index.
344 #[serde(rename = "outputIndex")]
345 pub output_index: u32,
346 /// Score/timestamp for this output (for pagination).
347 pub score: f64,
348}
349
350/// Initial response in the GASP protocol.
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct GASPInitialResponse {
353 /// List of UTXOs witnessed since the request's timestamp.
354 #[serde(rename = "UTXOList")]
355 pub utxo_list: Vec<GASPOutput>,
356 /// Timestamp from which the responder wants UTXOs back.
357 pub since: u64,
358}
359
360/// Reply to the initial response (UTXOs the requester sends back).
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct GASPInitialReply {
363 /// UTXOs not already in the initial response, after the response's since timestamp.
364 #[serde(rename = "UTXOList")]
365 pub utxo_list: Vec<GASPOutput>,
366}
367
368/// A node in a GASP transaction graph.
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct GASPNode {
371 /// Graph ID this node belongs to ("txid.outputIndex" format).
372 #[serde(rename = "graphID")]
373 pub graph_id: String,
374 /// Raw transaction hex.
375 #[serde(rename = "rawTx")]
376 pub raw_tx: String,
377 /// Output index in the transaction.
378 #[serde(rename = "outputIndex")]
379 pub output_index: u32,
380 /// BUMP merkle proof hex (if transaction is mined).
381 #[serde(skip_serializing_if = "Option::is_none")]
382 pub proof: Option<String>,
383 /// Transaction metadata hex (if requested).
384 #[serde(rename = "txMetadata", skip_serializing_if = "Option::is_none")]
385 pub tx_metadata: Option<String>,
386 /// Output metadata hex (if requested).
387 #[serde(rename = "outputMetadata", skip_serializing_if = "Option::is_none")]
388 pub output_metadata: Option<String>,
389 /// Input transaction references with metadata hashes.
390 #[serde(skip_serializing_if = "Option::is_none")]
391 pub inputs: Option<HashMap<String, GASPInputRef>>,
392}
393
394/// Reference to an input transaction in a GASP graph node.
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct GASPInputRef {
397 /// Hash of the input's metadata.
398 pub hash: String,
399}
400
401/// Response indicating which inputs are needed to complete a GASP graph.
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct GASPNodeResponse {
404 /// Map of txid → whether metadata is requested for that input.
405 #[serde(rename = "requestedInputs")]
406 pub requested_inputs: HashMap<String, GASPInputRequest>,
407}
408
409/// Request for a specific input in GASP sync.
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct GASPInputRequest {
412 /// Whether metadata should be provided for this input.
413 pub metadata: bool,
414}
415
416/// Applied transaction record (deduplication tracking).
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct AppliedTransaction {
419 pub txid: String,
420 pub topic: String,
421}
422
423// ============================================================================
424// UTXO Reference (used by SHIP/SLAP lookup responses)
425// ============================================================================
426
427/// A minimal reference to a UTXO — just txid and outputIndex.
428/// Returned by SHIP/SLAP lookup services.
429#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct UTXOReference {
431 pub txid: String,
432 #[serde(rename = "outputIndex")]
433 pub output_index: u32,
434}
435
436// ============================================================================
437// LookupResult (returned by LookupService::lookup)
438// ============================================================================
439
440/// What a `LookupService::lookup` call returns to the Engine.
441///
442/// Two shapes:
443///
444/// - `OutputList(Vec<UTXOReference>)` — the common case. The service
445/// yields outpoints; the Engine hydrates each with the BEEF from
446/// storage and assembles a `LookupAnswer::OutputList`. This is the
447/// cheap path: the service doesn't need access to BEEF storage, and
448/// the Engine can apply uniform history-selector hydration / sort /
449/// pagination on top.
450///
451/// - `Answer(LookupAnswer)` — the escape hatch. Services that return
452/// `Freeform` (aggregate stats like popular-tag counts) or `Formula`
453/// (cross-service lookup chains) emit the full answer themselves; the
454/// Engine passes it through verbatim. This avoids the
455/// `Vec<UTXOReference>` → `OutputList` shape mismatch that previously
456/// forced services to do hex tricks or hold back functionality.
457///
458/// Migration note: prior to this enum, the trait method returned
459/// `Vec<UTXOReference>` and the Engine always wrapped it in
460/// `LookupAnswer::OutputList`. Existing impls migrate mechanically by
461/// wrapping their result in `LookupResult::OutputList(refs)`. New impls
462/// that need freeform or formula answers return `LookupResult::Answer(...)`.
463#[derive(Debug, Clone)]
464pub enum LookupResult {
465 /// Outpoints to hydrate. The Engine will load BEEF per reference
466 /// (and optional history-selector ancestry) and assemble
467 /// `LookupAnswer::OutputList`.
468 OutputList(Vec<UTXOReference>),
469 /// Pre-formed `LookupAnswer` — Engine passes it through. Use for
470 /// `Freeform` (aggregate stats) and `Formula` (formula chains).
471 Answer(LookupAnswer),
472}
473
474impl LookupResult {
475 /// Convenience: wrap a `Vec<UTXOReference>` (the common path).
476 pub fn outputs(refs: Vec<UTXOReference>) -> Self {
477 Self::OutputList(refs)
478 }
479
480 /// Convenience: wrap a `LookupAnswer` (freeform / formula path).
481 pub fn answer(answer: LookupAnswer) -> Self {
482 Self::Answer(answer)
483 }
484
485 /// Borrow the `OutputList` variant, or `None` if `Answer`. Primarily
486 /// used by tests that want to assert on the reference list directly.
487 pub fn as_outputs(&self) -> Option<&[UTXOReference]> {
488 match self {
489 Self::OutputList(refs) => Some(refs),
490 Self::Answer(_) => None,
491 }
492 }
493
494 /// Consume self and extract `Vec<UTXOReference>`, or `None` if
495 /// `Answer`. Primarily used by tests + the small set of callers that
496 /// want references without going through Engine BEEF hydration.
497 pub fn into_outputs(self) -> Option<Vec<UTXOReference>> {
498 match self {
499 Self::OutputList(refs) => Some(refs),
500 Self::Answer(_) => None,
501 }
502 }
503}
504
505impl From<Vec<UTXOReference>> for LookupResult {
506 fn from(refs: Vec<UTXOReference>) -> Self {
507 Self::OutputList(refs)
508 }
509}
510
511impl From<LookupAnswer> for LookupResult {
512 fn from(answer: LookupAnswer) -> Self {
513 Self::Answer(answer)
514 }
515}
516
517// ============================================================================
518// Tests
519// ============================================================================
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn test_outpoint_graph_id_roundtrip() {
527 let op = Outpoint::new("abc123", 2);
528 let graph_id = op.to_graph_id();
529 assert_eq!(graph_id, "abc123.2");
530 let parsed = Outpoint::from_graph_id(&graph_id).unwrap();
531 assert_eq!(parsed, op);
532 }
533
534 #[test]
535 fn test_outpoint_from_graph_id_invalid() {
536 assert!(Outpoint::from_graph_id("no_dot").is_none());
537 assert!(Outpoint::from_graph_id("abc.notanum").is_none());
538 }
539
540 #[test]
541 fn test_output_serde_roundtrip() {
542 let output = Output {
543 txid: "deadbeef".to_string(),
544 output_index: 0,
545 output_script: vec![0x76, 0xa9],
546 satoshis: 1000,
547 topic: "tm_test".to_string(),
548 spent: false,
549 outputs_consumed: vec![Outpoint::new("prev", 1)],
550 consumed_by: vec![],
551 beef: None,
552 block_height: Some(800_000),
553 score: Some(1.0),
554 };
555 let json = serde_json::to_string(&output).unwrap();
556 let back: Output = serde_json::from_str(&json).unwrap();
557 assert_eq!(back.txid, "deadbeef");
558 assert_eq!(back.output_index, 0);
559 assert_eq!(back.satoshis, 1000);
560 assert_eq!(back.topic, "tm_test");
561 assert!(!back.spent);
562 assert_eq!(back.outputs_consumed.len(), 1);
563 assert_eq!(back.block_height, Some(800_000));
564 }
565
566 #[test]
567 fn test_advertisement_serde() {
568 let ad = Advertisement {
569 protocol: Protocol::Ship,
570 identity_key: "02abc".to_string(),
571 domain: "https://example.com".to_string(),
572 topic_or_service: "tm_test".to_string(),
573 beef: None,
574 output_index: None,
575 };
576 let json = serde_json::to_string(&ad).unwrap();
577 assert!(json.contains("SHIP"));
578 let back: Advertisement = serde_json::from_str(&json).unwrap();
579 assert_eq!(back.protocol, Protocol::Ship);
580 assert_eq!(back.topic_or_service, "tm_test");
581 }
582
583 #[test]
584 fn test_submit_mode_serde() {
585 let json = serde_json::to_string(&SubmitMode::HistoricalTxNoSpv).unwrap();
586 assert_eq!(json, "\"historical-tx-no-spv\"");
587 let back: SubmitMode = serde_json::from_str(&json).unwrap();
588 assert_eq!(back, SubmitMode::HistoricalTxNoSpv);
589 }
590
591 #[test]
592 fn test_gasp_initial_request_serde() {
593 let req = GASPInitialRequest {
594 version: 1,
595 since: 1_700_000_000,
596 limit: Some(10_000),
597 };
598 let json = serde_json::to_string(&req).unwrap();
599 let back: GASPInitialRequest = serde_json::from_str(&json).unwrap();
600 assert_eq!(back.version, 1);
601 assert_eq!(back.since, 1_700_000_000);
602 assert_eq!(back.limit, Some(10_000));
603 }
604
605 #[test]
606 fn test_gasp_node_serde() {
607 let node = GASPNode {
608 graph_id: "abc.0".to_string(),
609 raw_tx: "0100000001".to_string(),
610 output_index: 0,
611 proof: None,
612 tx_metadata: None,
613 output_metadata: None,
614 inputs: None,
615 };
616 let json = serde_json::to_string(&node).unwrap();
617 assert!(json.contains("graphID"));
618 assert!(json.contains("rawTx"));
619 let back: GASPNode = serde_json::from_str(&json).unwrap();
620 assert_eq!(back.graph_id, "abc.0");
621 }
622
623 #[test]
624 fn test_utxo_reference_serde() {
625 let r = UTXOReference {
626 txid: "dead".to_string(),
627 output_index: 3,
628 };
629 let json = serde_json::to_string(&r).unwrap();
630 assert!(json.contains("outputIndex"));
631 let back: UTXOReference = serde_json::from_str(&json).unwrap();
632 assert_eq!(back.output_index, 3);
633 }
634
635 #[test]
636 fn test_admission_mode_serde() {
637 let json = serde_json::to_string(&AdmissionMode::LockingScript).unwrap();
638 assert_eq!(json, "\"locking-script\"");
639 }
640
641 #[test]
642 fn test_spend_notification_mode_serde() {
643 let json = serde_json::to_string(&SpendNotificationMode::WholeTx).unwrap();
644 assert_eq!(json, "\"whole-tx\"");
645 }
646}