1use crate::{Height, Timestamp, TxId};
2use serde::Deserialize;
3use std::fmt;
4
5#[derive(Clone, Debug, PartialEq, Eq)]
7pub enum HintStatus {
8 Accepted,
10 Seen,
12 Mined {
14 height: Height,
16 },
17 StaleBlock,
19 Rejected {
21 reason: String,
23 },
24 DoubleSpend {
26 competitors: Vec<TxId>,
28 },
29 OrphanMempool,
31 Unknown,
33}
34#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct Hint {
37 pub source: String,
39 pub status: HintStatus,
41 pub observed: Timestamp,
43 pub verdict: Option<Verdict>,
46}
47impl Hint {
48 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#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct Verdict {
61 pub status: String,
63 pub height: Option<Height>,
65 pub reason: Option<String>,
67 pub competitors: Vec<TxId>,
69}
70impl Verdict {
71 pub fn from_arc_body(expected_txid: &str, bytes: &[u8]) -> Result<Self, VerdictError> {
82 #[derive(Deserialize)]
83 #[serde(rename_all = "camelCase")]
84 struct Body {
85 txid: Option<String>,
86 tx_status: String,
87 block_height: Option<Height>,
88 extra_info: Option<String>,
89 competing_txs: Option<Vec<TxId>>,
90 }
91 let body: Body =
92 serde_json::from_slice(bytes).map_err(|e| VerdictError::Shape(e.to_string()))?;
93 if body
94 .txid
95 .as_deref()
96 .is_some_and(|txid| !is_txid(txid) || !txid.eq_ignore_ascii_case(expected_txid))
97 {
98 return Err(VerdictError::TxidMismatch);
99 }
100 if !bounded_text(&body.tx_status, 128, false)
101 || body
102 .extra_info
103 .as_deref()
104 .is_some_and(|info| !bounded_text(info, 8_192, true))
105 {
106 return Err(VerdictError::InvalidMetadata);
107 }
108 let competitors: Vec<_> = body
109 .competing_txs
110 .unwrap_or_default()
111 .into_iter()
112 .map(|id| id.to_ascii_lowercase())
113 .collect();
114 if competitors.len() > 256
115 || competitors.iter().any(|id| !is_txid(id))
116 || competitors
117 .iter()
118 .collect::<std::collections::BTreeSet<_>>()
119 .len()
120 != competitors.len()
121 {
122 return Err(VerdictError::InvalidMetadata);
123 }
124 let known = matches!(
125 body.tx_status.to_ascii_uppercase().as_str(),
126 "SUCCESS"
127 | "UNKNOWN"
128 | "RECEIVED"
129 | "QUEUED"
130 | "STORED"
131 | "ANNOUNCED_TO_NETWORK"
132 | "REQUESTED_BY_NETWORK"
133 | "SENT_TO_NETWORK"
134 | "ACCEPTED_BY_NETWORK"
135 | "SEEN_ON_NETWORK"
136 | "SEEN_MULTIPLE_NODES"
137 | "MINED"
138 | "CONFIRMED"
139 | "IMMUTABLE"
140 | "MINED_IN_STALE_BLOCK"
141 | "REJECTED"
142 | "INVALID"
143 | "MALFORMED"
144 | "DOUBLE_SPEND_ATTEMPTED"
145 | "UTXO_SPENT"
146 | "SEEN_IN_ORPHAN_MEMPOOL"
147 | "PENDING_RETRY"
148 | "STUMP_PROCESSING"
149 );
150 if !known {
151 return Err(VerdictError::UnknownStatus(body.tx_status));
152 }
153 Ok(Self {
154 status: body.tx_status,
155 height: body.block_height,
156 reason: body.extra_info,
157 competitors,
158 })
159 }
160 pub fn into_hint(self, source: impl Into<String>, observed: Timestamp) -> Hint {
162 let status = match self.status.to_ascii_uppercase().as_str() {
163 "SUCCESS"
164 | "SENT_TO_NETWORK"
165 | "RECEIVED"
166 | "QUEUED"
167 | "STORED"
168 | "ANNOUNCED_TO_NETWORK"
169 | "REQUESTED_BY_NETWORK"
170 | "ACCEPTED_BY_NETWORK" => HintStatus::Accepted,
171 "SEEN_ON_NETWORK" | "SEEN_MULTIPLE_NODES" => HintStatus::Seen,
172 "MINED" | "CONFIRMED" | "IMMUTABLE" => self
173 .height
174 .map_or(HintStatus::Unknown, |height| HintStatus::Mined { height }),
175 "MINED_IN_STALE_BLOCK" | "REORG_UNMINED" | "REORG_UNCONFIRMED" => {
176 HintStatus::StaleBlock
177 }
178 "SEEN_IN_ORPHAN_MEMPOOL" => HintStatus::OrphanMempool,
179 "DOUBLE_SPEND_ATTEMPTED" | "UTXO_SPENT" => HintStatus::DoubleSpend {
180 competitors: self.competitors.clone(),
181 },
182 "REJECTED" | "INVALID" | "MALFORMED" => HintStatus::Rejected {
183 reason: self
184 .reason
185 .clone()
186 .filter(|s| !s.is_empty())
187 .unwrap_or_else(|| self.status.clone()),
188 },
189 _ => HintStatus::Unknown,
190 };
191 let status = if self
192 .reason
193 .as_deref()
194 .is_some_and(|r| r.eq_ignore_ascii_case("reorg_unmined"))
195 {
196 HintStatus::StaleBlock
197 } else if self.status.to_ascii_uppercase().contains("ORPHAN")
198 || self
199 .reason
200 .as_deref()
201 .is_some_and(|r| r.to_ascii_uppercase().contains("ORPHAN"))
202 {
203 HintStatus::OrphanMempool
204 } else if !self.competitors.is_empty()
205 && matches!(status, HintStatus::Accepted | HintStatus::Seen)
206 {
207 HintStatus::DoubleSpend {
209 competitors: self.competitors.clone(),
210 }
211 } else {
212 status
213 };
214 Hint {
215 source: source.into(),
216 status,
217 observed,
218 verdict: Some(self),
219 }
220 }
221}
222
223#[derive(Clone, Debug, PartialEq, Eq)]
225pub enum VerdictError {
226 Shape(String),
228 TxidMismatch,
230 InvalidMetadata,
232 UnknownStatus(String),
234}
235impl fmt::Display for VerdictError {
236 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237 write!(f, "{self:?}")
238 }
239}
240impl std::error::Error for VerdictError {}
241
242fn is_txid(value: &str) -> bool {
243 value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
244}
245fn bounded_text(value: &str, max: usize, empty: bool) -> bool {
246 (empty || !value.is_empty())
247 && value.len() <= max
248 && !value
249 .chars()
250 .any(|c| matches!(c as u32, 0..=31 | 127..=159))
251}