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//! # Bug 3: "present" is not "on the network" (2026-09-02)
48//!
49//! A 200 from the broadcaster we submitted through used to count as presence.
50//! It is not network evidence: Arcade answers `GET /tx/{txid}` with a 200 and
51//! `txStatus: RECEIVED` / `SENT_TO_NETWORK` for a transaction it holds but
52//! that no node has seen, and with a 200 and `txStatus: REJECTED` for one it
53//! will never relay. On 2026-09-02 four beta wallets sent EF children whose
54//! 202'd parents had never propagated; the children were orphans forever,
55//! and a verifier that read "200" as "present" could not tell.
56//!
57//! So a probe now reads the body. Every source yields one of: network-level
58//! [`NetworkEvidence`] (`SEEN_ON_NETWORK` / `SEEN_MULTIPLE_NODES` / `MINED`
59//! from an ARC-style store, any 200 from the chain index), *held* (the store
60//! has the bytes, the network has not vouched: pre-gate statuses, an
61//! orphan-pool hit), a *fatal* verdict from the broadcaster we submitted
62//! through (`REJECTED` / `DOUBLE_SPEND_ATTEMPTED`), absence, or unknown.
63//! [`BroadcastVerifier::verify_report`] surfaces all of it in a
64//! [`PresenceReport`]; the served follow-up credits the wallet's broadcast
65//! memory (`seen` for the tx AND its unproven ancestors: presence of the
66//! child implies the parents connected) and the reconciler runs its absence
67//! clock on it.
68//!
69//! # The model this module now implements
70//!
71//! Doctrine (`CLAUDE.md`): *"2xx is never success — truth = visible in our own
72//! index / on chain"*; a **positive** answer may be trusted, an **absence** must
73//! be chain-verified. Applied to the verifier itself: **absence from the wrong
74//! plane is not truth.**
75//!
76//! * **Presence is trusted from anybody.** A store holding the transaction
77//!   (held or seen) means the broadcast was not silently dropped. A
78//!   freshly-minted txid we just created cannot be known to a third party
79//!   unless it really propagated. So any held / seen answer → `Confirmed`.
80//! * **Absence is trusted from almost nobody.** See [`AbsenceAuthority`]: a 404
81//!   (or a fatal verdict) is evidence only from the broadcaster we personally
82//!   submitted through (scope) or from a real chain+mempool index after its
83//!   indexing window has elapsed (time), and we require **both** before
84//!   declaring `Rejected`.
85//! * **The broadcaster we used is consulted first**, so the happy path
86//!   short-circuits to `Confirmed` on a single request.
87//! * If we cannot satisfy that bar we return `Inconclusive`, and callers preserve
88//!   prior behaviour — a down (or unidentifiable) confirmation service never
89//!   turns a real send into a false failure.
90
91use std::time::{Duration, Instant};
92
93use bsv_wallet_toolbox::{
94    services::ARCADE_V2_MAINNET, BroadcastStatus, Chain, BROADCAST_PROVIDER_NETWORK,
95    PROVIDER_ARCADE_V2,
96};
97use reqwest::Client;
98
99/// Default number of probe rounds before an absence may become definitive.
100///
101/// # Why not the original 6 × 1500 ms (~7.5 s)?
102///
103/// 7.5 s was never defensible as a *mempool-index* window. It is plenty for the
104/// broadcaster we submitted through — that store knows about our submission the
105/// instant it 200s our POST — but an independent index like WhatsOnChain only
106/// learns of the transaction once it propagates to WoC's own node and WoC's
107/// mempool ingestion picks it up. Normally that is a few seconds; under network
108/// load, a provider hiccup, or an ARC→network relay delay it is routinely tens
109/// of seconds. Declaring "the funds were NOT sent" on a 7.5 s WoC miss is
110/// declaring a verdict on indexing latency, and that is exactly how the four
111/// observed false negatives happened.
112///
113/// ~26 s of wall clock (see [`INITIAL_DELAY_MS`] for the schedule) gives the
114/// independent index a realistic chance to catch up before its silence is
115/// treated as evidence.
116///
117/// The cost is paid **only by transactions that really are absent everywhere**:
118/// the happy path returns on the very first probe of the broadcaster, and the
119/// caller's spending lock is already released before verification runs, so a
120/// longer window does not serialize anything.
121const DEFAULT_ATTEMPTS: u32 = 14;
122/// Default CAP on the delay between probe rounds (ms). See [`INITIAL_DELAY_MS`].
123const DEFAULT_DELAY_MS: u64 = 2500;
124/// First inter-round delay (ms). The schedule is: probe immediately, then wait
125/// 250 ms, 500 ms, 1 s, 2 s, then [`DEFAULT_DELAY_MS`] between every further
126/// round. A cleanly accepted transaction is usually visible at the broadcaster
127/// within a second, so the early rounds are cheap; the later rounds keep the
128/// total window long enough for a lagging chain index. With the defaults the
129/// gaps sum to 250+500+1000+2000 + 9×2500 = 26,250 ms.
130const INITIAL_DELAY_MS: u64 = 250;
131/// Per-request timeout for a single status probe. Deliberately shorter than the
132/// inter-round delay so one slow source cannot stretch a round past the next.
133const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
134
135/// Outcome of verifying that a just-broadcast tx actually reached the network.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum BroadcastVerification {
138    /// At least one source holds the tx (accepted / seen / mined).
139    Confirmed,
140    /// Both the broadcaster we actually submitted through **and** an independent
141    /// chain index affirmatively report the tx absent (or, for the broadcaster,
142    /// fatally rejected) after the full probe window, and no source holds it:
143    /// the broadcast was silently dropped (classic ARC 465 fee-too-low on a
144    /// deep unconfirmed BEEF, an Arcade `REJECTED`). The funds were NOT sent.
145    Rejected,
146    /// No source could give an answer that clears the evidence bar. Callers must
147    /// NOT treat this as a failure (avoids false negatives when the confirmation
148    /// service is unreachable, or when only the *wrong* plane reports absence).
149    Inconclusive,
150}
151
152impl BroadcastVerification {
153    /// Map a verification into a `Result`, failing loudly only on a definitive
154    /// `Rejected`. `Confirmed` and `Inconclusive` are both treated as "proceed".
155    pub fn into_send_result(self, txid: &str) -> anyhow::Result<()> {
156        match self {
157            BroadcastVerification::Rejected => Err(anyhow::anyhow!(
158                "broadcast rejected: transaction {txid} is absent from BOTH the broadcaster \
159                 it was submitted to AND an independent chain index, after the full probe \
160                 window. The broadcaster dropped it — most likely error 465 \"fee too low\", \
161                 because a monitor-less wallet presented a deep unconfirmed BEEF and ARC \
162                 charged the fee for the whole unconfirmed package. The funds were NOT sent. \
163                 Fetch merkle proofs for the confirmed ancestors (run `bsv-wallet tick` with \
164                 CHAINTRACKS_URL set) or fund from a confirmed UTXO, then retry."
165            )),
166            BroadcastVerification::Confirmed | BroadcastVerification::Inconclusive => Ok(()),
167        }
168    }
169}
170
171/// Network-level presence a source reported: the transaction was seen by a
172/// node (so its parents connected), or mined.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
174pub enum NetworkEvidence {
175    /// `SEEN_ON_NETWORK` / `SEEN_MULTIPLE_NODES`, or an unconfirmed chain-index
176    /// hit.
177    Seen,
178    /// `MINED`, or a chain-index hit with confirmations.
179    Mined,
180}
181
182impl NetworkEvidence {
183    /// The broadcast-memory status this evidence records.
184    pub fn memory_status(self) -> &'static str {
185        match self {
186            NetworkEvidence::Seen => bsv_wallet_toolbox::BROADCAST_STATUS_SEEN,
187            NetworkEvidence::Mined => bsv_wallet_toolbox::BROADCAST_STATUS_MINED,
188        }
189    }
190}
191
192/// Everything one verification learned, for callers that act on more than
193/// the verdict (the broadcast memory, the absence clock).
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct PresenceReport {
196    /// The verdict (`verify`'s answer).
197    pub verification: BroadcastVerification,
198    /// Network-level evidence from some source, if any.
199    pub evidence: Option<NetworkEvidence>,
200    /// The broadcast-memory provider to credit with `evidence`:
201    /// [`PROVIDER_ARCADE_V2`] when the Arcade plane reported it,
202    /// [`BROADCAST_PROVIDER_NETWORK`] otherwise (a chain index, a third-party
203    /// store: the whole network has it).
204    pub evidence_provider: &'static str,
205    /// The broadcaster we submitted through reports a fatal verdict
206    /// (`REJECTED` / `DOUBLE_SPEND_ATTEMPTED`).
207    pub broadcaster_fatal: bool,
208    /// In the last probe round no source gave network evidence, the chain
209    /// index answered absent and the broadcaster answered (held, absent or
210    /// fatal): the transaction is not on the network right now. The absence
211    /// clock advances on it; it is NOT a verdict by itself.
212    pub network_absent: bool,
213}
214
215impl PresenceReport {
216    /// A report carrying only a verdict (tests, callers without a probe).
217    pub fn from_verification(verification: BroadcastVerification) -> Self {
218        Self {
219            verification,
220            evidence: None,
221            evidence_provider: BROADCAST_PROVIDER_NETWORK,
222            broadcaster_fatal: false,
223            network_absent: false,
224        }
225    }
226}
227
228/// Presence of a txid according to a single source.
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230enum Presence {
231    /// The source holds the bytes but has not seen them on the network (an
232    /// ARC/Arcade pre-gate status, an orphan-pool hit, a 200 without a
233    /// readable status).
234    Held,
235    /// The source saw the tx on the network (or mined).
236    Present(NetworkEvidence),
237    /// The broadcaster we submitted through reports `REJECTED` /
238    /// `DOUBLE_SPEND_ATTEMPTED`: a definitive negative from the scope that
239    /// holds our submission. Counts as its absence vote.
240    Fatal,
241    /// Source definitively does not have the tx (HTTP 404 from a real handler).
242    Absent,
243    /// Source could not give a definitive answer (auth error, 5xx, network
244    /// error, or a 404 that looks like "no such route" rather than "no such tx").
245    Unknown,
246}
247
248/// What a source's **absence** (404) answer is worth.
249///
250/// Presence is trusted from every source; absence is a different question
251/// entirely, and the answer depends on *why* that store would be expected to
252/// hold the transaction.
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254enum AbsenceAuthority {
255    /// **Worthless.** A submission-scoped store we did *not* submit to.
256    ///
257    /// ARC/metamorph instances index what was handed to *them*. They are not
258    /// chain indexes: `arc.gorillapool.io` returns 404 for the Bitcoin genesis
259    /// coinbase, a transaction with ~960,000 confirmations. A 404 from such a
260    /// store tells us only that *it* never received the transaction — which is
261    /// the expected answer whenever we broadcast somewhere else. These sources
262    /// are kept purely as extra chances to observe presence.
263    None,
264
265    /// **Scope-authoritative.** This is the broadcaster we personally submitted
266    /// through, so it *must* have a record of our own submission.
267    ///
268    /// This is the only store whose silence is meaningful immediately rather
269    /// than eventually. It is still not sufficient on its own:
270    ///   * in Arcade mode the toolbox keeps classic ARC as a failover provider,
271    ///     so the transaction may legitimately have gone out through the other
272    ///     provider and be unknown to the primary; and
273    ///   * a misconfigured base URL turns "no such route" into a 404 that is
274    ///     indistinguishable from "no such transaction" at the status-code level
275    ///     (Arcade V2 answers `GET /tx/{txid}` with `application/json
276    ///     {"error":"transaction not found"}` but answers the *wrong* path
277    ///     `GET /v1/tx/{txid}` with `text/plain "404 page not found"`).
278    ///
279    /// Hence the content-type guard in [`probe`] and the conjunction below.
280    Broadcaster,
281
282    /// **Time-authoritative.** An independent chain + mempool index (WhatsOnChain).
283    ///
284    /// Unlike a metamorph store this really does index the whole chain, so its
285    /// 404 is about the transaction and not about scope. Its weakness is
286    /// *latency*, not coverage: mempool ingestion lags acceptance. So its
287    /// absence counts only from the **final** probe round, after the window in
288    /// [`DEFAULT_ATTEMPTS`] has elapsed.
289    ChainIndex,
290}
291
292/// How a source's 200 body is read.
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294enum SourceKind {
295    /// Arcade V2: `{"txid","txStatus",...}`.
296    Arcade,
297    /// Classic ARC: `{"txid","txStatus",...}` with ARC's status vocabulary.
298    ClassicArc,
299    /// WhatsOnChain `/tx/hash/{txid}`: `{"confirmations",...}`.
300    ChainIndex,
301}
302
303/// Absence votes gathered during one probe round, grouped by authority class.
304///
305/// A `Rejected` verdict requires the **conjunction**: the plane we submitted
306/// through has no record of our submission (or rejected it) *and* an
307/// independent chain index still cannot see the transaction after the full
308/// window. Either one alone has a mundane innocent explanation (provider
309/// failover; indexing lag), and acting on either one alone is precisely what
310/// produced four false "funds were NOT sent" reports on transactions that were
311/// on chain.
312///
313/// Consequence, stated honestly: a wallet whose broadcaster cannot be probed
314/// (e.g. classic TAAL ARC with no API key, which answers 401 → `Unknown`) can
315/// never reach `Rejected`. That is the intended trade. A missed drop is caught
316/// downstream (the transaction simply never mines and the reconciler's
317/// absence clock retires it), whereas a false `Rejected` reports lost funds
318/// that were not lost, which is the more expensive error by far.
319#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
320struct AbsenceVotes {
321    /// The broadcaster we submitted through answered 404 (or fatal).
322    broadcaster: bool,
323    /// An independent chain index answered 404.
324    chain_index: bool,
325}
326
327impl AbsenceVotes {
328    fn record(&mut self, authority: AbsenceAuthority) {
329        match authority {
330            AbsenceAuthority::Broadcaster => self.broadcaster = true,
331            AbsenceAuthority::ChainIndex => self.chain_index = true,
332            // A store we did not submit to has no opinion about absence.
333            AbsenceAuthority::None => {}
334        }
335    }
336
337    /// Absence is definitive only when both authority classes agree.
338    fn is_definitive(self) -> bool {
339        self.broadcaster && self.chain_index
340    }
341}
342
343/// The broadcast plane the wallet is configured to submit through.
344///
345/// This mirrors `services_env::services_options_from_env` — the ONE place that
346/// decides which broadcaster the wallet uses — so the verifier asks the same
347/// endpoint the transaction was actually handed to.
348#[derive(Debug, Clone, PartialEq, Eq)]
349enum BroadcastPlane {
350    /// Arcade V2 (`ARC_MODE=arcade` / `ARCADE=1`).
351    ///
352    /// Status endpoint is `GET {base}/tx/{txid}` — **no `/v1` prefix**. Verified
353    /// two ways: `ArcadeV2Provider::get_tx_status` in `bsv-wallet-toolbox-rs`
354    /// builds `format!("{}/tx/{}", self.url, txid)`, and the live endpoint
355    /// answers that path with `application/json {"error":"transaction not
356    /// found"}` while `/v1/tx/{txid}` answers `text/plain "404 page not found"`
357    /// (i.e. the `/v1` path does not exist and its 404 is a routing artifact).
358    /// Keyless: Arcade's status read needs no `Authorization` header.
359    ArcadeV2 { base: String },
360    /// Classic ARC. Status endpoint is `GET {base}/v1/tx/{txid}`, matching
361    /// `ArcProvider::get_tx_status` in the toolbox.
362    ClassicArc { base: String },
363}
364
365impl BroadcastPlane {
366    /// Resolve the plane from explicit inputs (pure — unit-testable).
367    ///
368    /// `arcade_mode` and `arc_url` are read from the same env vars that
369    /// `services_env` reads, so the verifier cannot drift from the broadcaster.
370    fn resolve(chain: Chain, arcade_mode: bool, arc_url: Option<String>) -> Self {
371        let arc_url = arc_url
372            .map(|s| s.trim().to_string())
373            .filter(|s| !s.is_empty());
374        if arcade_mode {
375            BroadcastPlane::ArcadeV2 {
376                base: normalize_base(&arc_url.unwrap_or_else(|| ARCADE_V2_MAINNET.to_string())),
377            }
378        } else {
379            BroadcastPlane::ClassicArc {
380                base: normalize_base(&arc_url.unwrap_or_else(|| taal_arc_url(chain).to_string())),
381            }
382        }
383    }
384
385    fn from_env(chain: Chain) -> Self {
386        Self::resolve(
387            chain,
388            crate::services_env::arcade_mode_enabled(),
389            std::env::var("ARC_URL").ok(),
390        )
391    }
392
393    fn base(&self) -> &str {
394        match self {
395            BroadcastPlane::ArcadeV2 { base } | BroadcastPlane::ClassicArc { base } => base,
396        }
397    }
398
399    fn name(&self) -> &'static str {
400        match self {
401            BroadcastPlane::ArcadeV2 { .. } => "broadcaster(arcade-v2)",
402            BroadcastPlane::ClassicArc { .. } => "broadcaster(arc)",
403        }
404    }
405
406    fn kind(&self) -> SourceKind {
407        match self {
408            BroadcastPlane::ArcadeV2 { .. } => SourceKind::Arcade,
409            BroadcastPlane::ClassicArc { .. } => SourceKind::ClassicArc,
410        }
411    }
412
413    /// URL template with the literal `{txid}` placeholder.
414    fn status_template(&self) -> String {
415        match self {
416            // Arcade V2: `/tx/{txid}`. `/v1/tx/{txid}` is NOT a route there.
417            BroadcastPlane::ArcadeV2 { base } => format!("{base}/tx/{{txid}}"),
418            // Classic ARC: `/v1/tx/{txid}`.
419            BroadcastPlane::ClassicArc { base } => format!("{base}/v1/tx/{{txid}}"),
420        }
421    }
422}
423
424/// A network endpoint we can ask "do you know this txid?".
425#[derive(Clone, Debug)]
426struct StatusSource {
427    /// Human-readable name (diagnostics only).
428    name: &'static str,
429    /// URL template containing the literal `{txid}` placeholder.
430    url_template: String,
431    /// Full `Authorization` header value, if the endpoint needs one.
432    auth: Option<String>,
433    /// What this source's 404 is worth. See [`AbsenceAuthority`].
434    absence: AbsenceAuthority,
435    /// How its 200 body is read. See [`SourceKind`].
436    kind: SourceKind,
437}
438
439/// Build the ordered source list for a plane (pure — unit-testable).
440///
441/// Ordering is load-bearing: **index 0 is always the broadcaster we submitted
442/// through**, because it is both the fastest and the most authoritative answer
443/// available, and `verify` returns on the first presence.
444fn build_sources(
445    chain: Chain,
446    plane: &BroadcastPlane,
447    taal_key: Option<String>,
448) -> Vec<StatusSource> {
449    let mut sources = vec![StatusSource {
450        name: plane.name(),
451        url_template: plane.status_template(),
452        // Arcade's status read is keyless; classic ARC (TAAL) wants the key.
453        auth: match plane {
454            BroadcastPlane::ArcadeV2 { .. } => None,
455            BroadcastPlane::ClassicArc { .. } => taal_key.clone(),
456        },
457        absence: AbsenceAuthority::Broadcaster,
458        kind: plane.kind(),
459    }];
460
461    // The independent chain + mempool index. Keyless, reliable 200/404, and the
462    // only source here that indexes the chain rather than its own inbox.
463    sources.push(StatusSource {
464        name: "whatsonchain",
465        url_template: format!("{}/tx/hash/{{txid}}", woc_base(chain)),
466        auth: None,
467        absence: AbsenceAuthority::ChainIndex,
468        kind: SourceKind::ChainIndex,
469    });
470
471    // Third-party ARC stores: extra chances to observe presence, never a vote
472    // for absence (see AbsenceAuthority::None). Skipped when they *are* the
473    // broadcaster — that row is already at index 0 with real authority.
474    if let Some(gp) = gorillapool_arc_url(chain) {
475        if normalize_base(gp) != plane.base() {
476            sources.push(StatusSource {
477                name: "arc-gorillapool",
478                url_template: format!("{gp}/v1/tx/{{txid}}"),
479                auth: None,
480                absence: AbsenceAuthority::None,
481                kind: SourceKind::ClassicArc,
482            });
483        }
484    }
485    // TAAL only when we hold a key — keyless it answers 401 (`Unknown`), which
486    // is pure latency for zero information.
487    if let Some(key) = taal_key {
488        let taal = taal_arc_url(chain);
489        if normalize_base(taal) != plane.base() {
490            sources.push(StatusSource {
491                name: "arc-taal",
492                url_template: format!("{taal}/v1/tx/{{txid}}"),
493                auth: Some(key),
494                absence: AbsenceAuthority::None,
495                kind: SourceKind::ClassicArc,
496            });
497        }
498    }
499
500    sources
501}
502
503/// Verifies that a broadcast tx actually reached the network.
504///
505/// Cheap to clone (shares the reqwest connection pool). Built once and shared
506/// via an axum extension on the served path, or per-command on the CLI path.
507#[derive(Clone)]
508pub struct BroadcastVerifier {
509    client: Client,
510    sources: Vec<StatusSource>,
511    attempts: u32,
512    delay: Duration,
513    /// When false (env opt-out) `verify` short-circuits to `Inconclusive`.
514    enabled: bool,
515}
516
517impl BroadcastVerifier {
518    /// Build a verifier for `chain`, reading the broadcast plane and optional
519    /// overrides from the env:
520    /// - `ARC_MODE=arcade` / `ARCADE=1` + `ARC_URL` select the plane probed first.
521    /// - `BSV_WALLET_SKIP_BROADCAST_VERIFY=1` disables verification entirely.
522    /// - `BSV_WALLET_BROADCAST_VERIFY_ATTEMPTS` overrides the probe-round count.
523    /// - `BSV_WALLET_BROADCAST_VERIFY_DELAY_MS` overrides the inter-round delay.
524    /// - `TAAL_API_KEY` / `MAIN_TAAL_API_KEY` authenticate the TAAL ARC probe.
525    pub fn from_env(chain: Chain) -> Self {
526        let enabled = !env_truthy("BSV_WALLET_SKIP_BROADCAST_VERIFY");
527        let attempts = std::env::var("BSV_WALLET_BROADCAST_VERIFY_ATTEMPTS")
528            .ok()
529            .and_then(|v| v.parse::<u32>().ok())
530            .filter(|n| *n > 0)
531            .unwrap_or(DEFAULT_ATTEMPTS);
532        let delay_ms = std::env::var("BSV_WALLET_BROADCAST_VERIFY_DELAY_MS")
533            .ok()
534            .and_then(|v| v.parse::<u64>().ok())
535            .unwrap_or(DEFAULT_DELAY_MS);
536
537        // TAAL ARC uses a raw `Authorization: <key>` header (no "Bearer " prefix).
538        let taal_key = std::env::var("TAAL_API_KEY")
539            .ok()
540            .filter(|k| !k.is_empty())
541            .or_else(|| {
542                std::env::var("MAIN_TAAL_API_KEY")
543                    .ok()
544                    .filter(|k| !k.is_empty())
545            });
546
547        let plane = BroadcastPlane::from_env(chain);
548        tracing::debug!(plane = ?plane, "broadcast verifier plane");
549
550        Self {
551            client: Client::new(),
552            sources: build_sources(chain, &plane, taal_key),
553            attempts,
554            delay: Duration::from_millis(delay_ms),
555            enabled,
556        }
557    }
558
559    /// Wall-clock ceiling for the absence determination. A source that hangs
560    /// must not be able to stretch the window without bound, so rounds stop once
561    /// the nominal window (plus one probe timeout of slack) has elapsed.
562    /// ONE probe pass over every source (no retry window) — the verdict the
563    /// abandoned-tx reconcile needs (2026-08-29, THE RELEASE RULE): a
564    /// transaction is abandoned ONLY on DEFINITIVE absence (the broadcaster
565    /// it was submitted to answers a JSON 404 AND the chain index answers
566    /// 404, with no other source holding it). A lone index miss is
567    /// `Inconclusive` and must keep the tx: a fresh Arcade/GorillaPool-only
568    /// tx is a WoC 404 for minutes while a peer's orphan pool still holds it.
569    /// Honours `BSV_WALLET_SKIP_BROADCAST_VERIFY` like `from_env` — under it
570    /// every verdict is `Inconclusive`, so nothing is ever abandoned (the
571    /// fail-safe direction).
572    pub fn single_pass(chain: Chain) -> Self {
573        let mut v = Self::from_env(chain);
574        v.attempts = 1;
575        v.delay = Duration::ZERO;
576        v
577    }
578
579    fn absence_window(&self) -> Duration {
580        (1..self.attempts)
581            .map(|round| self.delay_before_round(round))
582            .sum::<Duration>()
583            + PROBE_TIMEOUT
584    }
585
586    /// The pause before probe round `round` (1-based; round 0 is immediate):
587    /// [`INITIAL_DELAY_MS`] doubling each round, capped at the configured
588    /// delay (`BSV_WALLET_BROADCAST_VERIFY_DELAY_MS`, default
589    /// [`DEFAULT_DELAY_MS`]). A cap below the initial delay simply flattens the
590    /// schedule to the cap.
591    fn delay_before_round(&self, round: u32) -> Duration {
592        let exponent = round.saturating_sub(1).min(16);
593        let grown = Duration::from_millis(INITIAL_DELAY_MS.saturating_mul(1u64 << exponent));
594        grown.min(self.delay)
595    }
596
597    /// Probe the network for `txid`, returning as soon as any source reports it
598    /// present, otherwise after the full probe window.
599    pub async fn verify(&self, txid: &str) -> BroadcastVerification {
600        self.verify_report(txid).await.verification
601    }
602
603    /// [`BroadcastVerifier::verify`] with everything the probes learned: the
604    /// network evidence (and which plane gave it), a fatal verdict from the
605    /// broadcaster, and whether the transaction is absent from the network
606    /// right now. Returns as soon as any source gives NETWORK evidence; a
607    /// source merely holding the tx does not end the round (a later source
608    /// may still vouch for the network), but it does make the verdict
609    /// `Confirmed`.
610    pub async fn verify_report(&self, txid: &str) -> PresenceReport {
611        let mut report = PresenceReport::from_verification(BroadcastVerification::Inconclusive);
612        if !self.enabled || self.sources.is_empty() {
613            return report;
614        }
615
616        let deadline = Instant::now() + self.absence_window();
617        // The LAST COMPLETED round decides. Using the last round (rather than
618        // any round) is what makes the chain-index vote time-authoritative: its
619        // silence only counts once the indexing window has actually elapsed.
620        let mut last: Option<RoundResult> = None;
621
622        for attempt in 0..self.attempts {
623            let mut round = RoundResult::default();
624            for src in &self.sources {
625                match probe(&self.client, src, txid).await {
626                    // Doctrine: a positive answer may be trusted from any
627                    // source. Network evidence ends the verification.
628                    Presence::Present(evidence) => {
629                        report.verification = BroadcastVerification::Confirmed;
630                        report.evidence = Some(evidence);
631                        report.evidence_provider = if src.kind == SourceKind::Arcade
632                            && src.absence == AbsenceAuthority::Broadcaster
633                        {
634                            PROVIDER_ARCADE_V2
635                        } else {
636                            BROADCAST_PROVIDER_NETWORK
637                        };
638                        return report;
639                    }
640                    Presence::Held => {
641                        round.held = true;
642                        if src.absence == AbsenceAuthority::Broadcaster {
643                            round.broadcaster_answered = true;
644                        }
645                    }
646                    Presence::Fatal => {
647                        round.fatal = true;
648                        round.broadcaster_answered = true;
649                        round.votes.record(src.absence);
650                    }
651                    Presence::Absent => {
652                        if src.absence == AbsenceAuthority::Broadcaster {
653                            round.broadcaster_answered = true;
654                        }
655                        round.votes.record(src.absence);
656                    }
657                    Presence::Unknown => {}
658                }
659            }
660            let held = round.held;
661            last = Some(round);
662
663            // A store holding the tx settles the verdict (Confirmed): the
664            // window exists to give absence time to become definitive, and
665            // nothing about a held transaction is absent. (Network evidence
666            // for the memory, if any comes, is the reconciler's business.)
667            if held {
668                break;
669            }
670
671            if attempt + 1 < self.attempts {
672                if Instant::now() >= deadline {
673                    // Slow sources already consumed the window; further rounds
674                    // would only extend the caller's wait, not the evidence.
675                    break;
676                }
677                tokio::time::sleep(self.delay_before_round(attempt + 1)).await;
678            }
679        }
680
681        if let Some(round) = last {
682            report.broadcaster_fatal = round.fatal;
683            report.network_absent = round.votes.chain_index && round.broadcaster_answered;
684            report.verification = if round.held {
685                BroadcastVerification::Confirmed
686            } else if round.votes.is_definitive() {
687                BroadcastVerification::Rejected
688            } else {
689                BroadcastVerification::Inconclusive
690            };
691        }
692        report
693    }
694}
695
696/// What one probe round learned when no source gave network evidence.
697#[derive(Debug, Clone, Copy, Default)]
698struct RoundResult {
699    votes: AbsenceVotes,
700    /// Some source holds the tx (no network evidence).
701    held: bool,
702    /// The broadcaster we submitted through answered (held, absent or fatal).
703    broadcaster_answered: bool,
704    /// The broadcaster reported a fatal verdict.
705    fatal: bool,
706}
707
708/// Read a 200 body according to the source kind.
709fn presence_of_body(src: &StatusSource, body: &str) -> Presence {
710    let json: Option<serde_json::Value> = serde_json::from_str(body).ok();
711    match src.kind {
712        SourceKind::ChainIndex => {
713            let confirmations = json
714                .as_ref()
715                .and_then(|v| v.get("confirmations"))
716                .and_then(|c| c.as_i64())
717                .unwrap_or(0);
718            if confirmations >= 1 {
719                Presence::Present(NetworkEvidence::Mined)
720            } else {
721                Presence::Present(NetworkEvidence::Seen)
722            }
723        }
724        SourceKind::Arcade | SourceKind::ClassicArc => {
725            let Some(tx_status) = json
726                .as_ref()
727                .and_then(|v| v.get("txStatus"))
728                .and_then(|s| s.as_str())
729            else {
730                return Presence::Held;
731            };
732            let status = match src.kind {
733                SourceKind::Arcade => BroadcastStatus::from_arcade_status(tx_status),
734                _ => BroadcastStatus::from_arc_status(tx_status),
735            };
736            match status {
737                BroadcastStatus::Seen => Presence::Present(NetworkEvidence::Seen),
738                BroadcastStatus::Mined => Presence::Present(NetworkEvidence::Mined),
739                BroadcastStatus::Rejected => {
740                    if src.absence == AbsenceAuthority::Broadcaster {
741                        Presence::Fatal
742                    } else {
743                        // A store we did not submit to rejecting a copy it
744                        // was handed by someone says nothing about ours.
745                        Presence::Unknown
746                    }
747                }
748                BroadcastStatus::Accepted | BroadcastStatus::Unknown => Presence::Held,
749            }
750        }
751    }
752}
753
754/// Probe a single source for a txid's presence.
755async fn probe(client: &Client, src: &StatusSource, txid: &str) -> Presence {
756    let url = src.url_template.replace("{txid}", txid);
757    let mut req = client.get(&url).timeout(PROBE_TIMEOUT);
758    if let Some(auth) = &src.auth {
759        req = req.header("Authorization", auth);
760    }
761    match req.send().await {
762        Ok(resp) => {
763            let status = resp.status().as_u16();
764            match status {
765                200 => {
766                    let body = resp.text().await.unwrap_or_default();
767                    let presence = presence_of_body(src, &body);
768                    tracing::debug!(source = src.name, ?presence, "broadcast probe");
769                    presence
770                }
771                404 => {
772                    // A 404 has two very different meanings: "I have no such
773                    // transaction" (a real answer from the ARC/Arcade handler,
774                    // always a JSON problem document) and "I have no such route"
775                    // (a misconfigured base URL — Go/edge routers answer
776                    // `text/plain "404 page not found"`). Only the former is
777                    // evidence, and only for a source whose absence we would act
778                    // on. Downgrading the routing artifact to `Unknown` keeps a
779                    // typo in `ARC_URL` from being reported as lost funds.
780                    if src.absence == AbsenceAuthority::Broadcaster && !is_json(&resp) {
781                        tracing::debug!(
782                            source = src.name,
783                            url = %url,
784                            "broadcaster 404 is not a JSON tx-status body — treating as \
785                             route-not-found (check ARC_URL / path shape), not absence"
786                        );
787                        return Presence::Unknown;
788                    }
789                    Presence::Absent
790                }
791                other => {
792                    tracing::debug!(
793                        source = src.name,
794                        status = other,
795                        "broadcast probe inconclusive"
796                    );
797                    Presence::Unknown
798                }
799            }
800        }
801        Err(e) => {
802            tracing::debug!(source = src.name, error = %e, "broadcast probe request failed");
803            Presence::Unknown
804        }
805    }
806}
807
808/// Whether a response carries a JSON body (the shape every ARC/Arcade status
809/// handler returns, including for "transaction not found").
810fn is_json(resp: &reqwest::Response) -> bool {
811    resp.headers()
812        .get(reqwest::header::CONTENT_TYPE)
813        .and_then(|v| v.to_str().ok())
814        .map(|ct| ct.to_ascii_lowercase().contains("json"))
815        .unwrap_or(false)
816}
817
818fn normalize_base(url: &str) -> String {
819    url.trim().trim_end_matches('/').to_string()
820}
821
822fn taal_arc_url(chain: Chain) -> &'static str {
823    match chain {
824        Chain::Main => "https://arc.taal.com",
825        Chain::Test => "https://arc-test.taal.com",
826    }
827}
828
829fn gorillapool_arc_url(chain: Chain) -> Option<&'static str> {
830    match chain {
831        Chain::Main => Some("https://arc.gorillapool.io"),
832        // GorillaPool testnet ARC is not commonly used; omit it.
833        Chain::Test => None,
834    }
835}
836
837fn woc_base(chain: Chain) -> &'static str {
838    match chain {
839        Chain::Main => "https://api.whatsonchain.com/v1/bsv/main",
840        Chain::Test => "https://api.whatsonchain.com/v1/bsv/test",
841    }
842}
843
844fn env_truthy(key: &str) -> bool {
845    std::env::var(key)
846        .map(|v| {
847            let v = v.trim().to_ascii_lowercase();
848            v == "1" || v == "true" || v == "yes" || v == "on"
849        })
850        .unwrap_or(false)
851}
852
853#[cfg(test)]
854mod tests {
855    use super::*;
856    use axum::http::StatusCode;
857    use axum::routing::get;
858    use axum::Router;
859    use std::net::SocketAddr;
860
861    // ---- synthetic values only (never a real txid / URL from any wallet) ----
862    const TXID: &str = "0000000000000000000000000000000000000000000000000000000000000001";
863    const SYNTHETIC_ARCADE: &str = "https://arcade.invalid";
864    const SYNTHETIC_ARC: &str = "https://arc.invalid";
865    const SYNTHETIC_KEY: &str = "test-key-not-a-real-credential";
866
867    // =====================================================================
868    // Source selection: which plane do we ask, and with what path shape?
869    // =====================================================================
870
871    /// THE RELEASE RULE's verdict source: one attempt, no retry window, and
872    /// with probing disabled every verdict is Inconclusive — a sweep that
873    /// cannot look can never abandon anything.
874    #[tokio::test]
875    async fn single_pass_is_one_attempt_and_disabled_means_inconclusive() {
876        let v = BroadcastVerifier::single_pass(Chain::Main);
877        assert_eq!(v.attempts, 1);
878        assert_eq!(v.delay, Duration::ZERO);
879        let off = BroadcastVerifier {
880            enabled: false,
881            ..v
882        };
883        assert_eq!(
884            off.verify(&"cd".repeat(32)).await,
885            BroadcastVerification::Inconclusive
886        );
887    }
888
889    #[test]
890    fn arcade_plane_uses_bare_tx_path_not_v1() {
891        // Arcade V2's status route is `/tx/{txid}`. `/v1/tx/{txid}` is not a
892        // route on Arcade at all (it answers with the router's text/plain 404),
893        // which would have made every Arcade tx look "absent".
894        let plane = BroadcastPlane::resolve(
895            Chain::Main,
896            /* arcade_mode */ true,
897            Some(SYNTHETIC_ARCADE.to_string()),
898        );
899        assert_eq!(
900            plane.status_template(),
901            format!("{SYNTHETIC_ARCADE}/tx/{{txid}}")
902        );
903        assert!(
904            !plane.status_template().contains("/v1/"),
905            "Arcade V2 must NOT be probed on the classic ARC /v1 path"
906        );
907        assert_eq!(plane.kind(), SourceKind::Arcade);
908    }
909
910    #[test]
911    fn classic_arc_plane_uses_v1_tx_path() {
912        let plane = BroadcastPlane::resolve(
913            Chain::Main,
914            /* arcade_mode */ false,
915            Some(SYNTHETIC_ARC.to_string()),
916        );
917        assert_eq!(
918            plane.status_template(),
919            format!("{SYNTHETIC_ARC}/v1/tx/{{txid}}")
920        );
921        assert_eq!(plane.kind(), SourceKind::ClassicArc);
922    }
923
924    #[test]
925    fn arcade_mode_defaults_to_the_arcade_endpoint_when_arc_url_is_unset() {
926        let plane = BroadcastPlane::resolve(Chain::Main, true, None);
927        assert_eq!(plane.base(), ARCADE_V2_MAINNET.trim_end_matches('/'));
928    }
929
930    #[test]
931    fn classic_mode_defaults_to_taal_and_respects_chain() {
932        assert_eq!(
933            BroadcastPlane::resolve(Chain::Main, false, None).base(),
934            "https://arc.taal.com"
935        );
936        assert_eq!(
937            BroadcastPlane::resolve(Chain::Test, false, None).base(),
938            "https://arc-test.taal.com"
939        );
940    }
941
942    #[test]
943    fn empty_arc_url_falls_back_to_the_default_rather_than_an_empty_base() {
944        let plane = BroadcastPlane::resolve(Chain::Main, true, Some("   ".to_string()));
945        assert_eq!(plane.base(), ARCADE_V2_MAINNET.trim_end_matches('/'));
946    }
947
948    #[test]
949    fn trailing_slash_in_arc_url_does_not_produce_a_double_slash() {
950        let plane = BroadcastPlane::resolve(
951            Chain::Main,
952            true,
953            Some(format!("{SYNTHETIC_ARCADE}/").to_string()),
954        );
955        assert_eq!(
956            plane.status_template(),
957            format!("{SYNTHETIC_ARCADE}/tx/{{txid}}")
958        );
959    }
960
961    #[test]
962    fn the_broadcaster_we_used_is_always_the_first_source_consulted() {
963        // This is the whole point of the fix: the plane that actually holds the
964        // answer must be asked FIRST, in both modes.
965        for plane in [
966            BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string())),
967            BroadcastPlane::resolve(Chain::Main, false, Some(SYNTHETIC_ARC.to_string())),
968        ] {
969            let sources = build_sources(Chain::Main, &plane, None);
970            assert_eq!(sources[0].absence, AbsenceAuthority::Broadcaster);
971            assert_eq!(sources[0].kind, plane.kind());
972            assert!(
973                sources[0].url_template.starts_with(plane.base()),
974                "source 0 ({}) must be the configured broadcaster {}",
975                sources[0].url_template,
976                plane.base()
977            );
978        }
979    }
980
981    #[test]
982    fn arcade_broadcaster_probe_is_keyless_even_when_a_taal_key_exists() {
983        let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
984        let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
985        assert!(sources[0].auth.is_none());
986    }
987
988    #[test]
989    fn classic_broadcaster_probe_carries_the_taal_key_when_present() {
990        let plane = BroadcastPlane::resolve(Chain::Main, false, None);
991        let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
992        assert_eq!(sources[0].auth.as_deref(), Some(SYNTHETIC_KEY));
993    }
994
995    #[test]
996    fn keyless_taal_is_not_probed_at_all() {
997        // Without a key TAAL answers 401 → Unknown: pure latency, zero signal.
998        let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
999        let sources = build_sources(Chain::Main, &plane, None);
1000        assert!(!sources.iter().any(|s| s.name == "arc-taal"));
1001    }
1002
1003    #[test]
1004    fn a_store_is_never_listed_twice_when_it_is_also_the_broadcaster() {
1005        // Broadcasting through GorillaPool in classic mode must not add a second
1006        // (presence-only) GorillaPool row.
1007        let plane = BroadcastPlane::resolve(
1008            Chain::Main,
1009            false,
1010            Some("https://arc.gorillapool.io".to_string()),
1011        );
1012        let sources = build_sources(Chain::Main, &plane, None);
1013        let gp_rows: Vec<_> = sources
1014            .iter()
1015            .filter(|s| s.url_template.contains("arc.gorillapool.io"))
1016            .collect();
1017        assert_eq!(gp_rows.len(), 1);
1018        assert_eq!(gp_rows[0].absence, AbsenceAuthority::Broadcaster);
1019    }
1020
1021    // =====================================================================
1022    // Absence authority: whose 404 may be believed, and when?
1023    // =====================================================================
1024
1025    #[test]
1026    fn a_third_party_arc_store_is_never_authoritative_for_absence() {
1027        // arc.gorillapool.io 404s for the genesis coinbase (~960k confirmations).
1028        // It is a submission-scoped metamorph store, not a chain index: when we
1029        // broadcast through Arcade, its 404 is the EXPECTED answer and carries
1030        // no information. Marking it authoritative caused false "funds not sent".
1031        let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
1032        let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
1033        for s in sources.iter().filter(|s| s.name.starts_with("arc-")) {
1034            assert_eq!(
1035                s.absence,
1036                AbsenceAuthority::None,
1037                "{} is not the broadcaster; its absence must carry no weight",
1038                s.name
1039            );
1040        }
1041    }
1042
1043    #[test]
1044    fn whatsonchain_is_the_chain_index_authority() {
1045        let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
1046        let sources = build_sources(Chain::Main, &plane, None);
1047        let woc = sources.iter().find(|s| s.name == "whatsonchain").unwrap();
1048        assert_eq!(woc.absence, AbsenceAuthority::ChainIndex);
1049        assert_eq!(woc.kind, SourceKind::ChainIndex);
1050    }
1051
1052    #[test]
1053    fn absence_is_definitive_only_when_broadcaster_and_chain_index_agree() {
1054        let mut none = AbsenceVotes::default();
1055        assert!(!none.is_definitive(), "no votes is not evidence");
1056
1057        // A store we did not submit to voting absent changes nothing.
1058        none.record(AbsenceAuthority::None);
1059        assert!(!none.is_definitive());
1060
1061        let mut broadcaster_only = AbsenceVotes::default();
1062        broadcaster_only.record(AbsenceAuthority::Broadcaster);
1063        assert!(
1064            !broadcaster_only.is_definitive(),
1065            "the primary may 404 while the tx went out through the failover provider"
1066        );
1067
1068        let mut index_only = AbsenceVotes::default();
1069        index_only.record(AbsenceAuthority::ChainIndex);
1070        assert!(
1071            !index_only.is_definitive(),
1072            "a chain index can simply be lagging its mempool ingestion"
1073        );
1074
1075        let mut both = AbsenceVotes::default();
1076        both.record(AbsenceAuthority::Broadcaster);
1077        both.record(AbsenceAuthority::ChainIndex);
1078        assert!(both.is_definitive());
1079    }
1080
1081    // =====================================================================
1082    // Reading a 200: held, seen, mined, fatal.
1083    // =====================================================================
1084
1085    fn src_of(kind: SourceKind, absence: AbsenceAuthority) -> StatusSource {
1086        StatusSource {
1087            name: "test",
1088            url_template: "http://127.0.0.1:1/tx/{txid}".to_string(),
1089            auth: None,
1090            absence,
1091            kind,
1092        }
1093    }
1094
1095    #[test]
1096    fn a_200_body_is_read_by_source_kind() {
1097        let arcade = src_of(SourceKind::Arcade, AbsenceAuthority::Broadcaster);
1098        assert_eq!(
1099            presence_of_body(&arcade, r#"{"txid":"x","txStatus":"RECEIVED"}"#),
1100            Presence::Held,
1101            "a pre-gate status is held, not network evidence"
1102        );
1103        assert_eq!(
1104            presence_of_body(&arcade, r#"{"txid":"x","txStatus":"ACCEPTED_BY_NETWORK"}"#),
1105            Presence::Held
1106        );
1107        assert_eq!(
1108            presence_of_body(&arcade, r#"{"txid":"x","txStatus":"SEEN_ON_NETWORK"}"#),
1109            Presence::Present(NetworkEvidence::Seen)
1110        );
1111        assert_eq!(
1112            presence_of_body(&arcade, r#"{"txid":"x","txStatus":"MINED"}"#),
1113            Presence::Present(NetworkEvidence::Mined)
1114        );
1115        assert_eq!(
1116            presence_of_body(&arcade, r#"{"txid":"x","txStatus":"REJECTED"}"#),
1117            Presence::Fatal
1118        );
1119        assert_eq!(
1120            presence_of_body(&arcade, "{}"),
1121            Presence::Held,
1122            "a 200 without a readable status still means the store holds it"
1123        );
1124        assert_eq!(presence_of_body(&arcade, "not json"), Presence::Held);
1125
1126        let arc = src_of(SourceKind::ClassicArc, AbsenceAuthority::None);
1127        assert_eq!(
1128            presence_of_body(&arc, r#"{"txStatus":"SEEN_IN_ORPHAN_MEMPOOL"}"#),
1129            Presence::Held,
1130            "an orphan-pool hit is held: the node lacks the parent"
1131        );
1132        assert_eq!(
1133            presence_of_body(&arc, r#"{"txStatus":"SEEN_ON_NETWORK"}"#),
1134            Presence::Present(NetworkEvidence::Seen)
1135        );
1136        assert_eq!(
1137            presence_of_body(&arc, r#"{"txStatus":"REJECTED"}"#),
1138            Presence::Unknown,
1139            "a third-party rejection of somebody's copy is no vote"
1140        );
1141
1142        let woc = src_of(SourceKind::ChainIndex, AbsenceAuthority::ChainIndex);
1143        assert_eq!(
1144            presence_of_body(&woc, r#"{"txid":"x","confirmations":0}"#),
1145            Presence::Present(NetworkEvidence::Seen)
1146        );
1147        assert_eq!(
1148            presence_of_body(&woc, r#"{"txid":"x","confirmations":3}"#),
1149            Presence::Present(NetworkEvidence::Mined)
1150        );
1151        assert_eq!(
1152            presence_of_body(&woc, r#"{"txid":"x"}"#),
1153            Presence::Present(NetworkEvidence::Seen)
1154        );
1155    }
1156
1157    // =====================================================================
1158    // End-to-end verdicts against local mock sources.
1159    // =====================================================================
1160
1161    /// Local mock answering every status path (`/tx/{txid}` and `/v1/tx/{txid}`)
1162    /// with `code`. Returns the base URL (`http://127.0.0.1:PORT`).
1163    async fn mock_status_server(code: StatusCode) -> String {
1164        mock_status_server_full(code, Some("application/json"), "{}").await
1165    }
1166
1167    /// As [`mock_status_server`], with an explicit `Content-Type` (or none).
1168    async fn mock_status_server_ct(code: StatusCode, content_type: Option<&'static str>) -> String {
1169        mock_status_server_full(code, content_type, "{}").await
1170    }
1171
1172    /// A 200 with this JSON body on every status path.
1173    async fn mock_status_server_body(body: &'static str) -> String {
1174        mock_status_server_full(StatusCode::OK, Some("application/json"), body).await
1175    }
1176
1177    async fn mock_status_server_full(
1178        code: StatusCode,
1179        content_type: Option<&'static str>,
1180        body: &'static str,
1181    ) -> String {
1182        let handler = move || async move {
1183            let mut resp = axum::response::Response::new(axum::body::Body::from(body));
1184            *resp.status_mut() = code;
1185            if let Some(ct) = content_type {
1186                resp.headers_mut()
1187                    .insert(reqwest::header::CONTENT_TYPE.as_str(), ct.parse().unwrap());
1188            } else {
1189                resp.headers_mut()
1190                    .remove(reqwest::header::CONTENT_TYPE.as_str());
1191            }
1192            resp
1193        };
1194        let app = Router::new()
1195            .route("/tx/{txid}", get(handler))
1196            .route("/v1/tx/{txid}", get(handler))
1197            .route("/tx/hash/{txid}", get(handler));
1198        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1199        let addr: SocketAddr = listener.local_addr().unwrap();
1200        tokio::spawn(async move {
1201            axum::serve(listener, app).await.ok();
1202        });
1203        format!("http://{}", addr)
1204    }
1205
1206    fn source(name: &'static str, base: &str, absence: AbsenceAuthority) -> StatusSource {
1207        source_kind(name, base, absence, SourceKind::ClassicArc)
1208    }
1209
1210    fn source_kind(
1211        name: &'static str,
1212        base: &str,
1213        absence: AbsenceAuthority,
1214        kind: SourceKind,
1215    ) -> StatusSource {
1216        StatusSource {
1217            name,
1218            url_template: format!("{base}/tx/{{txid}}"),
1219            auth: None,
1220            absence,
1221            kind,
1222        }
1223    }
1224
1225    /// Verifier over an explicit source list (fast: 2 rounds, no delay).
1226    fn verifier_with(sources: Vec<StatusSource>) -> BroadcastVerifier {
1227        BroadcastVerifier {
1228            client: Client::new(),
1229            sources,
1230            attempts: 2,
1231            delay: Duration::from_millis(0),
1232            enabled: true,
1233        }
1234    }
1235
1236    #[tokio::test]
1237    async fn rejected_when_broadcaster_and_chain_index_both_report_absent() {
1238        // The original purpose of the module (ARC 465 fee-too-low) still fires:
1239        // the plane we submitted to has no record AND the chain index cannot see
1240        // it after the window.
1241        let base = mock_status_server(StatusCode::NOT_FOUND).await;
1242        let verifier = verifier_with(vec![
1243            source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1244            source("chain-index", &base, AbsenceAuthority::ChainIndex),
1245        ]);
1246
1247        let report = verifier.verify_report(TXID).await;
1248        assert_eq!(report.verification, BroadcastVerification::Rejected);
1249        assert!(report.network_absent);
1250        assert!(!report.broadcaster_fatal);
1251        assert_eq!(report.evidence, None);
1252        assert!(
1253            report.verification.into_send_result(TXID).is_err(),
1254            "a Rejected verification must map to Err so the send fails loudly"
1255        );
1256    }
1257
1258    #[tokio::test]
1259    async fn the_false_negative_that_motivated_this_fix_is_now_inconclusive() {
1260        // Exactly the observed regression: the broadcaster we used is never
1261        // asked (or is unreachable), a third-party ARC store 404s because we
1262        // never submitted to it, and the chain index has not indexed the mempool
1263        // entry yet. Old code: Rejected ("the funds were NOT sent"). Every such
1264        // transaction was actually on chain.
1265        let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1266        let verifier = verifier_with(vec![
1267            // Broadcaster unreachable → Unknown, not a vote.
1268            source(
1269                "broadcaster",
1270                "http://127.0.0.1:1",
1271                AbsenceAuthority::Broadcaster,
1272            ),
1273            source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1274            source("arc-third-party", &absent, AbsenceAuthority::None),
1275        ]);
1276        let report = verifier.verify_report(TXID).await;
1277        assert_eq!(report.verification, BroadcastVerification::Inconclusive);
1278        assert!(
1279            !report.network_absent,
1280            "the absence clock does not run while the broadcaster is unreachable"
1281        );
1282    }
1283
1284    #[tokio::test]
1285    async fn third_party_absence_alone_never_rejects() {
1286        let base = mock_status_server(StatusCode::NOT_FOUND).await;
1287        let verifier = verifier_with(vec![
1288            source("arc-third-party-a", &base, AbsenceAuthority::None),
1289            source("arc-third-party-b", &base, AbsenceAuthority::None),
1290        ]);
1291        assert_eq!(
1292            verifier.verify(TXID).await,
1293            BroadcastVerification::Inconclusive
1294        );
1295    }
1296
1297    #[tokio::test]
1298    async fn broadcaster_absence_alone_never_rejects() {
1299        // The toolbox keeps a failover provider behind the primary, so the tx
1300        // may legitimately have gone out through the other plane.
1301        let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1302        let verifier = verifier_with(vec![
1303            source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1304            // Chain index unreachable → Unknown.
1305            source(
1306                "chain-index",
1307                "http://127.0.0.1:1",
1308                AbsenceAuthority::ChainIndex,
1309            ),
1310        ]);
1311        assert_eq!(
1312            verifier.verify(TXID).await,
1313            BroadcastVerification::Inconclusive
1314        );
1315    }
1316
1317    #[tokio::test]
1318    async fn chain_index_absence_alone_never_rejects() {
1319        let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1320        let verifier = verifier_with(vec![
1321            // Broadcaster answers 401 (keyless TAAL) → Unknown.
1322            source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1323            source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1324        ]);
1325        // Sanity: with both absent it WOULD reject...
1326        assert_eq!(verifier.verify(TXID).await, BroadcastVerification::Rejected);
1327
1328        // ...but with the broadcaster unreachable, the chain index alone must not.
1329        let unauth = mock_status_server(StatusCode::UNAUTHORIZED).await;
1330        let verifier = verifier_with(vec![
1331            source("broadcaster", &unauth, AbsenceAuthority::Broadcaster),
1332            source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1333        ]);
1334        assert_eq!(
1335            verifier.verify(TXID).await,
1336            BroadcastVerification::Inconclusive
1337        );
1338    }
1339
1340    #[tokio::test]
1341    async fn presence_from_any_source_confirms_even_when_others_say_absent() {
1342        // Doctrine: a positive answer may be trusted; an absence may not.
1343        let present = mock_status_server(StatusCode::OK).await;
1344        let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1345        let verifier = verifier_with(vec![
1346            source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1347            source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1348            source("arc-third-party", &present, AbsenceAuthority::None),
1349        ]);
1350        let outcome = verifier.verify(TXID).await;
1351        assert_eq!(outcome, BroadcastVerification::Confirmed);
1352        assert!(outcome.into_send_result(TXID).is_ok());
1353    }
1354
1355    #[tokio::test]
1356    async fn confirmed_broadcast_succeeds() {
1357        let base = mock_status_server(StatusCode::OK).await;
1358        let verifier = verifier_with(vec![source(
1359            "broadcaster",
1360            &base,
1361            AbsenceAuthority::Broadcaster,
1362        )]);
1363        let outcome = verifier.verify(TXID).await;
1364        assert_eq!(outcome, BroadcastVerification::Confirmed);
1365        assert!(outcome.into_send_result(TXID).is_ok());
1366    }
1367
1368    #[tokio::test]
1369    async fn unreachable_source_is_inconclusive_not_a_failure() {
1370        // 503 from every probe → we cannot confirm either way → Inconclusive,
1371        // which must NOT be a failure (no false negatives when the service is down).
1372        let base = mock_status_server(StatusCode::SERVICE_UNAVAILABLE).await;
1373        let verifier = verifier_with(vec![
1374            source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1375            source("chain-index", &base, AbsenceAuthority::ChainIndex),
1376        ]);
1377        let outcome = verifier.verify(TXID).await;
1378        assert_eq!(outcome, BroadcastVerification::Inconclusive);
1379        assert!(outcome.into_send_result(TXID).is_ok());
1380    }
1381
1382    #[tokio::test]
1383    async fn a_routing_404_from_the_broadcaster_is_not_absence() {
1384        // A wrong base URL / path shape yields `text/plain "404 page not found"`.
1385        // That must never be read as "the funds were NOT sent".
1386        let text_404 = mock_status_server_ct(StatusCode::NOT_FOUND, Some("text/plain")).await;
1387        let json_404 = mock_status_server(StatusCode::NOT_FOUND).await;
1388        let verifier = verifier_with(vec![
1389            source("broadcaster", &text_404, AbsenceAuthority::Broadcaster),
1390            source("chain-index", &json_404, AbsenceAuthority::ChainIndex),
1391        ]);
1392        assert_eq!(
1393            verifier.verify(TXID).await,
1394            BroadcastVerification::Inconclusive
1395        );
1396    }
1397
1398    #[tokio::test]
1399    async fn disabled_verifier_is_inconclusive() {
1400        let base = mock_status_server(StatusCode::NOT_FOUND).await;
1401        let mut verifier = verifier_with(vec![
1402            source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1403            source("chain-index", &base, AbsenceAuthority::ChainIndex),
1404        ]);
1405        verifier.enabled = false;
1406        assert_eq!(
1407            verifier.verify(TXID).await,
1408            BroadcastVerification::Inconclusive
1409        );
1410    }
1411
1412    // ---- the 2026-09-02 lesson: a 200 is not the network ---------------------
1413
1414    #[tokio::test]
1415    async fn seen_on_network_from_the_arcade_plane_is_network_evidence_for_arcade() {
1416        let seen = mock_status_server_body(r#"{"txid":"x","txStatus":"SEEN_ON_NETWORK"}"#).await;
1417        let verifier = verifier_with(vec![source_kind(
1418            "broadcaster",
1419            &seen,
1420            AbsenceAuthority::Broadcaster,
1421            SourceKind::Arcade,
1422        )]);
1423        let report = verifier.verify_report(TXID).await;
1424        assert_eq!(report.verification, BroadcastVerification::Confirmed);
1425        assert_eq!(report.evidence, Some(NetworkEvidence::Seen));
1426        assert_eq!(report.evidence_provider, PROVIDER_ARCADE_V2);
1427        assert!(!report.network_absent && !report.broadcaster_fatal);
1428    }
1429
1430    #[tokio::test]
1431    async fn a_pre_gate_status_is_held_only_and_the_absence_clock_runs() {
1432        // The incident shape: Arcade holds the tx (RECEIVED) but no node has
1433        // seen it and the chain index cannot find it. Not a rejection (the
1434        // store holds it), no network evidence, and the clock advances.
1435        let held = mock_status_server_body(r#"{"txid":"x","txStatus":"RECEIVED"}"#).await;
1436        let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1437        let verifier = verifier_with(vec![
1438            source_kind(
1439                "broadcaster",
1440                &held,
1441                AbsenceAuthority::Broadcaster,
1442                SourceKind::Arcade,
1443            ),
1444            source_kind(
1445                "chain-index",
1446                &absent,
1447                AbsenceAuthority::ChainIndex,
1448                SourceKind::ChainIndex,
1449            ),
1450        ]);
1451        let report = verifier.verify_report(TXID).await;
1452        assert_eq!(report.verification, BroadcastVerification::Confirmed);
1453        assert_eq!(report.evidence, None);
1454        assert!(report.network_absent);
1455        assert!(!report.broadcaster_fatal);
1456    }
1457
1458    #[tokio::test]
1459    async fn a_fatal_verdict_from_the_broadcaster_with_an_index_miss_is_rejected() {
1460        let fatal = mock_status_server_body(r#"{"txid":"x","txStatus":"REJECTED"}"#).await;
1461        let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1462        let verifier = verifier_with(vec![
1463            source_kind(
1464                "broadcaster",
1465                &fatal,
1466                AbsenceAuthority::Broadcaster,
1467                SourceKind::Arcade,
1468            ),
1469            source_kind(
1470                "chain-index",
1471                &absent,
1472                AbsenceAuthority::ChainIndex,
1473                SourceKind::ChainIndex,
1474            ),
1475        ]);
1476        let report = verifier.verify_report(TXID).await;
1477        assert_eq!(report.verification, BroadcastVerification::Rejected);
1478        assert!(report.broadcaster_fatal);
1479        assert!(report.network_absent);
1480
1481        // A fatal verdict alone (index unreachable) is still not definitive.
1482        let verifier = verifier_with(vec![
1483            source_kind(
1484                "broadcaster",
1485                &fatal,
1486                AbsenceAuthority::Broadcaster,
1487                SourceKind::Arcade,
1488            ),
1489            source_kind(
1490                "chain-index",
1491                "http://127.0.0.1:1",
1492                AbsenceAuthority::ChainIndex,
1493                SourceKind::ChainIndex,
1494            ),
1495        ]);
1496        let report = verifier.verify_report(TXID).await;
1497        assert_eq!(report.verification, BroadcastVerification::Inconclusive);
1498        assert!(report.broadcaster_fatal);
1499        assert!(!report.network_absent);
1500    }
1501
1502    #[tokio::test]
1503    async fn a_chain_index_hit_is_network_evidence_for_everyone() {
1504        let held = mock_status_server_body(r#"{"txid":"x","txStatus":"SENT_TO_NETWORK"}"#).await;
1505        let mined = mock_status_server_body(r#"{"txid":"x","confirmations":2}"#).await;
1506        let verifier = verifier_with(vec![
1507            source_kind(
1508                "broadcaster",
1509                &held,
1510                AbsenceAuthority::Broadcaster,
1511                SourceKind::Arcade,
1512            ),
1513            source_kind(
1514                "chain-index",
1515                &mined,
1516                AbsenceAuthority::ChainIndex,
1517                SourceKind::ChainIndex,
1518            ),
1519        ]);
1520        let report = verifier.verify_report(TXID).await;
1521        assert_eq!(report.verification, BroadcastVerification::Confirmed);
1522        assert_eq!(report.evidence, Some(NetworkEvidence::Mined));
1523        assert_eq!(report.evidence_provider, BROADCAST_PROVIDER_NETWORK);
1524        assert!(!report.network_absent);
1525    }
1526
1527    #[tokio::test]
1528    async fn a_third_party_rejection_alone_is_inconclusive() {
1529        let fatal = mock_status_server_body(r#"{"txid":"x","txStatus":"REJECTED"}"#).await;
1530        let verifier = verifier_with(vec![source_kind(
1531            "arc-third-party",
1532            &fatal,
1533            AbsenceAuthority::None,
1534            SourceKind::ClassicArc,
1535        )]);
1536        let report = verifier.verify_report(TXID).await;
1537        assert_eq!(report.verification, BroadcastVerification::Inconclusive);
1538        assert!(!report.broadcaster_fatal);
1539    }
1540
1541    #[test]
1542    fn absence_window_is_bounded_and_reflects_the_configured_rounds() {
1543        let v = BroadcastVerifier {
1544            client: Client::new(),
1545            sources: vec![],
1546            attempts: DEFAULT_ATTEMPTS,
1547            delay: Duration::from_millis(DEFAULT_DELAY_MS),
1548            enabled: true,
1549        };
1550        // 13 gaps: 250+500+1000+2000 then 9 × 2.5 s, plus 5 s slack — long
1551        // enough for a real mempool index to catch up, and hard-bounded so a
1552        // hung source cannot extend it.
1553        assert_eq!(
1554            v.absence_window(),
1555            Duration::from_millis(26_250) + PROBE_TIMEOUT
1556        );
1557    }
1558
1559    #[test]
1560    fn probe_schedule_starts_short_grows_and_caps() {
1561        // A clean tx is usually present within a second: the first re-probes
1562        // come quickly, then the gaps grow to the cap so the total window stays
1563        // long enough for a lagging chain index.
1564        let v = BroadcastVerifier {
1565            client: Client::new(),
1566            sources: vec![],
1567            attempts: DEFAULT_ATTEMPTS,
1568            delay: Duration::from_millis(DEFAULT_DELAY_MS),
1569            enabled: true,
1570        };
1571        let gaps: Vec<u64> = (1..v.attempts)
1572            .map(|r| v.delay_before_round(r).as_millis() as u64)
1573            .collect();
1574        assert_eq!(
1575            gaps,
1576            vec![250, 500, 1000, 2000, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500]
1577        );
1578        assert!(gaps.windows(2).all(|w| w[0] <= w[1]), "never shrinks");
1579        assert!(
1580            gaps.iter().all(|g| *g <= DEFAULT_DELAY_MS),
1581            "never exceeds the cap"
1582        );
1583
1584        // An env override below the initial delay flattens the schedule.
1585        let tight = BroadcastVerifier {
1586            delay: Duration::from_millis(100),
1587            ..v
1588        };
1589        assert!(
1590            (1..tight.attempts).all(|r| tight.delay_before_round(r) == Duration::from_millis(100))
1591        );
1592
1593        // single_pass has no gaps at all.
1594        let one = BroadcastVerifier::single_pass(Chain::Main);
1595        assert_eq!(one.absence_window(), PROBE_TIMEOUT);
1596    }
1597
1598    #[tokio::test]
1599    async fn a_present_tx_is_confirmed_on_the_first_probe_without_waiting() {
1600        // The served handler's ambiguous path and the CLI send bar both call
1601        // verify inline: presence must be answered by the immediate first
1602        // round, never after a sleep.
1603        let present = mock_status_server(StatusCode::OK).await;
1604        let verifier = BroadcastVerifier {
1605            client: Client::new(),
1606            sources: vec![source(
1607                "broadcaster",
1608                &present,
1609                AbsenceAuthority::Broadcaster,
1610            )],
1611            attempts: DEFAULT_ATTEMPTS,
1612            delay: Duration::from_millis(DEFAULT_DELAY_MS),
1613            enabled: true,
1614        };
1615        let started = std::time::Instant::now();
1616        assert_eq!(
1617            verifier.verify(TXID).await,
1618            BroadcastVerification::Confirmed
1619        );
1620        assert!(
1621            started.elapsed() < Duration::from_millis(INITIAL_DELAY_MS),
1622            "took {:?}",
1623            started.elapsed()
1624        );
1625    }
1626
1627    #[tokio::test]
1628    async fn an_absent_tx_is_retried_on_the_growing_schedule() {
1629        // 4 rounds against an absent broadcaster + index under a 200 ms cap: the
1630        // 250/500/1000 ms schedule flattens to 3 gaps of 200 ms, so the verdict
1631        // must arrive after ~600 ms — and only after every round has run.
1632        let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1633        let verifier = BroadcastVerifier {
1634            client: Client::new(),
1635            sources: vec![
1636                source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1637                source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1638            ],
1639            attempts: 4,
1640            delay: Duration::from_millis(200),
1641            enabled: true,
1642        };
1643        // Schedule under a 200 ms cap: 250→200, 500→200, 1000→200.
1644        assert!((1..4).all(|r| verifier.delay_before_round(r) == Duration::from_millis(200)));
1645        let started = std::time::Instant::now();
1646        assert_eq!(verifier.verify(TXID).await, BroadcastVerification::Rejected);
1647        let elapsed = started.elapsed();
1648        assert!(
1649            elapsed >= Duration::from_millis(600) && elapsed < Duration::from_millis(2_000),
1650            "took {:?}",
1651            elapsed
1652        );
1653    }
1654}