Files
routstrd/README.md
2b60d64563 feat(wallet): guided diagnostics for wallet migration conflicts (#80)
* fix(wallet): distinguish legacy cocod from routstrd locks

Treat the legacy Unix socket, rather than the shared cocod.pid file, as the authoritative cocod identity check. routstrd deliberately writes its own PID into cocod.pid as an exclusion fence, so the previous PID-only guard could falsely report an existing routstrd process as legacy cocod after an interrupted or slow startup.

Keep startup safe by relying on the atomic PID-file claim for the cocod-starting race, while continuing to fail closed for socket probe errors that do not prove cocod has stopped. Remove the now-unnecessary migration ignorePid workaround.

Harden stale-lock recovery by detecting Linux zombie processes through /proc/<pid>/stat, registering synchronous process-exit cleanup for owned PID files, and installing daemon signal handlers before migration and wallet initialization. Improve contention and startup-timeout errors with the lock identity, owning PID, and actionable recovery guidance.

Add regression tests for live shared PID owners with missing or stale cocod sockets, responsive cocod sockets, unsafe probe failures, zombie detection, and process-exit cleanup.

* Confirm expired mint quotes with their mints before failing locally

Problem
-------
0ce4c07 pruned expired pending mint quotes purely locally at startup:
any quote past its bolt11 expiry with no recorded PAID/ISSUED
observation was failed without contacting its mint. That invariant is
only forward-looking: a quote can be paid before expiry while the
daemon is down, leaving no local observation behind. Failing such a
quote strands the paid funds at the mint: failed operations are skipped
by recoverPendingMintOperations(), so the claimable proofs are never
claimed.

Concrete case: receiveBolt11 invoice created, daemon stops, user pays
within expiry, daemon restarts after expiry: the old prune failed the
op without ever asking the mint.

Change
------
Replace failExpiredMintsLocally() with settleExpiredMintQuotes(),
which adds one bounded observation round before any local fail:

1. Select expired, unobserved pending mint quotes as before
   (selectCleanupOperations, minAgeMs 0).
2. For each candidate, ask its mint for the quote state via
   MintOperationService.observePendingOperation() (the same check the
   mint sweep uses, reached through the existing structural cast)
   under a shared 15s wall-clock budget
   (EXPIRED_MINT_OBSERVATION_DEADLINE_MS).
3. Act on the answer from the mint:
   - UNPAID ("waiting"): the expired quote can never be issued, so
     failing it locally cannot strand funds; failPendingOperation().
   - PAID/ISSUED ("ready"/"completed"): leave pending; the mint
     recovery sweep (or the processor, via the emitted
     mint-op:quote-state-changed event) finalizes it and claims the
     proofs.
   - unreachable/slow mint or unknown quote: leave pending so a later
     startup can still recover it. Nothing is failed without a mint
     confirmation.

Why a deadline
--------------
coco-core issues mint requests via bare fetch() with no timeout, so a
hung mint could otherwise stall this phase (and with it the recovery
promise that gates value-moving operations) for minutes. The shared
budget caps the whole round at 15s; the unobserved remainder stays
pending and is handled by the normal sweep (background, per-op
contained).

Why not keep the blind local fail
---------------------------------
The mint sweep treats UNPAID as "waiting" and never fails expired
quotes itself, so some form of pruning is still required to keep
recovery quick on wallets with many dead quotes. The observation round
keeps that property: confirmed-unpaid quotes are failed before the
sweep and never contacted again, while the unsafe case (paid before
expiry, never observed) now goes through normal recovery.

Side effects
------------
- Asking the mint also closes the narrower race from the old flow
  (watcher records PAID between selection and fail): quotes are now
  failed only when the mint currently reports UNPAID past expiry.
- Recovery phase strings are now "Settling/Settled expired mint
  quotes"; settlement counts are logged to the startup stream.
- The explicit wallet cleanup command keeps its local-only semantics:
  it is user-invoked, supports dry-run, and defaults to a 7-day
  minimum age, giving ample observation opportunity beforehand.

Testing
-------
- New settleExpiredMintQuotes unit tests (6): mint-confirmed unpaid is
  failed locally; PAID/ISSUED is left for recovery; unreachable mint
  is left pending; hung mint is bounded by the shared deadline;
  unexpired/observed quotes untouched.
- bun run lint (tsc --noEmit) passes.
- bun run build passes.
- Wallet/cleanup tests pass (56/56).
- Full bun test shows one pre-existing, unrelated failure
  (mergeHermesConfig) that also fails on the parent commit.

* feat(wallet): add migration conflict diagnostics and wallet doctor

When both ~/.routstrd/wallet and ~/.cocod contain different wallets,
startup now refuses with a structured, privacy-safe comparison
(mnemonic fingerprints, timestamps, proof/mint summaries) instead of
a terse one-liner, and points to the new 'routstrd wallet doctor'
command for a full report and resolution steps.

The mnemonic is never printed: only a truncated SHA-256 fingerprint,
and only for unencrypted configs. Database summaries are read-only
and degrade gracefully on malformed or corrupt files.

* fix(wallet): polish doctor verdicts and conflict error surfacing

Review follow-ups:

- Gate the mv resolution steps on an actual conflict; the doctor no
  longer tells fresh installs or healthy single-wallet setups to move
  directories around.
- Print WalletMigrationConflictError cleanly at 'routstrd onboard'
  (message + exit 1) instead of Bun's unhandled-rejection dump with
  source snippet and stack trace; startDaemon failures likewise.
- New diagnoseWallets() classifies both wallet locations the way
  migration sees them (including incomplete db-only legacies) and
  drives the doctor verdict, resolution gating, and exit code.
- 'routstrd wallet doctor' exits 1 when startup would refuse to
  migrate, so scripts can detect the conflict state.
- Count mints from the mints registry table (falling back to mints
  seen in proofs), add thousands separators to balances, say 'just
  now' instead of '0s ago', and clarify the same-mnemonic verdict.
- The startup conflict message now includes the 'routstrd stop' step
  via the shared renderResolutionSteps().

* fix(wallet): share migration classifier between startup and doctor

diagnoseWallets previously re-derived startup state with looser rules (presence + fingerprint), which disagreed with migrateLegacyWallet on several states:

- target init + source db-only: migration returns already-current, but the doctor claimed startup would refuse
- target db-only + source absent: migration throws, but the doctor said no migration needed
- orphaned source SQLite sidecars: migration throws, but the doctor said fresh install
- same-mnemonic wallets: doctor could not distinguish byte-identical (already-current) from same-mnemonic-different-bytes (conflict)

Extract the exact decision order into classifyWalletMigration() in a new wallet-state.ts and make both migrateLegacyWallet and diagnoseWallets consume it, so the doctor's verdict, resolution gating, and exit code can never drift from actual startup behavior again.

Add regression tests for each previously mismatched state.

* fix(wallet): never let the doctor crash on unreadable wallet files

classifyWalletMigration does raw byte reads (filesEqual) that throw on
unreadable files or delete races — fine for startup, where the same
throw is loud either way, but the doctor exists to diagnose broken
states and must render its report regardless. diagnoseWallets now
catches classification errors and falls back to a conflict verdict
(preferring the 'could not be fully read' text when the guarded
summarizers already recorded the underlying error), so the report,
resolution steps, and exit code still reach the user.

Also adds the missing trailing newlines in wallet-state.ts and
diagnostics.test.ts.

---------

Co-authored-by: redshift <213178690+1ftredsh@users.noreply.github.com>
2026-08-18 20:39:03 +00:00

4.9 KiB

routstrd

Routstr daemon - A CLI tool for managing routstr processes, similar to cocod (a Cashu wallet daemon).

Overview

routstrd is a Bun-based CLI tool that provides a background daemon for the Routstr protocol. It integrates with cocod for wallet management and uses the Routstr SDK to handle provider routing and model discovery.

Routstr for Teams

For team-based routing, see routstrd-auth.

Features

  • Daemon Mode: Run routstrd as a background HTTP server
  • Wallet Integration: Works with cocod for Cashu token management
  • Provider Routing: Automatically discovers and routes requests to available providers
  • Config Management: Stores configuration in ~/.routstrd/

Requirements

curl -fsSL https://bun.com/install | bash

Installation

Step 1: Install

Global with bun:

bun i -g routstrd

OR - From source:

git clone https://github.com/routstr/routstrd.git
cd routstrd
bun install
bun link

Step 2: Setup & Fund

routstrd onboard
routstrd receive <cashu>       # receive a Cashu token
routstrd receive 2100         # to top up 2100 sats with lightning

Step 3: Integrate with Claude Code

routstrd clients add --claude-code  # or --pi-agent / --opencode

Use Routstrd Skill

Tip: You can also install the routstrd skill so the agent can manage routstrd for you.

More Commands

Start Daemon

Start the background daemon:

routstrd start

With custom port:

routstrd start --port 9000

The daemon binds to 127.0.0.1 by default. To expose it on another interface:

routstrd start --host 0.0.0.0

Only expose the daemon behind appropriate network controls.

With specific provider:

routstrd start --provider https://your-provider.com

CLI Commands

Check daemon status:

routstrd status

Get wallet balance:

routstrd balance

Test connection:

routstrd ping

Stop the daemon:

routstrd stop

NPC (Lightning Address)

The in-process wallet registers the NPC (npubx.cash) plugin, which gives the daemon a persistent Lightning address backed by the wallet's Cashu mints. Payments to the address are imported into the wallet automatically (websocket push, plus manual sync on demand).

# Show your NPC Lightning address (username@npubx.cash, or npub fallback)
routstrd wallet npc address

# Claim a username (quote first, then confirm to pay the claim fee from the wallet)
routstrd wallet npc username myname
routstrd wallet npc username myname --confirm

# Manually sync paid NPC quotes into the wallet
routstrd wallet npc sync

Equivalent daemon endpoints: GET /wallet/npc/address, POST /wallet/npc/username, POST /wallet/npc/sync.

Daemon API

The daemon exposes an HTTP server (default port 8008) with the following endpoints:

Health Check

GET /health

Route Request

POST /

Request body:

{
  "model": "model-id",
  "messages": [...],
  "stream": false
}

Response:

{
  "choices": [...],
  "usage": {...}
}

Wallet storage

The in-process Cashu wallet stores its mnemonic and proof database in ~/.routstrd/wallet/. On first startup, an existing wallet in ~/.cocod/ is migrated automatically after routstrd verifies that the legacy cocod daemon is not running. Back up your mnemonic before upgrading.

Set ROUTSTRD_WALLET_DIR to override the canonical wallet directory. The COCOD_DIR, COCOD_SOCKET, and COCOD_PID variables are retained only for locating and excluding a legacy external cocod process.

If both ~/.routstrd/wallet and ~/.cocod contain different wallets, startup refuses to migrate rather than picking a mnemonic for you. Run routstrd wallet doctor to compare the two wallets (mnemonic fingerprints, timestamps, and balances) and see which one to keep.

Configuration

Configuration is stored in ~/.routstrd/config.json:

{
  "port": 8008,
  "host": "127.0.0.1",
  "provider": null,
  "cocodPath": null
}

Environment Variables

  • ROUTSTRD_DIR - Config directory (default: ~/.routstrd)
  • ROUTSTRD_SOCKET - Socket path (default: ~/.routstrd/routstrd.sock)
  • ROUTSTRD_PID - PID file path (default: ~/.routstrd/routstrd.pid)

Development

Install dependencies:

bun install

Run CLI:

bun run start

Run daemon:

bun run start

Typecheck:

bun run lint

Project Structure

routstrd/
├── src/
│   ├── index.ts       # Entry point with shebang
│   ├── cli.ts         # Commander CLI commands
│   ├── cli-shared.ts  # IPC utilities
│   ├── daemon.ts      # HTTP server daemon
│   └── utils/
│       └── config.ts  # Path configuration
├── package.json
└── tsconfig.json

License

MIT