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