Skip to main content

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}