Skip to content

Commit 8c69d0b

Browse files
Calgooonclaude
andcommitted
release: v0.2.2 — auto-reconcile abandoned txs + covenant-input passthrough
- daemon: monitor ticker now runs cleanup-abandoned every 300s (TS TaskFailAbandoned analog) so jammed/abandoned actions self-heal instead of wedging coin selection (#18). - cleanup_abandoned: ReconcileReport summary; reconcile a server-spent UTXO via reviewSpendableOutputs(all,release)+abortAction. - server: createAction passthrough for caller-provided inputs / inputBEEF / lockTime / trustSelf so covenant spends (CLTV reclaim, delayed earnings) sign against the real prevout, not change. - deps: bsv-wallet-toolbox 0.3.38 -> 0.3.41 (covenant unlock fix). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6b84458 commit 8c69d0b

6 files changed

Lines changed: 225 additions & 43 deletions

File tree

Cargo.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ resolver = "2"
44

55
[package]
66
name = "bsv-wallet-cli"
7-
version = "0.2.1"
7+
version = "0.2.2"
88
edition = "2021"
99
license = "MIT"
1010
authors = ["John Calhoun"]
@@ -23,7 +23,7 @@ name = "bsv-wallet"
2323
path = "src/main.rs"
2424

2525
[dependencies]
26-
bsv-wallet-toolbox = { package = "bsv-wallet-toolbox-rs", version = "0.3.38", features = ["sqlite"] }
26+
bsv-wallet-toolbox = { package = "bsv-wallet-toolbox-rs", version = "0.3.41", features = ["sqlite"] }
2727
bsv-sdk = { package = "bsv-rs", version = "0.3.4", features = ["full"] }
2828
tokio = { version = "1", features = ["full"] }
2929
clap = { version = "4", features = ["derive"] }

src/commands/cleanup_abandoned.rs

Lines changed: 103 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,73 @@
11
use anyhow::{Context, Result};
2+
use bsv_wallet_toolbox::Chain;
23
use sqlx::Row;
34

45
use crate::commands::receive;
56
use 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(())

src/commands/daemon.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,19 @@ pub async fn run(cli: &Cli) -> Result<()> {
120120
.and_then(|v| v.parse().ok())
121121
.unwrap_or(3);
122122
let check_wallet = wallet_state.clone();
123+
// Auto-reconcile abandoned transactions (#18): a never-landed `unproven` tx's
124+
// change must not be selected to fund — and orphan — a new transaction. Each
125+
// tick we WoC-check unproven txs older than RECONCILE_ABANDONED_MIN_AGE_SECS
126+
// (default 1h, so an in-flight tx still propagating is never mis-classified)
127+
// and fail the ones missing on chain. Toggle off with RECONCILE_ABANDONED=0.
128+
let reconcile_chain = chain;
129+
let reconcile_enabled = std::env::var("RECONCILE_ABANDONED")
130+
.map(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
131+
.unwrap_or(true);
132+
let reconcile_min_age_secs: i64 = std::env::var("RECONCILE_ABANDONED_MIN_AGE_SECS")
133+
.ok()
134+
.and_then(|v| v.parse().ok())
135+
.unwrap_or(3600);
123136
tokio::spawn(async move {
124137
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
125138
loop {
@@ -157,6 +170,34 @@ pub async fn run(cli: &Cli) -> Result<()> {
157170
tracing::debug!("UTXO check failed: {}", e);
158171
}
159172
}
173+
174+
// Auto-reconcile abandoned (never-landed) transactions (#18).
175+
if reconcile_enabled {
176+
match crate::commands::cleanup_abandoned::reconcile(
177+
check_wallet.storage().pool(),
178+
reconcile_chain,
179+
reconcile_min_age_secs,
180+
true,
181+
)
182+
.await
183+
{
184+
Ok(r) if r.applied => {
185+
tracing::warn!(
186+
failed = r.failed,
187+
restored_count = r.restored_count,
188+
restored_sats = r.restored_sats,
189+
phantom_count = r.phantom_count,
190+
phantom_sats = r.phantom_sats,
191+
"auto-reconciled abandoned tx(s): marked {} failed, restored {} input(s) ({} sats), invalidated {} phantom output(s) ({} sats)",
192+
r.failed, r.restored_count, r.restored_sats, r.phantom_count, r.phantom_sats
193+
);
194+
}
195+
Ok(_) => {}
196+
Err(e) => {
197+
tracing::debug!("abandoned-tx reconcile skipped: {}", e);
198+
}
199+
}
200+
}
160201
}
161202
});
162203

src/server/handlers.rs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use bsv_sdk::wallet::{
1717
// Core types
1818
Counterparty,
1919
CreateActionArgs,
20+
CreateActionInput,
2021
CreateActionOptions,
2122
CreateActionOutput,
2223
CreateHmacArgs,
@@ -44,6 +45,7 @@ use bsv_sdk::wallet::{
4445
// Output types
4546
ListOutputsArgs,
4647
ListOutputsResult,
48+
Outpoint,
4749
Protocol,
4850
ProveCertificateArgs,
4951
ProveCertificateResult,
@@ -57,6 +59,7 @@ use bsv_sdk::wallet::{
5759
SecurityLevel,
5860
SignActionArgs,
5961
SignActionResult,
62+
TrustSelf,
6063
VerifyHmacArgs,
6164
VerifySignatureArgs,
6265
WalletCertificate,
@@ -297,19 +300,51 @@ pub async fn create_action(
297300
})
298301
.transpose()?;
299302

303+
// Map caller-provided explicit inputs (e.g. a covenant spend carrying a full
304+
// unlockingScript). An absent/empty list stays `Some(vec![])` so the wallet
305+
// auto-selects its own funding UTXOs — the prior behaviour for plain sends.
306+
// Before this, the handler discarded `inputs`/`inputBEEF`/`lockTime`, so any
307+
// covenant input was silently dropped and the tx was funded from change.
308+
let inputs = match req.inputs {
309+
Some(reqs) if !reqs.is_empty() => {
310+
let mut mapped = Vec::with_capacity(reqs.len());
311+
for i in reqs {
312+
let outpoint = Outpoint::from_string(&i.outpoint)
313+
.map_err(|e| anyhow::anyhow!("invalid input outpoint '{}': {e}", i.outpoint))?;
314+
let unlocking_script = match i.unlocking_script {
315+
Some(h) => Some(hex::decode(&h).context("invalid unlockingScript hex")?),
316+
None => None,
317+
};
318+
mapped.push(CreateActionInput {
319+
outpoint,
320+
input_description: i.input_description.unwrap_or_else(|| "input".to_string()),
321+
unlocking_script,
322+
unlocking_script_length: i.unlocking_script_length,
323+
sequence_number: i.sequence_number,
324+
});
325+
}
326+
Some(mapped)
327+
}
328+
_ => Some(vec![]),
329+
};
330+
300331
let args = CreateActionArgs {
301332
description: req.description,
302-
input_beef: None,
303-
inputs: Some(vec![]),
333+
input_beef: req.input_beef,
334+
inputs,
304335
outputs,
305-
lock_time: None,
336+
lock_time: req.lock_time,
306337
version: None,
307338
labels: req.labels,
308339
options: req.options.map(|o| CreateActionOptions {
309340
accept_delayed_broadcast: o.accept_delayed_broadcast,
310341
randomize_outputs: o.randomize_outputs,
311342
sign_and_process: o.sign_and_process,
312343
no_send: o.no_send,
344+
trust_self: match o.trust_self.as_deref() {
345+
Some("known") => Some(TrustSelf::Known),
346+
_ => None,
347+
},
313348
..Default::default()
314349
}),
315350
};

0 commit comments

Comments
 (0)