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}