use anyhow::{Context, Result};
use bsv_wallet_toolbox::Chain;
use sqlx::Row;
use crate::commands::receive;
use crate::context::WalletContext;
#[derive(Default, Debug, Clone)]
pub struct ReconcileReport {
pub checked: usize,
pub kept: Vec<String>,
pub abandoned: Vec<String>,
pub applied: bool,
pub failed: u64,
pub restored_count: u64,
pub restored_sats: u64,
pub phantom_count: u64,
pub phantom_sats: u64,
}
pub async fn reconcile(
pool: &sqlx::SqlitePool,
chain: Chain,
min_age_secs: i64,
execute: bool,
) -> Result<ReconcileReport> {
let mut report = ReconcileReport::default();
let rows = select_stale_unproven(pool, min_age_secs).await?;
report.checked = rows.len();
if rows.is_empty() {
return Ok(report);
}
let base = receive::woc_base(chain);
let client = reqwest::Client::new();
let mut to_fail: Vec<(i64, String)> = Vec::new();
for row in &rows {
let tx_id: i64 = row.get("transaction_id");
let txid: String = row.get("txid");
let resp = client
.get(format!("{}/tx/{}", base, txid))
.send()
.await
.with_context(|| format!("WoC tx fetch failed for {}", txid))?;
if resp.status().as_u16() == 404 {
to_fail.push((tx_id, txid));
} else {
report.kept.push(txid);
}
}
report.abandoned = to_fail.iter().map(|(_, t)| t.clone()).collect();
if to_fail.is_empty() || !execute {
return Ok(report);
}
let ids: Vec<i64> = to_fail.iter().map(|(id, _)| *id).collect();
let restored = restore_inputs(pool, &ids).await?;
let phantoms = remove_phantom_outputs(pool, &ids).await?;
let failed = mark_failed(pool, &ids).await?;
report.applied = true;
report.failed = failed;
report.restored_count = restored.0;
report.restored_sats = restored.1;
report.phantom_count = phantoms.0;
report.phantom_sats = phantoms.1;
Ok(report)
}
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(
"SELECT transaction_id, txid FROM transactions \
WHERE status='unproven' AND datetime(created_at) <= datetime('now', ?)",
)
.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 {
println!("No unproven transactions found.");
return Ok(());
}
println!(
"Found {} unproven transaction(s); checked against WoC.",
report.checked
);
println!(
" Missing on chain: {} Still tracked by network: {}",
report.abandoned.len(),
report.kept.len()
);
for txid in &report.kept {
println!(" keep: {}", txid);
}
for txid in &report.abandoned {
println!(" fail: {}", txid);
}
if report.abandoned.is_empty() {
println!("Nothing to clean up.");
return Ok(());
}
if !execute {
println!();
println!("Dry run. Re-run with --execute to apply.");
return Ok(());
}
println!();
println!("Applied:");
println!(" Transactions marked failed: {}", report.failed);
println!(
" Inputs restored to spendable: {} ({} sats)",
report.restored_count, report.restored_sats
);
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
);
Ok(())
}
async fn restore_inputs(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 restored = sqlx::query("SELECT output_id, satoshis FROM outputs WHERE spent_by = ?")
.bind(id)
.fetch_all(&mut *tx)
.await?;
for r in &restored {
count += 1;
sats += r.get::<i64, _>("satoshis") as u64;
}
sqlx::query(
"UPDATE outputs SET spendable = 1, spent_by = NULL, \
updated_at = CURRENT_TIMESTAMP WHERE spent_by = ?",
)
.bind(id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok((count, sats))
}
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, ids: &[i64]) -> Result<u64> {
let mut tx = pool.begin().await?;
let mut count = 0u64;
for id in ids {
let res = sqlx::query(
"UPDATE transactions SET status = 'failed', \
updated_at = CURRENT_TIMESTAMP WHERE transaction_id = ? AND status = 'unproven'",
)
.bind(id)
.execute(&mut *tx)
.await?;
count += res.rows_affected();
}
tx.commit().await?;
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
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 NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL)",
)
.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
}
#[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)));
}
#[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");
}
}