1use 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
83pub const DEFAULT_ABSENCE_MINUTES: i64 = 30;
86pub const DEFAULT_INTERVAL_SECS: u64 = 60;
89pub const DEFAULT_MAX_PROBES: usize = 20;
92pub const DEFAULT_MAX_LOCKED_CHECKS: usize = 20;
95pub const SERVE_MAX_AGE_HOURS: i64 = 24;
97const PROBE_PACE: Duration = Duration::from_millis(350);
99const SSE_BUDGET: Duration = Duration::from_secs(8);
101const SSE_IDLE: Duration = Duration::from_secs(2);
103
104pub type ServedWallet = Wallet<StorageSqlx, Services>;
106
107#[derive(Debug, Clone)]
109pub struct ArcadeSse {
110 pub url: String,
112 pub token: String,
114}
115
116#[derive(Debug, Clone)]
118pub struct ReconcileOptions {
119 pub execute: bool,
122 pub max_probes: usize,
124 pub max_age_hours: Option<i64>,
127 pub absence_minutes: i64,
130 pub max_locked_checks: usize,
132 pub sse: Option<ArcadeSse>,
134}
135
136impl ReconcileOptions {
137 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 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
168pub 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
180pub 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#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct Candidate {
195 pub txid: String,
197 pub created_at: DateTime<Utc>,
199}
200
201impl Candidate {
202 pub fn age_minutes(&self, now: DateTime<Utc>) -> i64 {
204 (now - self.created_at).num_minutes().max(0)
205 }
206}
207
208#[derive(Debug, Default, Clone)]
210pub struct ReconcileBroadcastsReport {
211 pub sse_events: u64,
213 pub sse_fatal: Vec<String>,
215 pub candidates: usize,
217 pub fresh: usize,
219 pub probed: usize,
221 pub seen: Vec<String>,
223 pub mined: Vec<String>,
225 pub held: Vec<String>,
227 pub inconclusive: Vec<String>,
229 pub absent: Vec<(String, i64)>,
232 pub fatal: Vec<String>,
234 pub retired: Vec<PoisonReport>,
236 pub locked: LockedInputReport,
238}
239
240impl ReconcileBroadcastsReport {
241 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 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 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
317pub 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
349pub 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 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 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 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 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 report.locked = storage
466 .recheck_locked_inputs(services, opts.max_locked_checks, opts.execute)
467 .await?;
468
469 Ok(report)
470}
471
472pub struct SweepReport {
476 pub poison: Vec<PoisonReport>,
478 pub locked: LockedInputReport,
480}
481
482pub 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
519pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569enum Probe {
570 Chain(NetworkEvidence),
572 Fatal,
574 Absent,
577 Held,
579 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
625async 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
651async 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
696async 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
723fn 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
742async 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
775async 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
790async 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
806async 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
819async 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 #[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 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}