Files
routstrd/docs/wallet-mint-recovery.md
T
redshift 9c75d2e202 feat(wallet): add live health checks to wallet doctor
wire the doctor helpers from the previous commit into a read-only health
check surfaced by the daemon and the CLI:

- coco-client: runWalletDoctor orchestrates the four checks against an
  injected structural source (unit-testable like runMintQuoteRecovery);
  diagnoseWallet() wires it to coco - trusted mints, pending/in-flight
  mint ops, and failed ops + inflight proofs via coco's private
  repositories, resolved fail-closed so a coco upgrade that renames them
  surfaces immediately. Quote checks are plain NUT-04 reads (never coco's
  observe-and-persist path), bounded by an overall budget and a
  concurrency limit; uncheckable quotes are counted, not dropped.
- http: GET /wallet/doctor returns the report (501 when the wallet client
  has no doctor support).
- cli: wallet doctor now prints the health section first (mint
  reachability, recent unpaid quotes, paid-but-not-issued with recover
  remediation, stuck melts), then the existing offline migration
  diagnosis. NO_COLOR/TTY-aware colors, --json for scripting, exit 1 when
  money is provably at risk or the wallets conflict. When the daemon is
  down the live section is skipped rather than auto-starting the daemon.
- tests: HTTP route tests, runWalletDoctor unit tests with a fake source,
  and real-Manager + fake-mint integration tests covering the full
  unpaid -> paid-unissued -> recovered -> clean arc and unreachable-mint
  degradation.
- docs: wallet doctor section in wallet-mint-recovery.md.
2026-10-04 20:52:16 +08:00

4.5 KiB

Mint quote recovery: scope and troubleshooting

routstrd wallet recover explicitly retries mint operations through coco using their existing stored outputs. It can restore signatures when a quote is already issued, and reopen failed operations when explicitly requested:

routstrd history --json
routstrd wallet recover --op <operationId> --include-failed

Failed operations require explicit IDs over both HTTP and the CLI. A successful re-run on an already finalized operation is a no-op. Requests that exceed their wait budget are not cancelled; explicit retries skip the operation while the underlying work is outstanding.

What this fixes—and what it does not

Coco already checks pending quotes on startup and the daemon periodically refreshes them. A quote paid while the daemon was offline does not, by itself, require a new issuance implementation.

This change makes normal cleanup confirm UNPAID with the mint before failing an expired quote. PAID, ISSUED and unverified quotes remain pending. It also gives operators a recovery path for operations previously marked failed.

It does not replace rejected outputs with fresh outputs on an active keyset. An inactive-keyset rejection can therefore remain retryable with zero recovery. Recovery reports coco's persisted mint error when available, rather than only a generic “remains pending” error.

Do not infer that the production incidents were caused by keyset retirement. Before claiming those incidents are fixed, collect:

  • The affected operation IDs, quote IDs, state and persisted error.
  • A fresh remote quote state and, where provided, paid/issued amounts.
  • The keyset IDs in the stored outputs and the mint's current keyset metadata.
  • A reproduction showing existing recovery fails and the proposed fix succeeds.

Inspect persisted operation data through a read-only database copy; do not edit rows or run recovery scripts concurrently with a daemon against the same wallet. Never share the mnemonic, output secrets, or full wallet database in a PR.

A future fresh-output path must preserve original outputs for uncertain issuance and NUT-09 restore, allocate fresh deterministic counters safely, and coordinate with coco's watcher/processor. It needs its own integration tests before handling real funds.

Wallet doctor

routstrd wallet doctor runs read-only health checks before the legacy migration diagnosis:

  1. Mint reachability — a NUT-06 probe against every trusted mint.
  2. Recent unpaid quotes — pending quotes from the last hour the mint still reports UNPAID (an invoice awaiting payment; informational).
  3. Paid but not issued — the stuck scenario this recovery feature fixes. Each finding prints its wallet recover --op <id> remediation, with --include-failed when the operation must be re-opened first.
  4. Stuck melts — prepared melts holding reserved proofs, in-flight melts, and failed melts whose input proofs were never released.

The doctor never mutates wallet state: quote checks are plain NUT-04 reads, not coco's observe-and-persist path, and remediation is always left to the operator. It exits non-zero when money is provably at risk (unreachable mint, paid-but-unissued quote, failed melt with locked proofs) or the migration diagnosis finds a conflict, so it can gate scripts. When the daemon is down only the offline migration section runs.

Cleanup preview and force

wallet cleanup --dry-run is local-only: it reports mintQuoteCandidates, not confirmed failures. failedMintQuotes and leftForRecovery are zero because no mint check or cleanup transition was performed. Send/melt counts remain planned cleanup counts in dry-run mode.

--force deliberately bypasses mint confirmation and can strand paid sats in a failed operation. Prefer normal cleanup. Forced operations can be retried with --op <operationId> --include-failed, but recovery still depends on the mint accepting their stored outputs or restoring their signatures.

Integration and release notes

The reopen helper uses private coco-core 1.0.1 methods. Retain real-Manager and HTTP fake-mint coverage, use frozen dependency installs, and re-run integration tests on coco upgrades. A controlled low-value live-mint smoke test remains recommended before release.

PR #118 removes cocod-client.ts. When integrating that change, move recovery and cleanup contracts into its replacement wallet-client.ts, rename HTTP error references accordingly, and make recovery mandatory for the in-process client. This follow-up does not pull in #118's unrelated removal.