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