1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
use anyhow::{Context, Result};
use bsv_sdk::wallet::{ListOutputsArgs, WalletInterface};
use serde::Deserialize;
use std::collections::HashSet;
use crate::brc29;
use crate::commands::receive;
use crate::context::WalletContext;
#[derive(Deserialize)]
struct WocUnspent {
tx_hash: String,
tx_pos: u32,
value: u64,
}
pub async fn run(ctx: &WalletContext, reconcile_spent: bool) -> Result<()> {
let address = brc29::deposit_address(&ctx.root_key, ctx.chain)?;
let base = receive::woc_base(ctx.chain);
let client = reqwest::Client::new();
let unspent: Vec<WocUnspent> = client
.get(format!("{}/address/{}/unspent", base, address))
.send()
.await
.with_context(|| format!("WoC unspent fetch failed for {}", address))?
.error_for_status()?
.json()
.await?;
let known = known_outpoints(ctx).await?;
let mut received = 0u32;
let mut skipped = 0u32;
let mut sats_in = 0u64;
for u in &unspent {
let outpoint = format!("{}.{}", u.tx_hash, u.tx_pos);
if known.contains(&outpoint) {
skipped += 1;
continue;
}
match receive::receive_txid(ctx, &u.tx_hash, Some(u.tx_pos)).await {
Ok((_, true)) => {
received += 1;
sats_in += u.value;
}
Ok((_, false)) => {
eprintln!("not accepted: {}", outpoint);
}
Err(e) => {
eprintln!("failed {}: {}", outpoint, e);
}
}
}
// --reconcile-spent (2026-08-27): a restored-from-backup wallet holds rows
// for outputs the chain has since seen SPENT; selecting them builds
// double-spend inputs the network refuses (the fleet-restore incident).
// For every DB outpoint MISSING from the chain's unspent set at the
// deposit address, ask WoC's per-outpoint spent endpoint; a definitive
// spender ⇒ relinquish. Anything else (404 / transport) is LEFT ALONE:
// only a positive spent answer may remove spendability (fail-safe —
// unknown never relinquishes).
let mut reconciled = 0u32;
let mut reconcile_checked = 0u32;
let mut phantom_parent = 0u32;
if reconcile_spent {
let chain_unspent: HashSet<String> = unspent
.iter()
.map(|u| format!("{}.{}", u.tx_hash, u.tx_pos))
.collect();
let db_outpoints = known_outpoints(ctx).await?;
for op in db_outpoints {
if chain_unspent.contains(&op) {
continue;
}
let Some((txid, vout)) = op.split_once('.') else {
continue;
};
reconcile_checked += 1;
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
let spent = matches!(
client
.get(format!("{}/tx/{}/{}/spent", base, txid, vout))
.send()
.await,
Ok(r) if r.status().is_success()
);
// PHANTOM-PARENT detection (2026-08-27, the float-recovery audit):
// the spent endpoint answers 404 both for "unspent" and for "the
// parent tx does not exist on chain at all" — and a DB full of
// never-delivered chains reads as spendable balance through that
// ambiguity (247k sats of archived fleet balance were exactly
// this). When the spend probe says nothing, ask whether the parent
// is even on the network; an absent parent is REPORTED (never
// auto-relinquished here — a transient indexer fault must not
// erase spendability; `cleanup-abandoned` owns the mutation).
if !spent {
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
let parent_present = matches!(
client.get(format!("{}/tx/hash/{}", base, txid)).send().await,
Ok(r) if r.status().is_success()
);
if !parent_present {
phantom_parent += 1;
eprintln!("phantom-parent output (parent tx not on chain): {}", op);
continue;
}
}
if spent {
use bsv_sdk::wallet::RelinquishOutputArgs;
match ctx
.wallet
.relinquish_output(
RelinquishOutputArgs {
basket: "default".to_string(),
output: match bsv_sdk::wallet::Outpoint::from_string(&op) {
Ok(o) => o,
Err(e) => {
eprintln!("bad outpoint {}: {}", op, e);
continue;
}
},
},
"bsv-wallet-cli",
)
.await
{
Ok(_) => {
reconciled += 1;
eprintln!("reconciled spent: {}", op);
}
Err(e) => eprintln!("relinquish failed {}: {}", op, e),
}
}
}
}
if ctx.json_output {
println!(
"{}",
serde_json::json!({
"address": address,
"unspent_on_chain": unspent.len(),
"received": received,
"skipped": skipped,
"sats_received": sats_in,
"reconcile_checked": reconcile_checked,
"reconciled_spent": reconciled,
"phantom_parent": phantom_parent,
})
);
} else {
println!(
"Sync complete: {} on chain, {} new received ({} sats), {} already known",
unspent.len(),
received,
sats_in,
skipped
);
// A money verb must never finish SILENT about what it did (or did not)
// touch: "checked 0" (nothing qualified) and "checked 12, relinquished
// 0" (all verified live) are different facts a drain decision rests on.
if reconcile_spent {
println!(
"Reconcile: {} outpoint(s) chain-checked, {} relinquished as spent, {} phantom-parent (run cleanup-abandoned)",
reconcile_checked, reconciled, phantom_parent
);
}
}
Ok(())
}
async fn known_outpoints(ctx: &WalletContext) -> Result<HashSet<String>> {
let mut known = HashSet::new();
let mut offset: i32 = 0;
let limit: u32 = 1000;
loop {
let res = ctx
.wallet
.list_outputs(
ListOutputsArgs {
basket: "default".to_string(),
tags: None,
tag_query_mode: None,
include: None,
include_custom_instructions: None,
include_tags: None,
include_labels: None,
limit: Some(limit),
offset: Some(offset),
seek_permission: None,
},
"bsv-wallet-cli",
)
.await?;
let n = res.outputs.len() as u32;
for o in &res.outputs {
known.insert(o.outpoint.to_string());
}
if n < limit {
break;
}
offset += n as i32;
}
Ok(known)
}