1use std::time::Duration;
35
36use bsv_wallet_toolbox::Chain;
37use reqwest::Client;
38
39const DEFAULT_ATTEMPTS: u32 = 6;
41const DEFAULT_DELAY_MS: u64 = 1500;
44const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum BroadcastVerification {
50 Confirmed,
52 Rejected,
57 Inconclusive,
61}
62
63impl BroadcastVerification {
64 pub fn into_send_result(self, txid: &str) -> anyhow::Result<()> {
67 match self {
68 BroadcastVerification::Rejected => Err(anyhow::anyhow!(
69 "broadcast rejected: transaction {txid} is not present on the network. \
70 The broadcaster (ARC) dropped it — most likely error 465 \"fee too low\", \
71 because a monitor-less wallet presented a deep unconfirmed BEEF and ARC \
72 charged the fee for the whole unconfirmed package. The funds were NOT sent. \
73 Fetch merkle proofs for the confirmed ancestors (run `bsv-wallet tick` with \
74 CHAINTRACKS_URL set) or fund from a confirmed UTXO, then retry."
75 )),
76 BroadcastVerification::Confirmed | BroadcastVerification::Inconclusive => Ok(()),
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83enum Presence {
84 Present,
86 Absent,
88 Unknown,
90}
91
92#[derive(Clone)]
94struct StatusSource {
95 name: &'static str,
97 url_template: String,
99 auth: Option<String>,
101 authoritative_absence: bool,
107}
108
109#[derive(Clone)]
114pub struct BroadcastVerifier {
115 client: Client,
116 sources: Vec<StatusSource>,
117 attempts: u32,
118 delay: Duration,
119 enabled: bool,
121}
122
123impl BroadcastVerifier {
124 pub fn from_env(chain: Chain) -> Self {
130 let enabled = !env_truthy("BSV_WALLET_SKIP_BROADCAST_VERIFY");
131 let attempts = std::env::var("BSV_WALLET_BROADCAST_VERIFY_ATTEMPTS")
132 .ok()
133 .and_then(|v| v.parse::<u32>().ok())
134 .filter(|n| *n > 0)
135 .unwrap_or(DEFAULT_ATTEMPTS);
136 let delay_ms = std::env::var("BSV_WALLET_BROADCAST_VERIFY_DELAY_MS")
137 .ok()
138 .and_then(|v| v.parse::<u64>().ok())
139 .unwrap_or(DEFAULT_DELAY_MS);
140
141 let taal_key = std::env::var("TAAL_API_KEY")
143 .ok()
144 .filter(|k| !k.is_empty())
145 .or_else(|| {
146 std::env::var("MAIN_TAAL_API_KEY")
147 .ok()
148 .filter(|k| !k.is_empty())
149 });
150
151 let mut sources = vec![
152 StatusSource {
155 name: "arc-taal",
156 url_template: format!("{}/v1/tx/{{txid}}", taal_arc_url(chain)),
157 auth: taal_key.clone(),
158 authoritative_absence: taal_key.is_some(),
159 },
160 StatusSource {
162 name: "whatsonchain",
163 url_template: format!("{}/tx/hash/{{txid}}", woc_base(chain)),
164 auth: None,
165 authoritative_absence: true,
166 },
167 ];
168 if let Some(gp) = gorillapool_arc_url(chain) {
170 sources.push(StatusSource {
171 name: "arc-gorillapool",
172 url_template: format!("{}/v1/tx/{{txid}}", gp),
173 auth: None,
174 authoritative_absence: true,
175 });
176 }
177
178 Self {
179 client: Client::new(),
180 sources,
181 attempts,
182 delay: Duration::from_millis(delay_ms),
183 enabled,
184 }
185 }
186
187 pub async fn verify(&self, txid: &str) -> BroadcastVerification {
190 if !self.enabled || self.sources.is_empty() {
191 return BroadcastVerification::Inconclusive;
192 }
193
194 let mut last_round_saw_authoritative_absence = false;
195 for attempt in 0..self.attempts {
196 let mut any_present = false;
197 let mut authoritative_absence = false;
198 for src in &self.sources {
199 match probe(&self.client, src, txid).await {
200 Presence::Present => any_present = true,
201 Presence::Absent if src.authoritative_absence => authoritative_absence = true,
202 _ => {}
203 }
204 }
205 if any_present {
206 return BroadcastVerification::Confirmed;
207 }
208 last_round_saw_authoritative_absence = authoritative_absence;
209 if attempt + 1 < self.attempts {
210 tokio::time::sleep(self.delay).await;
211 }
212 }
213
214 if last_round_saw_authoritative_absence {
215 BroadcastVerification::Rejected
216 } else {
217 BroadcastVerification::Inconclusive
218 }
219 }
220}
221
222async fn probe(client: &Client, src: &StatusSource, txid: &str) -> Presence {
224 let url = src.url_template.replace("{txid}", txid);
225 let mut req = client.get(&url).timeout(PROBE_TIMEOUT);
226 if let Some(auth) = &src.auth {
227 req = req.header("Authorization", auth);
228 }
229 match req.send().await {
230 Ok(resp) => match resp.status().as_u16() {
231 200 => Presence::Present,
232 404 => Presence::Absent,
233 other => {
234 tracing::debug!(
235 source = src.name,
236 status = other,
237 "broadcast probe inconclusive"
238 );
239 Presence::Unknown
240 }
241 },
242 Err(e) => {
243 tracing::debug!(source = src.name, error = %e, "broadcast probe request failed");
244 Presence::Unknown
245 }
246 }
247}
248
249fn taal_arc_url(chain: Chain) -> &'static str {
250 match chain {
251 Chain::Main => "https://arc.taal.com",
252 Chain::Test => "https://arc-test.taal.com",
253 }
254}
255
256fn gorillapool_arc_url(chain: Chain) -> Option<&'static str> {
257 match chain {
258 Chain::Main => Some("https://arc.gorillapool.io"),
259 Chain::Test => None,
261 }
262}
263
264fn woc_base(chain: Chain) -> &'static str {
265 match chain {
266 Chain::Main => "https://api.whatsonchain.com/v1/bsv/main",
267 Chain::Test => "https://api.whatsonchain.com/v1/bsv/test",
268 }
269}
270
271fn env_truthy(key: &str) -> bool {
272 std::env::var(key)
273 .map(|v| {
274 let v = v.trim().to_ascii_lowercase();
275 v == "1" || v == "true" || v == "yes" || v == "on"
276 })
277 .unwrap_or(false)
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use axum::http::StatusCode;
284 use axum::routing::get;
285 use axum::Router;
286 use std::net::SocketAddr;
287
288 async fn mock_status_server(code: StatusCode) -> String {
291 let app = Router::new().route("/v1/tx/{txid}", get(move || async move { code }));
292 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
293 let addr: SocketAddr = listener.local_addr().unwrap();
294 tokio::spawn(async move {
295 axum::serve(listener, app).await.ok();
296 });
297 format!("http://{}", addr)
298 }
299
300 fn verifier_for(base: &str, authoritative_absence: bool) -> BroadcastVerifier {
302 BroadcastVerifier {
303 client: Client::new(),
304 sources: vec![StatusSource {
305 name: "mock",
306 url_template: format!("{}/v1/tx/{{txid}}", base),
307 auth: None,
308 authoritative_absence,
309 }],
310 attempts: 2,
311 delay: Duration::from_millis(0),
312 enabled: true,
313 }
314 }
315
316 const TXID: &str = "0000000000000000000000000000000000000000000000000000000000000001";
317
318 #[tokio::test]
321 async fn rejected_broadcast_is_an_error_not_success() {
322 let base = mock_status_server(StatusCode::NOT_FOUND).await;
323 let verifier = verifier_for(&base, true);
324
325 let outcome = verifier.verify(TXID).await;
326 assert_eq!(
327 outcome,
328 BroadcastVerification::Rejected,
329 "an absent (404-everywhere) tx must be classified Rejected"
330 );
331 assert!(
333 outcome.into_send_result(TXID).is_err(),
334 "a Rejected verification must map to Err so the send fails loudly"
335 );
336 }
337
338 #[tokio::test]
339 async fn confirmed_broadcast_succeeds() {
340 let base = mock_status_server(StatusCode::OK).await;
341 let verifier = verifier_for(&base, true);
342
343 let outcome = verifier.verify(TXID).await;
344 assert_eq!(outcome, BroadcastVerification::Confirmed);
345 assert!(outcome.into_send_result(TXID).is_ok());
346 }
347
348 #[tokio::test]
349 async fn unreachable_source_is_inconclusive_not_a_failure() {
350 let base = mock_status_server(StatusCode::SERVICE_UNAVAILABLE).await;
353 let verifier = verifier_for(&base, true);
354
355 let outcome = verifier.verify(TXID).await;
356 assert_eq!(outcome, BroadcastVerification::Inconclusive);
357 assert!(outcome.into_send_result(TXID).is_ok());
358 }
359
360 #[tokio::test]
361 async fn non_authoritative_absence_is_inconclusive() {
362 let base = mock_status_server(StatusCode::NOT_FOUND).await;
365 let verifier = verifier_for(&base, false);
366
367 assert_eq!(
368 verifier.verify(TXID).await,
369 BroadcastVerification::Inconclusive
370 );
371 }
372
373 #[tokio::test]
374 async fn disabled_verifier_is_inconclusive() {
375 let base = mock_status_server(StatusCode::NOT_FOUND).await;
376 let mut verifier = verifier_for(&base, true);
377 verifier.enabled = false;
378 assert_eq!(
379 verifier.verify(TXID).await,
380 BroadcastVerification::Inconclusive
381 );
382 }
383}