Skip to main content

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}