bsv-wallet-cli 0.12.0

Self-hosted BSV wallet CLI and BRC-100 server, wire-compatible with MetaNet Client
Documentation
# bsv-wallet-cli

A self-hosted BSV command-line wallet and BRC-100 HTTP server, with SQLite
storage, signing, broadcasting and transaction tracking. **0.12.0** pins
**bsv-wallet-toolbox-rs =0.8.0**, **bsv-rs =0.6.1** and **bsv-tracker =0.4.0**;
[Cargo.lock](Cargo.lock) carries those versions.

## Install and inspect

Install the registry release into an explicit tool directory:

```sh
cargo install bsv-wallet-cli --version 0.12.0 --locked --root ./wallet-tools
./wallet-tools/bin/bsv-wallet --help
./wallet-tools/bin/bsv-wallet respend --help
```

The package includes `bsv-wallet` and the callback receiver `bsv-wallet-relay`.
The MCP companion is a separate workspace member in [mcp](mcp), not a binary
installed by the command above. [Cargo.toml](Cargo.toml) describes the workspace.

## The MCP companion

`bsv-wallet-mcp` (0.3.0, on rmcp 3.5) speaks MCP over stdio and calls the
wallet's HTTP server: the 28 wallet endpoints, `wallet_balance`, and five tools
over the host's own routes, one per route, no route added: `chain_feed_status`
(`GET /chain-feed`), `tx_word` (`GET /tx/{txid}`), and on the callback relay
`health` (`GET /health`), `pull` (`GET /pull`) and `register`
(`POST /register`, with the relay's admin bearer). Each tool's description
says what its word rests on: headers and a proof, or a hint.

It is a workspace member, so build it from the workspace root:

```sh
cargo build --release -p bsv-wallet-mcp
```

The binary lands in the workspace's `target/release/bsv-wallet-mcp` (or under
`$CARGO_TARGET_DIR/release`), not under `mcp/`. A client config, with the
paths and URLs your own:

```json
{
  "mcpServers": {
    "bsv-wallet": {
      "command": "/path/to/bsv-wallet-cli/target/release/bsv-wallet-mcp",
      "env": {
        "WALLET_URL": "http://localhost:3322",
        "WALLET_ORIGIN": "http://localhost",
        "RELAY_URL": "http://localhost:3390",
        "RELAY_ADMIN_TOKEN": "<the relay's admin token, only for register>"
      }
    }
  }
}
```

`RELAY_URL` and `RELAY_ADMIN_TOKEN` are optional; without `RELAY_URL` the
three relay tools answer that no relay is named and ask nothing.

## Use and configuration

The CLI offers initialization, address and balance reads, transaction creation,
internalization, outputs/actions queries and time-locked gifts. `daemon` runs
the monitor with the HTTP server; `serve` runs the server alone. Initialization
creates wallet state, while sends and gift actions can spend funds. Use the
command help for arguments before operating a wallet.

The server implements the wallet interface and serializes spending operations.
`serve`, `daemon` and `serve-fleet` refuse a non-loopback bind without
`AUTH_TOKEN`, unless the operator explicitly supplies `--allow-no-token`.
`CHAINTRACKS_URL` selects the header service. Broadcaster callbacks and status
feeds are hints until their evidence is checked. Configuration and defaults
are defined in [src](src); [GIFT.md](GIFT.md) documents gift flows and limits.

## The chain-event feed

`daemon` holds the header service's chain-event feed
([src/chain_feed.rs](src/chain_feed.rs)); the source is `CHAINTRACKS_URL`
(required, no default; `off` has no feed). On start it replays from its stored
cursor (0 at birth) through `GET /events?since=<cursor>` until `hasMore` is
false, then holds `GET /events/stream?since=<cursor>`. Every envelope goes
through the tracker's decoder and `Tracker::on_chain` with the daemon's
headers: a reorg turns the words of the transactions mined at or above its
height `stale` and asks for a new proof; a fork or a split marks them suspect
and moves no word; a tip or a resolved split rechecks the marked ones against
the headers; a freeze writes its outpoint to the frozen record,
`frozen_outpoints` (txid, vout, the envelope's cursor, when), which the spend
guard reads (never offered; nothing here clears a row, the charter's R6). The
cursor is stored after each delivery, in the same transaction as the tracker's
rows (`tracker_meta`, `feed_cursor`). The status object `GET /chain-feed`
serves (`status`: its `cursor`, `delivered`, `updatedAt`) is rewritten after
every delivery (N = 1), while replaying and while following, so `status.cursor`
equals the top-level `cursor` once the delivery's write lands.

A decode fault, a cursor ahead of the journal (409 `cursorAhead`), an
`event: error` from the feed or a refused request stops the loop at its
cursor, with no skip; the fault is in the daemon's status, `GET /chain-feed`
(`status.fault`: its kind, the cursor it stopped at, the offending cursor, the
text), with `frozenCount`, the outpoints in the frozen record. A restart resumes from the stored cursor. A stream that ends without an
error event reconnects: the page from the cursor, then the stream with
`Last-Event-ID`.

Load: one outbound stream per daemon, plus one page request per reconnect
(more only while the journal says `hasMore`). The daemon runs nothing periodic
of its own for the feed; a transport failure is retried after a pause that
doubles from one second to five minutes. Headers are read beyond the feed
only when an envelope names a tracked transaction.

## The tracker pass

`serve`, `daemon` (and each tenant of `serve-fleet`, with or without
`--daemon`) run the same served pass
([src/broadcast_reconcile.rs](src/broadcast_reconcile.rs),
`run_served_pass`) every `BROADCAST_RECONCILE_INTERVAL_SECS` (default 60;
`BROADCAST_RECONCILE=0` turns it off): the tracker's pass, the poisoned-chain
sweep and the spend guard. Under `serve` it drains the wallet's Arcade SSE
stream first; under `daemon` the monitor holds that stream and the pass drains
none. Its load: it reads the store and asks the header service for the tip and
the header at each height a stored path names (one read per height per pass).
Beyond that it asks only when a row is due: a merkle path for a re-ask that is
due (at most `TRACKER_MAX_ASKS`, default 20, per pass, through the toolbox's
couriers, below), a re-announce to the broadcasters at a lock time, and the
sweep's re-check of an input held locked past its due time.

Every creator of a coin we hold gets a row (the stack's wallet charter,
section 0: "About a transaction it believes only the tracker's word: each
action hosts one tracker state"). An `unproven` or `sending` transaction of
the wallet is adopted at `built`. A `completed` transaction with a stored proof
(`proven_tx_id` set; the toolbox's monitor proved it first) that creates a coin
we hold and has no row is adopted by replaying its stored path against the
headers of the moment, as the spend guard replays it: the row's word is
`mined(h)` with that path as its evidence when the header at h carries the
root, else the row has no word and the pending re-ask `proofFailed`, run by the
next pass. A header the service could not give adopts nothing that pass; the
toolbox's rows are never written; at most 100 such creators per pass. A creator
the toolbox moves to `completed` while a row exists keeps its row. So
`GET /tx/{txid}` answers for every coin the wallet holds.

## The spend guard on coin selection

Every coin the binary offers to `createAction` (the served `/createAction`,
`send`, `drain`, `split`, `gift-send`) passes the spend guard
([src/spend_gate.rs](src/spend_gate.rs)). The toolbox picks the coins inside
its own query, so the guard holds every coin it does not offer for the length
of the call (the coin's `change` flag, recorded in `spend_gate_holds`) and
restores exactly those after. Two settings:

- `SPEND_DEPTH` (default 1, and the floor: a smaller number reads as 1): the
  depth the block holding a coin's transaction must reach, checked against the
  header service's headers of the moment; the tip is depth 1. The stack's
  wallet charter **R3 (b)**: the spend depth is the host's parameter, 1 the
  floor.
- `SPEND_OWN_CHANGE` (default `1`; `0` turns it off): offer our own change
  (`made`: a transaction this wallet created and signed) whose transaction has
  no block yet. The charter's **R1 (b)**: the one rule a host may name for a
  coin whose creator is not yet mined is for its own change. A received coin
  (`internalizeAction`, any counterparty's payment) with no proof is never
  offered under any host setting. Here the guard departs from the reference
  toolbox, which spends unproven coins whoever made them.

One word per action, never a setting: `allowPending: "received"` in the
served `/createAction` body (beside `options`; absent or any other value is
off), or `--allow-pending received` on `send`, `drain`, `split` and
`gift-send`. The charter's **R1 (d)** (the owner's ruling of 2026-10-18):
under it, and only for that action, a received coin whose transaction has no
block yet is offered; its body was checked at internalize (the toolbox refuses
an incomplete ancestry and checks every proof it carries against the header
service), so the one word trusted is the sender's broadcast. Its cost: if the
sender double-spends, this action becomes `conflicted` and the coins of ours
it spent are lost with it, though nothing of ours was ever lost in fact. The
action records the word (`spend_gate_actions`), and `actions` and
`/listActions` show it as `underRule: "received-pending"`. A template made
for deferred signing (`signAndProcess: false`) records it by the action's
`reference` and gains its txid at `signAction`; an aborted template keeps the
record. There is no environment variable for it.

A coin whose transaction is `stale`, `rejected`, `conflicted` or `abandoned`
is never offered; a coin whose creator is `mined` at or above the
`forkHeight` of a `split` the feed delivered is held until that split's
`splitResolved` (the open splits are kept in `tracker_meta`, `open_splits`,
written in the delivery's transaction; the scenario
`tracker-split-observation-writes-no-word.md` column 4), with the reason
`suspect: a fork or an unresolved split at or below its height`; a frozen coin is never offered, and a coin an action of ours
holds is the toolbox's lock. Coins a caller names (`createAction`'s `inputs`,
`respend`'s exact inputs) pass the same judgment; a named coin the guard would
hold refuses the action, `403 INPUT_NOT_OFFERED` with the same `held` list (an
outpoint that is not one of the wallet's coins is left to the toolbox).
When no offered coin covers the action, the refusal lists every coin held
back: its outpoint, the word, the origin (`made` or `received`), its age in
minutes since the wallet learned of it, and why (`depth 1: 0 blocks`,
`received, no proof; offered under allowPending: received`,
`own change, SPEND_OWN_CHANGE=0`, `locked by <txid>`, `frozen: never offered`).
The route answers `402 INSUFFICIENT_FUNDS` with the list under `held`; the
commands print it, and under `--json` print the route's body as one JSON object
on stdout, `{"code":"INSUFFICIENT_FUNDS","message","offeredSatoshis","held":[...]}`
(`INPUT_NOT_OFFERED` for a named coin), exit 1. Nothing is fetched for it but the header service's tip and
headers: no chain scan, no explorer.

## One transaction's word

`GET /tx/{txid}` (behind the same bearer as `/chain-feed`) serves the
tracker's stored word for one transaction: `{txid, word, height, reask,
evidence}`, where `evidence` is, for `mined`, the stored proof's `root` and
`height` (the toolbox's `proven_txs`), else `null`; 404 when the tracker holds
no word for it. A view; nothing is decided from it. The MCP tool `tx_word`
reads it.

## Explorer calls (rule 28)

Every explorer call is deleted, because a header, a proof or our own index
already answers, or it is a break-glass read that says why at its site
([tests/rule_28_sites.rs](tests/rule_28_sites.rs) holds the CLI's own list).
The CLI's sites:

| site | what it asks, and when |
|---|---|
| `src/broadcast_verify.rs` (C1) | WhatsOnChain `GET /tx/hash/{txid}`, Bitails `GET /tx/{txid}`: after a send, whether the network holds what a broadcaster took |
| `src/commands/receive.rs` (C2) | WhatsOnChain `GET /tx/{txid}/beef`: `receive`, a payment's BEEF no store of ours holds |
| `src/commands/sync.rs` (C4) | `GET /address/{address}/unspent`: a chain scan for payments to our deposit address nobody handed us; `sync` only, by hand, one explorer |
| `src/commands/cleanup_abandoned.rs` (C7) | WhatsOnChain `GET /tx/{txid}/{vout}/spent`: who spent an outpoint, for the abandoned-transaction reconcile (no header or proof says an output is spent by someone else) |
| the toolbox's monitor, `check_for_proofs` (`daemon` only) | the monitor's own proof backstop, not the tracker's path: on each new header (and a two-hour fallback) it asks the status providers about every unproven transaction, then a merkle path for each one called mined, Arcade first when configured, then WhatsOnChain, then Bitails; the path is checked against the header service before it is stored. Each proof it stores is logged at info with the provider that served it: marker `proof_backstop_provider`, `provider`, `txid`, `height` ([src/backstop_log.rs](src/backstop_log.rs)); `status-answer` when the status answer carried the proof. No setting turns its explorer providers off (toolbox 0.8.0 `ServicesOptions` has none; `BREAK_GLASS_EXPLORER_HEADERS` governs headers only) |
| the tracker's re-ask (`Courier`, every served pass) | a merkle path for one named transaction whose re-ask is due, through the same toolbox couriers (Arcade, then WhatsOnChain, then Bitails), checked by the tracker against the header service |

## Deliberate respend

`respend` is the host's explicit choice to build and post a competing spend
from a previously announced transaction's reserved inputs. It does not retire
the original transaction or make its inputs spendable. The original remains
tracked until the competitor's checked proof establishes the conflict.

[src/respend.rs](src/respend.rs) refuses when a current status source still
holds the original, when a configured or previously accepting source cannot
answer, when no broadcaster ever took it, or when it already has proof or a
terminal word. It also refuses missing or invalid ancestry, inputs that are
not the wallet's reserved P2PKH outputs, changed reservations, insufficient
fee coverage, and an already tracked replacement. Silence must be established;
an unavailable source is not silence. A refused post of the replacement is
still a retryable hint.

## Build and gate

```sh
cargo build --locked
cargo fmt --all -- --check
```

[.github/workflows/ci.yml](.github/workflows/ci.yml) runs workspace tests,
formatting, clippy, release builds on Linux and macOS, and the status/covenant
vector checks on every PR and main push. [tests](tests) contains the HTTP and
tracker integration witnesses; local fixture tests are not live-wallet proof.

## Records and releases

[RELEASING.md](RELEASING.md) defines the machine gate and release publication.
[CHANGELOG.md](CHANGELOG.md) records changes and
[docs/reading-a-balance.md](docs/reading-a-balance.md) explains balance evidence.
Licensed under [MIT](LICENSE).