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