Skip to main content

bsv_wallet_cli/
broadcast_reconcile.rs

1//! The broadcast reconciler: what a served wallet does about transactions
2//! its broadcaster accepted but the network never got.
3//!
4//! # The incident (2026-09-02, beta, real sats)
5//!
6//! Toolbox 0.3.56 let a provider's acceptance stand in for network presence:
7//! four CLI wallets (served with `ARC_MODE=arcade`, no monitor) sent EF
8//! children alone after Arcade had 202'd their parents. The parents never
9//! propagated, the children were orphans forever, the overlay admitted and
10//! later evicted them, one wallet internalized 10,000 sats of a phantom
11//! upvote, and three wallets built later transactions on phantom change.
12//! `abortAction` refused (`unproven`), `cleanup-abandoned` found nothing (the
13//! broadcaster still HELD the bytes), `tick` re-relays nothing.
14//!
15//! The first cure (0.3.58) trusted a broadcaster's `SEEN_*` as network
16//! evidence. Live, Arcade kept reporting `SEEN_MULTIPLE_NODES` for the
17//! phantom roots two hours later while WhatsOnChain answered 404, and the
18//! SSE drain refreshed that `seen` every pass, so the roots were never
19//! probed and the real coin they had spent (224,575 sats, kept locked by one
20//! rate-limited lookup) was never retried. Hence the rules below.
21//!
22//! # The rules (0.3.59)
23//!
24//! 1. **A broadcaster's SEEN is not chain evidence.** For an unproven
25//!    transaction older than `BROADCAST_ABSENCE_MINUTES` (default 30) only a
26//!    fresh CHAIN-INDEX row (`chain|seen` within 10 minutes, or `chain|mined`)
27//!    exempts it from a probe, whatever the broadcaster said. A younger
28//!    transaction is provisionally trusted on any fresh network-evidence row.
29//! 2. **The chain index decides.** Every probe asks the broadcaster we
30//!    submitted through AND WhatsOnChain. A chain-index hit records `chain`
31//!    evidence for the transaction and its unproven parents (presence of the
32//!    child implies the parents connected). A chain-index 404 with the
33//!    broadcaster merely holding or having seen the transaction is
34//!    `network_absent`; past the threshold it retires.
35//! 3. **The poison runs both ways.** A retire climbs UP first (a phantom's
36//!    parent that is unproven and unknown to the chain is part of the same
37//!    poison; the climb stops at the first transaction the chain knows) and
38//!    then retires the root with every unproven descendant through the
39//!    toolbox's RELEASE-RULE path: outside inputs restored only on chain
40//!    verification, the set's outputs unspendable, internalized payments that
41//!    trace to a phantom marked unspendable and logged. The climb never
42//!    passes through a parent younger than the absence threshold (toolbox
43//!    0.3.60): a transaction the chain index has not seen yet is not absent,
44//!    and the verdict was about the child. On 2026-09-03 (beta, fleet w2)
45//!    the live bytes transaction of a lost head race was retired on a 404
46//!    48 s after broadcast and the five coins it had spent were released;
47//!    the next two actions double-spent them and were rejected in turn.
48//!    A "not in the unspent set" answer for a coin whose source the index
49//!    does not know is likewise `unknown` (re-checked), not `spent`.
50//! 4. **Kept-locked inputs are retried.** An outside input the chain could
51//!    not vouch for is re-checked every pass with exponential backoff until
52//!    it is verifiably unspent (restored) or spent (left); nothing stays
53//!    locked forever unattended.
54//!
55//! In Arcade mode the per-wallet SSE stream is drained once per pass first
56//! (verdicts go through the same mapping as the monitor's SSE task and the
57//! webhook). `serve` runs a pass every 60 s in the background (bounded, one
58//! summary line per pass); `bsv-wallet reconcile-broadcasts` runs one pass
59//! by hand (dry run by default); the daemon's ticker runs the sweep and the
60//! locked-input re-checks.
61
62use std::collections::HashSet;
63use std::sync::atomic::AtomicBool;
64use std::time::Duration;
65
66use anyhow::Result;
67use bsv_wallet_toolbox::monitor::ArcadeEventsTask;
68use bsv_wallet_toolbox::services::providers::arcade::statuses;
69use bsv_wallet_toolbox::{
70    ArcadeSseClient, ArcadeStatusEvent, BroadcastMemory, BroadcastSeenRecord, BroadcastStatus,
71    Chain, LockedInputReport, MonitorStorage, PoisonOutcome, PoisonReport, Services, StorageSqlx,
72    Wallet, WalletServices, BROADCAST_PROVIDER_CHAIN, BROADCAST_PROVIDER_NETWORK,
73    BROADCAST_SEEN_STALE_SECS, BROADCAST_STATUS_MINED, BROADCAST_STATUS_SEEN,
74    BROADCAST_STATUS_UNKNOWN, PROVIDER_ARCADE_V2,
75};
76use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
77use sqlx::Row;
78
79use crate::broadcast_verify::{
80    BroadcastVerification, BroadcastVerifier, ChainIndexAnswer, NetworkEvidence, PresenceReport,
81};
82
83/// Default minutes an unproven transaction must be old before a chain-index
84/// absence retires it (`BROADCAST_ABSENCE_MINUTES`).
85pub const DEFAULT_ABSENCE_MINUTES: i64 = 30;
86/// Default seconds between two passes of the served loop
87/// (`BROADCAST_RECONCILE_INTERVAL_SECS`).
88pub const DEFAULT_INTERVAL_SECS: u64 = 60;
89/// Default probes per pass of the served loop
90/// (`BROADCAST_RECONCILE_MAX_PROBES`).
91pub const DEFAULT_MAX_PROBES: usize = 20;
92/// Default locked-input re-checks per pass
93/// (`BROADCAST_RECONCILE_MAX_LOCKED_CHECKS`).
94pub const DEFAULT_MAX_LOCKED_CHECKS: usize = 20;
95/// The served loop only probes transactions younger than this.
96pub const SERVE_MAX_AGE_HOURS: i64 = 24;
97/// Pause between two probes of one pass (WhatsOnChain's public rate).
98const PROBE_PACE: Duration = Duration::from_millis(350);
99/// Wall-clock budget of one SSE drain.
100const SSE_BUDGET: Duration = Duration::from_secs(8);
101/// An SSE stream idle for this long has replayed everything it had.
102const SSE_IDLE: Duration = Duration::from_secs(2);
103
104/// The wallet this module works on.
105pub type ServedWallet = Wallet<StorageSqlx, Services>;
106
107/// Where the Arcade SSE stream of this wallet is.
108#[derive(Debug, Clone)]
109pub struct ArcadeSse {
110    /// Arcade base URL.
111    pub url: String,
112    /// The wallet's callback token (scopes the stream). Never logged.
113    pub token: String,
114}
115
116/// Knobs of one pass.
117#[derive(Debug, Clone)]
118pub struct ReconcileOptions {
119    /// Apply changes (`false` = report only; probes still run, they are
120    /// read-only).
121    pub execute: bool,
122    /// Probes per pass.
123    pub max_probes: usize,
124    /// Only transactions created within this many hours are probed (`None`
125    /// = every unproven transaction).
126    pub max_age_hours: Option<i64>,
127    /// Minutes an unproven transaction must be old before a chain-index
128    /// absence retires it.
129    pub absence_minutes: i64,
130    /// Locked-input re-checks per pass.
131    pub max_locked_checks: usize,
132    /// The Arcade SSE stream to drain first, when the wallet has one.
133    pub sse: Option<ArcadeSse>,
134}
135
136impl ReconcileOptions {
137    /// The served loop's options: apply, bounded probes, 24 h window, the
138    /// env knobs (`BROADCAST_ABSENCE_MINUTES`, `BROADCAST_RECONCILE_MAX_PROBES`,
139    /// `BROADCAST_RECONCILE_MAX_LOCKED_CHECKS`).
140    pub fn for_serve(sse: Option<ArcadeSse>) -> Self {
141        Self {
142            execute: true,
143            max_probes: env_parse("BROADCAST_RECONCILE_MAX_PROBES", DEFAULT_MAX_PROBES),
144            max_age_hours: Some(SERVE_MAX_AGE_HOURS),
145            absence_minutes: absence_minutes_from_env(),
146            max_locked_checks: env_parse(
147                "BROADCAST_RECONCILE_MAX_LOCKED_CHECKS",
148                DEFAULT_MAX_LOCKED_CHECKS,
149            ),
150            sse,
151        }
152    }
153
154    /// The command's options: every unproven transaction, `max_probes` of
155    /// them probed, applied only with `execute`.
156    pub fn for_command(execute: bool, max_probes: usize, sse: Option<ArcadeSse>) -> Self {
157        Self {
158            execute,
159            max_probes,
160            max_age_hours: None,
161            absence_minutes: absence_minutes_from_env(),
162            max_locked_checks: max_probes.max(DEFAULT_MAX_LOCKED_CHECKS),
163            sse,
164        }
165    }
166}
167
168/// `BROADCAST_ABSENCE_MINUTES`, default [`DEFAULT_ABSENCE_MINUTES`].
169pub fn absence_minutes_from_env() -> i64 {
170    env_parse("BROADCAST_ABSENCE_MINUTES", DEFAULT_ABSENCE_MINUTES)
171}
172
173fn env_parse<T: std::str::FromStr>(key: &str, default: T) -> T {
174    std::env::var(key)
175        .ok()
176        .and_then(|v| v.trim().parse::<T>().ok())
177        .unwrap_or(default)
178}
179
180/// The Arcade SSE location for a wallet at `db_path`, when it runs in Arcade
181/// mode (mirrors `services_env::arcade_runtime`).
182pub fn arcade_sse_for(db_path: &str) -> Option<ArcadeSse> {
183    crate::services_env::arcade_runtime(db_path)
184        .ok()
185        .flatten()
186        .map(|rt| ArcadeSse {
187            url: rt.url,
188            token: rt.callback_token,
189        })
190}
191
192/// An unproven (or stale `sending`) transaction of the wallet.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct Candidate {
195    /// Transaction id.
196    pub txid: String,
197    /// When the wallet created it.
198    pub created_at: DateTime<Utc>,
199}
200
201impl Candidate {
202    /// Minutes since creation (never negative).
203    pub fn age_minutes(&self, now: DateTime<Utc>) -> i64 {
204        (now - self.created_at).num_minutes().max(0)
205    }
206}
207
208/// What one pass found and did.
209#[derive(Debug, Default, Clone)]
210pub struct ReconcileBroadcastsReport {
211    /// SSE status events applied.
212    pub sse_events: u64,
213    /// SSE fatal verdicts (txids).
214    pub sse_fatal: Vec<String>,
215    /// Unproven (or stale `sending`) transactions in the window.
216    pub candidates: usize,
217    /// Candidates skipped for fresh evidence (see [`needs_probe`]).
218    pub fresh: usize,
219    /// Candidates probed this pass.
220    pub probed: usize,
221    /// Seen by the chain index (txids).
222    pub seen: Vec<String>,
223    /// Mined per the chain index (txids).
224    pub mined: Vec<String>,
225    /// Held or seen by a store, no chain-index answer (txids).
226    pub held: Vec<String>,
227    /// Nothing decisive (txids).
228    pub inconclusive: Vec<String>,
229    /// Absent from the chain index while the broadcaster holds it:
230    /// `(txid, age in minutes)`.
231    pub absent: Vec<(String, i64)>,
232    /// Fatal verdict from the broadcaster (txids).
233    pub fatal: Vec<String>,
234    /// The poison retirements run (or, on a dry run, simulated), in order.
235    pub retired: Vec<PoisonReport>,
236    /// The locked-input re-checks of this pass.
237    pub locked: LockedInputReport,
238}
239
240impl ReconcileBroadcastsReport {
241    /// Transactions the retirements touched (retirable statuses).
242    pub fn retired_txids(&self) -> Vec<String> {
243        self.retired
244            .iter()
245            .filter(|r| r.outcome == PoisonOutcome::Retired)
246            .flat_map(|r| r.retirable_txids())
247            .collect()
248    }
249
250    /// Whether the pass did or found anything worth an info line.
251    pub fn is_quiet(&self) -> bool {
252        self.probed == 0
253            && self.sse_events == 0
254            && self.retired.is_empty()
255            && self.locked.due == 0
256            && self.candidates == 0
257    }
258
259    /// One line for the log.
260    pub fn summary(&self, execute: bool) -> String {
261        let retired: Vec<&PoisonReport> = self
262            .retired
263            .iter()
264            .filter(|r| r.outcome == PoisonOutcome::Retired)
265            .collect();
266        let txs = self.retired_txids().len();
267        let restored: u32 = retired.iter().map(|r| r.restored).sum();
268        let restored_sats: i64 = retired.iter().map(|r| r.restored_sats).sum();
269        let kept: u32 = retired.iter().map(|r| r.kept).sum();
270        let invalidated: u32 = retired.iter().map(|r| r.invalidated).sum();
271        let invalidated_sats: i64 = retired.iter().map(|r| r.invalidated_sats).sum();
272        let internalized: usize = retired.iter().map(|r| r.internalized.len()).sum();
273        let climbed: usize = retired.iter().map(|r| r.climbed.len()).sum();
274        let alive = self
275            .retired
276            .iter()
277            .filter(|r| r.outcome == PoisonOutcome::Alive)
278            .count();
279        let refused = self
280            .retired
281            .iter()
282            .filter(|r| matches!(r.outcome, PoisonOutcome::Refused { .. }))
283            .count();
284        format!(
285            "reconcile-broadcasts{}: sse_events={} candidates={} fresh={} probed={} chain_seen={} chain_mined={} held={} inconclusive={} absent={} fatal={} retired_roots={} retired_txs={} climbed={} restored={} ({} sats) kept_locked={} invalidated={} ({} sats) internalized={} alive={} refused={} locked_due={} locked_restored={} ({} sats) locked_spent={} locked_unknown={}",
286            if execute { "" } else { " (dry run)" },
287            self.sse_events,
288            self.candidates,
289            self.fresh,
290            self.probed,
291            self.seen.len(),
292            self.mined.len(),
293            self.held.len(),
294            self.inconclusive.len(),
295            self.absent.len(),
296            self.fatal.len(),
297            retired.len(),
298            txs,
299            climbed,
300            restored,
301            restored_sats,
302            kept,
303            invalidated,
304            invalidated_sats,
305            internalized,
306            alive,
307            refused,
308            self.locked.due,
309            self.locked.restored,
310            self.locked.restored_sats,
311            self.locked.spent,
312            self.locked.unknown,
313        )
314    }
315}
316
317/// Whether a candidate of `age_minutes` needs a probe this pass, given its
318/// memory rows. Rule 1 of the module docs: past `absence_minutes` only fresh
319/// chain-index evidence (`chain|seen` within [`BROADCAST_SEEN_STALE_SECS`])
320/// or `chain|mined` exempts it; before that any fresh network-evidence row
321/// does. A `chain|mined` row exempts forever.
322pub fn needs_probe(
323    age_minutes: i64,
324    absence_minutes: i64,
325    records: &[BroadcastSeenRecord],
326    now: DateTime<Utc>,
327) -> bool {
328    let fresh =
329        |r: &BroadcastSeenRecord| (now - r.seen_at).num_seconds() <= BROADCAST_SEEN_STALE_SECS;
330    let chain_mined = records.iter().any(|r| {
331        r.provider == BROADCAST_PROVIDER_CHAIN && r.ladder_status() == Some(BroadcastStatus::Mined)
332    });
333    if chain_mined {
334        return false;
335    }
336    if age_minutes >= absence_minutes {
337        !records.iter().any(|r| {
338            r.provider == BROADCAST_PROVIDER_CHAIN
339                && r.ladder_status() == Some(BroadcastStatus::Seen)
340                && fresh(r)
341        })
342    } else {
343        !records
344            .iter()
345            .any(|r| r.ladder_status().is_some_and(|s| s.is_network_evidence()) && fresh(r))
346    }
347}
348
349/// One pass: SSE drain, probes, poison retirements, locked-input re-checks.
350/// See the module docs.
351pub async fn run_pass(
352    storage: &StorageSqlx,
353    services: &dyn WalletServices,
354    verifier: &BroadcastVerifier,
355    opts: &ReconcileOptions,
356) -> Result<ReconcileBroadcastsReport> {
357    let mut report = ReconcileBroadcastsReport::default();
358
359    // 1. Verdicts pushed by Arcade.
360    if let Some(sse) = &opts.sse {
361        let (events, fatal) = drain_sse(storage, sse).await;
362        report.sse_events = events;
363        report.sse_fatal = fatal;
364    }
365
366    // 2. Probes.
367    let candidates = select_candidates(storage, opts.max_age_hours).await?;
368    report.candidates = candidates.len();
369    let txids: Vec<String> = candidates.iter().map(|c| c.txid.clone()).collect();
370    let records = storage.broadcast_records(None, &txids).await?;
371    let now = Utc::now();
372    let mut to_probe: Vec<(String, i64)> = Vec::new();
373    let mut roots: Vec<(String, &'static str)> = Vec::new();
374    for candidate in &candidates {
375        let mine: Vec<BroadcastSeenRecord> = records
376            .iter()
377            .filter(|r| r.txid == candidate.txid)
378            .cloned()
379            .collect();
380        if mine
381            .iter()
382            .any(|r| r.ladder_status() == Some(BroadcastStatus::Rejected))
383        {
384            roots.push((candidate.txid.clone(), "rejected memory row"));
385            continue;
386        }
387        let age = candidate.age_minutes(now);
388        if !needs_probe(age, opts.absence_minutes, &mine, now) {
389            report.fresh += 1;
390            continue;
391        }
392        to_probe.push((candidate.txid.clone(), age));
393    }
394    for (index, (txid, age)) in to_probe.iter().take(opts.max_probes).enumerate() {
395        if index > 0 {
396            tokio::time::sleep(PROBE_PACE).await;
397        }
398        report.probed += 1;
399        let presence = verifier.verify_report(txid).await;
400        // The broadcaster's (or a peer node's) word is that provider's
401        // evidence: good for its reduced sends, recorded under its name.
402        if let Some(evidence) = presence.evidence {
403            if !matches!(presence.chain_index, ChainIndexAnswer::Present(_)) {
404                credit_provider(storage, txid, evidence, presence.evidence_provider).await;
405            }
406        }
407        match classify(&presence) {
408            Probe::Chain(evidence) => {
409                credit_chain(storage, txid, evidence).await;
410                match evidence {
411                    NetworkEvidence::Seen => report.seen.push(txid.clone()),
412                    NetworkEvidence::Mined => report.mined.push(txid.clone()),
413                }
414            }
415            Probe::Fatal => {
416                report.fatal.push(txid.clone());
417                roots.push((txid.clone(), "fatal verdict from the broadcaster"));
418            }
419            Probe::Absent => {
420                note_absence(storage, txid, *age, opts.absence_minutes).await;
421                report.absent.push((txid.clone(), *age));
422                if *age >= opts.absence_minutes {
423                    roots.push((
424                        txid.clone(),
425                        "absent from the chain index past the threshold",
426                    ));
427                }
428            }
429            Probe::Held => report.held.push(txid.clone()),
430            Probe::Inconclusive => report.inconclusive.push(txid.clone()),
431        }
432    }
433
434    // 3. Poisoned chains: this pass's verdicts plus whatever earlier
435    // verdicts (SSE, webhook, cleanup) left half done.
436    for root in sweep_roots(storage).await? {
437        if !roots.iter().any(|(t, _)| t == &root) {
438            roots.push((root, "failed transaction with unproven descendants"));
439        }
440    }
441    let mut covered: HashSet<String> = HashSet::new();
442    for (root, reason) in roots {
443        if covered.contains(&root) {
444            continue;
445        }
446        let poison = storage
447            .retire_poisoned_chain_from(
448                services,
449                &root,
450                "invalid",
451                opts.execute,
452                opts.absence_minutes,
453            )
454            .await?;
455        log_poison(&poison, reason, opts.execute);
456        covered.insert(poison.origin.clone());
457        covered.extend(poison.climbed.iter().cloned());
458        for tx in &poison.chain {
459            covered.insert(tx.txid.clone());
460        }
461        report.retired.push(poison);
462    }
463
464    // 4. Kept-locked inputs.
465    report.locked = storage
466        .recheck_locked_inputs(services, opts.max_locked_checks, opts.execute)
467        .await?;
468
469    Ok(report)
470}
471
472/// The poisoned-chain sweep and the locked-input re-checks alone (no SSE,
473/// no probes): the daemon's ticker and `cleanup-abandoned` call it after
474/// their own verdicts.
475pub struct SweepReport {
476    /// The poison retirements run.
477    pub poison: Vec<PoisonReport>,
478    /// The locked-input re-checks.
479    pub locked: LockedInputReport,
480}
481
482/// See [`SweepReport`].
483pub async fn run_sweep(
484    storage: &StorageSqlx,
485    services: &dyn WalletServices,
486    execute: bool,
487) -> Result<SweepReport> {
488    let mut poison = Vec::new();
489    let mut covered: HashSet<String> = HashSet::new();
490    let mut roots = sweep_roots(storage).await?;
491    roots.extend(rejected_roots(storage).await?);
492    for root in roots {
493        if covered.contains(&root) {
494            continue;
495        }
496        let report = storage
497            .retire_poisoned_chain_from(
498                services,
499                &root,
500                "invalid",
501                execute,
502                absence_minutes_from_env(),
503            )
504            .await?;
505        log_poison(&report, "sweep", execute);
506        covered.insert(report.origin.clone());
507        covered.extend(report.climbed.iter().cloned());
508        for tx in &report.chain {
509            covered.insert(tx.txid.clone());
510        }
511        poison.push(report);
512    }
513    let locked = storage
514        .recheck_locked_inputs(services, DEFAULT_MAX_LOCKED_CHECKS, execute)
515        .await?;
516    Ok(SweepReport { poison, locked })
517}
518
519/// Spawn the served loop: one pass every `BROADCAST_RECONCILE_INTERVAL_SECS`
520/// (default 60), applied, bounded, one summary line per pass. Disabled by
521/// `BROADCAST_RECONCILE=0`.
522pub fn spawn_serve_loop(
523    wallet: std::sync::Arc<ServedWallet>,
524    chain: Chain,
525    db_path: &str,
526) -> Option<tokio::task::JoinHandle<()>> {
527    let enabled = std::env::var("BROADCAST_RECONCILE")
528        .map(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
529        .unwrap_or(true);
530    if !enabled {
531        tracing::info!("broadcast reconcile loop disabled (BROADCAST_RECONCILE=0)");
532        return None;
533    }
534    let opts = ReconcileOptions::for_serve(arcade_sse_for(db_path));
535    let interval_secs =
536        env_parse("BROADCAST_RECONCILE_INTERVAL_SECS", DEFAULT_INTERVAL_SECS).max(5);
537    let verifier = BroadcastVerifier::single_pass(chain);
538    tracing::info!(
539        interval_secs,
540        max_probes = opts.max_probes,
541        absence_minutes = opts.absence_minutes,
542        max_locked_checks = opts.max_locked_checks,
543        sse = opts.sse.is_some(),
544        "broadcast reconcile loop started"
545    );
546    Some(tokio::spawn(async move {
547        let mut interval = tokio::time::interval(Duration::from_secs(interval_secs));
548        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
549        // The first tick fires at once; let the server settle first.
550        interval.tick().await;
551        loop {
552            interval.tick().await;
553            match run_pass(wallet.storage(), wallet.services(), &verifier, &opts).await {
554                Ok(report) => {
555                    if report.is_quiet() {
556                        tracing::debug!("{}", report.summary(true));
557                    } else {
558                        tracing::info!("{}", report.summary(true));
559                    }
560                }
561                Err(e) => tracing::warn!(error = %e, "broadcast reconcile pass failed"),
562            }
563        }
564    }))
565}
566
567/// What a probe report means to the reconciler.
568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569enum Probe {
570    /// The chain index holds it.
571    Chain(NetworkEvidence),
572    /// The broadcaster reports a fatal verdict.
573    Fatal,
574    /// The chain index answered 404 while the broadcaster holds or has seen
575    /// it, and no peer node vouches for it.
576    Absent,
577    /// A store holds or has seen it and the chain index gave no answer.
578    Held,
579    /// Nothing decisive.
580    Inconclusive,
581}
582
583fn classify(report: &PresenceReport) -> Probe {
584    if let ChainIndexAnswer::Present(evidence) = report.chain_index {
585        return Probe::Chain(evidence);
586    }
587    if report.broadcaster_fatal {
588        return Probe::Fatal;
589    }
590    if report.network_absent {
591        return Probe::Absent;
592    }
593    match report.verification {
594        BroadcastVerification::Confirmed => Probe::Held,
595        _ => Probe::Inconclusive,
596    }
597}
598
599fn log_poison(poison: &PoisonReport, reason: &str, execute: bool) {
600    match &poison.outcome {
601        PoisonOutcome::Retired => {
602            tracing::warn!(
603                root = %poison.root,
604                origin = %poison.origin,
605                climbed = poison.climbed.len(),
606                reason,
607                txs = poison.chain.len(),
608                restored = poison.restored,
609                kept_locked = poison.kept,
610                executed = poison.executed,
611                "reconcile-broadcasts: poisoned chain {}",
612                if execute { "retired" } else { "would be retired" }
613            );
614        }
615        PoisonOutcome::Alive => {
616            tracing::info!(root = %poison.root, origin = %poison.origin, reason, "reconcile-broadcasts: root is alive per the status service, kept");
617        }
618        PoisonOutcome::Refused { proven_txid } => {
619            tracing::error!(root = %poison.root, proven = %proven_txid, reason, "reconcile-broadcasts: retire refused, a descendant is proven");
620        }
621        PoisonOutcome::NotFound => {}
622    }
623}
624
625/// Record provider-level evidence for `txid` (a broadcaster's or a peer
626/// node's word): the ladder row under that provider, and the same status
627/// lift a push `SEEN_ON_NETWORK` performs.
628async fn credit_provider(
629    storage: &StorageSqlx,
630    txid: &str,
631    evidence: NetworkEvidence,
632    provider: &'static str,
633) {
634    if let Err(e) = storage
635        .mark_transaction_seen_on_network_by(txid, provider)
636        .await
637    {
638        tracing::warn!(txid = %txid, error = %e, "could not record the provider's evidence");
639    }
640    if evidence == NetworkEvidence::Mined {
641        if let Err(e) = storage
642            .record_broadcast_status(txid, provider, BROADCAST_STATUS_MINED)
643            .await
644        {
645            tracing::warn!(txid = %txid, error = %e, "could not record the provider's mined verdict");
646        }
647    }
648    tracing::debug!(txid = %txid, ?evidence, provider, "provider evidence recorded");
649}
650
651/// Record chain evidence for `txid` and `chain|seen` for its unproven
652/// parents (presence of the child on the chain index implies the parents
653/// connected).
654async fn credit_chain(storage: &StorageSqlx, txid: &str, evidence: NetworkEvidence) {
655    if let Err(e) = storage
656        .mark_transaction_seen_on_network_by(txid, BROADCAST_PROVIDER_CHAIN)
657        .await
658    {
659        tracing::warn!(txid = %txid, error = %e, "could not record the chain presence");
660    }
661    if evidence == NetworkEvidence::Mined {
662        if let Err(e) = storage
663            .record_broadcast_status(txid, BROADCAST_PROVIDER_CHAIN, BROADCAST_STATUS_MINED)
664            .await
665        {
666            tracing::warn!(txid = %txid, error = %e, "could not record the mined evidence");
667        }
668    }
669    match unproven_parents(storage, txid).await {
670        Ok(parents) if !parents.is_empty() => {
671            if let Err(e) = storage
672                .sqlx_broadcast_memory()
673                .record_broadcast_status_many(
674                    BROADCAST_PROVIDER_CHAIN,
675                    BROADCAST_STATUS_SEEN,
676                    &parents,
677                )
678                .await
679            {
680                tracing::warn!(txid = %txid, error = %e, "could not credit the parents");
681            }
682            tracing::info!(
683                txid = %txid,
684                ?evidence,
685                parents = parents.len(),
686                "chain index has it: the transaction and its unproven parents connected"
687            );
688        }
689        Ok(_) => {
690            tracing::info!(txid = %txid, ?evidence, "chain index has it");
691        }
692        Err(e) => tracing::warn!(txid = %txid, error = %e, "could not list the parents"),
693    }
694}
695
696/// Note a chain-index absence: the `network|unknown` row (its `seen_at` is
697/// the first observed absence, for diagnostics) and a log line with the
698/// transaction's age against the threshold.
699async fn note_absence(storage: &StorageSqlx, txid: &str, age_minutes: i64, threshold: i64) {
700    if let Err(e) = storage
701        .record_broadcast_status(txid, BROADCAST_PROVIDER_NETWORK, BROADCAST_STATUS_UNKNOWN)
702        .await
703    {
704        tracing::warn!(txid = %txid, error = %e, "could not record the absence");
705    }
706    if age_minutes >= threshold {
707        tracing::warn!(
708            txid = %txid,
709            age_minutes,
710            threshold,
711            "absent from the chain index past the threshold while the broadcaster holds it: a phantom"
712        );
713    } else {
714        tracing::info!(
715            txid = %txid,
716            age_minutes,
717            threshold,
718            "absent from the chain index (the broadcaster holds it); retired once older than the threshold"
719        );
720    }
721}
722
723/// Parse a timestamp the way the wallet database writes them: RFC 3339
724/// (the toolbox binds `DateTime<Utc>`) or SQLite's `CURRENT_TIMESTAMP`.
725fn parse_db_timestamp(text: &str) -> DateTime<Utc> {
726    let text = text.trim();
727    if let Ok(dt) = DateTime::parse_from_rfc3339(text) {
728        return dt.with_timezone(&Utc);
729    }
730    for format in [
731        "%Y-%m-%d %H:%M:%S%.f",
732        "%Y-%m-%d %H:%M:%S",
733        "%Y-%m-%dT%H:%M:%S%.f",
734    ] {
735        if let Ok(naive) = NaiveDateTime::parse_from_str(text, format) {
736            return Utc.from_utc_datetime(&naive);
737        }
738    }
739    Utc.timestamp_opt(0, 0).single().unwrap_or_default()
740}
741
742/// `unproven` transactions (and `sending` ones older than ten minutes),
743/// oldest first, optionally limited to the last `max_age_hours`.
744async fn select_candidates(
745    storage: &StorageSqlx,
746    max_age_hours: Option<i64>,
747) -> Result<Vec<Candidate>> {
748    let modifier = max_age_hours.map(|h| format!("-{} hours", h.max(0)));
749    let rows = sqlx::query(
750        "SELECT txid, CAST(created_at AS TEXT) AS created_at FROM transactions \
751         WHERE txid IS NOT NULL \
752           AND (status = 'unproven' \
753                OR (status = 'sending' AND datetime(created_at) <= datetime('now', '-600 seconds'))) \
754           AND (? IS NULL OR datetime(created_at) >= datetime('now', ?)) \
755         ORDER BY datetime(created_at) ASC, transaction_id ASC",
756    )
757    .bind(&modifier)
758    .bind(&modifier)
759    .fetch_all(storage.pool())
760    .await?;
761    let mut seen = HashSet::new();
762    Ok(rows
763        .iter()
764        .filter_map(|r| {
765            let txid: String = r.get("txid");
766            let created_at: String = r.get("created_at");
767            seen.insert(txid.clone()).then(|| Candidate {
768                txid,
769                created_at: parse_db_timestamp(&created_at),
770            })
771        })
772        .collect())
773}
774
775/// Unproven parents of `txid` in this wallet: the transactions whose
776/// outputs it spends.
777async fn unproven_parents(storage: &StorageSqlx, txid: &str) -> Result<Vec<String>> {
778    let rows = sqlx::query(
779        "SELECT DISTINCT t.txid FROM outputs o \
780         JOIN transactions t ON o.transaction_id = t.transaction_id \
781         WHERE o.spent_by = (SELECT transaction_id FROM transactions WHERE txid = ? LIMIT 1) \
782           AND t.status IN ('unproven', 'sending') AND t.txid IS NOT NULL",
783    )
784    .bind(txid)
785    .fetch_all(storage.pool())
786    .await?;
787    Ok(rows.iter().map(|r| r.get::<String, _>("txid")).collect())
788}
789
790/// Roots of poisoned chains an earlier verdict left half done: `failed`
791/// transactions with unproven spenders of their outputs.
792async fn sweep_roots(storage: &StorageSqlx) -> Result<Vec<String>> {
793    let rows = sqlx::query(
794        "SELECT DISTINCT p.txid FROM transactions p \
795         JOIN outputs o ON o.transaction_id = p.transaction_id \
796         JOIN transactions c ON c.transaction_id = o.spent_by \
797         WHERE p.status = 'failed' AND p.txid IS NOT NULL \
798           AND c.status IN ('unproven', 'sending', 'nosend') \
799         ORDER BY p.transaction_id ASC",
800    )
801    .fetch_all(storage.pool())
802    .await?;
803    Ok(rows.iter().map(|r| r.get::<String, _>("txid")).collect())
804}
805
806/// Unproven transactions the memory holds a `rejected` row for.
807async fn rejected_roots(storage: &StorageSqlx) -> Result<Vec<String>> {
808    let rows = sqlx::query(
809        "SELECT DISTINCT t.txid FROM transactions t \
810         JOIN broadcast_seen b ON b.txid = t.txid \
811         WHERE t.status IN ('unproven', 'sending') AND b.status = 'rejected' \
812         ORDER BY t.transaction_id ASC",
813    )
814    .fetch_all(storage.pool())
815    .await?;
816    Ok(rows.iter().map(|r| r.get::<String, _>("txid")).collect())
817}
818
819/// Drain the wallet's Arcade SSE stream once: connect, apply every status
820/// frame the replay delivers, stop when the stream idles or the budget is
821/// spent. Returns `(events applied, fatal txids)`.
822async fn drain_sse(storage: &StorageSqlx, sse: &ArcadeSse) -> (u64, Vec<String>) {
823    let mut client = match ArcadeSseClient::new(&sse.url, &sse.token) {
824        Ok(c) => c,
825        Err(e) => {
826            tracing::warn!(error = %e, "arcade SSE client could not be built");
827            return (0, Vec::new());
828        }
829    };
830    let (tx, mut rx) = tokio::sync::mpsc::channel::<ArcadeStatusEvent>(256);
831    let stream = tokio::spawn(async move { client.stream_once(tx).await });
832    let deadline = tokio::time::Instant::now() + SSE_BUDGET;
833    let trigger = AtomicBool::new(false);
834    let mut applied = 0u64;
835    let mut fatal = Vec::new();
836    loop {
837        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
838        if remaining.is_zero() {
839            break;
840        }
841        match tokio::time::timeout(SSE_IDLE.min(remaining), rx.recv()).await {
842            Ok(Some(ev)) => {
843                match ArcadeEventsTask::<StorageSqlx>::apply_event(storage, &ev, &trigger).await {
844                    Ok(updated) => {
845                        applied += 1;
846                        tracing::debug!(txid = %ev.txid, status = %ev.tx_status, updated, "arcade SSE status");
847                    }
848                    Err(e) => {
849                        tracing::warn!(txid = %ev.txid, status = %ev.tx_status, error = %e, "arcade SSE status not applied");
850                    }
851                }
852                if ev.tx_status == statuses::MINED {
853                    if let Err(e) = storage
854                        .record_broadcast_status(
855                            &ev.txid,
856                            PROVIDER_ARCADE_V2,
857                            BROADCAST_STATUS_MINED,
858                        )
859                        .await
860                    {
861                        tracing::warn!(txid = %ev.txid, error = %e, "could not record the mined verdict");
862                    }
863                }
864                if bsv_wallet_toolbox::is_fatal_status(&ev.tx_status) {
865                    fatal.push(ev.txid.clone());
866                }
867            }
868            Ok(None) => break,
869            Err(_) => break,
870        }
871    }
872    stream.abort();
873    (applied, fatal)
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879    use axum::http::StatusCode;
880    use axum::routing::get;
881    use axum::Router;
882    use bsv_wallet_toolbox::services::mock::MockWalletServices;
883    use bsv_wallet_toolbox::WalletStorageWriter;
884    use std::net::SocketAddr;
885
886    fn rec(txid: &str, provider: &str, status: &str, age_secs: i64) -> BroadcastSeenRecord {
887        BroadcastSeenRecord {
888            txid: txid.to_string(),
889            provider: provider.to_string(),
890            status: status.to_string(),
891            seen_at: Utc::now() - chrono::Duration::seconds(age_secs),
892        }
893    }
894
895    #[test]
896    fn a_probe_report_classifies_in_evidence_order() {
897        let mut r = PresenceReport::from_verification(BroadcastVerification::Confirmed);
898        assert_eq!(classify(&r), Probe::Held);
899        r.evidence = Some(NetworkEvidence::Seen);
900        r.evidence_provider = PROVIDER_ARCADE_V2;
901        assert_eq!(
902            classify(&r),
903            Probe::Held,
904            "a broadcaster's seen without a chain answer is held"
905        );
906        r.network_absent = true;
907        r.chain_index = ChainIndexAnswer::Absent;
908        assert_eq!(
909            classify(&r),
910            Probe::Absent,
911            "seen by the broadcaster, absent from the chain index"
912        );
913        r.broadcaster_fatal = true;
914        assert_eq!(classify(&r), Probe::Fatal);
915        r.chain_index = ChainIndexAnswer::Present(NetworkEvidence::Seen);
916        assert_eq!(classify(&r), Probe::Chain(NetworkEvidence::Seen));
917        let i = PresenceReport::from_verification(BroadcastVerification::Inconclusive);
918        assert_eq!(classify(&i), Probe::Inconclusive);
919        let mut rejected = PresenceReport::from_verification(BroadcastVerification::Rejected);
920        rejected.network_absent = true;
921        assert_eq!(classify(&rejected), Probe::Absent);
922    }
923
924    #[test]
925    fn only_chain_evidence_exempts_an_old_transaction_from_a_probe() {
926        let t = "aa".repeat(32);
927        let now = Utc::now();
928        let arcade_seen = vec![rec(&t, PROVIDER_ARCADE_V2, BROADCAST_STATUS_SEEN, 5)];
929        let chain_seen = vec![rec(&t, BROADCAST_PROVIDER_CHAIN, BROADCAST_STATUS_SEEN, 5)];
930        let chain_stale = vec![rec(
931            &t,
932            BROADCAST_PROVIDER_CHAIN,
933            BROADCAST_STATUS_SEEN,
934            1_000,
935        )];
936        let chain_mined = vec![rec(
937            &t,
938            BROADCAST_PROVIDER_CHAIN,
939            BROADCAST_STATUS_MINED,
940            90_000,
941        )];
942        let network_seen = vec![rec(
943            &t,
944            BROADCAST_PROVIDER_NETWORK,
945            BROADCAST_STATUS_SEEN,
946            5,
947        )];
948        // Old (past the threshold): the broadcaster's word does not count.
949        assert!(needs_probe(31, 30, &arcade_seen, now));
950        assert!(needs_probe(31, 30, &network_seen, now));
951        assert!(!needs_probe(31, 30, &chain_seen, now));
952        assert!(needs_probe(31, 30, &chain_stale, now));
953        assert!(
954            !needs_probe(31, 30, &chain_mined, now),
955            "mined exempts forever"
956        );
957        assert!(needs_probe(31, 30, &[], now));
958        // Young: any fresh network evidence is provisionally trusted.
959        assert!(!needs_probe(5, 30, &arcade_seen, now));
960        assert!(!needs_probe(5, 30, &network_seen, now));
961        assert!(needs_probe(5, 30, &[], now));
962        assert!(needs_probe(5, 30, &chain_stale, now));
963    }
964
965    #[tokio::test]
966    async fn a_broadcaster_status_refreshed_every_pass_never_becomes_chain_evidence() {
967        // The serve loop drains Arcade's SSE stream every minute, and every
968        // SEEN frame refreshes the broadcaster's row (`seen_at` = now). For
969        // a transaction past the absence threshold that refresh must never
970        // exempt it from the chain-index probe, however many passes repeat
971        // it (2026-09-03, fleet w2).
972        let (storage, _user_id, _basket) = wallet_storage().await;
973        let t = "ab".repeat(32);
974        for pass in 0..5 {
975            storage
976                .mark_transaction_seen_on_network_by(&t, PROVIDER_ARCADE_V2)
977                .await
978                .unwrap();
979            storage.mark_transaction_seen_on_network(&t).await.unwrap();
980            let records = storage
981                .broadcast_records(None, std::slice::from_ref(&t))
982                .await
983                .unwrap();
984            assert_eq!(records.len(), 2, "arcade and network rows, pass {}", pass);
985            assert!(
986                records
987                    .iter()
988                    .all(|r| (Utc::now() - r.seen_at).num_seconds() < 5),
989                "every row refreshed to now on pass {}",
990                pass
991            );
992            assert!(
993                needs_probe(31, 30, &records, Utc::now()),
994                "past the threshold the refreshed rows still do not exempt it (pass {})",
995                pass
996            );
997            assert!(
998                !needs_probe(5, 30, &records, Utc::now()),
999                "a young transaction is provisionally trusted on them"
1000            );
1001        }
1002        // Only the chain index's own row exempts it.
1003        credit_chain(&storage, &t, NetworkEvidence::Seen).await;
1004        let records = storage
1005            .broadcast_records(None, std::slice::from_ref(&t))
1006            .await
1007            .unwrap();
1008        assert!(!needs_probe(31, 30, &records, Utc::now()));
1009    }
1010
1011    #[test]
1012    fn options_read_the_env_knobs_with_defaults() {
1013        let serve = ReconcileOptions::for_serve(None);
1014        assert!(serve.execute);
1015        assert_eq!(serve.max_age_hours, Some(SERVE_MAX_AGE_HOURS));
1016        let command = ReconcileOptions::for_command(false, 7, None);
1017        assert!(!command.execute);
1018        assert_eq!(command.max_probes, 7);
1019        assert_eq!(command.max_age_hours, None);
1020        assert!(command.absence_minutes >= 1);
1021        assert!(command.max_locked_checks >= 7);
1022    }
1023
1024    #[test]
1025    fn the_summary_is_one_line() {
1026        let report = ReconcileBroadcastsReport::default();
1027        let line = report.summary(false);
1028        assert!(line.starts_with("reconcile-broadcasts (dry run):"));
1029        assert!(!line.contains('\n'));
1030        assert!(report.retired_txids().is_empty());
1031        assert!(report.is_quiet());
1032    }
1033
1034    #[test]
1035    fn db_timestamps_parse_in_both_forms() {
1036        let iso = parse_db_timestamp("2026-09-02T23:12:00.123456+00:00");
1037        let sqlite = parse_db_timestamp("2026-09-02 23:12:00");
1038        assert_eq!(iso.timestamp(), sqlite.timestamp());
1039        assert_eq!(parse_db_timestamp("nonsense").timestamp(), 0);
1040    }
1041
1042    /// A migrated in-memory wallet with one user and its default basket.
1043    async fn wallet_storage() -> (StorageSqlx, i64, i64) {
1044        let storage = StorageSqlx::in_memory().await.unwrap();
1045        storage
1046            .migrate("reconcile-tests", &("02".to_string() + &"ab".repeat(32)))
1047            .await
1048            .unwrap();
1049        storage.make_available().await.unwrap();
1050        let (user, _) = storage
1051            .find_or_insert_user(&("02".to_string() + &"cd".repeat(32)))
1052            .await
1053            .unwrap();
1054        let basket = storage
1055            .find_or_create_default_basket(user.user_id)
1056            .await
1057            .unwrap()
1058            .basket_id;
1059        (storage, user.user_id, basket)
1060    }
1061
1062    async fn insert_tx(
1063        storage: &StorageSqlx,
1064        user_id: i64,
1065        txid: &str,
1066        status: &str,
1067        created_at: &str,
1068    ) -> i64 {
1069        sqlx::query(
1070            "INSERT INTO transactions (user_id, status, reference, is_outgoing, satoshis, version, lock_time, description, txid, raw_tx, created_at, updated_at) \
1071             VALUES (?, ?, ?, 1, 0, 1, 0, 'd', ?, X'01000000', ?, ?)",
1072        )
1073        .bind(user_id)
1074        .bind(status)
1075        .bind(&txid[..6])
1076        .bind(txid)
1077        .bind(created_at)
1078        .bind(Utc::now())
1079        .execute(storage.pool())
1080        .await
1081        .unwrap()
1082        .last_insert_rowid()
1083    }
1084
1085    async fn insert_output(
1086        storage: &StorageSqlx,
1087        user_id: i64,
1088        basket: i64,
1089        tx_row: i64,
1090        txid: &str,
1091        spendable: bool,
1092        spent_by: Option<i64>,
1093    ) -> i64 {
1094        let lock = hex::decode("76a914dbc0a7c84983c5bf199b7b2d41b3acf0408ee5aa88ac").unwrap();
1095        let now = Utc::now();
1096        sqlx::query(
1097            "INSERT INTO outputs (user_id, transaction_id, basket_id, vout, satoshis, locking_script, txid, type, spendable, change, spent_by, provided_by, purpose, output_description, created_at, updated_at) \
1098             VALUES (?, ?, ?, 0, 1000, ?, ?, 'P2PKH', ?, 1, ?, 'storage', 'change', 'c', ?, ?)",
1099        )
1100        .bind(user_id)
1101        .bind(tx_row)
1102        .bind(basket)
1103        .bind(&lock)
1104        .bind(txid)
1105        .bind(spendable as i64)
1106        .bind(spent_by)
1107        .bind(now)
1108        .bind(now)
1109        .execute(storage.pool())
1110        .await
1111        .unwrap()
1112        .last_insert_rowid()
1113    }
1114
1115    async fn storage_with_chain() -> StorageSqlx {
1116        let (storage, user_id, basket) = wallet_storage().await;
1117        let now = Utc::now();
1118        let mut ids = Vec::new();
1119        for (txid, status, created) in [
1120            (
1121                "aa".repeat(32),
1122                "failed",
1123                "2020-01-01T00:00:00+00:00".to_string(),
1124            ),
1125            (
1126                "bb".repeat(32),
1127                "unproven",
1128                "2020-01-02T00:00:00+00:00".to_string(),
1129            ),
1130            (
1131                "cc".repeat(32),
1132                "sending",
1133                "2020-01-03 00:00:00".to_string(),
1134            ),
1135            ("dd".repeat(32), "unproven", now.to_rfc3339()),
1136        ] {
1137            ids.push(insert_tx(&storage, user_id, &txid, status, &created).await);
1138        }
1139        // aa (failed) -> bb (unproven) -> dd (unproven); cc alone.
1140        for (tx_row, txid, spent_by) in [
1141            (ids[0], "aa".repeat(32), Some(ids[1])),
1142            (ids[1], "bb".repeat(32), Some(ids[3])),
1143            (ids[3], "dd".repeat(32), None),
1144        ] {
1145            insert_output(
1146                &storage,
1147                user_id,
1148                basket,
1149                tx_row,
1150                &txid,
1151                spent_by.is_none(),
1152                spent_by,
1153            )
1154            .await;
1155        }
1156        storage
1157    }
1158
1159    #[tokio::test]
1160    async fn candidates_parents_and_sweep_roots_come_from_the_wallet_graph() {
1161        let storage = storage_with_chain().await;
1162        let all = select_candidates(&storage, None).await.unwrap();
1163        let txids: Vec<String> = all.iter().map(|c| c.txid.clone()).collect();
1164        assert_eq!(
1165            txids,
1166            vec!["bb".repeat(32), "cc".repeat(32), "dd".repeat(32)],
1167            "unproven and stale sending, oldest first"
1168        );
1169        assert!(all[0].age_minutes(Utc::now()) > 60 * 24 * 365);
1170        assert_eq!(all[2].age_minutes(Utc::now()), 0);
1171        let recent = select_candidates(&storage, Some(24)).await.unwrap();
1172        assert_eq!(recent.len(), 1);
1173        assert_eq!(recent[0].txid, "dd".repeat(32));
1174
1175        let parents = unproven_parents(&storage, &"dd".repeat(32)).await.unwrap();
1176        assert_eq!(parents, vec!["bb".repeat(32)]);
1177        assert!(
1178            unproven_parents(&storage, &"bb".repeat(32))
1179                .await
1180                .unwrap()
1181                .is_empty(),
1182            "a failed parent is not credited"
1183        );
1184
1185        let roots = sweep_roots(&storage).await.unwrap();
1186        assert_eq!(
1187            roots,
1188            vec!["aa".repeat(32)],
1189            "failed with unproven children"
1190        );
1191
1192        storage
1193            .record_broadcast_status(&"cc".repeat(32), "ArcadeV2", "rejected")
1194            .await
1195            .unwrap();
1196        assert_eq!(
1197            rejected_roots(&storage).await.unwrap(),
1198            vec!["cc".repeat(32)]
1199        );
1200    }
1201
1202    #[tokio::test]
1203    async fn an_absence_note_keeps_its_first_observation() {
1204        let storage = storage_with_chain().await;
1205        let txid = "bb".repeat(32);
1206        note_absence(&storage, &txid, 5, 30).await;
1207        let first = storage
1208            .broadcast_status_of(&txid, BROADCAST_PROVIDER_NETWORK)
1209            .await
1210            .unwrap()
1211            .expect("row")
1212            .seen_at;
1213        sqlx::query(
1214            "UPDATE broadcast_seen SET seen_at = datetime('now', '-31 minutes') WHERE txid = ?",
1215        )
1216        .bind(&txid)
1217        .execute(storage.pool())
1218        .await
1219        .unwrap();
1220        note_absence(&storage, &txid, 40, 30).await;
1221        let again = storage
1222            .broadcast_status_of(&txid, BROADCAST_PROVIDER_NETWORK)
1223            .await
1224            .unwrap()
1225            .expect("row");
1226        assert_eq!(again.status, BROADCAST_STATUS_UNKNOWN);
1227        assert!(
1228            again.seen_at < first,
1229            "the backdated first observation stands"
1230        );
1231    }
1232
1233    /// A local mock answering every status path with `code` and `body`.
1234    async fn mock_server(code: StatusCode, body: &'static str) -> String {
1235        let handler = move || async move {
1236            let mut resp = axum::response::Response::new(axum::body::Body::from(body));
1237            *resp.status_mut() = code;
1238            resp.headers_mut().insert(
1239                reqwest::header::CONTENT_TYPE.as_str(),
1240                "application/json".parse().unwrap(),
1241            );
1242            resp
1243        };
1244        let app = Router::new()
1245            .route("/tx/{txid}", get(handler))
1246            .route("/v1/tx/{txid}", get(handler))
1247            .route("/tx/hash/{txid}", get(handler));
1248        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1249        let addr: SocketAddr = listener.local_addr().unwrap();
1250        tokio::spawn(async move {
1251            axum::serve(listener, app).await.ok();
1252        });
1253        format!("http://{}", addr)
1254    }
1255
1256    async fn tx_status(storage: &StorageSqlx, txid: &str) -> String {
1257        sqlx::query_scalar("SELECT status FROM transactions WHERE txid = ?")
1258            .bind(txid)
1259            .fetch_one(storage.pool())
1260            .await
1261            .unwrap()
1262    }
1263
1264    async fn output_state(storage: &StorageSqlx, id: i64) -> (i64, Option<i64>) {
1265        sqlx::query_as("SELECT spendable, spent_by FROM outputs WHERE output_id = ?")
1266            .bind(id)
1267            .fetch_one(storage.pool())
1268            .await
1269            .unwrap()
1270    }
1271
1272    /// THE live shape (2026-09-02, w0): the broadcaster still says
1273    /// SEEN_MULTIPLE_NODES, the chain index says 404. G (on chain) -> X
1274    /// (unproven, 31 min old) -> C (unproven, 31 min old); Y (unproven, just
1275    /// created) on its own. X and C are phantoms and retire, G's coin comes
1276    /// back, Y is too young to judge.
1277    #[tokio::test]
1278    async fn a_seen_but_absent_phantom_is_retired_after_the_threshold() {
1279        let (storage, user_id, basket) = wallet_storage().await;
1280        let old = (Utc::now() - chrono::Duration::minutes(31)).to_rfc3339();
1281        let g = insert_tx(&storage, user_id, &"11".repeat(32), "completed", &old).await;
1282        let x = insert_tx(&storage, user_id, &"22".repeat(32), "unproven", &old).await;
1283        let c = insert_tx(&storage, user_id, &"33".repeat(32), "unproven", &old).await;
1284        let _y = insert_tx(
1285            &storage,
1286            user_id,
1287            &"44".repeat(32),
1288            "unproven",
1289            &Utc::now().to_rfc3339(),
1290        )
1291        .await;
1292        let g0 = insert_output(
1293            &storage,
1294            user_id,
1295            basket,
1296            g,
1297            &"11".repeat(32),
1298            false,
1299            Some(x),
1300        )
1301        .await;
1302        let x0 = insert_output(
1303            &storage,
1304            user_id,
1305            basket,
1306            x,
1307            &"22".repeat(32),
1308            false,
1309            Some(c),
1310        )
1311        .await;
1312        let c0 = insert_output(&storage, user_id, basket, c, &"33".repeat(32), true, None).await;
1313        // The SSE drain of an earlier pass left the broadcaster's word.
1314        storage
1315            .record_broadcast_status(&"22".repeat(32), PROVIDER_ARCADE_V2, BROADCAST_STATUS_SEEN)
1316            .await
1317            .unwrap();
1318
1319        let broadcaster = mock_server(
1320            StatusCode::OK,
1321            r#"{"txid":"x","txStatus":"SEEN_MULTIPLE_NODES"}"#,
1322        )
1323        .await;
1324        let chain = mock_server(
1325            StatusCode::NOT_FOUND,
1326            r#"{"error":"transaction not found"}"#,
1327        )
1328        .await;
1329        let verifier = BroadcastVerifier::explicit(true, &broadcaster, Some(&chain));
1330        // The status service knows nothing (not alive); the UTXO lookup
1331        // vouches for G's coin.
1332        let services = MockWalletServices::new();
1333        let opts = ReconcileOptions {
1334            execute: true,
1335            max_probes: 20,
1336            max_age_hours: None,
1337            absence_minutes: 30,
1338            max_locked_checks: 20,
1339            sse: None,
1340        };
1341
1342        let report = run_pass(&storage, &services, &verifier, &opts)
1343            .await
1344            .unwrap();
1345        assert_eq!(report.candidates, 3);
1346        assert_eq!(
1347            report.fresh, 0,
1348            "a broadcaster's seen exempts nothing past the threshold"
1349        );
1350        assert_eq!(report.probed, 3);
1351        assert_eq!(report.absent.len(), 3);
1352        assert!(report.seen.is_empty() && report.fatal.is_empty());
1353        // One retire from X covers C (its descendant); Y is too young.
1354        let retired: Vec<&PoisonReport> = report
1355            .retired
1356            .iter()
1357            .filter(|r| r.outcome == PoisonOutcome::Retired)
1358            .collect();
1359        assert_eq!(retired.len(), 1, "{:?}", report.retired);
1360        assert_eq!(retired[0].root, "22".repeat(32));
1361        assert_eq!(
1362            retired[0].retirable_txids(),
1363            vec!["22".repeat(32), "33".repeat(32)]
1364        );
1365        assert_eq!(tx_status(&storage, &"22".repeat(32)).await, "failed");
1366        assert_eq!(tx_status(&storage, &"33".repeat(32)).await, "failed");
1367        assert_eq!(tx_status(&storage, &"44".repeat(32)).await, "unproven");
1368        assert_eq!(tx_status(&storage, &"11".repeat(32)).await, "completed");
1369        assert_eq!(
1370            output_state(&storage, g0).await,
1371            (1, None),
1372            "G's coin is back"
1373        );
1374        assert_eq!(output_state(&storage, x0).await.0, 0);
1375        assert_eq!(output_state(&storage, c0).await.0, 0);
1376        // Y's absence is on the clock, nothing more.
1377        let y_row = storage
1378            .broadcast_status_of(&"44".repeat(32), BROADCAST_PROVIDER_NETWORK)
1379            .await
1380            .unwrap()
1381            .expect("absence row");
1382        assert_eq!(y_row.status, BROADCAST_STATUS_UNKNOWN);
1383        // The retired ones are remembered as rejected everywhere.
1384        let x_arcade = storage
1385            .broadcast_status_of(&"22".repeat(32), PROVIDER_ARCADE_V2)
1386            .await
1387            .unwrap()
1388            .expect("row");
1389        assert_eq!(x_arcade.status, "rejected");
1390        assert!(!report.summary(true).contains('\n'));
1391
1392        // The next pass: X and C are failed (not candidates), Y still young
1393        // and absent, nothing to retire, no locked inputs.
1394        let again = run_pass(&storage, &services, &verifier, &opts)
1395            .await
1396            .unwrap();
1397        assert_eq!(again.candidates, 1);
1398        assert!(again.retired.is_empty());
1399        assert_eq!(again.locked.due, 0);
1400    }
1401
1402    /// The climb through the reconciler: the verdict lands on the child,
1403    /// the poison starts at its unproven, absent parent.
1404    #[tokio::test]
1405    async fn an_absent_child_retires_from_its_absent_parent() {
1406        let (storage, user_id, basket) = wallet_storage().await;
1407        let old = (Utc::now() - chrono::Duration::minutes(45)).to_rfc3339();
1408        let g = insert_tx(&storage, user_id, &"11".repeat(32), "completed", &old).await;
1409        let p = insert_tx(&storage, user_id, &"22".repeat(32), "unproven", &old).await;
1410        let c = insert_tx(&storage, user_id, &"33".repeat(32), "unproven", &old).await;
1411        let g0 = insert_output(
1412            &storage,
1413            user_id,
1414            basket,
1415            g,
1416            &"11".repeat(32),
1417            false,
1418            Some(p),
1419        )
1420        .await;
1421        let _p0 = insert_output(
1422            &storage,
1423            user_id,
1424            basket,
1425            p,
1426            &"22".repeat(32),
1427            false,
1428            Some(c),
1429        )
1430        .await;
1431        let _c0 = insert_output(&storage, user_id, basket, c, &"33".repeat(32), true, None).await;
1432        // P has fresh chain evidence in the memory from a stale earlier pass
1433        // (it is not probed this pass); C is probed and absent.
1434        storage
1435            .record_broadcast_status(
1436                &"22".repeat(32),
1437                BROADCAST_PROVIDER_CHAIN,
1438                BROADCAST_STATUS_SEEN,
1439            )
1440            .await
1441            .unwrap();
1442        let broadcaster =
1443            mock_server(StatusCode::OK, r#"{"txid":"x","txStatus":"RECEIVED"}"#).await;
1444        let chain = mock_server(
1445            StatusCode::NOT_FOUND,
1446            r#"{"error":"transaction not found"}"#,
1447        )
1448        .await;
1449        let verifier = BroadcastVerifier::explicit(true, &broadcaster, Some(&chain));
1450        let services = MockWalletServices::new();
1451        let opts = ReconcileOptions {
1452            execute: true,
1453            max_probes: 20,
1454            max_age_hours: None,
1455            absence_minutes: 30,
1456            max_locked_checks: 20,
1457            sse: None,
1458        };
1459        let report = run_pass(&storage, &services, &verifier, &opts)
1460            .await
1461            .unwrap();
1462        assert_eq!(report.fresh, 1, "P exempt on fresh chain evidence");
1463        assert_eq!(report.probed, 1, "C");
1464        let retired: Vec<&PoisonReport> = report
1465            .retired
1466            .iter()
1467            .filter(|r| r.outcome == PoisonOutcome::Retired)
1468            .collect();
1469        assert_eq!(retired.len(), 1);
1470        assert_eq!(retired[0].origin, "33".repeat(32));
1471        assert_eq!(
1472            retired[0].root,
1473            "22".repeat(32),
1474            "climbed to the absent parent"
1475        );
1476        assert_eq!(retired[0].climbed, vec!["33".repeat(32)]);
1477        assert_eq!(tx_status(&storage, &"22".repeat(32)).await, "failed");
1478        assert_eq!(tx_status(&storage, &"33".repeat(32)).await, "failed");
1479        assert_eq!(output_state(&storage, g0).await, (1, None));
1480    }
1481}