use anyhow::{Context, Result};
use bsv_wallet_toolbox::Chain;
use sqlx::Row;
use std::future::Future;
use crate::broadcast_reconcile::absence_minutes_from_env;
use crate::broadcast_verify::{
BroadcastVerification, BroadcastVerifier, ChainIndexAnswer, PresenceReport,
};
use crate::commands::receive;
use crate::context::WalletContext;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputSpend {
Unspent,
SpentBy { txid: String, confirmed: bool },
Unknown,
}
const POLLED_REQ_STATUSES: &str =
"'unmined', 'unknown', 'callback', 'sending', 'unconfirmed', 'unsent', 'nosend'";
#[derive(Default, Debug, Clone)]
pub struct ReconcileReport {
pub absence_minutes: i64,
pub checked: usize,
pub kept: Vec<String>,
pub inconclusive: Vec<String>,
pub absent_on_clock: Vec<(String, i64)>,
pub abandoned: Vec<String>,
pub absent_past_threshold: Vec<(String, i64)>,
pub conflicted: Vec<String>,
pub stale_reqs_checked: usize,
pub stale_reqs_retired: Vec<String>,
pub stale_reqs_known: Vec<String>,
pub stale_reqs_kept: Vec<String>,
pub applied: bool,
pub failed: u64,
pub reqs_retired: u64,
pub restored_count: u64,
pub restored_sats: u64,
pub relinquished_count: u64,
pub relinquished_sats: u64,
pub kept_locked_count: u64,
pub phantom_count: u64,
pub phantom_sats: u64,
}
impl ReconcileReport {
pub fn dead_txids(&self) -> Vec<String> {
self.abandoned
.iter()
.cloned()
.chain(self.absent_past_threshold.iter().map(|(t, _)| t.clone()))
.chain(self.conflicted.iter().cloned())
.collect()
}
pub fn has_work(&self) -> bool {
!self.abandoned.is_empty()
|| !self.absent_past_threshold.is_empty()
|| !self.conflicted.is_empty()
|| !self.stale_reqs_retired.is_empty()
}
}
pub async fn reconcile(
pool: &sqlx::SqlitePool,
chain: Chain,
min_age_secs: i64,
execute: bool,
) -> Result<ReconcileReport> {
let verifier = BroadcastVerifier::single_pass(chain);
let client = reqwest::Client::new();
let base = receive::woc_base(chain);
reconcile_with(
pool,
min_age_secs,
absence_minutes_from_env(),
execute,
|txid: String| {
let v = verifier.clone();
async move { v.verify_report(&txid).await }
},
|src: String, vout: u32| {
let c = client.clone();
async move { probe_input_spend(&c, base, &src, vout).await }
},
)
.await
}
pub(crate) async fn probe_input_spend(
client: &reqwest::Client,
base: &str,
src: &str,
vout: u32,
) -> InputSpend {
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
let spent = match client
.get(format!("{}/tx/{}/{}/spent", base, src, vout))
.send()
.await
{
Ok(r) if r.status().is_success() => match r.json::<serde_json::Value>().await {
Ok(v) => v
.get("txid")
.and_then(|t| t.as_str())
.map(|t| t.to_ascii_lowercase()),
Err(_) => return InputSpend::Unknown,
},
Ok(r) if r.status().as_u16() == 404 => None,
_ => return InputSpend::Unknown,
};
match spent {
Some(spender) => {
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
let confirmed = match client
.get(format!("{}/tx/hash/{}", base, spender))
.send()
.await
{
Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>()
.await
.ok()
.and_then(|v| v.get("confirmations").and_then(|c| c.as_i64()))
.is_some_and(|c| c >= 1),
_ => false,
};
InputSpend::SpentBy {
txid: spender,
confirmed,
}
}
None => {
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
match client.get(format!("{}/tx/hash/{}", base, src)).send().await {
Ok(r) if r.status().is_success() => InputSpend::Unspent,
_ => InputSpend::Unknown,
}
}
}
}
type DeadCandidate = (i64, String, Vec<(TrackedInput, InputSpend)>);
struct TrackedInput {
output_id: i64,
satoshis: i64,
source_txid: String,
vout: u32,
}
async fn tracked_inputs(pool: &sqlx::SqlitePool, tx_id: i64) -> Result<Vec<TrackedInput>> {
let rows = sqlx::query(
"SELECT o.output_id, o.satoshis, o.vout, t.txid AS src \
FROM outputs o JOIN transactions t ON t.transaction_id = o.transaction_id \
WHERE o.spent_by = ?",
)
.bind(tx_id)
.fetch_all(pool)
.await?;
Ok(rows
.iter()
.map(|r| TrackedInput {
output_id: r.get("output_id"),
satoshis: r.get::<i64, _>("satoshis"),
source_txid: r.get::<String, _>("src"),
vout: r.get::<i64, _>("vout") as u32,
})
.collect())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Kept,
Inconclusive,
AbsentOnClock,
DeadAbsent,
DeadAbsentPastThreshold,
DeadConflict,
}
impl Verdict {
pub fn is_dead(self) -> bool {
matches!(
self,
Verdict::DeadAbsent | Verdict::DeadAbsentPastThreshold | Verdict::DeadConflict
)
}
}
pub fn presence_verdict(
presence: &PresenceReport,
age_minutes: i64,
absence_minutes: i64,
) -> Verdict {
if matches!(presence.chain_index, ChainIndexAnswer::Present(_)) {
return Verdict::Kept;
}
match presence.verification {
BroadcastVerification::Rejected => Verdict::DeadAbsent,
_ if presence.network_absent => {
if age_minutes >= absence_minutes {
Verdict::DeadAbsentPastThreshold
} else {
Verdict::AbsentOnClock
}
}
BroadcastVerification::Confirmed => Verdict::Kept,
BroadcastVerification::Inconclusive => Verdict::Inconclusive,
}
}
pub fn verdict_for(
our_txid: &str,
inputs: &[InputSpend],
presence: &PresenceReport,
age_minutes: i64,
absence_minutes: i64,
) -> Verdict {
if has_conflict(our_txid, inputs) {
return Verdict::DeadConflict;
}
presence_verdict(presence, age_minutes, absence_minutes)
}
fn has_conflict(our_txid: &str, inputs: &[InputSpend]) -> bool {
inputs.iter().any(|i| {
matches!(i, InputSpend::SpentBy { txid, confirmed: true }
if !txid.eq_ignore_ascii_case(our_txid))
})
}
pub async fn reconcile_with<P, PF, I, IF>(
pool: &sqlx::SqlitePool,
min_age_secs: i64,
absence_minutes: i64,
execute: bool,
probe: P,
input_spend: I,
) -> Result<ReconcileReport>
where
P: Fn(String) -> PF,
PF: Future<Output = PresenceReport>,
I: Fn(String, u32) -> IF,
IF: Future<Output = InputSpend>,
{
let mut report = ReconcileReport {
absence_minutes,
..ReconcileReport::default()
};
let rows = select_stale_unproven(pool, min_age_secs).await?;
report.checked = rows.len();
let mut dead: Vec<DeadCandidate> = Vec::new();
for row in &rows {
let tx_id: i64 = row.get("transaction_id");
let txid: String = row.get("txid");
let age_minutes: i64 = row.get("age_minutes");
let inputs = tracked_inputs(pool, tx_id).await?;
let mut answers: Vec<(TrackedInput, InputSpend)> = Vec::with_capacity(inputs.len());
for i in inputs {
let a = input_spend(i.source_txid.clone(), i.vout).await;
answers.push((i, a));
}
let spends: Vec<InputSpend> = answers.iter().map(|(_, a)| a.clone()).collect();
let presence = if has_conflict(&txid, &spends) {
PresenceReport::from_verification(BroadcastVerification::Inconclusive)
} else {
probe(txid.clone()).await
};
let verdict = verdict_for(&txid, &spends, &presence, age_minutes, absence_minutes);
match verdict {
Verdict::Kept => report.kept.push(txid.clone()),
Verdict::Inconclusive => report.inconclusive.push(txid.clone()),
Verdict::AbsentOnClock => report.absent_on_clock.push((txid.clone(), age_minutes)),
Verdict::DeadAbsent => report.abandoned.push(txid.clone()),
Verdict::DeadAbsentPastThreshold => report
.absent_past_threshold
.push((txid.clone(), age_minutes)),
Verdict::DeadConflict => report.conflicted.push(txid.clone()),
}
if verdict.is_dead() {
dead.push((tx_id, txid, answers));
}
}
let stale = select_stale_reqs(pool, min_age_secs).await?;
report.stale_reqs_checked = stale.len();
let mut retire_reqs: Vec<(i64, String)> = Vec::new();
for row in &stale {
let req_id: i64 = row.get("proven_tx_req_id");
let txid: String = row.get("txid");
let req_status: String = row.get("req_status");
let age_minutes: i64 = row.get("age_minutes");
let presence = probe(txid.clone()).await;
match presence_verdict(&presence, age_minutes, absence_minutes) {
Verdict::DeadAbsent | Verdict::DeadAbsentPastThreshold => {
report.stale_reqs_retired.push(txid);
retire_reqs.push((req_id, req_status));
}
Verdict::Kept if matches!(presence.chain_index, ChainIndexAnswer::Present(_)) => {
report.stale_reqs_known.push(txid);
}
_ => report.stale_reqs_kept.push(txid),
}
}
if !execute || (dead.is_empty() && retire_reqs.is_empty()) {
return Ok(report);
}
if !dead.is_empty() {
let mut tx = pool.begin().await?;
for (tx_id, our_txid, answers) in &dead {
for (input, answer) in answers {
match answer {
InputSpend::Unspent => {
sqlx::query(
"UPDATE outputs SET spendable = 1, spent_by = NULL, \
updated_at = CURRENT_TIMESTAMP WHERE output_id = ? AND spent_by = ?",
)
.bind(input.output_id)
.bind(tx_id)
.execute(&mut *tx)
.await?;
report.restored_count += 1;
report.restored_sats += input.satoshis.max(0) as u64;
}
InputSpend::SpentBy {
txid,
confirmed: true,
} if !txid.eq_ignore_ascii_case(our_txid) => {
sqlx::query(
"UPDATE outputs SET spendable = 0, spent_by = NULL, \
updated_at = CURRENT_TIMESTAMP WHERE output_id = ? AND spent_by = ?",
)
.bind(input.output_id)
.bind(tx_id)
.execute(&mut *tx)
.await?;
report.relinquished_count += 1;
report.relinquished_sats += input.satoshis.max(0) as u64;
}
_ => {
report.kept_locked_count += 1;
}
}
}
}
tx.commit().await.context("commit per-input release")?;
let ids: Vec<i64> = dead.iter().map(|(id, _, _)| *id).collect();
let phantoms = remove_phantom_outputs(pool, &ids).await?;
let targets: Vec<(i64, String)> = dead
.iter()
.map(|(id, txid, _)| (*id, txid.clone()))
.collect();
let (failed, reqs) = mark_failed(pool, &targets).await?;
report.failed = failed;
report.reqs_retired += reqs;
report.phantom_count = phantoms.0;
report.phantom_sats = phantoms.1;
}
report.reqs_retired += retire_stale_reqs(pool, &retire_reqs).await?;
report.applied = true;
Ok(report)
}
const AGE_MINUTES_SQL: &str =
"CAST((julianday('now') - julianday(datetime(created_at))) * 1440 AS INTEGER)";
async fn select_stale_unproven(
pool: &sqlx::SqlitePool,
min_age_secs: i64,
) -> Result<Vec<sqlx::sqlite::SqliteRow>> {
let age_modifier = format!("-{} seconds", min_age_secs.max(0));
Ok(sqlx::query(&format!(
"SELECT transaction_id, txid, {AGE_MINUTES_SQL} AS age_minutes FROM transactions \
WHERE (status='unproven' AND datetime(created_at) <= datetime('now', ?)) \
OR (status='sending' AND datetime(created_at) <= datetime('now', ?) \
AND datetime(created_at) <= datetime('now', '-600 seconds'))"
))
.bind(&age_modifier)
.bind(&age_modifier)
.fetch_all(pool)
.await?)
}
async fn select_stale_reqs(
pool: &sqlx::SqlitePool,
min_age_secs: i64,
) -> Result<Vec<sqlx::sqlite::SqliteRow>> {
let age_modifier = format!("-{} seconds", min_age_secs.max(0));
let age = AGE_MINUTES_SQL.replace("created_at", "t.created_at");
Ok(sqlx::query(&format!(
"SELECT r.proven_tx_req_id, r.txid, r.status AS req_status, {age} AS age_minutes \
FROM proven_tx_reqs r JOIN transactions t ON t.txid = r.txid \
WHERE t.status = 'failed' AND r.status IN ({POLLED_REQ_STATUSES}) \
AND datetime(t.created_at) <= datetime('now', ?) \
ORDER BY r.proven_tx_req_id ASC"
))
.bind(&age_modifier)
.fetch_all(pool)
.await?)
}
pub async fn run(ctx: &WalletContext, db_path: &str, execute: bool) -> Result<()> {
let pool = sqlx::SqlitePool::connect(&format!("sqlite:{}", db_path))
.await
.with_context(|| format!("failed to open {} (is the daemon running?)", db_path))?;
let report = reconcile(&pool, ctx.chain, 0, execute).await?;
if report.checked == 0 && report.stale_reqs_checked == 0 {
println!("No unproven (or stale sending) transactions found, and no stale proof requests.");
return Ok(());
}
println!(
"Found {} unproven/stale-sending transaction(s); probed every broadcast source (absence threshold {} min).",
report.checked, report.absence_minutes
);
println!(
" Definitively absent: {} Absent past the threshold: {} Dead by conflict: {} Held by a source: {} On the clock: {} Undecidable (kept): {}",
report.abandoned.len(),
report.absent_past_threshold.len(),
report.conflicted.len(),
report.kept.len(),
report.absent_on_clock.len(),
report.inconclusive.len()
);
for txid in &report.kept {
println!(" keep: {}", txid);
}
for (txid, age) in &report.absent_on_clock {
println!(
" keep (absent from the chain index for {} min while the broadcaster holds it; retired once past {} min): {}",
age, report.absence_minutes, txid
);
}
for txid in &report.inconclusive {
println!(
" keep (inconclusive — an unknown never releases money): {}",
txid
);
}
for txid in &report.abandoned {
println!(" fail (absent everywhere): {}", txid);
}
for (txid, age) in &report.absent_past_threshold {
println!(
" fail (absent from the chain index for {} min, past the {}-min threshold; a broadcaster's SEEN is not chain evidence): {}",
age, report.absence_minutes, txid
);
}
for txid in &report.conflicted {
println!(
" fail (an input is chain-spent by another confirmed tx — dead however held): {}",
txid
);
}
if report.stale_reqs_checked > 0 {
println!(
"Proof requests still polled for {} failed transaction(s): retire {}, chain index knows {}, undecided {}.",
report.stale_reqs_checked,
report.stale_reqs_retired.len(),
report.stale_reqs_known.len(),
report.stale_reqs_kept.len()
);
for txid in &report.stale_reqs_retired {
println!(" retire proof request (absent everywhere): {}", txid);
}
for txid in &report.stale_reqs_known {
println!(
" keep proof request (the chain index KNOWS this failed transaction; left to the proof pass and the unfail path): {}",
txid
);
}
for txid in &report.stale_reqs_kept {
println!(
" keep proof request (undecided, asked again next pass): {}",
txid
);
}
}
if !report.has_work() {
println!("Nothing to clean up.");
return Ok(());
}
let mut descendants_retired = 0usize;
for txid in report.dead_txids() {
let poison = ctx
.wallet
.storage()
.retire_poisoned_chain_from(
ctx.wallet.services(),
&txid,
"invalid",
execute,
report.absence_minutes,
)
.await?;
let descendants: Vec<_> = poison.chain.iter().filter(|t| t.depth > 0).collect();
if descendants.is_empty() {
continue;
}
println!(
" {} unproven descendant(s) of {} {}:",
descendants.len(),
txid,
if execute {
"retired"
} else {
"would be retired"
}
);
for tx in &descendants {
println!(
" depth {} {} ({}{})",
tx.depth,
tx.txid,
tx.status,
if tx.is_outgoing { "" } else { ", received" }
);
}
if poison.executed {
descendants_retired += poison.retirable_txids().len();
for p in &poison.internalized {
println!(
" internalized payment {}:{} ({} sats) traces to a phantom source: unspendable",
p.txid, p.vout, p.satoshis
);
}
}
}
if !execute {
println!();
println!("Dry run. Re-run with --execute to apply.");
return Ok(());
}
println!();
println!("Applied:");
if descendants_retired > 0 {
println!(" Descendant transactions retired: {}", descendants_retired);
}
println!(" Transactions marked failed: {}", report.failed);
println!(
" Proof requests retired (invalid): {} ({} of them for transactions failed earlier)",
report.reqs_retired,
report.stale_reqs_retired.len()
);
println!(
" Inputs restored to spendable (verified unspent): {} ({} sats)",
report.restored_count, report.restored_sats
);
println!(
" Inputs relinquished (chain-spent by another tx): {} ({} sats) Inputs kept locked (unknown): {}",
report.relinquished_count, report.relinquished_sats, report.kept_locked_count
);
println!(
" Phantom outputs unspendable: {} ({} sats)",
report.phantom_count, report.phantom_sats
);
println!();
println!(
"Net balance delta: {:+} sats. Restart the daemon to refresh its in-memory view.",
report.restored_sats as i64 - report.phantom_sats as i64 - report.relinquished_sats as i64
);
Ok(())
}
async fn remove_phantom_outputs(pool: &sqlx::SqlitePool, ids: &[i64]) -> Result<(u64, u64)> {
let mut count = 0u64;
let mut sats = 0u64;
let mut tx = pool.begin().await?;
for id in ids {
let outs = sqlx::query(
"SELECT output_id, satoshis FROM outputs \
WHERE transaction_id = ? AND spendable = 1",
)
.bind(id)
.fetch_all(&mut *tx)
.await?;
for r in &outs {
count += 1;
sats += r.get::<i64, _>("satoshis") as u64;
}
sqlx::query(
"UPDATE outputs SET spendable = 0, updated_at = CURRENT_TIMESTAMP \
WHERE transaction_id = ?",
)
.bind(id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok((count, sats))
}
async fn mark_failed(pool: &sqlx::SqlitePool, targets: &[(i64, String)]) -> Result<(u64, u64)> {
let mut tx = pool.begin().await?;
let mut failed = 0u64;
let mut reqs = 0u64;
for (id, txid) in targets {
let res = sqlx::query(
"UPDATE transactions SET status = 'failed', \
updated_at = CURRENT_TIMESTAMP WHERE transaction_id = ? AND status IN ('unproven', 'sending')",
)
.bind(id)
.execute(&mut *tx)
.await?;
failed += res.rows_affected();
let res = sqlx::query(&format!(
"UPDATE proven_tx_reqs SET status = 'invalid', attempts = attempts + 1, \
updated_at = CURRENT_TIMESTAMP WHERE txid = ? AND status IN ({POLLED_REQ_STATUSES})"
))
.bind(txid)
.execute(&mut *tx)
.await?;
reqs += res.rows_affected();
}
tx.commit().await?;
Ok((failed, reqs))
}
async fn retire_stale_reqs(pool: &sqlx::SqlitePool, reqs: &[(i64, String)]) -> Result<u64> {
let mut count = 0u64;
let mut tx = pool.begin().await?;
for (id, status) in reqs {
let res = sqlx::query(
"UPDATE proven_tx_reqs SET status = 'invalid', attempts = attempts + 1, \
updated_at = CURRENT_TIMESTAMP WHERE proven_tx_req_id = ? AND status = ?",
)
.bind(id)
.bind(status)
.execute(&mut *tx)
.await?;
count += res.rows_affected();
}
tx.commit().await?;
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::broadcast_verify::NetworkEvidence;
use bsv_wallet_toolbox::{BROADCAST_PROVIDER_CHAIN, PROVIDER_ARCADE_V2};
use sqlx::Row;
async fn mem_pool_with(rows: &[(&str, &str, &str)]) -> sqlx::SqlitePool {
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query(
"CREATE TABLE transactions (
transaction_id INTEGER PRIMARY KEY AUTOINCREMENT,
txid TEXT, status TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE outputs (
output_id INTEGER PRIMARY KEY AUTOINCREMENT,
transaction_id INTEGER NOT NULL, spendable INTEGER NOT NULL DEFAULT 0,
spent_by INTEGER, satoshis INTEGER NOT NULL DEFAULT 0, vout INTEGER NOT NULL DEFAULT 0,
updated_at TEXT)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE proven_tx_reqs (
proven_tx_req_id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT NOT NULL DEFAULT 'unknown', attempts INTEGER NOT NULL DEFAULT 0,
txid TEXT NOT NULL UNIQUE, updated_at TEXT)",
)
.execute(&pool)
.await
.unwrap();
for (txid, status, created_at) in rows {
sqlx::query("INSERT INTO transactions (txid, status, created_at) VALUES (?,?,?)")
.bind(txid)
.bind(status)
.bind(created_at)
.execute(&pool)
.await
.unwrap();
}
pool
}
async fn insert_req(pool: &sqlx::SqlitePool, txid: &str, status: &str, attempts: i64) {
sqlx::query(
"INSERT INTO proven_tx_reqs (txid, status, attempts, updated_at) VALUES (?,?,?,'2026-06-29T15:43:34.936649+00:00')",
)
.bind(txid)
.bind(status)
.bind(attempts)
.execute(pool)
.await
.unwrap();
}
async fn req_state(pool: &sqlx::SqlitePool, txid: &str) -> (String, i64) {
sqlx::query_as("SELECT status, attempts FROM proven_tx_reqs WHERE txid = ?")
.bind(txid)
.fetch_one(pool)
.await
.unwrap()
}
async fn tx_state(pool: &sqlx::SqlitePool, id: i64) -> (String, Option<String>) {
sqlx::query_as("SELECT status, updated_at FROM transactions WHERE transaction_id = ?")
.bind(id)
.fetch_one(pool)
.await
.unwrap()
}
fn presence(verification: BroadcastVerification) -> PresenceReport {
PresenceReport::from_verification(verification)
}
fn chain_mined() -> PresenceReport {
PresenceReport {
verification: BroadcastVerification::Confirmed,
evidence: Some(NetworkEvidence::Mined),
evidence_provider: BROADCAST_PROVIDER_CHAIN,
chain_index: ChainIndexAnswer::Present(NetworkEvidence::Mined),
broadcaster_fatal: false,
network_absent: false,
}
}
fn seen_by_arcade_absent_from_chain() -> PresenceReport {
PresenceReport {
verification: BroadcastVerification::Confirmed,
evidence: Some(NetworkEvidence::Seen),
evidence_provider: PROVIDER_ARCADE_V2,
chain_index: ChainIndexAnswer::Absent,
broadcaster_fatal: false,
network_absent: true,
}
}
fn absent_everywhere() -> PresenceReport {
PresenceReport {
verification: BroadcastVerification::Rejected,
evidence: None,
evidence_provider: bsv_wallet_toolbox::BROADCAST_PROVIDER_NETWORK,
chain_index: ChainIndexAnswer::Absent,
broadcaster_fatal: false,
network_absent: true,
}
}
#[tokio::test]
async fn selects_iso8601_and_space_format_rows() {
let pool = mem_pool_with(&[
(
"aa".repeat(32).leak(),
"unproven",
"2020-01-01T00:00:00.123456+00:00",
),
("bb".repeat(32).leak(), "unproven", "2020-01-02 00:00:00"),
(
"cc".repeat(32).leak(),
"failed",
"2020-01-01T00:00:00+00:00",
), ])
.await;
let rows = select_stale_unproven(&pool, 0).await.unwrap();
let txids: Vec<String> = rows.iter().map(|r| r.get("txid")).collect();
assert_eq!(
txids.len(),
2,
"both timestamp formats must be swept: {txids:?}"
);
assert!(txids.contains(&"aa".repeat(32)));
assert!(txids.contains(&"bb".repeat(32)));
for row in &rows {
let age: i64 = row.get("age_minutes");
assert!(age > 60 * 24 * 365 * 5, "years old in minutes: {age}");
}
}
#[tokio::test]
async fn min_age_guard_respected_across_formats() {
let pool = mem_pool_with(&[]).await;
sqlx::query(
"INSERT INTO transactions (txid, status, created_at) VALUES
('fresh_iso', 'unproven', strftime('%Y-%m-%dT%H:%M:%f+00:00','now')),
('fresh_sp', 'unproven', datetime('now')),
('old_iso', 'unproven', '2020-01-01T00:00:00+00:00')",
)
.execute(&pool)
.await
.unwrap();
let guarded = select_stale_unproven(&pool, 300).await.unwrap();
let txids: Vec<String> = guarded.iter().map(|r| r.get("txid")).collect();
assert_eq!(
txids,
vec!["old_iso".to_string()],
"fresh rows must be age-guarded"
);
let unguarded = select_stale_unproven(&pool, 0).await.unwrap();
assert_eq!(unguarded.len(), 3, "0-second guard selects everything");
let fresh_age: i64 = unguarded
.iter()
.find(|r| r.get::<String, _>("txid") == "fresh_iso")
.unwrap()
.get("age_minutes");
assert_eq!(fresh_age, 0);
}
async fn rule_fixture() -> sqlx::SqlitePool {
rule_fixture_aged("2020-01-01T00:00:00+00:00").await
}
async fn rule_fixture_aged(created_at: &str) -> sqlx::SqlitePool {
let pool = mem_pool_with(&[("ab".repeat(32).leak(), "unproven", created_at)]).await;
sqlx::query(
"INSERT INTO transactions (transaction_id, txid, status, created_at) VALUES (99, ?, 'completed', '2019-12-31T00:00:00+00:00')",
)
.bind("cd".repeat(32))
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO outputs (transaction_id, spendable, spent_by, satoshis) VALUES
(99, 0, 1, 5000),
(1, 1, NULL, 4000)",
)
.execute(&pool)
.await
.unwrap();
insert_req(&pool, &"ab".repeat(32), "unmined", 1).await;
pool
}
async fn lock(pool: &sqlx::SqlitePool, output_id: i64) -> (i64, Option<i64>) {
sqlx::query_as("SELECT spendable, spent_by FROM outputs WHERE output_id = ?")
.bind(output_id)
.fetch_one(pool)
.await
.unwrap()
}
#[tokio::test]
async fn a_lone_index_miss_is_inconclusive_and_releases_nothing() {
let pool = rule_fixture().await;
let report = reconcile_with(
&pool,
0,
30,
true,
|_txid| async { presence(BroadcastVerification::Inconclusive) },
|_src, _vout| async { InputSpend::Unspent },
)
.await
.unwrap();
assert_eq!(report.checked, 1);
assert_eq!(report.inconclusive.len(), 1);
assert!(report.abandoned.is_empty());
assert!(!report.applied, "nothing to apply");
assert_eq!(lock(&pool, 1).await, (0, Some(1)), "the input stays locked");
assert_eq!(lock(&pool, 2).await.0, 1, "its change untouched");
assert_eq!(tx_state(&pool, 1).await.0, "unproven");
assert_eq!(req_state(&pool, &"ab".repeat(32)).await.0, "unmined");
}
#[tokio::test]
async fn a_held_tx_is_kept() {
let pool = rule_fixture().await;
let report = reconcile_with(
&pool,
0,
30,
true,
|_txid| async { presence(BroadcastVerification::Confirmed) },
|_src, _vout| async { InputSpend::Unspent },
)
.await
.unwrap();
assert_eq!(report.kept.len(), 1);
assert!(report.abandoned.is_empty() && report.inconclusive.is_empty());
assert_eq!(lock(&pool, 1).await, (0, Some(1)));
}
#[tokio::test]
async fn a_definitive_absence_abandons_and_restores_under_execute() {
let pool = rule_fixture().await;
let dry = reconcile_with(
&pool,
0,
30,
false,
|_txid| async { absent_everywhere() },
|_src, _vout| async { InputSpend::Unspent },
)
.await
.unwrap();
assert_eq!(dry.abandoned.len(), 1);
assert!(!dry.applied);
assert_eq!(
lock(&pool, 1).await,
(0, Some(1)),
"dry run touches nothing"
);
assert_eq!(
req_state(&pool, &"ab".repeat(32)).await,
("unmined".into(), 1)
);
let wet = reconcile_with(
&pool,
0,
30,
true,
|_txid| async { absent_everywhere() },
|_src, _vout| async { InputSpend::Unspent },
)
.await
.unwrap();
assert!(wet.applied);
assert_eq!(
(wet.failed, wet.restored_count, wet.restored_sats),
(1, 1, 5000)
);
assert_eq!((wet.phantom_count, wet.phantom_sats), (1, 4000));
assert_eq!(lock(&pool, 1).await, (1, None), "the input is released");
assert_eq!(lock(&pool, 2).await.0, 0, "the phantom change is dead");
assert_eq!(tx_state(&pool, 1).await.0, "failed");
assert_eq!(wet.reqs_retired, 1);
assert_eq!(
req_state(&pool, &"ab".repeat(32)).await,
("invalid".into(), 2),
"the proof pass stops asking about it"
);
}
#[tokio::test]
async fn a_held_tx_whose_input_is_chain_spent_by_another_confirmed_tx_is_dead() {
let pool = rule_fixture().await;
let probed = std::cell::Cell::new(false);
let report = reconcile_with(
&pool,
0,
30,
true,
|_txid| {
probed.set(true);
async { presence(BroadcastVerification::Confirmed) }
},
|_src, _vout| async {
InputSpend::SpentBy {
txid: "98".repeat(32),
confirmed: true,
}
},
)
.await
.unwrap();
assert!(!probed.get(), "a chain-dead tx needs no presence probe");
assert_eq!(report.conflicted.len(), 1);
assert!(report.kept.is_empty() && report.abandoned.is_empty());
assert!(report.applied);
assert_eq!(
(report.relinquished_count, report.relinquished_sats),
(1, 5000)
);
assert_eq!(
report.restored_count, 0,
"a spent-elsewhere coin is never restored"
);
assert_eq!(
lock(&pool, 1).await,
(0, None),
"relinquished: unspendable and unlocked"
);
assert_eq!(lock(&pool, 2).await.0, 0, "phantom change dead");
assert_eq!(tx_state(&pool, 1).await.0, "failed");
assert_eq!(req_state(&pool, &"ab".repeat(32)).await.0, "invalid");
}
#[tokio::test]
async fn an_unconfirmed_competitor_does_not_kill_a_held_tx() {
let pool = rule_fixture().await;
let report = reconcile_with(
&pool,
0,
30,
true,
|_txid| async { presence(BroadcastVerification::Confirmed) },
|_src, _vout| async {
InputSpend::SpentBy {
txid: "98".repeat(32),
confirmed: false,
}
},
)
.await
.unwrap();
assert_eq!(report.kept.len(), 1);
assert!(report.conflicted.is_empty());
assert_eq!(lock(&pool, 1).await, (0, Some(1)));
}
#[tokio::test]
async fn our_own_confirmed_spend_is_not_a_conflict() {
let pool = rule_fixture().await;
let report = reconcile_with(
&pool,
0,
30,
true,
|_txid| async { presence(BroadcastVerification::Confirmed) },
|_src, _vout| async {
InputSpend::SpentBy {
txid: "AB".repeat(32), confirmed: true,
}
},
)
.await
.unwrap();
assert_eq!(report.kept.len(), 1);
assert!(report.conflicted.is_empty());
}
#[tokio::test]
async fn a_definitive_absence_keeps_an_unknown_input_locked() {
let pool = rule_fixture().await;
let report = reconcile_with(
&pool,
0,
30,
true,
|_txid| async { absent_everywhere() },
|_src, _vout| async { InputSpend::Unknown },
)
.await
.unwrap();
assert!(report.applied);
assert_eq!(report.abandoned.len(), 1);
assert_eq!(report.restored_count, 0);
assert_eq!(report.kept_locked_count, 1);
assert_eq!(
lock(&pool, 1).await,
(0, Some(1)),
"an unknown never releases money"
);
assert_eq!(lock(&pool, 2).await.0, 0, "the phantom change still dies");
}
#[tokio::test]
async fn a_seen_forever_phantom_past_the_threshold_is_retired() {
let pool = rule_fixture_aged("2026-01-01T00:00:00+00:00").await;
let dry = reconcile_with(
&pool,
0,
30,
false,
|_txid| async { seen_by_arcade_absent_from_chain() },
|_src, _vout| async { InputSpend::Unspent },
)
.await
.unwrap();
assert_eq!(dry.absent_past_threshold.len(), 1);
assert_eq!(dry.absent_past_threshold[0].0, "ab".repeat(32));
assert!(dry.absent_past_threshold[0].1 >= 30);
assert!(dry.kept.is_empty() && dry.abandoned.is_empty());
assert!(!dry.applied);
assert_eq!(
lock(&pool, 1).await,
(0, Some(1)),
"dry run touches nothing"
);
let wet = reconcile_with(
&pool,
0,
30,
true,
|_txid| async { seen_by_arcade_absent_from_chain() },
|_src, _vout| async { InputSpend::Unspent },
)
.await
.unwrap();
assert!(wet.applied);
assert_eq!(wet.dead_txids(), vec!["ab".repeat(32)]);
assert_eq!(
(wet.failed, wet.restored_count, wet.restored_sats),
(1, 1, 5000)
);
assert_eq!((wet.phantom_count, wet.phantom_sats), (1, 4000));
assert_eq!(lock(&pool, 1).await, (1, None), "the coin is back");
assert_eq!(lock(&pool, 2).await.0, 0, "the phantom change is dead");
assert_eq!(tx_state(&pool, 1).await.0, "failed");
assert_eq!(req_state(&pool, &"ab".repeat(32)).await.0, "invalid");
}
#[tokio::test]
async fn a_seen_but_absent_tx_younger_than_the_threshold_is_on_the_clock() {
let pool = mem_pool_with(&[]).await;
sqlx::query(
"INSERT INTO transactions (transaction_id, txid, status, created_at) VALUES
(1, ?, 'unproven', strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now', '-5 minutes'))",
)
.bind("ab".repeat(32))
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO outputs (transaction_id, spendable, spent_by, satoshis) VALUES (1, 1, NULL, 4000)")
.execute(&pool)
.await
.unwrap();
insert_req(&pool, &"ab".repeat(32), "unmined", 0).await;
let report = reconcile_with(
&pool,
0,
30,
true,
|_txid| async { seen_by_arcade_absent_from_chain() },
|_src, _vout| async { InputSpend::Unspent },
)
.await
.unwrap();
assert_eq!(report.absent_on_clock.len(), 1);
assert_eq!(report.absent_on_clock[0].0, "ab".repeat(32));
assert!(
(4..=6).contains(&report.absent_on_clock[0].1),
"{:?}",
report.absent_on_clock
);
assert!(report.absent_past_threshold.is_empty() && report.kept.is_empty());
assert!(!report.applied);
assert_eq!(lock(&pool, 1).await.0, 1, "its change untouched");
assert_eq!(tx_state(&pool, 1).await.0, "unproven");
assert_eq!(req_state(&pool, &"ab".repeat(32)).await.0, "unmined");
}
#[tokio::test]
async fn the_june_shape_a_failed_tx_whose_req_the_proof_pass_still_polls() {
let pool = mem_pool_with(&[]).await;
let june: [(&str, i64, i64); 3] = [
("e9512972dc4f57b6", 2, 14),
("ca29779e41865656", 1, 15),
("28e3b96e8f26f5db", 1, 21),
];
for (prefix, attempts, id) in june {
let txid = format!("{prefix}{}", "0".repeat(64 - prefix.len()));
sqlx::query(
"INSERT INTO transactions (transaction_id, txid, status, created_at, updated_at) \
VALUES (?, ?, 'failed', '2026-06-29T15:42:52.146700+00:00', '2026-06-29 17:34:15')",
)
.bind(id)
.bind(&txid)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO outputs (transaction_id, spendable, spent_by, satoshis) VALUES (?, 0, NULL, 10000)")
.bind(id)
.execute(&pool)
.await
.unwrap();
insert_req(&pool, &txid, "unmined", attempts).await;
}
let probed = std::cell::RefCell::new(Vec::new());
let dry = reconcile_with(
&pool,
3600,
30,
false,
|txid| {
probed.borrow_mut().push(txid);
async { absent_everywhere() }
},
|_src, _vout| async { InputSpend::Unknown },
)
.await
.unwrap();
assert_eq!(
dry.checked, 0,
"a failed transaction is not a sweep candidate"
);
assert_eq!(dry.stale_reqs_checked, 3);
assert_eq!(dry.stale_reqs_retired.len(), 3);
assert!(dry.stale_reqs_known.is_empty() && dry.stale_reqs_kept.is_empty());
assert!(dry.has_work());
assert!(!dry.applied, "a dry run writes nothing");
assert_eq!(
probed.borrow().len(),
3,
"each req is probed, never retired blind"
);
for (prefix, attempts, _) in june {
let txid = format!("{prefix}{}", "0".repeat(64 - prefix.len()));
assert_eq!(req_state(&pool, &txid).await, ("unmined".into(), attempts));
}
let wet = reconcile_with(
&pool,
3600,
30,
true,
|_txid| async { absent_everywhere() },
|_src, _vout| async { InputSpend::Unknown },
)
.await
.unwrap();
assert!(wet.applied);
assert_eq!(wet.reqs_retired, 3);
assert_eq!(
(wet.failed, wet.restored_count, wet.phantom_count),
(0, 0, 0)
);
for (prefix, attempts, id) in june {
let txid = format!("{prefix}{}", "0".repeat(64 - prefix.len()));
assert_eq!(
req_state(&pool, &txid).await,
("invalid".into(), attempts + 1),
"the proof pass stops asking"
);
assert_eq!(
tx_state(&pool, id).await,
("failed".into(), Some("2026-06-29 17:34:15".into())),
"the transaction row is not touched"
);
}
let again = reconcile_with(
&pool,
3600,
30,
true,
|_txid| async { absent_everywhere() },
|_src, _vout| async { InputSpend::Unknown },
)
.await
.unwrap();
assert_eq!(again.stale_reqs_checked, 0);
assert!(!again.applied);
}
#[tokio::test]
async fn the_september_shape_a_mined_tx_arcade_still_calls_in_flight_is_kept() {
let pool = mem_pool_with(&[]).await;
let ours = format!("f5258b036f7b2694{}", "0".repeat(48));
sqlx::query(
"INSERT INTO transactions (transaction_id, txid, status, created_at) VALUES
(20, ?, 'completed', '2026-06-29T00:00:00+00:00'),
(22, ?, 'unproven', strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now', '-2 days'))",
)
.bind(format!("ec7373a33c77cf6c{}", "0".repeat(48)))
.bind(&ours)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO outputs (output_id, transaction_id, spendable, spent_by, satoshis, vout) VALUES
(65, 20, 0, 22, 30092, 1),
(70, 22, 1, NULL, 1, 0),
(71, 22, 0, 23, 29947, 1)",
)
.execute(&pool)
.await
.unwrap();
insert_req(&pool, &ours, "unmined", 1).await;
let spender = ours.clone();
let report = reconcile_with(
&pool,
3600,
30,
true,
|_txid| async { chain_mined() },
move |_src, _vout| {
let spender = spender.clone();
async move {
InputSpend::SpentBy {
txid: spender,
confirmed: true,
}
}
},
)
.await
.unwrap();
assert_eq!(report.checked, 1);
assert_eq!(report.kept, vec![ours.clone()]);
assert!(report.dead_txids().is_empty() && report.absent_on_clock.is_empty());
assert_eq!(report.stale_reqs_checked, 0);
assert!(!report.applied);
assert_eq!(
lock(&pool, 65).await,
(0, Some(22)),
"its input stays spent by it"
);
assert_eq!(
lock(&pool, 70).await,
(1, None),
"its change stays spendable"
);
assert_eq!(tx_state(&pool, 22).await.0, "unproven");
assert_eq!(req_state(&pool, &ours).await, ("unmined".into(), 1));
}
#[tokio::test]
async fn a_stale_req_the_chain_index_knows_or_cannot_judge_is_kept() {
let pool = mem_pool_with(&[
(
"11".repeat(32).leak(),
"failed",
"2026-01-01T00:00:00+00:00",
),
(
"22".repeat(32).leak(),
"failed",
"2026-01-01T00:00:00+00:00",
),
])
.await;
insert_req(&pool, &"11".repeat(32), "unmined", 3).await;
insert_req(&pool, &"22".repeat(32), "callback", 0).await;
let report = reconcile_with(
&pool,
0,
30,
true,
|txid| async move {
if txid == "11".repeat(32) {
chain_mined()
} else {
presence(BroadcastVerification::Inconclusive)
}
},
|_src, _vout| async { InputSpend::Unknown },
)
.await
.unwrap();
assert_eq!(report.stale_reqs_checked, 2);
assert_eq!(report.stale_reqs_known, vec!["11".repeat(32)]);
assert_eq!(report.stale_reqs_kept, vec!["22".repeat(32)]);
assert!(report.stale_reqs_retired.is_empty());
assert!(!report.has_work() && !report.applied);
assert_eq!(
req_state(&pool, &"11".repeat(32)).await,
("unmined".into(), 3)
);
assert_eq!(
req_state(&pool, &"22".repeat(32)).await,
("callback".into(), 0)
);
}
#[tokio::test]
async fn stale_req_candidates_are_polled_reqs_of_old_failed_txs_only() {
let pool = mem_pool_with(&[
(
"11".repeat(32).leak(),
"failed",
"2026-01-01T00:00:00+00:00",
),
(
"22".repeat(32).leak(),
"failed",
"2026-01-01T00:00:00+00:00",
),
(
"33".repeat(32).leak(),
"failed",
"2026-01-01T00:00:00+00:00",
),
(
"55".repeat(32).leak(),
"completed",
"2026-01-01T00:00:00+00:00",
),
])
.await;
sqlx::query(
"INSERT INTO transactions (txid, status, created_at) VALUES (?, 'failed', strftime('%Y-%m-%dT%H:%M:%f+00:00','now'))",
)
.bind("44".repeat(32))
.execute(&pool)
.await
.unwrap();
insert_req(&pool, &"11".repeat(32), "unmined", 0).await;
insert_req(&pool, &"22".repeat(32), "invalid", 4).await;
insert_req(&pool, &"33".repeat(32), "completed", 0).await;
insert_req(&pool, &"44".repeat(32), "unmined", 0).await;
insert_req(&pool, &"55".repeat(32), "unmined", 0).await;
let report = reconcile_with(
&pool,
3600,
30,
true,
|_txid| async { seen_by_arcade_absent_from_chain() },
|_src, _vout| async { InputSpend::Unknown },
)
.await
.unwrap();
assert_eq!(
report.stale_reqs_checked, 1,
"only the old failed tx's polled req"
);
assert_eq!(report.stale_reqs_retired, vec!["11".repeat(32)]);
assert!(report.applied);
assert_eq!(report.reqs_retired, 1);
assert_eq!(
req_state(&pool, &"11".repeat(32)).await,
("invalid".into(), 1)
);
assert_eq!(
req_state(&pool, &"22".repeat(32)).await,
("invalid".into(), 4)
);
assert_eq!(
req_state(&pool, &"33".repeat(32)).await,
("completed".into(), 0)
);
assert_eq!(
req_state(&pool, &"44".repeat(32)).await,
("unmined".into(), 0)
);
assert_eq!(
req_state(&pool, &"55".repeat(32)).await,
("unmined".into(), 0)
);
}
#[test]
fn verdict_table() {
let ours = "ab".repeat(32);
let other = InputSpend::SpentBy {
txid: "98".repeat(32),
confirmed: true,
};
let other_unconf = InputSpend::SpentBy {
txid: "98".repeat(32),
confirmed: false,
};
let mine = InputSpend::SpentBy {
txid: ours.to_ascii_uppercase(),
confirmed: true,
};
use BroadcastVerification as V;
let held = presence(V::Confirmed);
assert_eq!(
verdict_for(&ours, std::slice::from_ref(&other), &held, 0, 30),
Verdict::DeadConflict
);
assert_eq!(
verdict_for(
&ours,
&[InputSpend::Unspent, other],
&absent_everywhere(),
0,
30
),
Verdict::DeadConflict
);
assert_eq!(
verdict_for(&ours, &[other_unconf], &held, 0, 30),
Verdict::Kept
);
assert_eq!(
verdict_for(&ours, &[mine], &presence(V::Inconclusive), 0, 30),
Verdict::Inconclusive
);
assert_eq!(
verdict_for(&ours, &[], &absent_everywhere(), 0, 30),
Verdict::DeadAbsent,
"definitive absence needs no age"
);
assert_eq!(
verdict_for(&ours, &[InputSpend::Unknown], &held, 0, 30),
Verdict::Kept
);
let seen = seen_by_arcade_absent_from_chain();
assert_eq!(
verdict_for(&ours, &[], &seen, 29, 30),
Verdict::AbsentOnClock
);
assert_eq!(
verdict_for(&ours, &[], &seen, 30, 30),
Verdict::DeadAbsentPastThreshold
);
assert_eq!(
verdict_for(&ours, &[], &seen, 100_000, 30),
Verdict::DeadAbsentPastThreshold
);
assert_eq!(
verdict_for(&ours, &[], &chain_mined(), 100_000, 30),
Verdict::Kept
);
let mut chain_seen = chain_mined();
chain_seen.chain_index = ChainIndexAnswer::Present(NetworkEvidence::Seen);
assert_eq!(
verdict_for(&ours, &[], &chain_seen, 100_000, 30),
Verdict::Kept
);
assert!(Verdict::DeadAbsentPastThreshold.is_dead());
assert!(!Verdict::AbsentOnClock.is_dead());
}
}