bsv_wallet_cli/broadcast_verify.rs
1//! Post-broadcast verification — fail loudly when a broadcast was silently
2//! dropped, and **never** when it was not.
3//!
4//! # Bug 1 — the silent data loss this module was built to close
5//!
6//! A `send` (CLI `send` or the served `/createAction` endpoint) delegates to
7//! `Wallet::create_action`, which signs the tx and broadcasts it. For a
8//! **monitor-less served wallet** (no chain monitor / chaintracks) the wallet
9//! has never fetched merkle proofs for its *confirmed* ancestors, so the BEEF it
10//! hands ARC carries the whole unconfirmed chain. ARC then charges the fee for
11//! the **entire package** and rejects the tx with **error 465 "fee too low"**.
12//!
13//! In `bsv-wallet-toolbox-rs`, an ARC 465 is tagged `service_error = true`, so
14//! `classify_broadcast_results` treats it as a *transient* `ServiceError`
15//! (retryable) rather than a permanent `InvalidTx`. `create_action` therefore
16//! returns `Ok` with a txid — a **phantom txid that never propagates**. The send
17//! path reported success and exit 0 while the funds were never sent.
18//!
19//! # Bug 2 — the false negative this module *introduced* (fixed here)
20//!
21//! The first cut of this module polled a **hardcoded** source list and never
22//! looked at which broadcaster the wallet had actually been configured to use.
23//! It was plane-blind, and every one of its sources was marked authoritative for
24//! absence. Three separate defects fell out of that:
25//!
26//! 1. **The plane that actually holds the answer was never asked.** A wallet in
27//! Arcade V2 mode (`ARC_MODE=arcade`) submits to the Arcade endpoint, and the
28//! verifier never queried it — so the one store that is *guaranteed* to have
29//! a record of our own submission contributed nothing.
30//! 2. **`arc.gorillapool.io` was trusted for absence unconditionally.** It is a
31//! submission-scoped metamorph store, **not a chain index**: it answers 404
32//! for transactions that are mined with hundreds of thousands of
33//! confirmations. (Verified directly: `GET
34//! https://arc.gorillapool.io/v1/tx/<genesis coinbase txid>` → `404
35//! {"extraInfo":"transaction not found"}`.) Its 404 carries no information
36//! about a transaction it was never handed.
37//! 3. **Only WhatsOnChain could ever vote `Present`, inside a ~7.5 s window.**
38//! So the verdict reduced to a coin flip on WoC's mempool-indexing latency.
39//!
40//! The module's own comment asserted that "ARC keeps recently submitted txs
41//! queryable … so all default sources are authoritative here". That is true only
42//! of the ARC instance you actually submitted to. That unchecked proposition was
43//! the root cause: in the dHouse funder's entire history, all four `Rejected`
44//! verdicts were **false negatives** — every one of those transactions was on
45//! chain.
46//!
47//! # The model this module now implements
48//!
49//! Doctrine (`CLAUDE.md`): *"2xx is never success — truth = visible in our own
50//! index / on chain"*; a **positive** answer may be trusted, an **absence** must
51//! be chain-verified. Applied to the verifier itself: **absence from the wrong
52//! plane is not truth.**
53//!
54//! * **Presence is trusted from anybody.** A 200 from any store means that store
55//! holds the transaction. A freshly-minted txid we just created cannot be
56//! known to a third party unless it really propagated. So any `Present` →
57//! `Confirmed`, immediately.
58//! * **Absence is trusted from almost nobody.** See [`AbsenceAuthority`]: a 404
59//! is evidence only from the broadcaster we personally submitted through
60//! (scope) or from a real chain+mempool index after its indexing window has
61//! elapsed (time) — and we require **both** before declaring `Rejected`.
62//! * **The broadcaster we used is consulted first**, so the happy path
63//! short-circuits to `Confirmed` on a single request.
64//! * If we cannot satisfy that bar we return `Inconclusive`, and callers preserve
65//! prior behaviour — a down (or unidentifiable) confirmation service never
66//! turns a real send into a false failure.
67
68use std::time::{Duration, Instant};
69
70use bsv_wallet_toolbox::{services::ARCADE_V2_MAINNET, Chain};
71use reqwest::Client;
72
73/// Default number of probe rounds before an absence may become definitive.
74///
75/// # Why not the original 6 × 1500 ms (~7.5 s)?
76///
77/// 7.5 s was never defensible as a *mempool-index* window. It is plenty for the
78/// broadcaster we submitted through — that store knows about our submission the
79/// instant it 200s our POST — but an independent index like WhatsOnChain only
80/// learns of the transaction once it propagates to WoC's own node and WoC's
81/// mempool ingestion picks it up. Normally that is a few seconds; under network
82/// load, a provider hiccup, or an ARC→network relay delay it is routinely tens
83/// of seconds. Declaring "the funds were NOT sent" on a 7.5 s WoC miss is
84/// declaring a verdict on indexing latency, and that is exactly how the four
85/// observed false negatives happened.
86///
87/// 12 rounds × 2500 ms ≈ 27.5 s of wall clock gives the independent index a
88/// realistic chance to catch up before its silence is treated as evidence.
89///
90/// The cost is paid **only by transactions that really are absent everywhere**:
91/// the happy path returns on the very first probe of the broadcaster, and the
92/// caller's spending lock is already released before verification runs, so a
93/// longer window does not serialize anything.
94const DEFAULT_ATTEMPTS: u32 = 12;
95/// Default delay between probe rounds (ms). See [`DEFAULT_ATTEMPTS`].
96const DEFAULT_DELAY_MS: u64 = 2500;
97/// Per-request timeout for a single status probe. Deliberately shorter than the
98/// inter-round delay so one slow source cannot stretch a round past the next.
99const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
100
101/// Outcome of verifying that a just-broadcast tx actually reached the network.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum BroadcastVerification {
104 /// At least one source confirms the tx exists (accepted / seen / mined).
105 Confirmed,
106 /// Both the broadcaster we actually submitted through **and** an independent
107 /// chain index affirmatively report the tx absent after the full probe
108 /// window, and no source reports it present — the broadcast was silently
109 /// dropped (classic ARC 465 fee-too-low on a deep unconfirmed BEEF).
110 /// The funds were NOT sent.
111 Rejected,
112 /// No source could give an answer that clears the evidence bar. Callers must
113 /// NOT treat this as a failure (avoids false negatives when the confirmation
114 /// service is unreachable, or when only the *wrong* plane reports absence).
115 Inconclusive,
116}
117
118impl BroadcastVerification {
119 /// Map a verification into a `Result`, failing loudly only on a definitive
120 /// `Rejected`. `Confirmed` and `Inconclusive` are both treated as "proceed".
121 pub fn into_send_result(self, txid: &str) -> anyhow::Result<()> {
122 match self {
123 BroadcastVerification::Rejected => Err(anyhow::anyhow!(
124 "broadcast rejected: transaction {txid} is absent from BOTH the broadcaster \
125 it was submitted to AND an independent chain index, after the full probe \
126 window. The broadcaster dropped it — most likely error 465 \"fee too low\", \
127 because a monitor-less wallet presented a deep unconfirmed BEEF and ARC \
128 charged the fee for the whole unconfirmed package. The funds were NOT sent. \
129 Fetch merkle proofs for the confirmed ancestors (run `bsv-wallet tick` with \
130 CHAINTRACKS_URL set) or fund from a confirmed UTXO, then retry."
131 )),
132 BroadcastVerification::Confirmed | BroadcastVerification::Inconclusive => Ok(()),
133 }
134 }
135}
136
137/// Presence of a txid according to a single source.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139enum Presence {
140 /// Source has the tx (HTTP 200).
141 Present,
142 /// Source definitively does not have the tx (HTTP 404 from a real handler).
143 Absent,
144 /// Source could not give a definitive answer (auth error, 5xx, network
145 /// error, or a 404 that looks like "no such route" rather than "no such tx").
146 Unknown,
147}
148
149/// What a source's **absence** (404) answer is worth.
150///
151/// Presence is trusted from every source; absence is a different question
152/// entirely, and the answer depends on *why* that store would be expected to
153/// hold the transaction.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155enum AbsenceAuthority {
156 /// **Worthless.** A submission-scoped store we did *not* submit to.
157 ///
158 /// ARC/metamorph instances index what was handed to *them*. They are not
159 /// chain indexes: `arc.gorillapool.io` returns 404 for the Bitcoin genesis
160 /// coinbase, a transaction with ~960,000 confirmations. A 404 from such a
161 /// store tells us only that *it* never received the transaction — which is
162 /// the expected answer whenever we broadcast somewhere else. These sources
163 /// are kept purely as extra chances to observe `Present`.
164 None,
165
166 /// **Scope-authoritative.** This is the broadcaster we personally submitted
167 /// through, so it *must* have a record of our own submission.
168 ///
169 /// This is the only store whose silence is meaningful immediately rather
170 /// than eventually. It is still not sufficient on its own:
171 /// * in Arcade mode the toolbox keeps classic ARC as a failover provider,
172 /// so the transaction may legitimately have gone out through the other
173 /// provider and be unknown to the primary; and
174 /// * a misconfigured base URL turns "no such route" into a 404 that is
175 /// indistinguishable from "no such transaction" at the status-code level
176 /// (Arcade V2 answers `GET /tx/{txid}` with `application/json
177 /// {"error":"transaction not found"}` but answers the *wrong* path
178 /// `GET /v1/tx/{txid}` with `text/plain "404 page not found"`).
179 ///
180 /// Hence the content-type guard in [`probe`] and the conjunction below.
181 Broadcaster,
182
183 /// **Time-authoritative.** An independent chain + mempool index (WhatsOnChain).
184 ///
185 /// Unlike a metamorph store this really does index the whole chain, so its
186 /// 404 is about the transaction and not about scope. Its weakness is
187 /// *latency*, not coverage: mempool ingestion lags acceptance. So its
188 /// absence counts only from the **final** probe round, after the window in
189 /// [`DEFAULT_ATTEMPTS`] has elapsed.
190 ChainIndex,
191}
192
193/// Absence votes gathered during one probe round, grouped by authority class.
194///
195/// A `Rejected` verdict requires the **conjunction**: the plane we submitted
196/// through has no record of our submission *and* an independent chain index
197/// still cannot see the transaction after the full window. Either one alone has
198/// a mundane innocent explanation (provider failover; indexing lag), and acting
199/// on either one alone is precisely what produced four false "funds were NOT
200/// sent" reports on transactions that were on chain.
201///
202/// Consequence, stated honestly: a wallet whose broadcaster cannot be probed
203/// (e.g. classic TAAL ARC with no API key, which answers 401 → `Unknown`) can
204/// never reach `Rejected`. That is the intended trade. A missed drop is caught
205/// downstream — the transaction simply never mines and the unfail canary
206/// reconciles it — whereas a false `Rejected` reports lost funds that were not
207/// lost, which is the more expensive error by far.
208#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
209struct AbsenceVotes {
210 /// The broadcaster we submitted through answered 404.
211 broadcaster: bool,
212 /// An independent chain index answered 404.
213 chain_index: bool,
214}
215
216impl AbsenceVotes {
217 fn record(&mut self, authority: AbsenceAuthority) {
218 match authority {
219 AbsenceAuthority::Broadcaster => self.broadcaster = true,
220 AbsenceAuthority::ChainIndex => self.chain_index = true,
221 // A store we did not submit to has no opinion about absence.
222 AbsenceAuthority::None => {}
223 }
224 }
225
226 /// Absence is definitive only when both authority classes agree.
227 fn is_definitive(self) -> bool {
228 self.broadcaster && self.chain_index
229 }
230}
231
232/// The broadcast plane the wallet is configured to submit through.
233///
234/// This mirrors `services_env::services_options_from_env` — the ONE place that
235/// decides which broadcaster the wallet uses — so the verifier asks the same
236/// endpoint the transaction was actually handed to.
237#[derive(Debug, Clone, PartialEq, Eq)]
238enum BroadcastPlane {
239 /// Arcade V2 (`ARC_MODE=arcade` / `ARCADE=1`).
240 ///
241 /// Status endpoint is `GET {base}/tx/{txid}` — **no `/v1` prefix**. Verified
242 /// two ways: `ArcadeV2Provider::get_tx_status` in `bsv-wallet-toolbox-rs`
243 /// builds `format!("{}/tx/{}", self.url, txid)`, and the live endpoint
244 /// answers that path with `application/json {"error":"transaction not
245 /// found"}` while `/v1/tx/{txid}` answers `text/plain "404 page not found"`
246 /// (i.e. the `/v1` path does not exist and its 404 is a routing artifact).
247 /// Keyless: Arcade's status read needs no `Authorization` header.
248 ArcadeV2 { base: String },
249 /// Classic ARC. Status endpoint is `GET {base}/v1/tx/{txid}`, matching
250 /// `ArcProvider::get_tx_status` in the toolbox.
251 ClassicArc { base: String },
252}
253
254impl BroadcastPlane {
255 /// Resolve the plane from explicit inputs (pure — unit-testable).
256 ///
257 /// `arcade_mode` and `arc_url` are read from the same env vars that
258 /// `services_env` reads, so the verifier cannot drift from the broadcaster.
259 fn resolve(chain: Chain, arcade_mode: bool, arc_url: Option<String>) -> Self {
260 let arc_url = arc_url
261 .map(|s| s.trim().to_string())
262 .filter(|s| !s.is_empty());
263 if arcade_mode {
264 BroadcastPlane::ArcadeV2 {
265 base: normalize_base(&arc_url.unwrap_or_else(|| ARCADE_V2_MAINNET.to_string())),
266 }
267 } else {
268 BroadcastPlane::ClassicArc {
269 base: normalize_base(&arc_url.unwrap_or_else(|| taal_arc_url(chain).to_string())),
270 }
271 }
272 }
273
274 fn from_env(chain: Chain) -> Self {
275 Self::resolve(
276 chain,
277 crate::services_env::arcade_mode_enabled(),
278 std::env::var("ARC_URL").ok(),
279 )
280 }
281
282 fn base(&self) -> &str {
283 match self {
284 BroadcastPlane::ArcadeV2 { base } | BroadcastPlane::ClassicArc { base } => base,
285 }
286 }
287
288 fn name(&self) -> &'static str {
289 match self {
290 BroadcastPlane::ArcadeV2 { .. } => "broadcaster(arcade-v2)",
291 BroadcastPlane::ClassicArc { .. } => "broadcaster(arc)",
292 }
293 }
294
295 /// URL template with the literal `{txid}` placeholder.
296 fn status_template(&self) -> String {
297 match self {
298 // Arcade V2: `/tx/{txid}`. `/v1/tx/{txid}` is NOT a route there.
299 BroadcastPlane::ArcadeV2 { base } => format!("{base}/tx/{{txid}}"),
300 // Classic ARC: `/v1/tx/{txid}`.
301 BroadcastPlane::ClassicArc { base } => format!("{base}/v1/tx/{{txid}}"),
302 }
303 }
304}
305
306/// A network endpoint we can ask "do you know this txid?".
307#[derive(Clone, Debug)]
308struct StatusSource {
309 /// Human-readable name (diagnostics only).
310 name: &'static str,
311 /// URL template containing the literal `{txid}` placeholder.
312 url_template: String,
313 /// Full `Authorization` header value, if the endpoint needs one.
314 auth: Option<String>,
315 /// What this source's 404 is worth. See [`AbsenceAuthority`].
316 absence: AbsenceAuthority,
317}
318
319/// Build the ordered source list for a plane (pure — unit-testable).
320///
321/// Ordering is load-bearing: **index 0 is always the broadcaster we submitted
322/// through**, because it is both the fastest and the most authoritative answer
323/// available, and `verify` returns on the first `Present`.
324fn build_sources(
325 chain: Chain,
326 plane: &BroadcastPlane,
327 taal_key: Option<String>,
328) -> Vec<StatusSource> {
329 let mut sources = vec![StatusSource {
330 name: plane.name(),
331 url_template: plane.status_template(),
332 // Arcade's status read is keyless; classic ARC (TAAL) wants the key.
333 auth: match plane {
334 BroadcastPlane::ArcadeV2 { .. } => None,
335 BroadcastPlane::ClassicArc { .. } => taal_key.clone(),
336 },
337 absence: AbsenceAuthority::Broadcaster,
338 }];
339
340 // The independent chain + mempool index. Keyless, reliable 200/404, and the
341 // only source here that indexes the chain rather than its own inbox.
342 sources.push(StatusSource {
343 name: "whatsonchain",
344 url_template: format!("{}/tx/hash/{{txid}}", woc_base(chain)),
345 auth: None,
346 absence: AbsenceAuthority::ChainIndex,
347 });
348
349 // Third-party ARC stores: extra chances to observe `Present`, never a vote
350 // for absence (see AbsenceAuthority::None). Skipped when they *are* the
351 // broadcaster — that row is already at index 0 with real authority.
352 if let Some(gp) = gorillapool_arc_url(chain) {
353 if normalize_base(gp) != plane.base() {
354 sources.push(StatusSource {
355 name: "arc-gorillapool",
356 url_template: format!("{gp}/v1/tx/{{txid}}"),
357 auth: None,
358 absence: AbsenceAuthority::None,
359 });
360 }
361 }
362 // TAAL only when we hold a key — keyless it answers 401 (`Unknown`), which
363 // is pure latency for zero information.
364 if let Some(key) = taal_key {
365 let taal = taal_arc_url(chain);
366 if normalize_base(taal) != plane.base() {
367 sources.push(StatusSource {
368 name: "arc-taal",
369 url_template: format!("{taal}/v1/tx/{{txid}}"),
370 auth: Some(key),
371 absence: AbsenceAuthority::None,
372 });
373 }
374 }
375
376 sources
377}
378
379/// Verifies that a broadcast tx actually reached the network.
380///
381/// Cheap to clone (shares the reqwest connection pool). Built once and shared
382/// via an axum extension on the served path, or per-command on the CLI path.
383#[derive(Clone)]
384pub struct BroadcastVerifier {
385 client: Client,
386 sources: Vec<StatusSource>,
387 attempts: u32,
388 delay: Duration,
389 /// When false (env opt-out) `verify` short-circuits to `Inconclusive`.
390 enabled: bool,
391}
392
393impl BroadcastVerifier {
394 /// Build a verifier for `chain`, reading the broadcast plane and optional
395 /// overrides from the env:
396 /// - `ARC_MODE=arcade` / `ARCADE=1` + `ARC_URL` select the plane probed first.
397 /// - `BSV_WALLET_SKIP_BROADCAST_VERIFY=1` disables verification entirely.
398 /// - `BSV_WALLET_BROADCAST_VERIFY_ATTEMPTS` overrides the probe-round count.
399 /// - `BSV_WALLET_BROADCAST_VERIFY_DELAY_MS` overrides the inter-round delay.
400 /// - `TAAL_API_KEY` / `MAIN_TAAL_API_KEY` authenticate the TAAL ARC probe.
401 pub fn from_env(chain: Chain) -> Self {
402 let enabled = !env_truthy("BSV_WALLET_SKIP_BROADCAST_VERIFY");
403 let attempts = std::env::var("BSV_WALLET_BROADCAST_VERIFY_ATTEMPTS")
404 .ok()
405 .and_then(|v| v.parse::<u32>().ok())
406 .filter(|n| *n > 0)
407 .unwrap_or(DEFAULT_ATTEMPTS);
408 let delay_ms = std::env::var("BSV_WALLET_BROADCAST_VERIFY_DELAY_MS")
409 .ok()
410 .and_then(|v| v.parse::<u64>().ok())
411 .unwrap_or(DEFAULT_DELAY_MS);
412
413 // TAAL ARC uses a raw `Authorization: <key>` header (no "Bearer " prefix).
414 let taal_key = std::env::var("TAAL_API_KEY")
415 .ok()
416 .filter(|k| !k.is_empty())
417 .or_else(|| {
418 std::env::var("MAIN_TAAL_API_KEY")
419 .ok()
420 .filter(|k| !k.is_empty())
421 });
422
423 let plane = BroadcastPlane::from_env(chain);
424 tracing::debug!(plane = ?plane, "broadcast verifier plane");
425
426 Self {
427 client: Client::new(),
428 sources: build_sources(chain, &plane, taal_key),
429 attempts,
430 delay: Duration::from_millis(delay_ms),
431 enabled,
432 }
433 }
434
435 /// Wall-clock ceiling for the absence determination. A source that hangs
436 /// must not be able to stretch the window without bound, so rounds stop once
437 /// the nominal window (plus one probe timeout of slack) has elapsed.
438 fn absence_window(&self) -> Duration {
439 self.delay * self.attempts.saturating_sub(1) + PROBE_TIMEOUT
440 }
441
442 /// Probe the network for `txid`, returning as soon as any source reports it
443 /// present, otherwise after the full probe window.
444 pub async fn verify(&self, txid: &str) -> BroadcastVerification {
445 if !self.enabled || self.sources.is_empty() {
446 return BroadcastVerification::Inconclusive;
447 }
448
449 let deadline = Instant::now() + self.absence_window();
450 // Votes from the LAST COMPLETED round. Using the last round (rather than
451 // any round) is what makes the chain-index vote time-authoritative: its
452 // silence only counts once the indexing window has actually elapsed.
453 let mut last_votes: Option<AbsenceVotes> = None;
454
455 for attempt in 0..self.attempts {
456 let mut votes = AbsenceVotes::default();
457 for src in &self.sources {
458 match probe(&self.client, src, txid).await {
459 // Doctrine: a positive answer may be trusted from any source.
460 // A txid we minted moments ago cannot be known to a third
461 // party unless it genuinely propagated.
462 Presence::Present => return BroadcastVerification::Confirmed,
463 Presence::Absent => votes.record(src.absence),
464 Presence::Unknown => {}
465 }
466 }
467 last_votes = Some(votes);
468
469 if attempt + 1 < self.attempts {
470 if Instant::now() >= deadline {
471 // Slow sources already consumed the window; further rounds
472 // would only extend the caller's wait, not the evidence.
473 break;
474 }
475 tokio::time::sleep(self.delay).await;
476 }
477 }
478
479 match last_votes {
480 Some(v) if v.is_definitive() => BroadcastVerification::Rejected,
481 _ => BroadcastVerification::Inconclusive,
482 }
483 }
484}
485
486/// Probe a single source for a txid's presence.
487async fn probe(client: &Client, src: &StatusSource, txid: &str) -> Presence {
488 let url = src.url_template.replace("{txid}", txid);
489 let mut req = client.get(&url).timeout(PROBE_TIMEOUT);
490 if let Some(auth) = &src.auth {
491 req = req.header("Authorization", auth);
492 }
493 match req.send().await {
494 Ok(resp) => {
495 let status = resp.status().as_u16();
496 match status {
497 200 => Presence::Present,
498 404 => {
499 // A 404 has two very different meanings: "I have no such
500 // transaction" (a real answer from the ARC/Arcade handler,
501 // always a JSON problem document) and "I have no such route"
502 // (a misconfigured base URL — Go/edge routers answer
503 // `text/plain "404 page not found"`). Only the former is
504 // evidence, and only for a source whose absence we would act
505 // on. Downgrading the routing artifact to `Unknown` keeps a
506 // typo in `ARC_URL` from being reported as lost funds.
507 if src.absence == AbsenceAuthority::Broadcaster && !is_json(&resp) {
508 tracing::debug!(
509 source = src.name,
510 url = %url,
511 "broadcaster 404 is not a JSON tx-status body — treating as \
512 route-not-found (check ARC_URL / path shape), not absence"
513 );
514 return Presence::Unknown;
515 }
516 Presence::Absent
517 }
518 other => {
519 tracing::debug!(
520 source = src.name,
521 status = other,
522 "broadcast probe inconclusive"
523 );
524 Presence::Unknown
525 }
526 }
527 }
528 Err(e) => {
529 tracing::debug!(source = src.name, error = %e, "broadcast probe request failed");
530 Presence::Unknown
531 }
532 }
533}
534
535/// Whether a response carries a JSON body (the shape every ARC/Arcade status
536/// handler returns, including for "transaction not found").
537fn is_json(resp: &reqwest::Response) -> bool {
538 resp.headers()
539 .get(reqwest::header::CONTENT_TYPE)
540 .and_then(|v| v.to_str().ok())
541 .map(|ct| ct.to_ascii_lowercase().contains("json"))
542 .unwrap_or(false)
543}
544
545fn normalize_base(url: &str) -> String {
546 url.trim().trim_end_matches('/').to_string()
547}
548
549fn taal_arc_url(chain: Chain) -> &'static str {
550 match chain {
551 Chain::Main => "https://arc.taal.com",
552 Chain::Test => "https://arc-test.taal.com",
553 }
554}
555
556fn gorillapool_arc_url(chain: Chain) -> Option<&'static str> {
557 match chain {
558 Chain::Main => Some("https://arc.gorillapool.io"),
559 // GorillaPool testnet ARC is not commonly used; omit it.
560 Chain::Test => None,
561 }
562}
563
564fn woc_base(chain: Chain) -> &'static str {
565 match chain {
566 Chain::Main => "https://api.whatsonchain.com/v1/bsv/main",
567 Chain::Test => "https://api.whatsonchain.com/v1/bsv/test",
568 }
569}
570
571fn env_truthy(key: &str) -> bool {
572 std::env::var(key)
573 .map(|v| {
574 let v = v.trim().to_ascii_lowercase();
575 v == "1" || v == "true" || v == "yes" || v == "on"
576 })
577 .unwrap_or(false)
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583 use axum::http::StatusCode;
584 use axum::routing::get;
585 use axum::Router;
586 use std::net::SocketAddr;
587
588 // ---- synthetic values only (never a real txid / URL from any wallet) ----
589 const TXID: &str = "0000000000000000000000000000000000000000000000000000000000000001";
590 const SYNTHETIC_ARCADE: &str = "https://arcade.invalid";
591 const SYNTHETIC_ARC: &str = "https://arc.invalid";
592 const SYNTHETIC_KEY: &str = "test-key-not-a-real-credential";
593
594 // =====================================================================
595 // Source selection: which plane do we ask, and with what path shape?
596 // =====================================================================
597
598 #[test]
599 fn arcade_plane_uses_bare_tx_path_not_v1() {
600 // Arcade V2's status route is `/tx/{txid}`. `/v1/tx/{txid}` is not a
601 // route on Arcade at all (it answers with the router's text/plain 404),
602 // which would have made every Arcade tx look "absent".
603 let plane = BroadcastPlane::resolve(
604 Chain::Main,
605 /* arcade_mode */ true,
606 Some(SYNTHETIC_ARCADE.to_string()),
607 );
608 assert_eq!(
609 plane.status_template(),
610 format!("{SYNTHETIC_ARCADE}/tx/{{txid}}")
611 );
612 assert!(
613 !plane.status_template().contains("/v1/"),
614 "Arcade V2 must NOT be probed on the classic ARC /v1 path"
615 );
616 }
617
618 #[test]
619 fn classic_arc_plane_uses_v1_tx_path() {
620 let plane = BroadcastPlane::resolve(
621 Chain::Main,
622 /* arcade_mode */ false,
623 Some(SYNTHETIC_ARC.to_string()),
624 );
625 assert_eq!(
626 plane.status_template(),
627 format!("{SYNTHETIC_ARC}/v1/tx/{{txid}}")
628 );
629 }
630
631 #[test]
632 fn arcade_mode_defaults_to_the_arcade_endpoint_when_arc_url_is_unset() {
633 let plane = BroadcastPlane::resolve(Chain::Main, true, None);
634 assert_eq!(plane.base(), ARCADE_V2_MAINNET.trim_end_matches('/'));
635 }
636
637 #[test]
638 fn classic_mode_defaults_to_taal_and_respects_chain() {
639 assert_eq!(
640 BroadcastPlane::resolve(Chain::Main, false, None).base(),
641 "https://arc.taal.com"
642 );
643 assert_eq!(
644 BroadcastPlane::resolve(Chain::Test, false, None).base(),
645 "https://arc-test.taal.com"
646 );
647 }
648
649 #[test]
650 fn empty_arc_url_falls_back_to_the_default_rather_than_an_empty_base() {
651 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(" ".to_string()));
652 assert_eq!(plane.base(), ARCADE_V2_MAINNET.trim_end_matches('/'));
653 }
654
655 #[test]
656 fn trailing_slash_in_arc_url_does_not_produce_a_double_slash() {
657 let plane = BroadcastPlane::resolve(
658 Chain::Main,
659 true,
660 Some(format!("{SYNTHETIC_ARCADE}/").to_string()),
661 );
662 assert_eq!(
663 plane.status_template(),
664 format!("{SYNTHETIC_ARCADE}/tx/{{txid}}")
665 );
666 }
667
668 #[test]
669 fn the_broadcaster_we_used_is_always_the_first_source_consulted() {
670 // This is the whole point of the fix: the plane that actually holds the
671 // answer must be asked FIRST, in both modes.
672 for plane in [
673 BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string())),
674 BroadcastPlane::resolve(Chain::Main, false, Some(SYNTHETIC_ARC.to_string())),
675 ] {
676 let sources = build_sources(Chain::Main, &plane, None);
677 assert_eq!(sources[0].absence, AbsenceAuthority::Broadcaster);
678 assert!(
679 sources[0].url_template.starts_with(plane.base()),
680 "source 0 ({}) must be the configured broadcaster {}",
681 sources[0].url_template,
682 plane.base()
683 );
684 }
685 }
686
687 #[test]
688 fn arcade_broadcaster_probe_is_keyless_even_when_a_taal_key_exists() {
689 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
690 let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
691 assert!(sources[0].auth.is_none());
692 }
693
694 #[test]
695 fn classic_broadcaster_probe_carries_the_taal_key_when_present() {
696 let plane = BroadcastPlane::resolve(Chain::Main, false, None);
697 let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
698 assert_eq!(sources[0].auth.as_deref(), Some(SYNTHETIC_KEY));
699 }
700
701 #[test]
702 fn keyless_taal_is_not_probed_at_all() {
703 // Without a key TAAL answers 401 → Unknown: pure latency, zero signal.
704 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
705 let sources = build_sources(Chain::Main, &plane, None);
706 assert!(!sources.iter().any(|s| s.name == "arc-taal"));
707 }
708
709 #[test]
710 fn a_store_is_never_listed_twice_when_it_is_also_the_broadcaster() {
711 // Broadcasting through GorillaPool in classic mode must not add a second
712 // (presence-only) GorillaPool row.
713 let plane = BroadcastPlane::resolve(
714 Chain::Main,
715 false,
716 Some("https://arc.gorillapool.io".to_string()),
717 );
718 let sources = build_sources(Chain::Main, &plane, None);
719 let gp_rows: Vec<_> = sources
720 .iter()
721 .filter(|s| s.url_template.contains("arc.gorillapool.io"))
722 .collect();
723 assert_eq!(gp_rows.len(), 1);
724 assert_eq!(gp_rows[0].absence, AbsenceAuthority::Broadcaster);
725 }
726
727 // =====================================================================
728 // Absence authority: whose 404 may be believed, and when?
729 // =====================================================================
730
731 #[test]
732 fn a_third_party_arc_store_is_never_authoritative_for_absence() {
733 // arc.gorillapool.io 404s for the genesis coinbase (~960k confirmations).
734 // It is a submission-scoped metamorph store, not a chain index: when we
735 // broadcast through Arcade, its 404 is the EXPECTED answer and carries
736 // no information. Marking it authoritative caused false "funds not sent".
737 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
738 let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
739 for s in sources.iter().filter(|s| s.name.starts_with("arc-")) {
740 assert_eq!(
741 s.absence,
742 AbsenceAuthority::None,
743 "{} is not the broadcaster; its absence must carry no weight",
744 s.name
745 );
746 }
747 }
748
749 #[test]
750 fn whatsonchain_is_the_chain_index_authority() {
751 let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
752 let sources = build_sources(Chain::Main, &plane, None);
753 let woc = sources.iter().find(|s| s.name == "whatsonchain").unwrap();
754 assert_eq!(woc.absence, AbsenceAuthority::ChainIndex);
755 }
756
757 #[test]
758 fn absence_is_definitive_only_when_broadcaster_and_chain_index_agree() {
759 let mut none = AbsenceVotes::default();
760 assert!(!none.is_definitive(), "no votes is not evidence");
761
762 // A store we did not submit to voting absent changes nothing.
763 none.record(AbsenceAuthority::None);
764 assert!(!none.is_definitive());
765
766 let mut broadcaster_only = AbsenceVotes::default();
767 broadcaster_only.record(AbsenceAuthority::Broadcaster);
768 assert!(
769 !broadcaster_only.is_definitive(),
770 "the primary may 404 while the tx went out through the failover provider"
771 );
772
773 let mut index_only = AbsenceVotes::default();
774 index_only.record(AbsenceAuthority::ChainIndex);
775 assert!(
776 !index_only.is_definitive(),
777 "a chain index can simply be lagging its mempool ingestion"
778 );
779
780 let mut both = AbsenceVotes::default();
781 both.record(AbsenceAuthority::Broadcaster);
782 both.record(AbsenceAuthority::ChainIndex);
783 assert!(both.is_definitive());
784 }
785
786 // =====================================================================
787 // End-to-end verdicts against local mock sources.
788 // =====================================================================
789
790 /// Local mock answering every status path (`/tx/{txid}` and `/v1/tx/{txid}`)
791 /// with `code`. Returns the base URL (`http://127.0.0.1:PORT`).
792 async fn mock_status_server(code: StatusCode) -> String {
793 mock_status_server_ct(code, Some("application/json")).await
794 }
795
796 /// As [`mock_status_server`], with an explicit `Content-Type` (or none).
797 async fn mock_status_server_ct(code: StatusCode, content_type: Option<&'static str>) -> String {
798 let handler = move || async move {
799 let mut resp = axum::response::Response::new(axum::body::Body::from("{}"));
800 *resp.status_mut() = code;
801 if let Some(ct) = content_type {
802 resp.headers_mut()
803 .insert(reqwest::header::CONTENT_TYPE.as_str(), ct.parse().unwrap());
804 } else {
805 resp.headers_mut()
806 .remove(reqwest::header::CONTENT_TYPE.as_str());
807 }
808 resp
809 };
810 let app = Router::new()
811 .route("/tx/{txid}", get(handler))
812 .route("/v1/tx/{txid}", get(handler))
813 .route("/tx/hash/{txid}", get(handler));
814 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
815 let addr: SocketAddr = listener.local_addr().unwrap();
816 tokio::spawn(async move {
817 axum::serve(listener, app).await.ok();
818 });
819 format!("http://{}", addr)
820 }
821
822 fn source(name: &'static str, base: &str, absence: AbsenceAuthority) -> StatusSource {
823 StatusSource {
824 name,
825 url_template: format!("{base}/tx/{{txid}}"),
826 auth: None,
827 absence,
828 }
829 }
830
831 /// Verifier over an explicit source list (fast: 2 rounds, no delay).
832 fn verifier_with(sources: Vec<StatusSource>) -> BroadcastVerifier {
833 BroadcastVerifier {
834 client: Client::new(),
835 sources,
836 attempts: 2,
837 delay: Duration::from_millis(0),
838 enabled: true,
839 }
840 }
841
842 #[tokio::test]
843 async fn rejected_when_broadcaster_and_chain_index_both_report_absent() {
844 // The original purpose of the module (ARC 465 fee-too-low) still fires:
845 // the plane we submitted to has no record AND the chain index cannot see
846 // it after the window.
847 let base = mock_status_server(StatusCode::NOT_FOUND).await;
848 let verifier = verifier_with(vec![
849 source("broadcaster", &base, AbsenceAuthority::Broadcaster),
850 source("chain-index", &base, AbsenceAuthority::ChainIndex),
851 ]);
852
853 let outcome = verifier.verify(TXID).await;
854 assert_eq!(outcome, BroadcastVerification::Rejected);
855 assert!(
856 outcome.into_send_result(TXID).is_err(),
857 "a Rejected verification must map to Err so the send fails loudly"
858 );
859 }
860
861 #[tokio::test]
862 async fn the_false_negative_that_motivated_this_fix_is_now_inconclusive() {
863 // Exactly the observed regression: the broadcaster we used is never
864 // asked (or is unreachable), a third-party ARC store 404s because we
865 // never submitted to it, and the chain index has not indexed the mempool
866 // entry yet. Old code: Rejected ("the funds were NOT sent"). Every such
867 // transaction was actually on chain.
868 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
869 let verifier = verifier_with(vec![
870 // Broadcaster unreachable → Unknown, not a vote.
871 source(
872 "broadcaster",
873 "http://127.0.0.1:1",
874 AbsenceAuthority::Broadcaster,
875 ),
876 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
877 source("arc-third-party", &absent, AbsenceAuthority::None),
878 ]);
879 assert_eq!(
880 verifier.verify(TXID).await,
881 BroadcastVerification::Inconclusive
882 );
883 }
884
885 #[tokio::test]
886 async fn third_party_absence_alone_never_rejects() {
887 let base = mock_status_server(StatusCode::NOT_FOUND).await;
888 let verifier = verifier_with(vec![
889 source("arc-third-party-a", &base, AbsenceAuthority::None),
890 source("arc-third-party-b", &base, AbsenceAuthority::None),
891 ]);
892 assert_eq!(
893 verifier.verify(TXID).await,
894 BroadcastVerification::Inconclusive
895 );
896 }
897
898 #[tokio::test]
899 async fn broadcaster_absence_alone_never_rejects() {
900 // The toolbox keeps a failover provider behind the primary, so the tx
901 // may legitimately have gone out through the other plane.
902 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
903 let verifier = verifier_with(vec![
904 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
905 // Chain index unreachable → Unknown.
906 source(
907 "chain-index",
908 "http://127.0.0.1:1",
909 AbsenceAuthority::ChainIndex,
910 ),
911 ]);
912 assert_eq!(
913 verifier.verify(TXID).await,
914 BroadcastVerification::Inconclusive
915 );
916 }
917
918 #[tokio::test]
919 async fn chain_index_absence_alone_never_rejects() {
920 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
921 let verifier = verifier_with(vec![
922 // Broadcaster answers 401 (keyless TAAL) → Unknown.
923 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
924 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
925 ]);
926 // Sanity: with both absent it WOULD reject...
927 assert_eq!(verifier.verify(TXID).await, BroadcastVerification::Rejected);
928
929 // ...but with the broadcaster unreachable, the chain index alone must not.
930 let unauth = mock_status_server(StatusCode::UNAUTHORIZED).await;
931 let verifier = verifier_with(vec![
932 source("broadcaster", &unauth, AbsenceAuthority::Broadcaster),
933 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
934 ]);
935 assert_eq!(
936 verifier.verify(TXID).await,
937 BroadcastVerification::Inconclusive
938 );
939 }
940
941 #[tokio::test]
942 async fn presence_from_any_source_confirms_even_when_others_say_absent() {
943 // Doctrine: a positive answer may be trusted; an absence may not.
944 let present = mock_status_server(StatusCode::OK).await;
945 let absent = mock_status_server(StatusCode::NOT_FOUND).await;
946 let verifier = verifier_with(vec![
947 source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
948 source("chain-index", &absent, AbsenceAuthority::ChainIndex),
949 source("arc-third-party", &present, AbsenceAuthority::None),
950 ]);
951 let outcome = verifier.verify(TXID).await;
952 assert_eq!(outcome, BroadcastVerification::Confirmed);
953 assert!(outcome.into_send_result(TXID).is_ok());
954 }
955
956 #[tokio::test]
957 async fn confirmed_broadcast_succeeds() {
958 let base = mock_status_server(StatusCode::OK).await;
959 let verifier = verifier_with(vec![source(
960 "broadcaster",
961 &base,
962 AbsenceAuthority::Broadcaster,
963 )]);
964 let outcome = verifier.verify(TXID).await;
965 assert_eq!(outcome, BroadcastVerification::Confirmed);
966 assert!(outcome.into_send_result(TXID).is_ok());
967 }
968
969 #[tokio::test]
970 async fn unreachable_source_is_inconclusive_not_a_failure() {
971 // 503 from every probe → we cannot confirm either way → Inconclusive,
972 // which must NOT be a failure (no false negatives when the service is down).
973 let base = mock_status_server(StatusCode::SERVICE_UNAVAILABLE).await;
974 let verifier = verifier_with(vec![
975 source("broadcaster", &base, AbsenceAuthority::Broadcaster),
976 source("chain-index", &base, AbsenceAuthority::ChainIndex),
977 ]);
978 let outcome = verifier.verify(TXID).await;
979 assert_eq!(outcome, BroadcastVerification::Inconclusive);
980 assert!(outcome.into_send_result(TXID).is_ok());
981 }
982
983 #[tokio::test]
984 async fn a_routing_404_from_the_broadcaster_is_not_absence() {
985 // A wrong base URL / path shape yields `text/plain "404 page not found"`.
986 // That must never be read as "the funds were NOT sent".
987 let text_404 = mock_status_server_ct(StatusCode::NOT_FOUND, Some("text/plain")).await;
988 let json_404 = mock_status_server(StatusCode::NOT_FOUND).await;
989 let verifier = verifier_with(vec![
990 source("broadcaster", &text_404, AbsenceAuthority::Broadcaster),
991 source("chain-index", &json_404, AbsenceAuthority::ChainIndex),
992 ]);
993 assert_eq!(
994 verifier.verify(TXID).await,
995 BroadcastVerification::Inconclusive
996 );
997 }
998
999 #[tokio::test]
1000 async fn disabled_verifier_is_inconclusive() {
1001 let base = mock_status_server(StatusCode::NOT_FOUND).await;
1002 let mut verifier = verifier_with(vec![
1003 source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1004 source("chain-index", &base, AbsenceAuthority::ChainIndex),
1005 ]);
1006 verifier.enabled = false;
1007 assert_eq!(
1008 verifier.verify(TXID).await,
1009 BroadcastVerification::Inconclusive
1010 );
1011 }
1012
1013 #[test]
1014 fn absence_window_is_bounded_and_reflects_the_configured_rounds() {
1015 let v = BroadcastVerifier {
1016 client: Client::new(),
1017 sources: vec![],
1018 attempts: DEFAULT_ATTEMPTS,
1019 delay: Duration::from_millis(DEFAULT_DELAY_MS),
1020 enabled: true,
1021 };
1022 // 11 gaps × 2.5s + 5s slack — long enough for a real mempool index to
1023 // catch up, and hard-bounded so a hung source cannot extend it.
1024 assert_eq!(
1025 v.absence_window(),
1026 Duration::from_millis(27_500) + PROBE_TIMEOUT
1027 );
1028 }
1029}