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
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "bsv-wallet", about = "Self-contained BSV wallet", version)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
/// Use testnet instead of mainnet
#[arg(long, global = true)]
pub testnet: bool,
/// SQLite database path
#[arg(long, global = true, default_value = "wallet.db")]
pub db: String,
/// HTTP server port
#[arg(long, global = true, default_value_t = 3322)]
pub port: u16,
/// Output JSON instead of tables
#[arg(long, global = true)]
pub json: bool,
/// Enable debug logging
#[arg(short, long, global = true)]
pub verbose: bool,
}
#[derive(Subcommand)]
pub enum Commands {
/// Generate/import identity, create wallet database
Init {
/// Import existing root key (hex)
#[arg(long)]
key: Option<String>,
/// Overwrite existing .env (DESTROYS the existing wallet's key)
#[arg(long)]
force: bool,
},
/// Show public key and address
Identity,
/// Show spendable balance
Balance,
/// Show BRC-29 funding address
Address,
/// Send BSV to a P2PKH address
Send {
/// Destination address
address: String,
/// Amount in satoshis
satoshis: u64,
},
/// Send a time-locked BSV gift to a recipient's public key
GiftSend {
/// Recipient's 33-byte compressed public key (hex)
recipient: String,
/// Gift amount in satoshis
satoshis: u64,
/// Unlock time: unix timestamp, YYYY-MM-DD, or RFC-3339
#[arg(long)]
unlock: String,
/// Recipient-owned fee UTXO (sats) bundled so the claim self-funds its fee
#[arg(long, default_value_t = 1000)]
fee_utxo: u64,
},
/// Inspect a time-locked gift (prove it's yours + when it unlocks; no claim)
GiftInspect {
/// The gift (deposit) transaction id
txid: String,
},
/// Claim a time-locked gift after its unlock time
GiftClaim {
/// The gift (deposit) transaction id
txid: String,
/// Hand the claim over before unlock. A broadcaster that takes it holds
/// it until the unlock; one that calls it not final is not a failure:
/// the claim is kept and announced again at the unlock time by the
/// tracker's pass (`serve`, or `tracker-tick`)
#[arg(long)]
force: bool,
},
/// Send all spendable funds to one address (no change)
Drain {
/// Destination address
address: String,
},
/// Internalize a BEEF transaction (receive funds): the routine way to receive, with the
/// BEEF the payer hands over; checked against headers, no explorer asked
Fund {
/// BEEF transaction in hex (standard or AtomicBEEF)
beef_hex: String,
/// Output index to internalize (default: 0)
#[arg(long, default_value_t = 0)]
vout: u32,
},
/// Break-glass courier: fetch a transaction's BEEF by txid and internalize it
///
/// For a payment to our bare address that came with no BEEF from its sender. The BEEF
/// is fetched from a courier (WhatsOnChain's BEEF route, then the wallet's own
/// services) and then checked like any other: its proofs must meet the header
/// service's roots. The routine path is `fund` with the BEEF the payer hands over.
Receive {
/// Transaction id (hex)
txid: String,
/// Output index; if omitted, auto-selects the vout matching our deposit address
#[arg(long)]
vout: Option<u32>,
},
/// List unspent outputs
Outputs {
/// Filter by basket name
#[arg(long)]
basket: Option<String>,
/// Filter by tag
#[arg(long)]
tag: Option<String>,
},
/// List transaction history
Actions {
/// Filter by label
#[arg(long)]
label: Option<String>,
},
/// Break-glass chain scan: ask an explorer for the unspent outputs at our deposit
/// address and internalize new ones
///
/// This is a chain scan (an address lookup at WhatsOnChain, one explorer), kept for
/// one case: finding a payment nobody handed us. It is not the routine way to
/// receive, and no daemon path runs it. The routine path is `fund` with the BEEF the
/// payer hands over: that answer is checked against headers and asks no explorer.
/// An empty list from the explorer means "it lists nothing", not "nothing was paid".
Sync {
/// Also RECONCILE: every DB-unspent output missing from the chain's
/// unspent set is put to the spend probe (the one `cleanup-abandoned`
/// and `reconcile-outputs` use), and an output spent by a transaction
/// we hold a merkle proof of is relinquished. A spender not proven yet,
/// or an answer the probe could not get, leaves the output alone (heals
/// a restored-from-backup wallet whose stale rows otherwise produce
/// double-spend inputs).
#[arg(long)]
reconcile_spent: bool,
},
/// Run all monitor tasks once and exit (one-shot equivalent of `daemon`). The
/// header task's state and the proof gate persist in the wallet database, so
/// the FIRST run queues the chain tip and the SECOND run (one that still sees
/// that tip) processes it and opens the proof gate: no proof is stored before
/// the second run, exactly as the daemon accepts nothing before a header has
/// stayed the tip for a full cycle. Refused (non-zero exit) when
/// CHAINTRACKS_URL=off: with no header service every proof is refused
Tick,
/// Run one pass of the transaction tracker and exit: each of this wallet's
/// unproven transactions holds a word (built, announced, seen, mined); a
/// broadcaster's word is a hint; a merkle proof is asked for one named
/// transaction when a hint disagrees or its age is due (TRACKER_AGE_SECS,
/// default 600), and only a proof checked against the header service
/// writes `mined`. Asks no chain index. `serve` runs the same pass every
/// 60 s. Refused (non-zero exit) when CHAINTRACKS_URL=off
TrackerTick,
/// Run monitor + HTTP server (foreground)
Daemon {
/// Bind beyond loopback (BIND_ADDR) with no AUTH_TOKEN set: without
/// this flag such a bind is refused at startup
#[arg(long)]
allow_no_token: bool,
},
/// Run HTTP server only (no monitor)
Serve {
/// Bind beyond loopback (BIND_ADDR) with no AUTH_TOKEN set: without
/// this flag such a bind is refused at startup
#[arg(long)]
allow_no_token: bool,
},
/// ONE process serving MANY wallets (fleet mode): each --wallet
/// <seat-dir>:<port> serves <seat-dir>/wallet.db on its own port with the
/// ROOT_KEY read from <seat-dir>/.env (never process env). Per-seat port
/// contract unchanged; any tenant exiting ends the whole process.
ServeFleet {
/// Repeated: <seat-dir>:<port> (dir holds wallet.db + .env)
#[arg(long = "wallet", required = true)]
wallet: Vec<String>,
/// Run each tenant's FULL daemon (monitor + auto-reconcile), not bare
/// HTTP — what a fleet playing back-to-back hands needs (bare serve
/// never proves 0-conf ancestry; the createAction-502 class returns).
#[arg(long)]
daemon: bool,
/// Bind beyond loopback (BIND_ADDR) with no AUTH_TOKEN set: without
/// this flag such a bind is refused at startup
#[arg(long)]
allow_no_token: bool,
},
/// Split UTXOs into multiple outputs for concurrency
Split {
/// Number of output UTXOs to create
#[arg(long, default_value_t = 3)]
count: u32,
},
/// Show blockchain service status
Services,
/// Open database inspector UI in browser
Ui {
/// UI server port (default: 9321)
#[arg(long, default_value_t = 9321)]
ui_port: u16,
},
/// Compact stored BEEF blobs to reduce transaction proof sizes
Compact,
/// Bundle .env + wallet.db into a tar.gz for off-machine backup
Backup {
/// Output path (default: ./bsv-wallet-backup-<timestamp>.tar.gz)
#[arg(long)]
to: Option<std::path::PathBuf>,
},
/// Write a BEEF file for every unspent UTXO, built from the proofs and transactions
/// the wallet already holds (no explorer is asked)
ExportBeefs {
/// Output directory (will be created if missing)
#[arg(long)]
to: std::path::PathBuf,
},
/// Find unproven txs that aren't on chain, mark failed, restore their inputs
CleanupAbandoned {
/// Apply changes (default is dry-run)
#[arg(long)]
execute: bool,
},
/// Probe unproven txs for network presence (broadcaster, chain index), credit the
/// broadcast memory, and retire a REJECTED tx no broadcaster took, together with
/// every unproven descendant (inputs released only on chain verification); one a
/// broadcaster took, or an absence past BROADCAST_ABSENCE_MINUTES, is named and kept
ReconcileBroadcasts {
/// Apply changes (default is dry-run; probes run either way)
#[arg(long)]
execute: bool,
/// Unproven transactions to probe this pass (run again to continue)
#[arg(long, default_value_t = 200)]
max_probes: usize,
},
/// Re-mark spent every output a LIVE tx of this wallet already spends (DB-only), then
/// chain-check the remaining spendable outputs per outpoint: relinquish when a confirmed
/// tx spent them, keep untouched when unknown (an unknown never moves money)
ReconcileOutputs {
/// Apply changes (default is dry-run)
#[arg(long)]
execute: bool,
/// Spendable outputs to chain-check per pass (run again to continue)
#[arg(long, default_value_t = 200)]
max_chain_checks: usize,
},
/// Re-prove stored merkle proofs the chain no longer confirms (after a reorg).
/// Compares every stored proof's merkle root in the window with the canonical
/// header's and lists the disagreements. Dry-run by default (no storage write).
/// With --execute: a provider's validated proof for the canonical block replaces
/// the stored one; providers still naming the stored block, or faulting, retain
/// it (the monitor retries); only positive evidence (the tracker refutes the
/// stored root, two providers answer cleanly "not mined", no provider serves a
/// path) demotes it to unproven with its bytes kept. --execute first raises the
/// persisted proof gate to tip - 1 when it is closed or below that, and is
/// refused when CHAINTRACKS_URL=off (nothing can be refuted without a header
/// service).
Reproof {
/// Only proofs at or above this height (default: the last 288 blocks). The
/// window includes the un-aged tip: a replacement there is deferred by the
/// proof gate and re-presented by the monitor, never stored early
#[arg(long)]
since_height: Option<u32>,
/// Every stored proof, whatever its height: one chaintracks read per distinct
/// height with no bound (progress every 50 heights on stderr)
#[arg(long)]
all: bool,
/// Apply changes (default is dry-run)
#[arg(long)]
execute: bool,
},
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
fn help_of(name: &str) -> String {
let mut cli = Cli::command();
let sub = cli.find_subcommand_mut(name).expect("the subcommand");
sub.render_long_help().to_string()
}
/// C4 (Rule 28): `sync --help` says what the command is, a chain scan
/// and a break-glass read, and names the routine path. Red at the base:
/// "Scan WhatsOnChain for unspent UTXOs at our deposit address and
/// internalize new ones", and nothing else.
#[test]
fn sync_help_says_it_is_a_break_glass_chain_scan_and_names_fund() {
let help = help_of("sync");
for words in ["chain scan", "Break-glass", "`fund`", "BEEF"] {
assert!(help.contains(words), "sync --help lacks `{words}`:\n{help}");
}
}
}