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}