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