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}