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 `txStatus` word, trimmed and upper-cased by [`Verdict::from_arc_body`].
62    pub status: String,
63    /// The raw `blockHeight`, when present.
64    pub height: Option<Height>,
65    /// The raw `extraInfo`, when present: text, never a status.
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    /// The word is trimmed before the vocabulary check and kept trimmed and
75    /// upper-cased, so `" SEEN_ON_NETWORK "` reads as `SEEN_ON_NETWORK`
76    /// (0.4.1; bsv-stack-lean #169, the spaced body of LOW's door test
77    /// `tracker_vocabulary_differs_from_the_door_exactly_here` in
78    /// bsv-overlay-cloudflare `crates/overlay-cloudflare/src/door_words.rs`).
79    ///
80    /// # Errors
81    ///
82    /// [`VerdictError::Shape`] when the body does not decode,
83    /// [`VerdictError::TxidMismatch`] when it echoes another or a malformed
84    /// txid, [`VerdictError::InvalidMetadata`] when its text or competitors
85    /// break the bounds, and [`VerdictError::UnknownStatus`] for a status word
86    /// outside the vocabulary.
87    pub fn from_arc_body(expected_txid: &str, bytes: &[u8]) -> Result<Self, VerdictError> {
88        #[derive(Deserialize)]
89        #[serde(rename_all = "camelCase")]
90        struct Body {
91            txid: Option<String>,
92            tx_status: String,
93            block_height: Option<Height>,
94            extra_info: Option<String>,
95            competing_txs: Option<Vec<TxId>>,
96        }
97        let body: Body =
98            serde_json::from_slice(bytes).map_err(|e| VerdictError::Shape(e.to_string()))?;
99        if body
100            .txid
101            .as_deref()
102            .is_some_and(|txid| !is_txid(txid) || !txid.eq_ignore_ascii_case(expected_txid))
103        {
104            return Err(VerdictError::TxidMismatch);
105        }
106        if !bounded_text(&body.tx_status, 128, false)
107            || body
108                .extra_info
109                .as_deref()
110                .is_some_and(|info| !bounded_text(info, 8_192, true))
111        {
112            return Err(VerdictError::InvalidMetadata);
113        }
114        let competitors: Vec<_> = body
115            .competing_txs
116            .unwrap_or_default()
117            .into_iter()
118            .map(|id| id.to_ascii_lowercase())
119            .collect();
120        if competitors.len() > 256
121            || competitors.iter().any(|id| !is_txid(id))
122            || competitors
123                .iter()
124                .collect::<std::collections::BTreeSet<_>>()
125                .len()
126                != competitors.len()
127        {
128            return Err(VerdictError::InvalidMetadata);
129        }
130        let status = body.tx_status.trim().to_ascii_uppercase();
131        let known = matches!(
132            status.as_str(),
133            "SUCCESS"
134                | "UNKNOWN"
135                | "RECEIVED"
136                | "QUEUED"
137                | "STORED"
138                | "ANNOUNCED_TO_NETWORK"
139                | "REQUESTED_BY_NETWORK"
140                | "SENT_TO_NETWORK"
141                | "ACCEPTED_BY_NETWORK"
142                | "SEEN_ON_NETWORK"
143                | "SEEN_MULTIPLE_NODES"
144                | "MINED"
145                | "CONFIRMED"
146                | "IMMUTABLE"
147                | "MINED_IN_STALE_BLOCK"
148                | "REJECTED"
149                | "INVALID"
150                | "MALFORMED"
151                | "DOUBLE_SPEND_ATTEMPTED"
152                | "UTXO_SPENT"
153                | "SEEN_IN_ORPHAN_MEMPOOL"
154                | "PENDING_RETRY"
155                | "STUMP_PROCESSING"
156        );
157        if !known {
158            return Err(VerdictError::UnknownStatus(body.tx_status));
159        }
160        Ok(Self {
161            status,
162            height: body.block_height,
163            reason: body.extra_info,
164            competitors,
165        })
166    }
167    /// Interpret a status as a hint; no result is a node verdict or a proof.
168    ///
169    /// Only the status word moves the status (0.4.1; bsv-stack-lean #169,
170    /// LOW's door test `tracker_vocabulary_differs_from_the_door_exactly_here`
171    /// in bsv-overlay-cloudflare `crates/overlay-cloudflare/src/door_words.rs`):
172    /// `extraInfo` is text and stays on `reason`, so an `ORPHAN` or
173    /// `reorg_unmined` there no longer overrides the word; only
174    /// `SEEN_IN_ORPHAN_MEMPOOL` is [`HintStatus::OrphanMempool`] and only the
175    /// stale-block words are [`HintStatus::StaleBlock`]. `PENDING_RETRY` and
176    /// `STUMP_PROCESSING`, a broadcaster still working on it, are
177    /// [`HintStatus::Accepted`]. A mined word with no height stays
178    /// [`HintStatus::Unknown`], and named competitors still turn an accepting
179    /// word into [`HintStatus::DoubleSpend`].
180    pub fn into_hint(self, source: impl Into<String>, observed: Timestamp) -> Hint {
181        let status = match self.status.to_ascii_uppercase().as_str() {
182            "SUCCESS"
183            | "SENT_TO_NETWORK"
184            | "RECEIVED"
185            | "QUEUED"
186            | "STORED"
187            | "ANNOUNCED_TO_NETWORK"
188            | "REQUESTED_BY_NETWORK"
189            | "ACCEPTED_BY_NETWORK"
190            | "PENDING_RETRY"
191            | "STUMP_PROCESSING" => HintStatus::Accepted,
192            "SEEN_ON_NETWORK" | "SEEN_MULTIPLE_NODES" => HintStatus::Seen,
193            "MINED" | "CONFIRMED" | "IMMUTABLE" => self
194                .height
195                .map_or(HintStatus::Unknown, |height| HintStatus::Mined { height }),
196            "MINED_IN_STALE_BLOCK" | "REORG_UNMINED" | "REORG_UNCONFIRMED" => {
197                HintStatus::StaleBlock
198            }
199            "SEEN_IN_ORPHAN_MEMPOOL" => HintStatus::OrphanMempool,
200            "DOUBLE_SPEND_ATTEMPTED" | "UTXO_SPENT" => HintStatus::DoubleSpend {
201                competitors: self.competitors.clone(),
202            },
203            "REJECTED" | "INVALID" | "MALFORMED" => HintStatus::Rejected {
204                reason: self
205                    .reason
206                    .clone()
207                    .filter(|s| !s.is_empty())
208                    .unwrap_or_else(|| self.status.clone()),
209            },
210            _ => HintStatus::Unknown,
211        };
212        let status = if !self.competitors.is_empty()
213            && matches!(status, HintStatus::Accepted | HintStatus::Seen)
214        {
215            // A named competitor cannot disappear behind an accepting word.
216            HintStatus::DoubleSpend {
217                competitors: self.competitors.clone(),
218            }
219        } else {
220            status
221        };
222        Hint {
223            source: source.into(),
224            status,
225            observed,
226            verdict: Some(self),
227        }
228    }
229}
230
231/// An invalid broadcaster body is a reported fault, never chain evidence.
232#[derive(Clone, Debug, PartialEq, Eq)]
233pub enum VerdictError {
234    /// The expected fields cannot be decoded.
235    Shape(String),
236    /// The body names another transaction or a malformed identifier.
237    TxidMismatch,
238    /// Text or competing identifiers violate the pinned metadata bounds.
239    InvalidMetadata,
240    /// The sender uses a word this adapter does not know.
241    UnknownStatus(String),
242}
243impl fmt::Display for VerdictError {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        write!(f, "{self:?}")
246    }
247}
248impl std::error::Error for VerdictError {}
249
250fn is_txid(value: &str) -> bool {
251    value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
252}
253fn bounded_text(value: &str, max: usize, empty: bool) -> bool {
254    (empty || !value.is_empty())
255        && value.len() <= max
256        && !value
257            .chars()
258            .any(|c| matches!(c as u32, 0..=31 | 127..=159))
259}