11use anyhow:: { Context , Result } ;
2+ use bsv_wallet_toolbox:: Chain ;
23use sqlx:: Row ;
34
45use crate :: commands:: receive;
56use crate :: context:: WalletContext ;
67
7- pub async fn run ( ctx : & WalletContext , db_path : & str , execute : bool ) -> Result < ( ) > {
8- let pool = sqlx:: SqlitePool :: connect ( & format ! ( "sqlite:{}" , db_path) )
9- . await
10- . with_context ( || format ! ( "failed to open {} (is the daemon running?)" , db_path) ) ?;
11-
12- let rows = sqlx:: query ( "SELECT transaction_id, txid FROM transactions WHERE status='unproven'" )
13- . fetch_all ( & pool)
14- . await ?;
8+ /// Summary of one reconcile pass over abandoned transactions.
9+ #[ derive( Default , Debug , Clone ) ]
10+ pub struct ReconcileReport {
11+ /// `unproven` txs inspected (after the min-age filter).
12+ pub checked : usize ,
13+ /// txids still tracked by the network (kept spendable).
14+ pub kept : Vec < String > ,
15+ /// txids missing on chain (HTTP 404) — the abandoned set.
16+ pub abandoned : Vec < String > ,
17+ /// Whether `execute` actually applied the cleanup.
18+ pub applied : bool ,
19+ /// Transactions transitioned `unproven` -> `failed`.
20+ pub failed : u64 ,
21+ /// Inputs of abandoned txs restored to spendable.
22+ pub restored_count : u64 ,
23+ pub restored_sats : u64 ,
24+ /// Phantom outputs of abandoned txs invalidated (spendable=0).
25+ pub phantom_count : u64 ,
26+ pub phantom_sats : u64 ,
27+ }
1528
29+ /// Core reconcile, shared by the CLI `cleanup-abandoned` command and the daemon's
30+ /// periodic ticker.
31+ ///
32+ /// Scans `status='unproven'` transactions that are at least `min_age_secs` old
33+ /// (so a freshly-broadcast tx that has not yet propagated to WhatsOnChain is
34+ /// never mis-classified), checks each against WoC, and — when `execute` — fails
35+ /// the ones missing on chain: restore their inputs, invalidate their own phantom
36+ /// outputs, and mark them `failed` (which excludes them from coin selection,
37+ /// preventing a never-landed tx's change from funding — and orphaning — a new
38+ /// transaction).
39+ ///
40+ /// Operates on the caller-provided pool so the daemon reuses its existing
41+ /// connection rather than opening a second one (the wallet DB is not in WAL
42+ /// mode and a fresh pool would lack the daemon's `busy_timeout`).
43+ pub async fn reconcile (
44+ pool : & sqlx:: SqlitePool ,
45+ chain : Chain ,
46+ min_age_secs : i64 ,
47+ execute : bool ,
48+ ) -> Result < ReconcileReport > {
49+ let mut report = ReconcileReport :: default ( ) ;
50+
51+ // `datetime('now', '-N seconds')`: only consider txs at least N seconds old.
52+ // min_age_secs == 0 -> '-0 seconds' == now, i.e. no effective age guard
53+ // (the CLI command's historical behavior).
54+ let age_modifier = format ! ( "-{} seconds" , min_age_secs. max( 0 ) ) ;
55+ let rows = sqlx:: query (
56+ "SELECT transaction_id, txid FROM transactions \
57+ WHERE status='unproven' AND created_at <= datetime('now', ?)",
58+ )
59+ . bind ( & age_modifier)
60+ . fetch_all ( pool)
61+ . await ?;
62+
63+ report. checked = rows. len ( ) ;
1664 if rows. is_empty ( ) {
17- println ! ( "No unproven transactions found." ) ;
18- return Ok ( ( ) ) ;
65+ return Ok ( report) ;
1966 }
20- println ! (
21- "Found {} unproven transaction(s); checking WoC..." ,
22- rows. len( )
23- ) ;
2467
25- let base = receive:: woc_base ( ctx . chain ) ;
68+ let base = receive:: woc_base ( chain) ;
2669 let client = reqwest:: Client :: new ( ) ;
2770 let mut to_fail: Vec < ( i64 , String ) > = Vec :: new ( ) ;
28- let mut still_in_mempool: Vec < String > = Vec :: new ( ) ;
2971
3072 for row in & rows {
3173 let tx_id: i64 = row. get ( "transaction_id" ) ;
@@ -38,54 +80,82 @@ pub async fn run(ctx: &WalletContext, db_path: &str, execute: bool) -> Result<()
3880 if resp. status ( ) . as_u16 ( ) == 404 {
3981 to_fail. push ( ( tx_id, txid) ) ;
4082 } else {
41- still_in_mempool . push ( txid) ;
83+ report . kept . push ( txid) ;
4284 }
4385 }
86+ report. abandoned = to_fail. iter ( ) . map ( |( _, t) | t. clone ( ) ) . collect ( ) ;
87+
88+ if to_fail. is_empty ( ) || !execute {
89+ return Ok ( report) ;
90+ }
91+
92+ let ids: Vec < i64 > = to_fail. iter ( ) . map ( |( id, _) | * id) . collect ( ) ;
93+ let restored = restore_inputs ( pool, & ids) . await ?;
94+ let phantoms = remove_phantom_outputs ( pool, & ids) . await ?;
95+ let failed = mark_failed ( pool, & ids) . await ?;
96+
97+ report. applied = true ;
98+ report. failed = failed;
99+ report. restored_count = restored. 0 ;
100+ report. restored_sats = restored. 1 ;
101+ report. phantom_count = phantoms. 0 ;
102+ report. phantom_sats = phantoms. 1 ;
103+ Ok ( report)
104+ }
44105
106+ pub async fn run ( ctx : & WalletContext , db_path : & str , execute : bool ) -> Result < ( ) > {
107+ let pool = sqlx:: SqlitePool :: connect ( & format ! ( "sqlite:{}" , db_path) )
108+ . await
109+ . with_context ( || format ! ( "failed to open {} (is the daemon running?)" , db_path) ) ?;
110+
111+ // Operator-initiated: no age guard (inspect every unproven tx).
112+ let report = reconcile ( & pool, ctx. chain , 0 , execute) . await ?;
113+
114+ if report. checked == 0 {
115+ println ! ( "No unproven transactions found." ) ;
116+ return Ok ( ( ) ) ;
117+ }
118+ println ! (
119+ "Found {} unproven transaction(s); checked against WoC." ,
120+ report. checked
121+ ) ;
45122 println ! (
46123 " Missing on chain: {} Still tracked by network: {}" ,
47- to_fail . len( ) ,
48- still_in_mempool . len( )
124+ report . abandoned . len( ) ,
125+ report . kept . len( )
49126 ) ;
50- for txid in & still_in_mempool {
127+ for txid in & report . kept {
51128 println ! ( " keep: {}" , txid) ;
52129 }
53- for ( _ , txid) in & to_fail {
130+ for txid in & report . abandoned {
54131 println ! ( " fail: {}" , txid) ;
55132 }
56133
57- if to_fail . is_empty ( ) {
134+ if report . abandoned . is_empty ( ) {
58135 println ! ( "Nothing to clean up." ) ;
59136 return Ok ( ( ) ) ;
60137 }
61-
62138 if !execute {
63139 println ! ( ) ;
64140 println ! ( "Dry run. Re-run with --execute to apply." ) ;
65141 return Ok ( ( ) ) ;
66142 }
67143
68- let ids: Vec < i64 > = to_fail. iter ( ) . map ( |( id, _) | * id) . collect ( ) ;
69-
70- let restored = restore_inputs ( & pool, & ids) . await ?;
71- let phantoms = remove_phantom_outputs ( & pool, & ids) . await ?;
72- let failed = mark_failed ( & pool, & ids) . await ?;
73-
74144 println ! ( ) ;
75145 println ! ( "Applied:" ) ;
76- println ! ( " Transactions marked failed: {}" , failed) ;
146+ println ! ( " Transactions marked failed: {}" , report . failed) ;
77147 println ! (
78148 " Inputs restored to spendable: {} ({} sats)" ,
79- restored . 0 , restored . 1
149+ report . restored_count , report . restored_sats
80150 ) ;
81151 println ! (
82152 " Phantom outputs unspendable: {} ({} sats)" ,
83- phantoms . 0 , phantoms . 1
153+ report . phantom_count , report . phantom_sats
84154 ) ;
85155 println ! ( ) ;
86156 println ! (
87157 "Net balance delta: {:+} sats. Restart the daemon to refresh its in-memory view." ,
88- restored . 1 as i64 - phantoms . 1 as i64
158+ report . restored_sats as i64 - report . phantom_sats as i64
89159 ) ;
90160
91161 Ok ( ( ) )
0 commit comments