Skip to main content

bsv_tracker/
hints.rs

1use crate::{Height, Timestamp, TxId};
2use serde::Deserialize;
3use std::fmt;
4
5/// A broadcaster's claim reduced to the Lean hint vocabulary.
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub enum HintStatus {
8    Accepted,
9    Seen,
10    Mined { height: Height },
11    StaleBlock,
12    Rejected { reason: String },
13    DoubleSpend { competitors: Vec<TxId> },
14    OrphanMempool,
15    Unknown,
16}
17/// An observed hint, carrying its source and host supplied time.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct Hint {
20    pub source: String,
21    pub status: HintStatus,
22    pub observed: Timestamp,
23    /// The original broadcaster metadata, when reduced through `Verdict`.
24    /// This retains the raw status, reason and named competitors for re-asks.
25    pub verdict: Option<Verdict>,
26}
27impl Hint {
28    /// A reduced hint, for hosts whose source already supplies the Lean vocabulary.
29    pub fn new(source: impl Into<String>, status: HintStatus, observed: Timestamp) -> Self {
30        Self {
31            source: source.into(),
32            status,
33            observed,
34            verdict: None,
35        }
36    }
37}
38/// Raw broadcaster metadata. HTTP acceptance is not chain evidence.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct Verdict {
41    pub status: String,
42    pub height: Option<Height>,
43    pub reason: Option<String>,
44    pub competitors: Vec<TxId>,
45}
46impl Verdict {
47    /// Read an ARC body as metadata, checking its echoed txid and the bounded
48    /// status vocabulary at bsv-rs@7bc623c. HTTP status is never consulted.
49    pub fn from_arc_body(expected_txid: &str, bytes: &[u8]) -> Result<Self, VerdictError> {
50        #[derive(Deserialize)]
51        #[serde(rename_all = "camelCase")]
52        struct Body {
53            txid: Option<String>,
54            tx_status: String,
55            block_height: Option<Height>,
56            extra_info: Option<String>,
57            competing_txs: Option<Vec<TxId>>,
58        }
59        let body: Body =
60            serde_json::from_slice(bytes).map_err(|e| VerdictError::Shape(e.to_string()))?;
61        if body
62            .txid
63            .as_deref()
64            .is_some_and(|txid| !is_txid(txid) || !txid.eq_ignore_ascii_case(expected_txid))
65        {
66            return Err(VerdictError::TxidMismatch);
67        }
68        if !bounded_text(&body.tx_status, 128, false)
69            || body
70                .extra_info
71                .as_deref()
72                .is_some_and(|info| !bounded_text(info, 8_192, true))
73        {
74            return Err(VerdictError::InvalidMetadata);
75        }
76        let competitors: Vec<_> = body
77            .competing_txs
78            .unwrap_or_default()
79            .into_iter()
80            .map(|id| id.to_ascii_lowercase())
81            .collect();
82        if competitors.len() > 256
83            || competitors.iter().any(|id| !is_txid(id))
84            || competitors
85                .iter()
86                .collect::<std::collections::BTreeSet<_>>()
87                .len()
88                != competitors.len()
89        {
90            return Err(VerdictError::InvalidMetadata);
91        }
92        let known = matches!(
93            body.tx_status.to_ascii_uppercase().as_str(),
94            "SUCCESS"
95                | "UNKNOWN"
96                | "RECEIVED"
97                | "QUEUED"
98                | "STORED"
99                | "ANNOUNCED_TO_NETWORK"
100                | "REQUESTED_BY_NETWORK"
101                | "SENT_TO_NETWORK"
102                | "ACCEPTED_BY_NETWORK"
103                | "SEEN_ON_NETWORK"
104                | "SEEN_MULTIPLE_NODES"
105                | "MINED"
106                | "CONFIRMED"
107                | "IMMUTABLE"
108                | "MINED_IN_STALE_BLOCK"
109                | "REJECTED"
110                | "INVALID"
111                | "MALFORMED"
112                | "DOUBLE_SPEND_ATTEMPTED"
113                | "UTXO_SPENT"
114                | "SEEN_IN_ORPHAN_MEMPOOL"
115                | "PENDING_RETRY"
116                | "STUMP_PROCESSING"
117        );
118        if !known {
119            return Err(VerdictError::UnknownStatus(body.tx_status));
120        }
121        Ok(Self {
122            status: body.tx_status,
123            height: body.block_height,
124            reason: body.extra_info,
125            competitors,
126        })
127    }
128    /// Interpret a status as a hint; no result is a node verdict or a proof.
129    pub fn into_hint(self, source: impl Into<String>, observed: Timestamp) -> Hint {
130        let status = match self.status.to_ascii_uppercase().as_str() {
131            "SUCCESS"
132            | "SENT_TO_NETWORK"
133            | "RECEIVED"
134            | "QUEUED"
135            | "STORED"
136            | "ANNOUNCED_TO_NETWORK"
137            | "REQUESTED_BY_NETWORK"
138            | "ACCEPTED_BY_NETWORK" => HintStatus::Accepted,
139            "SEEN_ON_NETWORK" | "SEEN_MULTIPLE_NODES" => HintStatus::Seen,
140            "MINED" | "CONFIRMED" | "IMMUTABLE" => self
141                .height
142                .map_or(HintStatus::Unknown, |height| HintStatus::Mined { height }),
143            "MINED_IN_STALE_BLOCK" | "REORG_UNMINED" | "REORG_UNCONFIRMED" => {
144                HintStatus::StaleBlock
145            }
146            "SEEN_IN_ORPHAN_MEMPOOL" => HintStatus::OrphanMempool,
147            "DOUBLE_SPEND_ATTEMPTED" | "UTXO_SPENT" => HintStatus::DoubleSpend {
148                competitors: self.competitors.clone(),
149            },
150            "REJECTED" | "INVALID" | "MALFORMED" => HintStatus::Rejected {
151                reason: self
152                    .reason
153                    .clone()
154                    .filter(|s| !s.is_empty())
155                    .unwrap_or_else(|| self.status.clone()),
156            },
157            _ => HintStatus::Unknown,
158        };
159        let status = if self
160            .reason
161            .as_deref()
162            .is_some_and(|r| r.eq_ignore_ascii_case("reorg_unmined"))
163        {
164            HintStatus::StaleBlock
165        } else if self.status.to_ascii_uppercase().contains("ORPHAN")
166            || self
167                .reason
168                .as_deref()
169                .is_some_and(|r| r.to_ascii_uppercase().contains("ORPHAN"))
170        {
171            HintStatus::OrphanMempool
172        } else if !self.competitors.is_empty()
173            && matches!(status, HintStatus::Accepted | HintStatus::Seen)
174        {
175            // A named competitor cannot disappear behind an accepting word.
176            HintStatus::DoubleSpend {
177                competitors: self.competitors.clone(),
178            }
179        } else {
180            status
181        };
182        Hint {
183            source: source.into(),
184            status,
185            observed,
186            verdict: Some(self),
187        }
188    }
189}
190
191/// An invalid broadcaster body is a reported fault, never chain evidence.
192#[derive(Clone, Debug, PartialEq, Eq)]
193pub enum VerdictError {
194    /// The expected fields cannot be decoded.
195    Shape(String),
196    /// The body names another transaction or a malformed identifier.
197    TxidMismatch,
198    /// Text or competing identifiers violate the pinned metadata bounds.
199    InvalidMetadata,
200    /// The sender uses a word this adapter does not know.
201    UnknownStatus(String),
202}
203impl fmt::Display for VerdictError {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        write!(f, "{self:?}")
206    }
207}
208impl std::error::Error for VerdictError {}
209
210fn is_txid(value: &str) -> bool {
211    value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
212}
213fn bounded_text(value: &str, max: usize, empty: bool) -> bool {
214    (empty || !value.is_empty())
215        && value.len() <= max
216        && !value
217            .chars()
218            .any(|c| matches!(c as u32, 0..=31 | 127..=159))
219}