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}