Skip to main content

bsv_wallet_cli/
broadcast_verify.rs

1//! Post-broadcast verification — fail loudly when a broadcast was silently
2//! dropped, and **never** when it was not.
3//!
4//! # Bug 1 — the silent data loss this module was built to close
5//!
6//! A `send` (CLI `send` or the served `/createAction` endpoint) delegates to
7//! `Wallet::create_action`, which signs the tx and broadcasts it. For a
8//! **monitor-less served wallet** (no chain monitor / chaintracks) the wallet
9//! has never fetched merkle proofs for its *confirmed* ancestors, so the BEEF it
10//! hands ARC carries the whole unconfirmed chain. ARC then charges the fee for
11//! the **entire package** and rejects the tx with **error 465 "fee too low"**.
12//!
13//! In `bsv-wallet-toolbox-rs`, an ARC 465 is tagged `service_error = true`, so
14//! `classify_broadcast_results` treats it as a *transient* `ServiceError`
15//! (retryable) rather than a permanent `InvalidTx`. `create_action` therefore
16//! returns `Ok` with a txid — a **phantom txid that never propagates**. The send
17//! path reported success and exit 0 while the funds were never sent.
18//!
19//! # Bug 2 — the false negative this module *introduced* (fixed here)
20//!
21//! The first cut of this module polled a **hardcoded** source list and never
22//! looked at which broadcaster the wallet had actually been configured to use.
23//! It was plane-blind, and every one of its sources was marked authoritative for
24//! absence. Three separate defects fell out of that:
25//!
26//! 1. **The plane that actually holds the answer was never asked.** A wallet in
27//!    Arcade V2 mode (`ARC_MODE=arcade`) submits to the Arcade endpoint, and the
28//!    verifier never queried it — so the one store that is *guaranteed* to have
29//!    a record of our own submission contributed nothing.
30//! 2. **`arc.gorillapool.io` was trusted for absence unconditionally.** It is a
31//!    submission-scoped metamorph store, **not a chain index**: it answers 404
32//!    for transactions that are mined with hundreds of thousands of
33//!    confirmations. (Verified directly: `GET
34//!    https://arc.gorillapool.io/v1/tx/<genesis coinbase txid>` → `404
35//!    {"extraInfo":"transaction not found"}`.) Its 404 carries no information
36//!    about a transaction it was never handed.
37//! 3. **Only WhatsOnChain could ever vote `Present`, inside a ~7.5 s window.**
38//!    So the verdict reduced to a coin flip on WoC's mempool-indexing latency.
39//!
40//! The module's own comment asserted that "ARC keeps recently submitted txs
41//! queryable … so all default sources are authoritative here". That is true only
42//! of the ARC instance you actually submitted to. That unchecked proposition was
43//! the root cause: in the dHouse funder's entire history, all four `Rejected`
44//! verdicts were **false negatives** — every one of those transactions was on
45//! chain.
46//!
47//! # The model this module now implements
48//!
49//! Doctrine (`CLAUDE.md`): *"2xx is never success — truth = visible in our own
50//! index / on chain"*; a **positive** answer may be trusted, an **absence** must
51//! be chain-verified. Applied to the verifier itself: **absence from the wrong
52//! plane is not truth.**
53//!
54//! * **Presence is trusted from anybody.** A 200 from any store means that store
55//!   holds the transaction. A freshly-minted txid we just created cannot be
56//!   known to a third party unless it really propagated. So any `Present` →
57//!   `Confirmed`, immediately.
58//! * **Absence is trusted from almost nobody.** See [`AbsenceAuthority`]: a 404
59//!   is evidence only from the broadcaster we personally submitted through
60//!   (scope) or from a real chain+mempool index after its indexing window has
61//!   elapsed (time) — and we require **both** before declaring `Rejected`.
62//! * **The broadcaster we used is consulted first**, so the happy path
63//!   short-circuits to `Confirmed` on a single request.
64//! * If we cannot satisfy that bar we return `Inconclusive`, and callers preserve
65//!   prior behaviour — a down (or unidentifiable) confirmation service never
66//!   turns a real send into a false failure.
67
68use std::time::{Duration, Instant};
69
70use bsv_wallet_toolbox::{services::ARCADE_V2_MAINNET, Chain};
71use reqwest::Client;
72
73/// Default number of probe rounds before an absence may become definitive.
74///
75/// # Why not the original 6 × 1500 ms (~7.5 s)?
76///
77/// 7.5 s was never defensible as a *mempool-index* window. It is plenty for the
78/// broadcaster we submitted through — that store knows about our submission the
79/// instant it 200s our POST — but an independent index like WhatsOnChain only
80/// learns of the transaction once it propagates to WoC's own node and WoC's
81/// mempool ingestion picks it up. Normally that is a few seconds; under network
82/// load, a provider hiccup, or an ARC→network relay delay it is routinely tens
83/// of seconds. Declaring "the funds were NOT sent" on a 7.5 s WoC miss is
84/// declaring a verdict on indexing latency, and that is exactly how the four
85/// observed false negatives happened.
86///
87/// ~26 s of wall clock (see [`INITIAL_DELAY_MS`] for the schedule) gives the
88/// independent index a realistic chance to catch up before its silence is
89/// treated as evidence.
90///
91/// The cost is paid **only by transactions that really are absent everywhere**:
92/// the happy path returns on the very first probe of the broadcaster, and the
93/// caller's spending lock is already released before verification runs, so a
94/// longer window does not serialize anything.
95const DEFAULT_ATTEMPTS: u32 = 14;
96/// Default CAP on the delay between probe rounds (ms). See [`INITIAL_DELAY_MS`].
97const DEFAULT_DELAY_MS: u64 = 2500;
98/// First inter-round delay (ms). The schedule is: probe immediately, then wait
99/// 250 ms, 500 ms, 1 s, 2 s, then [`DEFAULT_DELAY_MS`] between every further
100/// round. A cleanly accepted transaction is usually visible at the broadcaster
101/// within a second, so the early rounds are cheap; the later rounds keep the
102/// total window long enough for a lagging chain index. With the defaults the
103/// gaps sum to 250+500+1000+2000 + 9×2500 = 26,250 ms.
104const INITIAL_DELAY_MS: u64 = 250;
105/// Per-request timeout for a single status probe. Deliberately shorter than the
106/// inter-round delay so one slow source cannot stretch a round past the next.
107const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
108
109/// Outcome of verifying that a just-broadcast tx actually reached the network.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum BroadcastVerification {
112    /// At least one source confirms the tx exists (accepted / seen / mined).
113    Confirmed,
114    /// Both the broadcaster we actually submitted through **and** an independent
115    /// chain index affirmatively report the tx absent after the full probe
116    /// window, and no source reports it present — the broadcast was silently
117    /// dropped (classic ARC 465 fee-too-low on a deep unconfirmed BEEF).
118    /// The funds were NOT sent.
119    Rejected,
120    /// No source could give an answer that clears the evidence bar. Callers must
121    /// NOT treat this as a failure (avoids false negatives when the confirmation
122    /// service is unreachable, or when only the *wrong* plane reports absence).
123    Inconclusive,
124}
125
126impl BroadcastVerification {
127    /// Map a verification into a `Result`, failing loudly only on a definitive
128    /// `Rejected`. `Confirmed` and `Inconclusive` are both treated as "proceed".
129    pub fn into_send_result(self, txid: &str) -> anyhow::Result<()> {
130        match self {
131            BroadcastVerification::Rejected => Err(anyhow::anyhow!(
132                "broadcast rejected: transaction {txid} is absent from BOTH the broadcaster \
133                 it was submitted to AND an independent chain index, after the full probe \
134                 window. The broadcaster dropped it — most likely error 465 \"fee too low\", \
135                 because a monitor-less wallet presented a deep unconfirmed BEEF and ARC \
136                 charged the fee for the whole unconfirmed package. The funds were NOT sent. \
137                 Fetch merkle proofs for the confirmed ancestors (run `bsv-wallet tick` with \
138                 CHAINTRACKS_URL set) or fund from a confirmed UTXO, then retry."
139            )),
140            BroadcastVerification::Confirmed | BroadcastVerification::Inconclusive => Ok(()),
141        }
142    }
143}
144
145/// Presence of a txid according to a single source.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147enum Presence {
148    /// Source has the tx (HTTP 200).
149    Present,
150    /// Source definitively does not have the tx (HTTP 404 from a real handler).
151    Absent,
152    /// Source could not give a definitive answer (auth error, 5xx, network
153    /// error, or a 404 that looks like "no such route" rather than "no such tx").
154    Unknown,
155}
156
157/// What a source's **absence** (404) answer is worth.
158///
159/// Presence is trusted from every source; absence is a different question
160/// entirely, and the answer depends on *why* that store would be expected to
161/// hold the transaction.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163enum AbsenceAuthority {
164    /// **Worthless.** A submission-scoped store we did *not* submit to.
165    ///
166    /// ARC/metamorph instances index what was handed to *them*. They are not
167    /// chain indexes: `arc.gorillapool.io` returns 404 for the Bitcoin genesis
168    /// coinbase, a transaction with ~960,000 confirmations. A 404 from such a
169    /// store tells us only that *it* never received the transaction — which is
170    /// the expected answer whenever we broadcast somewhere else. These sources
171    /// are kept purely as extra chances to observe `Present`.
172    None,
173
174    /// **Scope-authoritative.** This is the broadcaster we personally submitted
175    /// through, so it *must* have a record of our own submission.
176    ///
177    /// This is the only store whose silence is meaningful immediately rather
178    /// than eventually. It is still not sufficient on its own:
179    ///   * in Arcade mode the toolbox keeps classic ARC as a failover provider,
180    ///     so the transaction may legitimately have gone out through the other
181    ///     provider and be unknown to the primary; and
182    ///   * a misconfigured base URL turns "no such route" into a 404 that is
183    ///     indistinguishable from "no such transaction" at the status-code level
184    ///     (Arcade V2 answers `GET /tx/{txid}` with `application/json
185    ///     {"error":"transaction not found"}` but answers the *wrong* path
186    ///     `GET /v1/tx/{txid}` with `text/plain "404 page not found"`).
187    ///
188    /// Hence the content-type guard in [`probe`] and the conjunction below.
189    Broadcaster,
190
191    /// **Time-authoritative.** An independent chain + mempool index (WhatsOnChain).
192    ///
193    /// Unlike a metamorph store this really does index the whole chain, so its
194    /// 404 is about the transaction and not about scope. Its weakness is
195    /// *latency*, not coverage: mempool ingestion lags acceptance. So its
196    /// absence counts only from the **final** probe round, after the window in
197    /// [`DEFAULT_ATTEMPTS`] has elapsed.
198    ChainIndex,
199}
200
201/// Absence votes gathered during one probe round, grouped by authority class.
202///
203/// A `Rejected` verdict requires the **conjunction**: the plane we submitted
204/// through has no record of our submission *and* an independent chain index
205/// still cannot see the transaction after the full window. Either one alone has
206/// a mundane innocent explanation (provider failover; indexing lag), and acting
207/// on either one alone is precisely what produced four false "funds were NOT
208/// sent" reports on transactions that were on chain.
209///
210/// Consequence, stated honestly: a wallet whose broadcaster cannot be probed
211/// (e.g. classic TAAL ARC with no API key, which answers 401 → `Unknown`) can
212/// never reach `Rejected`. That is the intended trade. A missed drop is caught
213/// downstream — the transaction simply never mines and the unfail canary
214/// reconciles it — whereas a false `Rejected` reports lost funds that were not
215/// lost, which is the more expensive error by far.
216#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
217struct AbsenceVotes {
218    /// The broadcaster we submitted through answered 404.
219    broadcaster: bool,
220    /// An independent chain index answered 404.
221    chain_index: bool,
222}
223
224impl AbsenceVotes {
225    fn record(&mut self, authority: AbsenceAuthority) {
226        match authority {
227            AbsenceAuthority::Broadcaster => self.broadcaster = true,
228            AbsenceAuthority::ChainIndex => self.chain_index = true,
229            // A store we did not submit to has no opinion about absence.
230            AbsenceAuthority::None => {}
231        }
232    }
233
234    /// Absence is definitive only when both authority classes agree.
235    fn is_definitive(self) -> bool {
236        self.broadcaster && self.chain_index
237    }
238}
239
240/// The broadcast plane the wallet is configured to submit through.
241///
242/// This mirrors `services_env::services_options_from_env` — the ONE place that
243/// decides which broadcaster the wallet uses — so the verifier asks the same
244/// endpoint the transaction was actually handed to.
245#[derive(Debug, Clone, PartialEq, Eq)]
246enum BroadcastPlane {
247    /// Arcade V2 (`ARC_MODE=arcade` / `ARCADE=1`).
248    ///
249    /// Status endpoint is `GET {base}/tx/{txid}` — **no `/v1` prefix**. Verified
250    /// two ways: `ArcadeV2Provider::get_tx_status` in `bsv-wallet-toolbox-rs`
251    /// builds `format!("{}/tx/{}", self.url, txid)`, and the live endpoint
252    /// answers that path with `application/json {"error":"transaction not
253    /// found"}` while `/v1/tx/{txid}` answers `text/plain "404 page not found"`
254    /// (i.e. the `/v1` path does not exist and its 404 is a routing artifact).
255    /// Keyless: Arcade's status read needs no `Authorization` header.
256    ArcadeV2 { base: String },
257    /// Classic ARC. Status endpoint is `GET {base}/v1/tx/{txid}`, matching
258    /// `ArcProvider::get_tx_status` in the toolbox.
259    ClassicArc { base: String },
260}
261
262impl BroadcastPlane {
263    /// Resolve the plane from explicit inputs (pure — unit-testable).
264    ///
265    /// `arcade_mode` and `arc_url` are read from the same env vars that
266    /// `services_env` reads, so the verifier cannot drift from the broadcaster.
267    fn resolve(chain: Chain, arcade_mode: bool, arc_url: Option<String>) -> Self {
268        let arc_url = arc_url
269            .map(|s| s.trim().to_string())
270            .filter(|s| !s.is_empty());
271        if arcade_mode {
272            BroadcastPlane::ArcadeV2 {
273                base: normalize_base(&arc_url.unwrap_or_else(|| ARCADE_V2_MAINNET.to_string())),
274            }
275        } else {
276            BroadcastPlane::ClassicArc {
277                base: normalize_base(&arc_url.unwrap_or_else(|| taal_arc_url(chain).to_string())),
278            }
279        }
280    }
281
282    fn from_env(chain: Chain) -> Self {
283        Self::resolve(
284            chain,
285            crate::services_env::arcade_mode_enabled(),
286            std::env::var("ARC_URL").ok(),
287        )
288    }
289
290    fn base(&self) -> &str {
291        match self {
292            BroadcastPlane::ArcadeV2 { base } | BroadcastPlane::ClassicArc { base } => base,
293        }
294    }
295
296    fn name(&self) -> &'static str {
297        match self {
298            BroadcastPlane::ArcadeV2 { .. } => "broadcaster(arcade-v2)",
299            BroadcastPlane::ClassicArc { .. } => "broadcaster(arc)",
300        }
301    }
302
303    /// URL template with the literal `{txid}` placeholder.
304    fn status_template(&self) -> String {
305        match self {
306            // Arcade V2: `/tx/{txid}`. `/v1/tx/{txid}` is NOT a route there.
307            BroadcastPlane::ArcadeV2 { base } => format!("{base}/tx/{{txid}}"),
308            // Classic ARC: `/v1/tx/{txid}`.
309            BroadcastPlane::ClassicArc { base } => format!("{base}/v1/tx/{{txid}}"),
310        }
311    }
312}
313
314/// A network endpoint we can ask "do you know this txid?".
315#[derive(Clone, Debug)]
316struct StatusSource {
317    /// Human-readable name (diagnostics only).
318    name: &'static str,
319    /// URL template containing the literal `{txid}` placeholder.
320    url_template: String,
321    /// Full `Authorization` header value, if the endpoint needs one.
322    auth: Option<String>,
323    /// What this source's 404 is worth. See [`AbsenceAuthority`].
324    absence: AbsenceAuthority,
325}
326
327/// Build the ordered source list for a plane (pure — unit-testable).
328///
329/// Ordering is load-bearing: **index 0 is always the broadcaster we submitted
330/// through**, because it is both the fastest and the most authoritative answer
331/// available, and `verify` returns on the first `Present`.
332fn build_sources(
333    chain: Chain,
334    plane: &BroadcastPlane,
335    taal_key: Option<String>,
336) -> Vec<StatusSource> {
337    let mut sources = vec![StatusSource {
338        name: plane.name(),
339        url_template: plane.status_template(),
340        // Arcade's status read is keyless; classic ARC (TAAL) wants the key.
341        auth: match plane {
342            BroadcastPlane::ArcadeV2 { .. } => None,
343            BroadcastPlane::ClassicArc { .. } => taal_key.clone(),
344        },
345        absence: AbsenceAuthority::Broadcaster,
346    }];
347
348    // The independent chain + mempool index. Keyless, reliable 200/404, and the
349    // only source here that indexes the chain rather than its own inbox.
350    sources.push(StatusSource {
351        name: "whatsonchain",
352        url_template: format!("{}/tx/hash/{{txid}}", woc_base(chain)),
353        auth: None,
354        absence: AbsenceAuthority::ChainIndex,
355    });
356
357    // Third-party ARC stores: extra chances to observe `Present`, never a vote
358    // for absence (see AbsenceAuthority::None). Skipped when they *are* the
359    // broadcaster — that row is already at index 0 with real authority.
360    if let Some(gp) = gorillapool_arc_url(chain) {
361        if normalize_base(gp) != plane.base() {
362            sources.push(StatusSource {
363                name: "arc-gorillapool",
364                url_template: format!("{gp}/v1/tx/{{txid}}"),
365                auth: None,
366                absence: AbsenceAuthority::None,
367            });
368        }
369    }
370    // TAAL only when we hold a key — keyless it answers 401 (`Unknown`), which
371    // is pure latency for zero information.
372    if let Some(key) = taal_key {
373        let taal = taal_arc_url(chain);
374        if normalize_base(taal) != plane.base() {
375            sources.push(StatusSource {
376                name: "arc-taal",
377                url_template: format!("{taal}/v1/tx/{{txid}}"),
378                auth: Some(key),
379                absence: AbsenceAuthority::None,
380            });
381        }
382    }
383
384    sources
385}
386
387/// Verifies that a broadcast tx actually reached the network.
388///
389/// Cheap to clone (shares the reqwest connection pool). Built once and shared
390/// via an axum extension on the served path, or per-command on the CLI path.
391#[derive(Clone)]
392pub struct BroadcastVerifier {
393    client: Client,
394    sources: Vec<StatusSource>,
395    attempts: u32,
396    delay: Duration,
397    /// When false (env opt-out) `verify` short-circuits to `Inconclusive`.
398    enabled: bool,
399}
400
401impl BroadcastVerifier {
402    /// Build a verifier for `chain`, reading the broadcast plane and optional
403    /// overrides from the env:
404    /// - `ARC_MODE=arcade` / `ARCADE=1` + `ARC_URL` select the plane probed first.
405    /// - `BSV_WALLET_SKIP_BROADCAST_VERIFY=1` disables verification entirely.
406    /// - `BSV_WALLET_BROADCAST_VERIFY_ATTEMPTS` overrides the probe-round count.
407    /// - `BSV_WALLET_BROADCAST_VERIFY_DELAY_MS` overrides the inter-round delay.
408    /// - `TAAL_API_KEY` / `MAIN_TAAL_API_KEY` authenticate the TAAL ARC probe.
409    pub fn from_env(chain: Chain) -> Self {
410        let enabled = !env_truthy("BSV_WALLET_SKIP_BROADCAST_VERIFY");
411        let attempts = std::env::var("BSV_WALLET_BROADCAST_VERIFY_ATTEMPTS")
412            .ok()
413            .and_then(|v| v.parse::<u32>().ok())
414            .filter(|n| *n > 0)
415            .unwrap_or(DEFAULT_ATTEMPTS);
416        let delay_ms = std::env::var("BSV_WALLET_BROADCAST_VERIFY_DELAY_MS")
417            .ok()
418            .and_then(|v| v.parse::<u64>().ok())
419            .unwrap_or(DEFAULT_DELAY_MS);
420
421        // TAAL ARC uses a raw `Authorization: <key>` header (no "Bearer " prefix).
422        let taal_key = std::env::var("TAAL_API_KEY")
423            .ok()
424            .filter(|k| !k.is_empty())
425            .or_else(|| {
426                std::env::var("MAIN_TAAL_API_KEY")
427                    .ok()
428                    .filter(|k| !k.is_empty())
429            });
430
431        let plane = BroadcastPlane::from_env(chain);
432        tracing::debug!(plane = ?plane, "broadcast verifier plane");
433
434        Self {
435            client: Client::new(),
436            sources: build_sources(chain, &plane, taal_key),
437            attempts,
438            delay: Duration::from_millis(delay_ms),
439            enabled,
440        }
441    }
442
443    /// Wall-clock ceiling for the absence determination. A source that hangs
444    /// must not be able to stretch the window without bound, so rounds stop once
445    /// the nominal window (plus one probe timeout of slack) has elapsed.
446    /// ONE probe pass over every source (no retry window) — the verdict the
447    /// abandoned-tx reconcile needs (2026-08-29, THE RELEASE RULE): a
448    /// transaction is abandoned ONLY on DEFINITIVE absence (the broadcaster
449    /// it was submitted to answers a JSON 404 AND the chain index answers
450    /// 404, with no other source holding it). A lone index miss is
451    /// `Inconclusive` and must keep the tx: a fresh Arcade/GorillaPool-only
452    /// tx is a WoC 404 for minutes while a peer's orphan pool still holds it.
453    /// Honours `BSV_WALLET_SKIP_BROADCAST_VERIFY` like `from_env` — under it
454    /// every verdict is `Inconclusive`, so nothing is ever abandoned (the
455    /// fail-safe direction).
456    pub fn single_pass(chain: Chain) -> Self {
457        let mut v = Self::from_env(chain);
458        v.attempts = 1;
459        v.delay = Duration::ZERO;
460        v
461    }
462
463    fn absence_window(&self) -> Duration {
464        (1..self.attempts)
465            .map(|round| self.delay_before_round(round))
466            .sum::<Duration>()
467            + PROBE_TIMEOUT
468    }
469
470    /// The pause before probe round `round` (1-based; round 0 is immediate):
471    /// [`INITIAL_DELAY_MS`] doubling each round, capped at the configured
472    /// delay (`BSV_WALLET_BROADCAST_VERIFY_DELAY_MS`, default
473    /// [`DEFAULT_DELAY_MS`]). A cap below the initial delay simply flattens the
474    /// schedule to the cap.
475    fn delay_before_round(&self, round: u32) -> Duration {
476        let exponent = round.saturating_sub(1).min(16);
477        let grown = Duration::from_millis(INITIAL_DELAY_MS.saturating_mul(1u64 << exponent));
478        grown.min(self.delay)
479    }
480
481    /// Probe the network for `txid`, returning as soon as any source reports it
482    /// present, otherwise after the full probe window.
483    pub async fn verify(&self, txid: &str) -> BroadcastVerification {
484        if !self.enabled || self.sources.is_empty() {
485            return BroadcastVerification::Inconclusive;
486        }
487
488        let deadline = Instant::now() + self.absence_window();
489        // Votes from the LAST COMPLETED round. Using the last round (rather than
490        // any round) is what makes the chain-index vote time-authoritative: its
491        // silence only counts once the indexing window has actually elapsed.
492        let mut last_votes: Option<AbsenceVotes> = None;
493
494        for attempt in 0..self.attempts {
495            let mut votes = AbsenceVotes::default();
496            for src in &self.sources {
497                match probe(&self.client, src, txid).await {
498                    // Doctrine: a positive answer may be trusted from any source.
499                    // A txid we minted moments ago cannot be known to a third
500                    // party unless it genuinely propagated.
501                    Presence::Present => return BroadcastVerification::Confirmed,
502                    Presence::Absent => votes.record(src.absence),
503                    Presence::Unknown => {}
504                }
505            }
506            last_votes = Some(votes);
507
508            if attempt + 1 < self.attempts {
509                if Instant::now() >= deadline {
510                    // Slow sources already consumed the window; further rounds
511                    // would only extend the caller's wait, not the evidence.
512                    break;
513                }
514                tokio::time::sleep(self.delay_before_round(attempt + 1)).await;
515            }
516        }
517
518        match last_votes {
519            Some(v) if v.is_definitive() => BroadcastVerification::Rejected,
520            _ => BroadcastVerification::Inconclusive,
521        }
522    }
523}
524
525/// Probe a single source for a txid's presence.
526async fn probe(client: &Client, src: &StatusSource, txid: &str) -> Presence {
527    let url = src.url_template.replace("{txid}", txid);
528    let mut req = client.get(&url).timeout(PROBE_TIMEOUT);
529    if let Some(auth) = &src.auth {
530        req = req.header("Authorization", auth);
531    }
532    match req.send().await {
533        Ok(resp) => {
534            let status = resp.status().as_u16();
535            match status {
536                200 => Presence::Present,
537                404 => {
538                    // A 404 has two very different meanings: "I have no such
539                    // transaction" (a real answer from the ARC/Arcade handler,
540                    // always a JSON problem document) and "I have no such route"
541                    // (a misconfigured base URL — Go/edge routers answer
542                    // `text/plain "404 page not found"`). Only the former is
543                    // evidence, and only for a source whose absence we would act
544                    // on. Downgrading the routing artifact to `Unknown` keeps a
545                    // typo in `ARC_URL` from being reported as lost funds.
546                    if src.absence == AbsenceAuthority::Broadcaster && !is_json(&resp) {
547                        tracing::debug!(
548                            source = src.name,
549                            url = %url,
550                            "broadcaster 404 is not a JSON tx-status body — treating as \
551                             route-not-found (check ARC_URL / path shape), not absence"
552                        );
553                        return Presence::Unknown;
554                    }
555                    Presence::Absent
556                }
557                other => {
558                    tracing::debug!(
559                        source = src.name,
560                        status = other,
561                        "broadcast probe inconclusive"
562                    );
563                    Presence::Unknown
564                }
565            }
566        }
567        Err(e) => {
568            tracing::debug!(source = src.name, error = %e, "broadcast probe request failed");
569            Presence::Unknown
570        }
571    }
572}
573
574/// Whether a response carries a JSON body (the shape every ARC/Arcade status
575/// handler returns, including for "transaction not found").
576fn is_json(resp: &reqwest::Response) -> bool {
577    resp.headers()
578        .get(reqwest::header::CONTENT_TYPE)
579        .and_then(|v| v.to_str().ok())
580        .map(|ct| ct.to_ascii_lowercase().contains("json"))
581        .unwrap_or(false)
582}
583
584fn normalize_base(url: &str) -> String {
585    url.trim().trim_end_matches('/').to_string()
586}
587
588fn taal_arc_url(chain: Chain) -> &'static str {
589    match chain {
590        Chain::Main => "https://arc.taal.com",
591        Chain::Test => "https://arc-test.taal.com",
592    }
593}
594
595fn gorillapool_arc_url(chain: Chain) -> Option<&'static str> {
596    match chain {
597        Chain::Main => Some("https://arc.gorillapool.io"),
598        // GorillaPool testnet ARC is not commonly used; omit it.
599        Chain::Test => None,
600    }
601}
602
603fn woc_base(chain: Chain) -> &'static str {
604    match chain {
605        Chain::Main => "https://api.whatsonchain.com/v1/bsv/main",
606        Chain::Test => "https://api.whatsonchain.com/v1/bsv/test",
607    }
608}
609
610fn env_truthy(key: &str) -> bool {
611    std::env::var(key)
612        .map(|v| {
613            let v = v.trim().to_ascii_lowercase();
614            v == "1" || v == "true" || v == "yes" || v == "on"
615        })
616        .unwrap_or(false)
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use axum::http::StatusCode;
623    use axum::routing::get;
624    use axum::Router;
625    use std::net::SocketAddr;
626
627    // ---- synthetic values only (never a real txid / URL from any wallet) ----
628    const TXID: &str = "0000000000000000000000000000000000000000000000000000000000000001";
629    const SYNTHETIC_ARCADE: &str = "https://arcade.invalid";
630    const SYNTHETIC_ARC: &str = "https://arc.invalid";
631    const SYNTHETIC_KEY: &str = "test-key-not-a-real-credential";
632
633    // =====================================================================
634    // Source selection: which plane do we ask, and with what path shape?
635    // =====================================================================
636
637    /// THE RELEASE RULE's verdict source: one attempt, no retry window, and
638    /// with probing disabled every verdict is Inconclusive — a sweep that
639    /// cannot look can never abandon anything.
640    #[tokio::test]
641    async fn single_pass_is_one_attempt_and_disabled_means_inconclusive() {
642        let v = BroadcastVerifier::single_pass(Chain::Main);
643        assert_eq!(v.attempts, 1);
644        assert_eq!(v.delay, Duration::ZERO);
645        let off = BroadcastVerifier {
646            enabled: false,
647            ..v
648        };
649        assert_eq!(
650            off.verify(&"cd".repeat(32)).await,
651            BroadcastVerification::Inconclusive
652        );
653    }
654
655    #[test]
656    fn arcade_plane_uses_bare_tx_path_not_v1() {
657        // Arcade V2's status route is `/tx/{txid}`. `/v1/tx/{txid}` is not a
658        // route on Arcade at all (it answers with the router's text/plain 404),
659        // which would have made every Arcade tx look "absent".
660        let plane = BroadcastPlane::resolve(
661            Chain::Main,
662            /* arcade_mode */ true,
663            Some(SYNTHETIC_ARCADE.to_string()),
664        );
665        assert_eq!(
666            plane.status_template(),
667            format!("{SYNTHETIC_ARCADE}/tx/{{txid}}")
668        );
669        assert!(
670            !plane.status_template().contains("/v1/"),
671            "Arcade V2 must NOT be probed on the classic ARC /v1 path"
672        );
673    }
674
675    #[test]
676    fn classic_arc_plane_uses_v1_tx_path() {
677        let plane = BroadcastPlane::resolve(
678            Chain::Main,
679            /* arcade_mode */ false,
680            Some(SYNTHETIC_ARC.to_string()),
681        );
682        assert_eq!(
683            plane.status_template(),
684            format!("{SYNTHETIC_ARC}/v1/tx/{{txid}}")
685        );
686    }
687
688    #[test]
689    fn arcade_mode_defaults_to_the_arcade_endpoint_when_arc_url_is_unset() {
690        let plane = BroadcastPlane::resolve(Chain::Main, true, None);
691        assert_eq!(plane.base(), ARCADE_V2_MAINNET.trim_end_matches('/'));
692    }
693
694    #[test]
695    fn classic_mode_defaults_to_taal_and_respects_chain() {
696        assert_eq!(
697            BroadcastPlane::resolve(Chain::Main, false, None).base(),
698            "https://arc.taal.com"
699        );
700        assert_eq!(
701            BroadcastPlane::resolve(Chain::Test, false, None).base(),
702            "https://arc-test.taal.com"
703        );
704    }
705
706    #[test]
707    fn empty_arc_url_falls_back_to_the_default_rather_than_an_empty_base() {
708        let plane = BroadcastPlane::resolve(Chain::Main, true, Some("   ".to_string()));
709        assert_eq!(plane.base(), ARCADE_V2_MAINNET.trim_end_matches('/'));
710    }
711
712    #[test]
713    fn trailing_slash_in_arc_url_does_not_produce_a_double_slash() {
714        let plane = BroadcastPlane::resolve(
715            Chain::Main,
716            true,
717            Some(format!("{SYNTHETIC_ARCADE}/").to_string()),
718        );
719        assert_eq!(
720            plane.status_template(),
721            format!("{SYNTHETIC_ARCADE}/tx/{{txid}}")
722        );
723    }
724
725    #[test]
726    fn the_broadcaster_we_used_is_always_the_first_source_consulted() {
727        // This is the whole point of the fix: the plane that actually holds the
728        // answer must be asked FIRST, in both modes.
729        for plane in [
730            BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string())),
731            BroadcastPlane::resolve(Chain::Main, false, Some(SYNTHETIC_ARC.to_string())),
732        ] {
733            let sources = build_sources(Chain::Main, &plane, None);
734            assert_eq!(sources[0].absence, AbsenceAuthority::Broadcaster);
735            assert!(
736                sources[0].url_template.starts_with(plane.base()),
737                "source 0 ({}) must be the configured broadcaster {}",
738                sources[0].url_template,
739                plane.base()
740            );
741        }
742    }
743
744    #[test]
745    fn arcade_broadcaster_probe_is_keyless_even_when_a_taal_key_exists() {
746        let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
747        let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
748        assert!(sources[0].auth.is_none());
749    }
750
751    #[test]
752    fn classic_broadcaster_probe_carries_the_taal_key_when_present() {
753        let plane = BroadcastPlane::resolve(Chain::Main, false, None);
754        let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
755        assert_eq!(sources[0].auth.as_deref(), Some(SYNTHETIC_KEY));
756    }
757
758    #[test]
759    fn keyless_taal_is_not_probed_at_all() {
760        // Without a key TAAL answers 401 → Unknown: pure latency, zero signal.
761        let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
762        let sources = build_sources(Chain::Main, &plane, None);
763        assert!(!sources.iter().any(|s| s.name == "arc-taal"));
764    }
765
766    #[test]
767    fn a_store_is_never_listed_twice_when_it_is_also_the_broadcaster() {
768        // Broadcasting through GorillaPool in classic mode must not add a second
769        // (presence-only) GorillaPool row.
770        let plane = BroadcastPlane::resolve(
771            Chain::Main,
772            false,
773            Some("https://arc.gorillapool.io".to_string()),
774        );
775        let sources = build_sources(Chain::Main, &plane, None);
776        let gp_rows: Vec<_> = sources
777            .iter()
778            .filter(|s| s.url_template.contains("arc.gorillapool.io"))
779            .collect();
780        assert_eq!(gp_rows.len(), 1);
781        assert_eq!(gp_rows[0].absence, AbsenceAuthority::Broadcaster);
782    }
783
784    // =====================================================================
785    // Absence authority: whose 404 may be believed, and when?
786    // =====================================================================
787
788    #[test]
789    fn a_third_party_arc_store_is_never_authoritative_for_absence() {
790        // arc.gorillapool.io 404s for the genesis coinbase (~960k confirmations).
791        // It is a submission-scoped metamorph store, not a chain index: when we
792        // broadcast through Arcade, its 404 is the EXPECTED answer and carries
793        // no information. Marking it authoritative caused false "funds not sent".
794        let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
795        let sources = build_sources(Chain::Main, &plane, Some(SYNTHETIC_KEY.to_string()));
796        for s in sources.iter().filter(|s| s.name.starts_with("arc-")) {
797            assert_eq!(
798                s.absence,
799                AbsenceAuthority::None,
800                "{} is not the broadcaster; its absence must carry no weight",
801                s.name
802            );
803        }
804    }
805
806    #[test]
807    fn whatsonchain_is_the_chain_index_authority() {
808        let plane = BroadcastPlane::resolve(Chain::Main, true, Some(SYNTHETIC_ARCADE.to_string()));
809        let sources = build_sources(Chain::Main, &plane, None);
810        let woc = sources.iter().find(|s| s.name == "whatsonchain").unwrap();
811        assert_eq!(woc.absence, AbsenceAuthority::ChainIndex);
812    }
813
814    #[test]
815    fn absence_is_definitive_only_when_broadcaster_and_chain_index_agree() {
816        let mut none = AbsenceVotes::default();
817        assert!(!none.is_definitive(), "no votes is not evidence");
818
819        // A store we did not submit to voting absent changes nothing.
820        none.record(AbsenceAuthority::None);
821        assert!(!none.is_definitive());
822
823        let mut broadcaster_only = AbsenceVotes::default();
824        broadcaster_only.record(AbsenceAuthority::Broadcaster);
825        assert!(
826            !broadcaster_only.is_definitive(),
827            "the primary may 404 while the tx went out through the failover provider"
828        );
829
830        let mut index_only = AbsenceVotes::default();
831        index_only.record(AbsenceAuthority::ChainIndex);
832        assert!(
833            !index_only.is_definitive(),
834            "a chain index can simply be lagging its mempool ingestion"
835        );
836
837        let mut both = AbsenceVotes::default();
838        both.record(AbsenceAuthority::Broadcaster);
839        both.record(AbsenceAuthority::ChainIndex);
840        assert!(both.is_definitive());
841    }
842
843    // =====================================================================
844    // End-to-end verdicts against local mock sources.
845    // =====================================================================
846
847    /// Local mock answering every status path (`/tx/{txid}` and `/v1/tx/{txid}`)
848    /// with `code`. Returns the base URL (`http://127.0.0.1:PORT`).
849    async fn mock_status_server(code: StatusCode) -> String {
850        mock_status_server_ct(code, Some("application/json")).await
851    }
852
853    /// As [`mock_status_server`], with an explicit `Content-Type` (or none).
854    async fn mock_status_server_ct(code: StatusCode, content_type: Option<&'static str>) -> String {
855        let handler = move || async move {
856            let mut resp = axum::response::Response::new(axum::body::Body::from("{}"));
857            *resp.status_mut() = code;
858            if let Some(ct) = content_type {
859                resp.headers_mut()
860                    .insert(reqwest::header::CONTENT_TYPE.as_str(), ct.parse().unwrap());
861            } else {
862                resp.headers_mut()
863                    .remove(reqwest::header::CONTENT_TYPE.as_str());
864            }
865            resp
866        };
867        let app = Router::new()
868            .route("/tx/{txid}", get(handler))
869            .route("/v1/tx/{txid}", get(handler))
870            .route("/tx/hash/{txid}", get(handler));
871        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
872        let addr: SocketAddr = listener.local_addr().unwrap();
873        tokio::spawn(async move {
874            axum::serve(listener, app).await.ok();
875        });
876        format!("http://{}", addr)
877    }
878
879    fn source(name: &'static str, base: &str, absence: AbsenceAuthority) -> StatusSource {
880        StatusSource {
881            name,
882            url_template: format!("{base}/tx/{{txid}}"),
883            auth: None,
884            absence,
885        }
886    }
887
888    /// Verifier over an explicit source list (fast: 2 rounds, no delay).
889    fn verifier_with(sources: Vec<StatusSource>) -> BroadcastVerifier {
890        BroadcastVerifier {
891            client: Client::new(),
892            sources,
893            attempts: 2,
894            delay: Duration::from_millis(0),
895            enabled: true,
896        }
897    }
898
899    #[tokio::test]
900    async fn rejected_when_broadcaster_and_chain_index_both_report_absent() {
901        // The original purpose of the module (ARC 465 fee-too-low) still fires:
902        // the plane we submitted to has no record AND the chain index cannot see
903        // it after the window.
904        let base = mock_status_server(StatusCode::NOT_FOUND).await;
905        let verifier = verifier_with(vec![
906            source("broadcaster", &base, AbsenceAuthority::Broadcaster),
907            source("chain-index", &base, AbsenceAuthority::ChainIndex),
908        ]);
909
910        let outcome = verifier.verify(TXID).await;
911        assert_eq!(outcome, BroadcastVerification::Rejected);
912        assert!(
913            outcome.into_send_result(TXID).is_err(),
914            "a Rejected verification must map to Err so the send fails loudly"
915        );
916    }
917
918    #[tokio::test]
919    async fn the_false_negative_that_motivated_this_fix_is_now_inconclusive() {
920        // Exactly the observed regression: the broadcaster we used is never
921        // asked (or is unreachable), a third-party ARC store 404s because we
922        // never submitted to it, and the chain index has not indexed the mempool
923        // entry yet. Old code: Rejected ("the funds were NOT sent"). Every such
924        // transaction was actually on chain.
925        let absent = mock_status_server(StatusCode::NOT_FOUND).await;
926        let verifier = verifier_with(vec![
927            // Broadcaster unreachable → Unknown, not a vote.
928            source(
929                "broadcaster",
930                "http://127.0.0.1:1",
931                AbsenceAuthority::Broadcaster,
932            ),
933            source("chain-index", &absent, AbsenceAuthority::ChainIndex),
934            source("arc-third-party", &absent, AbsenceAuthority::None),
935        ]);
936        assert_eq!(
937            verifier.verify(TXID).await,
938            BroadcastVerification::Inconclusive
939        );
940    }
941
942    #[tokio::test]
943    async fn third_party_absence_alone_never_rejects() {
944        let base = mock_status_server(StatusCode::NOT_FOUND).await;
945        let verifier = verifier_with(vec![
946            source("arc-third-party-a", &base, AbsenceAuthority::None),
947            source("arc-third-party-b", &base, AbsenceAuthority::None),
948        ]);
949        assert_eq!(
950            verifier.verify(TXID).await,
951            BroadcastVerification::Inconclusive
952        );
953    }
954
955    #[tokio::test]
956    async fn broadcaster_absence_alone_never_rejects() {
957        // The toolbox keeps a failover provider behind the primary, so the tx
958        // may legitimately have gone out through the other plane.
959        let absent = mock_status_server(StatusCode::NOT_FOUND).await;
960        let verifier = verifier_with(vec![
961            source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
962            // Chain index unreachable → Unknown.
963            source(
964                "chain-index",
965                "http://127.0.0.1:1",
966                AbsenceAuthority::ChainIndex,
967            ),
968        ]);
969        assert_eq!(
970            verifier.verify(TXID).await,
971            BroadcastVerification::Inconclusive
972        );
973    }
974
975    #[tokio::test]
976    async fn chain_index_absence_alone_never_rejects() {
977        let absent = mock_status_server(StatusCode::NOT_FOUND).await;
978        let verifier = verifier_with(vec![
979            // Broadcaster answers 401 (keyless TAAL) → Unknown.
980            source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
981            source("chain-index", &absent, AbsenceAuthority::ChainIndex),
982        ]);
983        // Sanity: with both absent it WOULD reject...
984        assert_eq!(verifier.verify(TXID).await, BroadcastVerification::Rejected);
985
986        // ...but with the broadcaster unreachable, the chain index alone must not.
987        let unauth = mock_status_server(StatusCode::UNAUTHORIZED).await;
988        let verifier = verifier_with(vec![
989            source("broadcaster", &unauth, AbsenceAuthority::Broadcaster),
990            source("chain-index", &absent, AbsenceAuthority::ChainIndex),
991        ]);
992        assert_eq!(
993            verifier.verify(TXID).await,
994            BroadcastVerification::Inconclusive
995        );
996    }
997
998    #[tokio::test]
999    async fn presence_from_any_source_confirms_even_when_others_say_absent() {
1000        // Doctrine: a positive answer may be trusted; an absence may not.
1001        let present = mock_status_server(StatusCode::OK).await;
1002        let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1003        let verifier = verifier_with(vec![
1004            source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1005            source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1006            source("arc-third-party", &present, AbsenceAuthority::None),
1007        ]);
1008        let outcome = verifier.verify(TXID).await;
1009        assert_eq!(outcome, BroadcastVerification::Confirmed);
1010        assert!(outcome.into_send_result(TXID).is_ok());
1011    }
1012
1013    #[tokio::test]
1014    async fn confirmed_broadcast_succeeds() {
1015        let base = mock_status_server(StatusCode::OK).await;
1016        let verifier = verifier_with(vec![source(
1017            "broadcaster",
1018            &base,
1019            AbsenceAuthority::Broadcaster,
1020        )]);
1021        let outcome = verifier.verify(TXID).await;
1022        assert_eq!(outcome, BroadcastVerification::Confirmed);
1023        assert!(outcome.into_send_result(TXID).is_ok());
1024    }
1025
1026    #[tokio::test]
1027    async fn unreachable_source_is_inconclusive_not_a_failure() {
1028        // 503 from every probe → we cannot confirm either way → Inconclusive,
1029        // which must NOT be a failure (no false negatives when the service is down).
1030        let base = mock_status_server(StatusCode::SERVICE_UNAVAILABLE).await;
1031        let verifier = verifier_with(vec![
1032            source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1033            source("chain-index", &base, AbsenceAuthority::ChainIndex),
1034        ]);
1035        let outcome = verifier.verify(TXID).await;
1036        assert_eq!(outcome, BroadcastVerification::Inconclusive);
1037        assert!(outcome.into_send_result(TXID).is_ok());
1038    }
1039
1040    #[tokio::test]
1041    async fn a_routing_404_from_the_broadcaster_is_not_absence() {
1042        // A wrong base URL / path shape yields `text/plain "404 page not found"`.
1043        // That must never be read as "the funds were NOT sent".
1044        let text_404 = mock_status_server_ct(StatusCode::NOT_FOUND, Some("text/plain")).await;
1045        let json_404 = mock_status_server(StatusCode::NOT_FOUND).await;
1046        let verifier = verifier_with(vec![
1047            source("broadcaster", &text_404, AbsenceAuthority::Broadcaster),
1048            source("chain-index", &json_404, AbsenceAuthority::ChainIndex),
1049        ]);
1050        assert_eq!(
1051            verifier.verify(TXID).await,
1052            BroadcastVerification::Inconclusive
1053        );
1054    }
1055
1056    #[tokio::test]
1057    async fn disabled_verifier_is_inconclusive() {
1058        let base = mock_status_server(StatusCode::NOT_FOUND).await;
1059        let mut verifier = verifier_with(vec![
1060            source("broadcaster", &base, AbsenceAuthority::Broadcaster),
1061            source("chain-index", &base, AbsenceAuthority::ChainIndex),
1062        ]);
1063        verifier.enabled = false;
1064        assert_eq!(
1065            verifier.verify(TXID).await,
1066            BroadcastVerification::Inconclusive
1067        );
1068    }
1069
1070    #[test]
1071    fn absence_window_is_bounded_and_reflects_the_configured_rounds() {
1072        let v = BroadcastVerifier {
1073            client: Client::new(),
1074            sources: vec![],
1075            attempts: DEFAULT_ATTEMPTS,
1076            delay: Duration::from_millis(DEFAULT_DELAY_MS),
1077            enabled: true,
1078        };
1079        // 13 gaps: 250+500+1000+2000 then 9 × 2.5 s, plus 5 s slack — long
1080        // enough for a real mempool index to catch up, and hard-bounded so a
1081        // hung source cannot extend it.
1082        assert_eq!(
1083            v.absence_window(),
1084            Duration::from_millis(26_250) + PROBE_TIMEOUT
1085        );
1086    }
1087
1088    #[test]
1089    fn probe_schedule_starts_short_grows_and_caps() {
1090        // A clean tx is usually present within a second: the first re-probes
1091        // come quickly, then the gaps grow to the cap so the total window stays
1092        // long enough for a lagging chain index.
1093        let v = BroadcastVerifier {
1094            client: Client::new(),
1095            sources: vec![],
1096            attempts: DEFAULT_ATTEMPTS,
1097            delay: Duration::from_millis(DEFAULT_DELAY_MS),
1098            enabled: true,
1099        };
1100        let gaps: Vec<u64> = (1..v.attempts)
1101            .map(|r| v.delay_before_round(r).as_millis() as u64)
1102            .collect();
1103        assert_eq!(
1104            gaps,
1105            vec![250, 500, 1000, 2000, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500, 2500]
1106        );
1107        assert!(gaps.windows(2).all(|w| w[0] <= w[1]), "never shrinks");
1108        assert!(
1109            gaps.iter().all(|g| *g <= DEFAULT_DELAY_MS),
1110            "never exceeds the cap"
1111        );
1112
1113        // An env override below the initial delay flattens the schedule.
1114        let tight = BroadcastVerifier {
1115            delay: Duration::from_millis(100),
1116            ..v
1117        };
1118        assert!(
1119            (1..tight.attempts).all(|r| tight.delay_before_round(r) == Duration::from_millis(100))
1120        );
1121
1122        // single_pass has no gaps at all.
1123        let one = BroadcastVerifier::single_pass(Chain::Main);
1124        assert_eq!(one.absence_window(), PROBE_TIMEOUT);
1125    }
1126
1127    #[tokio::test]
1128    async fn a_present_tx_is_confirmed_on_the_first_probe_without_waiting() {
1129        // The served handler's ambiguous path and the CLI send bar both call
1130        // verify inline: presence must be answered by the immediate first
1131        // round, never after a sleep.
1132        let present = mock_status_server(StatusCode::OK).await;
1133        let verifier = BroadcastVerifier {
1134            client: Client::new(),
1135            sources: vec![source(
1136                "broadcaster",
1137                &present,
1138                AbsenceAuthority::Broadcaster,
1139            )],
1140            attempts: DEFAULT_ATTEMPTS,
1141            delay: Duration::from_millis(DEFAULT_DELAY_MS),
1142            enabled: true,
1143        };
1144        let started = std::time::Instant::now();
1145        assert_eq!(
1146            verifier.verify(TXID).await,
1147            BroadcastVerification::Confirmed
1148        );
1149        assert!(
1150            started.elapsed() < Duration::from_millis(INITIAL_DELAY_MS),
1151            "took {:?}",
1152            started.elapsed()
1153        );
1154    }
1155
1156    #[tokio::test]
1157    async fn an_absent_tx_is_retried_on_the_growing_schedule() {
1158        // 4 rounds against an absent broadcaster + index under a 200 ms cap: the
1159        // 250/500/1000 ms schedule flattens to 3 gaps of 200 ms, so the verdict
1160        // must arrive after ~600 ms — and only after every round has run.
1161        let absent = mock_status_server(StatusCode::NOT_FOUND).await;
1162        let verifier = BroadcastVerifier {
1163            client: Client::new(),
1164            sources: vec![
1165                source("broadcaster", &absent, AbsenceAuthority::Broadcaster),
1166                source("chain-index", &absent, AbsenceAuthority::ChainIndex),
1167            ],
1168            attempts: 4,
1169            delay: Duration::from_millis(200),
1170            enabled: true,
1171        };
1172        // Schedule under a 200 ms cap: 250→200, 500→200, 1000→200.
1173        assert!((1..4).all(|r| verifier.delay_before_round(r) == Duration::from_millis(200)));
1174        let started = std::time::Instant::now();
1175        assert_eq!(verifier.verify(TXID).await, BroadcastVerification::Rejected);
1176        let elapsed = started.elapsed();
1177        assert!(
1178            elapsed >= Duration::from_millis(600) && elapsed < Duration::from_millis(2_000),
1179            "took {:?}",
1180            elapsed
1181        );
1182    }
1183}