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