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