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