mirror of
https://github.com/Routstr/routstrd.git
synced 2026-09-14 02:55:07 +00:00
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>
This commit is contained in:
@@ -172,6 +172,11 @@ 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`:
|
||||
|
||||
+40
-6
@@ -35,6 +35,13 @@ import {
|
||||
} from "./daemon/wallet/coco-client";
|
||||
import { migrateLegacyWallet } from "./daemon/wallet/migration";
|
||||
import {
|
||||
diagnoseWallets,
|
||||
renderWalletDoctor,
|
||||
summarizeWalletDirectory,
|
||||
WalletMigrationConflictError,
|
||||
} from "./daemon/wallet/diagnostics";
|
||||
import {
|
||||
legacyCocodDir,
|
||||
legacyCocodPidPath,
|
||||
legacyCocodSocketPath,
|
||||
walletDir as defaultWalletDir,
|
||||
@@ -570,7 +577,17 @@ program
|
||||
)
|
||||
.action(async () => {
|
||||
await requireLocalDaemon();
|
||||
await initDaemon();
|
||||
try {
|
||||
await initDaemon();
|
||||
} catch (error) {
|
||||
// An expected, user-actionable refusal — print the structured message
|
||||
// without Bun's unhandled-rejection source snippet and stack trace.
|
||||
if (error instanceof WalletMigrationConflictError) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Start - start the background daemon
|
||||
@@ -584,11 +601,18 @@ program
|
||||
await requireLocalDaemon();
|
||||
const config = await loadConfig();
|
||||
await stopLegacyCocod();
|
||||
await startDaemon({
|
||||
port: options.port || String(config.port || 8008),
|
||||
host: options.host || config.host || undefined,
|
||||
provider: options.provider,
|
||||
});
|
||||
try {
|
||||
await startDaemon({
|
||||
port: options.port || String(config.port || 8008),
|
||||
host: options.host || config.host || undefined,
|
||||
provider: options.provider,
|
||||
});
|
||||
} catch (error) {
|
||||
// startDaemon embeds the failed daemon's own output in the message
|
||||
// (including the wallet-conflict report), so print it without a stack.
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Status - check daemon status
|
||||
@@ -1523,6 +1547,16 @@ walletCmd
|
||||
await handleDaemonCommand("/wallet/status");
|
||||
});
|
||||
|
||||
walletCmd
|
||||
.command("doctor")
|
||||
.description("Diagnose conflicting wallets (current routstrd wallet vs legacy cocod)")
|
||||
.action(async () => {
|
||||
const target = summarizeWalletDirectory(defaultWalletDir(), "canonical");
|
||||
const source = summarizeWalletDirectory(legacyCocodDir(), "legacy");
|
||||
console.log(renderWalletDoctor(target, source));
|
||||
if (diagnoseWallets(target, source).conflict) process.exit(1);
|
||||
});
|
||||
|
||||
walletCmd
|
||||
.command("unlock <passphrase>")
|
||||
.description("Unlock the wallet")
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import {
|
||||
diagnoseWallets,
|
||||
mnemonicFingerprint,
|
||||
renderWalletDoctor,
|
||||
summarizeWalletDirectory,
|
||||
WalletMigrationConflictError,
|
||||
} from "./diagnostics";
|
||||
|
||||
const roots: string[] = [];
|
||||
function root(): string {
|
||||
const path = mkdtempSync(join(tmpdir(), "routstrd-diagnostics-"));
|
||||
roots.push(path);
|
||||
return path;
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const MNEMONIC_A =
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||
const MNEMONIC_B =
|
||||
"legal winner thank year wave sausage worth useful legal winner thank yellow";
|
||||
|
||||
function writeWalletConfig(dir: string, config: unknown): void {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "config.json"), JSON.stringify(config));
|
||||
}
|
||||
|
||||
function writeFakeDbFile(dir: string): void {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "coco.db"), "not a real sqlite database");
|
||||
}
|
||||
|
||||
function writeProofsDb(dir: string): void {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const db = new Database(join(dir, "coco.db"));
|
||||
db.exec(`
|
||||
CREATE TABLE coco_cashu_proofs (mintUrl TEXT, state TEXT, amount INTEGER);
|
||||
INSERT INTO coco_cashu_proofs VALUES ('https://mint.example', 'ready', 100);
|
||||
INSERT INTO coco_cashu_proofs VALUES ('https://mint.example', 'ready', 50);
|
||||
INSERT INTO coco_cashu_proofs VALUES ('https://mint.example', 'pending', 30);
|
||||
INSERT INTO coco_cashu_proofs VALUES ('https://other.example', 'spent', 20);
|
||||
CREATE TABLE coco_cashu_mints (mintUrl TEXT, trusted INTEGER);
|
||||
INSERT INTO coco_cashu_mints VALUES ('https://mint.example', 1);
|
||||
INSERT INTO coco_cashu_mints VALUES ('https://other.example', 1);
|
||||
INSERT INTO coco_cashu_mints VALUES ('https://third.example', 0);
|
||||
`);
|
||||
db.close();
|
||||
}
|
||||
|
||||
describe("mnemonicFingerprint", () => {
|
||||
it("is deterministic and normalizes whitespace", () => {
|
||||
const base = mnemonicFingerprint(MNEMONIC_A);
|
||||
expect(mnemonicFingerprint(MNEMONIC_A)).toBe(base);
|
||||
expect(
|
||||
mnemonicFingerprint(" abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about "),
|
||||
).toBe(base);
|
||||
});
|
||||
|
||||
it("distinguishes different mnemonics and never returns the mnemonic", () => {
|
||||
const a = mnemonicFingerprint(MNEMONIC_A);
|
||||
const b = mnemonicFingerprint(MNEMONIC_B);
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).not.toContain("abandon");
|
||||
expect(b).not.toContain("legal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeWalletDirectory", () => {
|
||||
it("reports an empty directory", () => {
|
||||
const dir = join(root(), "empty");
|
||||
const diag = summarizeWalletDirectory(dir, "canonical");
|
||||
expect(diag.config.exists).toBe(false);
|
||||
expect(diag.db.exists).toBe(false);
|
||||
});
|
||||
|
||||
it("summarizes a decrypted config with fingerprint and metadata", () => {
|
||||
const dir = join(root(), "wallet");
|
||||
writeWalletConfig(dir, {
|
||||
version: 1,
|
||||
mnemonic: MNEMONIC_A,
|
||||
encrypted: false,
|
||||
createdAt: "2026-08-20T14:03:22.000Z",
|
||||
defaultMintUrl: "https://mint.example",
|
||||
});
|
||||
const diag = summarizeWalletDirectory(dir, "canonical");
|
||||
expect(diag.config.exists).toBe(true);
|
||||
expect(diag.config.fingerprint).toBe(mnemonicFingerprint(MNEMONIC_A));
|
||||
expect(diag.config.hasMnemonic).toBe(true);
|
||||
expect(diag.config.encrypted).toBe(false);
|
||||
expect(diag.config.createdAt).toBe("2026-08-20T14:03:22.000Z");
|
||||
expect(diag.config.defaultMintUrl).toBe("https://mint.example");
|
||||
expect(diag.config.mtimeMs).toBeTypeOf("number");
|
||||
});
|
||||
|
||||
it("does not derive a fingerprint for encrypted wallets", () => {
|
||||
const dir = join(root(), "encrypted");
|
||||
writeWalletConfig(dir, { mnemonic: MNEMONIC_A, encrypted: true });
|
||||
const diag = summarizeWalletDirectory(dir, "canonical");
|
||||
expect(diag.config.exists).toBe(true);
|
||||
expect(diag.config.encrypted).toBe(true);
|
||||
expect(diag.config.fingerprint).toBeUndefined();
|
||||
});
|
||||
|
||||
it("degrades gracefully when config.json is malformed", () => {
|
||||
const dir = join(root(), "bad-config");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "config.json"), "not json");
|
||||
const diag = summarizeWalletDirectory(dir, "canonical");
|
||||
expect(diag.config.exists).toBe(true);
|
||||
expect(diag.config.error).toBeTruthy();
|
||||
expect(diag.config.fingerprint).toBeUndefined();
|
||||
});
|
||||
|
||||
it("summarizes a proof database without opening it read-write", () => {
|
||||
const dir = join(root(), "with-db");
|
||||
writeProofsDb(dir);
|
||||
const diag = summarizeWalletDirectory(dir, "canonical");
|
||||
expect(diag.db.exists).toBe(true);
|
||||
expect(diag.db.summary).toBeDefined();
|
||||
expect(diag.db.summary?.totalProofs).toBe(4);
|
||||
expect(diag.db.summary?.totalAmount).toBe(200);
|
||||
expect(diag.db.summary?.distinctMints).toBe(2);
|
||||
expect(diag.db.summary?.mints).toHaveLength(3);
|
||||
expect(diag.db.summary?.amountByState).toEqual({
|
||||
ready: 150,
|
||||
pending: 30,
|
||||
spent: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WalletMigrationConflictError", () => {
|
||||
it("renders a structured conflict message without leaking mnemonics", () => {
|
||||
const targetDir = join(root(), "wallet");
|
||||
const sourceDir = join(root(), ".cocod");
|
||||
writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
|
||||
writeWalletConfig(sourceDir, { mnemonic: MNEMONIC_B });
|
||||
|
||||
const err = new WalletMigrationConflictError(
|
||||
summarizeWalletDirectory(targetDir, "canonical"),
|
||||
summarizeWalletDirectory(sourceDir, "legacy"),
|
||||
);
|
||||
expect(err.target.dir).toBe(targetDir);
|
||||
expect(err.source.dir).toBe(sourceDir);
|
||||
expect(err.message).toContain(targetDir);
|
||||
expect(err.message).toContain(sourceDir);
|
||||
expect(err.message).toContain("routstrd wallet doctor");
|
||||
expect(err.message).toContain("routstrd stop");
|
||||
expect(err.message).not.toContain("abandon");
|
||||
expect(err.message).not.toContain("legal winner");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderWalletDoctor", () => {
|
||||
it("reports different mnemonics as a conflict with resolution steps", () => {
|
||||
const targetDir = join(root(), "wallet");
|
||||
const sourceDir = join(root(), ".cocod");
|
||||
writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
|
||||
writeWalletConfig(sourceDir, { mnemonic: MNEMONIC_B });
|
||||
|
||||
const report = renderWalletDoctor(
|
||||
summarizeWalletDirectory(targetDir, "canonical"),
|
||||
summarizeWalletDirectory(sourceDir, "legacy"),
|
||||
);
|
||||
expect(report).toContain("DIFFERENT mnemonics");
|
||||
expect(report).toContain("routstrd stop");
|
||||
expect(report).toContain("mv \"");
|
||||
});
|
||||
|
||||
it("reports matching mnemonics and warns startup still refuses", () => {
|
||||
const targetDir = join(root(), "wallet");
|
||||
const sourceDir = join(root(), ".cocod");
|
||||
writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
|
||||
// Same mnemonic, but different bytes (extra metadata) — migration compares
|
||||
// files, not mnemonics, so startup still refuses.
|
||||
writeWalletConfig(sourceDir, {
|
||||
mnemonic: MNEMONIC_A,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const report = renderWalletDoctor(
|
||||
summarizeWalletDirectory(targetDir, "canonical"),
|
||||
summarizeWalletDirectory(sourceDir, "legacy"),
|
||||
);
|
||||
expect(report).toContain("share the same mnemonic");
|
||||
expect(report).toContain("startup still refuses");
|
||||
expect(report).toContain("mv \"");
|
||||
});
|
||||
|
||||
it("omits resolution steps when there is nothing to resolve", () => {
|
||||
const targetDir = join(root(), "wallet");
|
||||
const emptyDir = join(root(), ".cocod");
|
||||
writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
|
||||
|
||||
const report = renderWalletDoctor(
|
||||
summarizeWalletDirectory(targetDir, "canonical"),
|
||||
summarizeWalletDirectory(emptyDir, "legacy"),
|
||||
);
|
||||
expect(report).toContain("no migration needed");
|
||||
expect(report).not.toContain("routstrd stop");
|
||||
expect(report).not.toContain("mv \"");
|
||||
});
|
||||
|
||||
it("omits resolution steps for a fresh install", () => {
|
||||
const report = renderWalletDoctor(
|
||||
summarizeWalletDirectory(join(root(), "nope"), "canonical"),
|
||||
summarizeWalletDirectory(join(root(), "alsonope"), "legacy"),
|
||||
);
|
||||
expect(report).toContain("fresh install");
|
||||
expect(report).not.toContain("mv \"");
|
||||
});
|
||||
|
||||
it("counts trusted mints from the mints table and formats amounts", () => {
|
||||
const targetDir = join(root(), "wallet");
|
||||
const sourceDir = join(root(), ".cocod");
|
||||
writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
|
||||
writeWalletConfig(sourceDir, { mnemonic: MNEMONIC_B });
|
||||
writeProofsDb(sourceDir);
|
||||
|
||||
const report = renderWalletDoctor(
|
||||
summarizeWalletDirectory(targetDir, "canonical"),
|
||||
summarizeWalletDirectory(sourceDir, "legacy"),
|
||||
);
|
||||
// 3 registered mints, not the 2 mints that happen to hold proofs.
|
||||
expect(report).toContain("3 mints");
|
||||
});
|
||||
|
||||
it("formats large balances with thousands separators", () => {
|
||||
const targetDir = join(root(), "wallet");
|
||||
const sourceDir = join(root(), ".cocod");
|
||||
writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
|
||||
writeWalletConfig(sourceDir, { mnemonic: MNEMONIC_B });
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
const db = new Database(join(sourceDir, "coco.db"));
|
||||
db.exec(`
|
||||
CREATE TABLE coco_cashu_proofs (mintUrl TEXT, state TEXT, amount INTEGER);
|
||||
INSERT INTO coco_cashu_proofs VALUES ('https://mint.example', 'ready', 21000);
|
||||
`);
|
||||
db.close();
|
||||
|
||||
const report = renderWalletDoctor(
|
||||
summarizeWalletDirectory(targetDir, "canonical"),
|
||||
summarizeWalletDirectory(sourceDir, "legacy"),
|
||||
);
|
||||
expect(report).toContain("ready: 21,000 sats");
|
||||
});
|
||||
});
|
||||
|
||||
describe("diagnoseWallets", () => {
|
||||
it("flags conflicting mnemonics as a conflict needing resolution", () => {
|
||||
const targetDir = join(root(), "wallet");
|
||||
const sourceDir = join(root(), ".cocod");
|
||||
writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
|
||||
writeWalletConfig(sourceDir, { mnemonic: MNEMONIC_B });
|
||||
|
||||
const verdict = diagnoseWallets(
|
||||
summarizeWalletDirectory(targetDir, "canonical"),
|
||||
summarizeWalletDirectory(sourceDir, "legacy"),
|
||||
);
|
||||
expect(verdict.conflict).toBe(true);
|
||||
expect(verdict.showResolution).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a single existing wallet as no conflict", () => {
|
||||
const targetDir = join(root(), "wallet");
|
||||
writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
|
||||
|
||||
const verdict = diagnoseWallets(
|
||||
summarizeWalletDirectory(targetDir, "canonical"),
|
||||
summarizeWalletDirectory(join(root(), ".cocod"), "legacy"),
|
||||
);
|
||||
expect(verdict.conflict).toBe(false);
|
||||
expect(verdict.showResolution).toBe(false);
|
||||
});
|
||||
|
||||
it("flags a legacy database without config as an incomplete conflict", () => {
|
||||
const sourceDir = join(root(), ".cocod");
|
||||
writeProofsDb(sourceDir);
|
||||
|
||||
const verdict = diagnoseWallets(
|
||||
summarizeWalletDirectory(join(root(), "wallet"), "canonical"),
|
||||
summarizeWalletDirectory(sourceDir, "legacy"),
|
||||
);
|
||||
expect(verdict.conflict).toBe(true);
|
||||
expect(verdict.text).toContain("incomplete");
|
||||
});
|
||||
|
||||
it("treats a stray legacy database as already-current when the canonical wallet exists", () => {
|
||||
const targetDir = join(root(), "wallet");
|
||||
const sourceDir = join(root(), ".cocod");
|
||||
writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
|
||||
writeFakeDbFile(sourceDir);
|
||||
|
||||
const verdict = diagnoseWallets(
|
||||
summarizeWalletDirectory(targetDir, "canonical"),
|
||||
summarizeWalletDirectory(sourceDir, "legacy"),
|
||||
);
|
||||
expect(verdict.conflict).toBe(false);
|
||||
expect(verdict.showResolution).toBe(false);
|
||||
});
|
||||
|
||||
it("flags an incomplete canonical database as a conflict", () => {
|
||||
const targetDir = join(root(), "wallet");
|
||||
writeFakeDbFile(targetDir);
|
||||
|
||||
const verdict = diagnoseWallets(
|
||||
summarizeWalletDirectory(targetDir, "canonical"),
|
||||
summarizeWalletDirectory(join(root(), ".cocod"), "legacy"),
|
||||
);
|
||||
expect(verdict.conflict).toBe(true);
|
||||
expect(verdict.showResolution).toBe(false);
|
||||
});
|
||||
|
||||
it("flags orphaned legacy sidecars as a conflict", () => {
|
||||
const sourceDir = join(root(), ".cocod");
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
writeFileSync(join(sourceDir, "coco.db-wal"), "wal");
|
||||
writeFileSync(join(sourceDir, "coco.db-shm"), "shm");
|
||||
|
||||
const verdict = diagnoseWallets(
|
||||
summarizeWalletDirectory(join(root(), "wallet"), "canonical"),
|
||||
summarizeWalletDirectory(sourceDir, "legacy"),
|
||||
);
|
||||
expect(verdict.conflict).toBe(true);
|
||||
expect(verdict.showResolution).toBe(false);
|
||||
});
|
||||
|
||||
it("treats byte-identical wallets as already-current", () => {
|
||||
const targetDir = join(root(), "wallet");
|
||||
const sourceDir = join(root(), ".cocod");
|
||||
writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
|
||||
writeWalletConfig(sourceDir, { mnemonic: MNEMONIC_A });
|
||||
|
||||
const verdict = diagnoseWallets(
|
||||
summarizeWalletDirectory(targetDir, "canonical"),
|
||||
summarizeWalletDirectory(sourceDir, "legacy"),
|
||||
);
|
||||
expect(verdict.conflict).toBe(false);
|
||||
expect(verdict.showResolution).toBe(false);
|
||||
});
|
||||
|
||||
it("survives an unreadable wallet file without crashing", () => {
|
||||
const targetDir = join(root(), "wallet");
|
||||
const sourceDir = join(root(), ".cocod");
|
||||
// Equal sizes force the classifier's filesEqual past the size check into
|
||||
// raw reads, where the permission error would otherwise crash the doctor.
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
writeFileSync(join(targetDir, "config.json"), `{"mnemonic":"${"a".repeat(50)}"}`);
|
||||
const sourceConfig = join(sourceDir, "config.json");
|
||||
writeFileSync(sourceConfig, `{"mnemonic":"${"b".repeat(50)}"}`);
|
||||
chmodSync(sourceConfig, 0o000);
|
||||
|
||||
try {
|
||||
const target = summarizeWalletDirectory(targetDir, "canonical");
|
||||
const source = summarizeWalletDirectory(sourceDir, "legacy");
|
||||
expect(source.config.error).toBeTruthy();
|
||||
|
||||
const verdict = diagnoseWallets(target, source);
|
||||
expect(verdict.conflict).toBe(true);
|
||||
expect(verdict.showResolution).toBe(true);
|
||||
expect(verdict.text).toContain("could not be fully read");
|
||||
|
||||
// The full report must render too — this is the user-facing path.
|
||||
const report = renderWalletDoctor(target, source);
|
||||
expect(report).toContain("could not be fully read");
|
||||
expect(report).toContain("mv \"");
|
||||
} finally {
|
||||
chmodSync(sourceConfig, 0o600);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,471 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { join } from "path";
|
||||
import {
|
||||
classifyWalletMigration,
|
||||
type WalletMigrationClass,
|
||||
} from "./wallet-state";
|
||||
|
||||
/**
|
||||
* Wallet diagnostics for the cocod → routstrd wallet-directory migration.
|
||||
*
|
||||
* The migration in `migration.ts` refuses to choose between two wallets when
|
||||
* both `~/.routstrd/wallet` and `~/.cocod` contain different data. These
|
||||
* helpers summarize each side with privacy-safe, decision-relevant signals
|
||||
* (mnemonic fingerprint, timestamps, and database proof/amount summaries) so a
|
||||
* user can tell which wallet is the real one without us ever printing secrets.
|
||||
*/
|
||||
|
||||
export type WalletRole = "canonical" | "legacy";
|
||||
|
||||
export type WalletSummary = Record<string, unknown[]>;
|
||||
|
||||
/** Queries shared with the migration snapshot verifier. */
|
||||
export const SUMMARY_QUERIES: Record<string, string> = {
|
||||
proofs:
|
||||
"SELECT mintUrl, state, COUNT(*) count, COALESCE(SUM(amount), 0) amount FROM coco_cashu_proofs GROUP BY mintUrl, state ORDER BY mintUrl, state",
|
||||
counters:
|
||||
"SELECT mintUrl, keysetId, counter FROM coco_cashu_counters ORDER BY mintUrl, keysetId",
|
||||
mintOperations:
|
||||
"SELECT state, COUNT(*) count FROM coco_cashu_mint_operations GROUP BY state ORDER BY state",
|
||||
sendOperations:
|
||||
"SELECT state, COUNT(*) count FROM coco_cashu_send_operations GROUP BY state ORDER BY state",
|
||||
meltOperations:
|
||||
"SELECT state, COUNT(*) count FROM coco_cashu_melt_operations GROUP BY state ORDER BY state",
|
||||
mints: "SELECT mintUrl, trusted FROM coco_cashu_mints ORDER BY mintUrl",
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a database is healthy and return a wallet summary. Throws on corrupt
|
||||
* databases because the migration must never proceed over them.
|
||||
*/
|
||||
export function verifyDatabase(database: Database, label: string): WalletSummary {
|
||||
const checks = database.query("PRAGMA quick_check").values() as unknown[][];
|
||||
if (checks.length !== 1 || checks[0]?.[0] !== "ok") {
|
||||
throw new Error(`${label} failed PRAGMA quick_check: ${JSON.stringify(checks)}`);
|
||||
}
|
||||
|
||||
const tables = new Set(
|
||||
(database
|
||||
.query("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.values() as string[][]).map(([name]) => name),
|
||||
);
|
||||
const summary: WalletSummary = {};
|
||||
for (const [name, query] of Object.entries(SUMMARY_QUERIES)) {
|
||||
const table = query.match(/FROM\s+(coco_cashu_\w+)/i)?.[1];
|
||||
summary[name] = table && tables.has(table) ? database.query(query).all() : [];
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export interface ProofSummaryRow {
|
||||
mintUrl: string;
|
||||
state: string;
|
||||
count: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface DbDiagnosticSummary {
|
||||
proofs: ProofSummaryRow[];
|
||||
mints: { mintUrl: string; trusted: number }[];
|
||||
totalProofs: number;
|
||||
totalAmount: number;
|
||||
amountByState: Record<string, number>;
|
||||
distinctMints: number;
|
||||
}
|
||||
|
||||
export interface WalletConfigDiagnostic {
|
||||
exists: boolean;
|
||||
path: string;
|
||||
mtimeMs?: number;
|
||||
error?: string;
|
||||
fingerprint?: string;
|
||||
encrypted?: boolean;
|
||||
createdAt?: string;
|
||||
version?: number;
|
||||
defaultMintUrl?: string;
|
||||
hasMnemonic?: boolean;
|
||||
}
|
||||
|
||||
export interface WalletDbDiagnostic {
|
||||
exists: boolean;
|
||||
path: string;
|
||||
mtimeMs?: number;
|
||||
sizeBytes?: number;
|
||||
error?: string;
|
||||
summary?: DbDiagnosticSummary;
|
||||
}
|
||||
|
||||
export interface WalletDiagnostic {
|
||||
dir: string;
|
||||
role: WalletRole;
|
||||
config: WalletConfigDiagnostic;
|
||||
db: WalletDbDiagnostic;
|
||||
}
|
||||
|
||||
/** A one-way, truncated fingerprint of a mnemonic. Never returns the mnemonic. */
|
||||
export function mnemonicFingerprint(mnemonic: string): string {
|
||||
const normalized = mnemonic.trim().split(/\s+/).join(" ");
|
||||
const hex = createHash("sha256").update(normalized).digest("hex");
|
||||
return hex.slice(0, 16).match(/.{1,4}/g)?.join("-") ?? hex.slice(0, 16);
|
||||
}
|
||||
|
||||
/** Read-only DB summary; never throws, degrades to an error string. */
|
||||
export function summarizeDbReadonly(
|
||||
dbPath: string,
|
||||
): { summary?: DbDiagnosticSummary; error?: string } {
|
||||
let database: Database | undefined;
|
||||
try {
|
||||
database = new Database(dbPath, { readonly: true });
|
||||
const raw = verifyDatabase(database, "Wallet database");
|
||||
|
||||
const proofs = (raw.proofs ?? []) as ProofSummaryRow[];
|
||||
const mints = (raw.mints ?? []) as { mintUrl: string; trusted: number }[];
|
||||
const amountByState: Record<string, number> = {};
|
||||
let totalProofs = 0;
|
||||
let totalAmount = 0;
|
||||
for (const row of proofs) {
|
||||
const count = typeof row.count === "number" ? row.count : 0;
|
||||
const amount = typeof row.amount === "number" ? row.amount : 0;
|
||||
totalProofs += count;
|
||||
totalAmount += amount;
|
||||
const state = row.state || "unknown";
|
||||
amountByState[state] = (amountByState[state] ?? 0) + amount;
|
||||
}
|
||||
|
||||
return {
|
||||
summary: {
|
||||
proofs,
|
||||
mints,
|
||||
totalProofs,
|
||||
totalAmount,
|
||||
amountByState,
|
||||
distinctMints: new Set(proofs.map((row) => row.mintUrl).filter(Boolean)).size,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
} finally {
|
||||
database?.close();
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeConfig(configPath: string): WalletConfigDiagnostic {
|
||||
const result: WalletConfigDiagnostic = { exists: false, path: configPath };
|
||||
try {
|
||||
if (!existsSync(configPath)) return result;
|
||||
result.exists = true;
|
||||
result.mtimeMs = statSync(configPath).mtimeMs;
|
||||
|
||||
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
|
||||
result.encrypted = parsed.encrypted === true;
|
||||
if (typeof parsed.createdAt === "string") result.createdAt = parsed.createdAt;
|
||||
if (typeof parsed.version === "number") result.version = parsed.version;
|
||||
if (typeof parsed.defaultMintUrl === "string") {
|
||||
result.defaultMintUrl = parsed.defaultMintUrl;
|
||||
}
|
||||
if (typeof parsed.mnemonic === "string") {
|
||||
result.hasMnemonic = true;
|
||||
if (!result.encrypted) result.fingerprint = mnemonicFingerprint(parsed.mnemonic);
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function summarizeDb(dbPath: string): WalletDbDiagnostic {
|
||||
const result: WalletDbDiagnostic = { exists: false, path: dbPath };
|
||||
try {
|
||||
if (!existsSync(dbPath)) return result;
|
||||
result.exists = true;
|
||||
const stats = statSync(dbPath);
|
||||
result.mtimeMs = stats.mtimeMs;
|
||||
result.sizeBytes = stats.size;
|
||||
|
||||
const { summary, error } = summarizeDbReadonly(dbPath);
|
||||
if (summary) result.summary = summary;
|
||||
if (error) result.error = error;
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function summarizeWalletDirectory(
|
||||
dir: string,
|
||||
role: WalletRole,
|
||||
): WalletDiagnostic {
|
||||
return {
|
||||
dir,
|
||||
role,
|
||||
config: summarizeConfig(join(dir, "config.json")),
|
||||
db: summarizeDb(join(dir, "coco.db")),
|
||||
};
|
||||
}
|
||||
|
||||
export class WalletMigrationConflictError extends Error {
|
||||
readonly target: WalletDiagnostic;
|
||||
readonly source: WalletDiagnostic;
|
||||
|
||||
constructor(target: WalletDiagnostic, source: WalletDiagnostic) {
|
||||
super(renderWalletConflict(target, source));
|
||||
this.name = "WalletMigrationConflictError";
|
||||
this.target = target;
|
||||
this.source = source;
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(ms?: number): string | undefined {
|
||||
if (ms === undefined || !Number.isFinite(ms)) return undefined;
|
||||
const elapsed = Date.now() - ms;
|
||||
if (elapsed < 0) return undefined;
|
||||
const seconds = Math.floor(elapsed / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 60) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 24) return `${months}mo ago`;
|
||||
return `${Math.floor(days / 365)}y ago`;
|
||||
}
|
||||
|
||||
function modifiedDescription(config: WalletConfigDiagnostic): string {
|
||||
if (config.createdAt) return `created ${config.createdAt}`;
|
||||
const ago = timeAgo(config.mtimeMs);
|
||||
if (ago) return `modified ${ago}`;
|
||||
return "modified time unknown";
|
||||
}
|
||||
|
||||
function roleLabel(role: WalletRole): string {
|
||||
return role === "canonical" ? "current routstrd wallet" : "legacy cocod wallet";
|
||||
}
|
||||
|
||||
function configLine(config: WalletConfigDiagnostic): string {
|
||||
if (!config.exists) return "config.json absent";
|
||||
if (config.error) return `config.json present (unreadable: ${config.error})`;
|
||||
const parts: string[] = ["config.json present"];
|
||||
if (config.fingerprint) parts.push(`fingerprint ${config.fingerprint}`);
|
||||
else if (config.encrypted) parts.push("encrypted (mnemonic not readable)");
|
||||
else parts.push("no mnemonic found");
|
||||
parts.push(modifiedDescription(config));
|
||||
if (config.defaultMintUrl) parts.push(`default mint ${config.defaultMintUrl}`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return value.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
function amountByStateLine(summary: DbDiagnosticSummary): string {
|
||||
const parts = Object.entries(summary.amountByState).filter(([, amount]) => amount > 0);
|
||||
if (parts.length === 0) return "0 sats";
|
||||
return parts.map(([state, amount]) => `${state}: ${formatNumber(amount)} sats`).join(", ");
|
||||
}
|
||||
|
||||
function dbLine(db: WalletDbDiagnostic): string {
|
||||
if (!db.exists) return "coco.db absent";
|
||||
if (db.error && !db.summary) return `coco.db present (unreadable: ${db.error})`;
|
||||
const parts: string[] = ["coco.db present"];
|
||||
if (db.summary) {
|
||||
const { totalProofs, mints, distinctMints } = db.summary;
|
||||
// The mints table is the wallet's mint registry; fall back to mints seen
|
||||
// in proofs for databases old enough to lack the registry table.
|
||||
const mintCount = mints.length > 0 ? mints.length : distinctMints;
|
||||
parts.push(`${formatNumber(totalProofs)} proof${totalProofs === 1 ? "" : "s"}`);
|
||||
parts.push(amountByStateLine(db.summary));
|
||||
parts.push(`${mintCount} mint${mintCount === 1 ? "" : "s"}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function block(diag: WalletDiagnostic, letter: string): string {
|
||||
return [
|
||||
` ${letter}) ${diag.dir} (${roleLabel(diag.role)})`,
|
||||
` ${configLine(diag.config)}`,
|
||||
` ${dbLine(diag.db)}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function moveAsideCommands(target: WalletDiagnostic, source: WalletDiagnostic): string {
|
||||
return [
|
||||
" • If B is your wallet (keep the legacy wallet):",
|
||||
` mv "${target.dir}" "${target.dir}.old"`,
|
||||
"",
|
||||
" • If A is your wallet (keep the normal routstrd wallet):",
|
||||
` mv "${source.dir}" "${source.dir}.old"`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function renderResolutionSteps(
|
||||
target: WalletDiagnostic,
|
||||
source: WalletDiagnostic,
|
||||
): string {
|
||||
return [
|
||||
"To resolve, decide which wallet is yours, move the other one aside, and run",
|
||||
"'routstrd onboard' (or 'routstrd start') again. Stop the daemon first:",
|
||||
"",
|
||||
" routstrd stop",
|
||||
"",
|
||||
"Then:",
|
||||
"",
|
||||
moveAsideCommands(target, source),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function renderWalletConflict(
|
||||
target: WalletDiagnostic,
|
||||
source: WalletDiagnostic,
|
||||
): string {
|
||||
return [
|
||||
"Cannot migrate wallet: two different wallets were found, and routstrd will",
|
||||
"not choose a mnemonic or merge databases automatically — the wrong choice",
|
||||
"could lose access to your funds.",
|
||||
"",
|
||||
block(target, "A"),
|
||||
block(source, "B"),
|
||||
"",
|
||||
"No files were changed.",
|
||||
"",
|
||||
renderResolutionSteps(target, source),
|
||||
"",
|
||||
"For a full comparison and safe cleanup, run: routstrd wallet doctor",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export interface WalletVerdict {
|
||||
/** One-line human verdict for the doctor report. */
|
||||
text: string;
|
||||
/** Whether the mv-aside resolution steps apply to this state. */
|
||||
showResolution: boolean;
|
||||
/** Whether startup would (or may) refuse to migrate in this state. */
|
||||
conflict: boolean;
|
||||
}
|
||||
|
||||
function conflictVerdict(
|
||||
target: WalletDiagnostic,
|
||||
source: WalletDiagnostic,
|
||||
): WalletVerdict {
|
||||
const unreadable =
|
||||
!!target.config.error ||
|
||||
!!source.config.error ||
|
||||
!!target.db.error ||
|
||||
!!source.db.error;
|
||||
if (unreadable) {
|
||||
return {
|
||||
text: "Verdict: both wallets exist but one or both could not be fully read; startup will refuse to migrate. See the details above.",
|
||||
showResolution: true,
|
||||
conflict: true,
|
||||
};
|
||||
}
|
||||
if (target.config.fingerprint && source.config.fingerprint) {
|
||||
if (target.config.fingerprint === source.config.fingerprint) {
|
||||
return {
|
||||
text: "Verdict: both wallets share the same mnemonic but the files differ, so startup still refuses. Keep the wallet with your funds and move the other aside.",
|
||||
showResolution: true,
|
||||
conflict: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: "Verdict: the two wallets have DIFFERENT mnemonics. Startup will refuse to migrate.",
|
||||
showResolution: true,
|
||||
conflict: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: "Verdict: both wallets exist but their mnemonics cannot be compared (encrypted or missing). Startup will refuse to migrate unless the files are identical.",
|
||||
showResolution: true,
|
||||
conflict: true,
|
||||
};
|
||||
}
|
||||
|
||||
function verdictFromClassification(
|
||||
target: WalletDiagnostic,
|
||||
source: WalletDiagnostic,
|
||||
classification: WalletMigrationClass,
|
||||
): WalletVerdict {
|
||||
switch (classification.kind) {
|
||||
case "fresh":
|
||||
return {
|
||||
text: "Verdict: no wallet found in either location (fresh install).",
|
||||
showResolution: false,
|
||||
conflict: false,
|
||||
};
|
||||
case "already-current":
|
||||
return {
|
||||
text: "Verdict: the current routstrd wallet is authoritative; no migration needed.",
|
||||
showResolution: false,
|
||||
conflict: false,
|
||||
};
|
||||
case "migrate":
|
||||
return {
|
||||
text: "Verdict: only the legacy cocod wallet exists; it will be migrated on next startup.",
|
||||
showResolution: false,
|
||||
conflict: false,
|
||||
};
|
||||
case "conflict":
|
||||
return conflictVerdict(target, source);
|
||||
case "database-only":
|
||||
return {
|
||||
text: "Verdict: a wallet is incomplete (coco.db without config.json). Startup will refuse to migrate. See the details above.",
|
||||
showResolution: false,
|
||||
conflict: true,
|
||||
};
|
||||
case "orphaned-sidecars":
|
||||
return {
|
||||
text: "Verdict: the legacy wallet has SQLite sidecar files without coco.db. Startup will refuse to migrate. Restore the matching database first.",
|
||||
showResolution: false,
|
||||
conflict: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Map the shared migration classification onto a human doctor verdict. */
|
||||
export function diagnoseWallets(
|
||||
target: WalletDiagnostic,
|
||||
source: WalletDiagnostic,
|
||||
): WalletVerdict {
|
||||
let classification: WalletMigrationClass;
|
||||
try {
|
||||
classification = classifyWalletMigration(target.dir, source.dir);
|
||||
} catch {
|
||||
// classifyWalletMigration does raw reads (filesEqual) that can throw on
|
||||
// unreadable or concurrently-removed files. The doctor exists to diagnose
|
||||
// broken states, so it must never crash on one. Fail safe: treat the
|
||||
// state as a conflict — startup hits the same read error and refuses.
|
||||
if (target.config.error || source.config.error || target.db.error || source.db.error) {
|
||||
return conflictVerdict(target, source);
|
||||
}
|
||||
return {
|
||||
text: "Verdict: the wallets could not be fully compared (read error); startup will refuse to migrate. See the details above.",
|
||||
showResolution: true,
|
||||
conflict: true,
|
||||
};
|
||||
}
|
||||
return verdictFromClassification(target, source, classification);
|
||||
}
|
||||
|
||||
export function renderWalletDoctor(
|
||||
target: WalletDiagnostic,
|
||||
source: WalletDiagnostic,
|
||||
): string {
|
||||
const verdict = diagnoseWallets(target, source);
|
||||
const lines = [
|
||||
"Routstr wallet diagnostic",
|
||||
"========================",
|
||||
"",
|
||||
block(target, "A"),
|
||||
block(source, "B"),
|
||||
"",
|
||||
verdict.text,
|
||||
];
|
||||
if (verdict.showResolution) {
|
||||
lines.push("", renderResolutionSteps(target, source));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { migrateLegacyWallet } from "./migration";
|
||||
import { WalletMigrationConflictError } from "./diagnostics";
|
||||
|
||||
const roots: string[] = [];
|
||||
function root(): string {
|
||||
@@ -135,13 +136,36 @@ describe("migrateLegacyWallet", () => {
|
||||
const walletDir = join(base, ".routstrd", "wallet");
|
||||
mkdirSync(legacyDir, { recursive: true });
|
||||
mkdirSync(walletDir, { recursive: true });
|
||||
writeFileSync(join(legacyDir, "config.json"), "legacy");
|
||||
writeFileSync(join(walletDir, "config.json"), "current");
|
||||
|
||||
await expect(migrateLegacyWallet({ walletDir, legacyDir })).rejects.toThrow(
|
||||
"both",
|
||||
writeFileSync(
|
||||
join(legacyDir, "config.json"),
|
||||
JSON.stringify({ mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(walletDir, "config.json"),
|
||||
JSON.stringify({ mnemonic: "legal winner thank year wave sausage worth useful legal winner thank yellow" }),
|
||||
);
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await migrateLegacyWallet({ walletDir, legacyDir });
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(WalletMigrationConflictError);
|
||||
const err = thrown as WalletMigrationConflictError;
|
||||
expect(err.target.dir).toBe(walletDir);
|
||||
expect(err.source.dir).toBe(legacyDir);
|
||||
expect(err.message).toContain("two different wallets were found");
|
||||
expect(err.message).toContain(walletDir);
|
||||
expect(err.message).toContain(legacyDir);
|
||||
expect(err.message).not.toContain("abandon");
|
||||
expect(err.message).not.toContain("legal winner");
|
||||
expect(readFileSync(join(walletDir, "config.json"), "utf8")).toContain(
|
||||
"legal winner",
|
||||
);
|
||||
expect(readFileSync(join(legacyDir, "config.json"), "utf8")).toContain(
|
||||
"abandon abandon",
|
||||
);
|
||||
expect(readFileSync(join(walletDir, "config.json"), "utf8")).toBe("current");
|
||||
expect(readFileSync(join(legacyDir, "config.json"), "utf8")).toBe("legacy");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
@@ -11,6 +10,13 @@ import {
|
||||
import { Database } from "bun:sqlite";
|
||||
import { basename, dirname, join } from "path";
|
||||
import { legacyCocodDir, walletDir } from "./paths";
|
||||
import {
|
||||
summarizeWalletDirectory,
|
||||
verifyDatabase,
|
||||
WalletMigrationConflictError,
|
||||
type WalletSummary,
|
||||
} from "./diagnostics";
|
||||
import { classifyWalletMigration } from "./wallet-state";
|
||||
|
||||
export type WalletMigrationResult =
|
||||
| { status: "fresh" }
|
||||
@@ -26,60 +32,10 @@ export interface WalletMigrationOptions {
|
||||
acquireLegacyLock?: () => (() => void) | Promise<() => void>;
|
||||
}
|
||||
|
||||
type WalletState = "absent" | "database-only" | "initialized";
|
||||
|
||||
function state(configPath: string, dbPath: string): WalletState {
|
||||
const hasConfig = existsSync(configPath);
|
||||
const hasDb = existsSync(dbPath);
|
||||
if (hasConfig) return "initialized";
|
||||
if (hasDb) return "database-only";
|
||||
return "absent";
|
||||
}
|
||||
|
||||
function filesEqual(left: string, right: string): boolean {
|
||||
if (!existsSync(left) || !existsSync(right)) return false;
|
||||
if (statSync(left).size !== statSync(right).size) return false;
|
||||
return readFileSync(left).equals(readFileSync(right));
|
||||
}
|
||||
|
||||
function sqlString(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
type WalletSummary = Record<string, unknown[]>;
|
||||
const SUMMARY_QUERIES: Record<string, string> = {
|
||||
proofs:
|
||||
"SELECT mintUrl, state, COUNT(*) count, COALESCE(SUM(amount), 0) amount FROM coco_cashu_proofs GROUP BY mintUrl, state ORDER BY mintUrl, state",
|
||||
counters:
|
||||
"SELECT mintUrl, keysetId, counter FROM coco_cashu_counters ORDER BY mintUrl, keysetId",
|
||||
mintOperations:
|
||||
"SELECT state, COUNT(*) count FROM coco_cashu_mint_operations GROUP BY state ORDER BY state",
|
||||
sendOperations:
|
||||
"SELECT state, COUNT(*) count FROM coco_cashu_send_operations GROUP BY state ORDER BY state",
|
||||
meltOperations:
|
||||
"SELECT state, COUNT(*) count FROM coco_cashu_melt_operations GROUP BY state ORDER BY state",
|
||||
mints: "SELECT mintUrl, trusted FROM coco_cashu_mints ORDER BY mintUrl",
|
||||
};
|
||||
|
||||
function verifyDatabase(database: Database, label: string): WalletSummary {
|
||||
const checks = database.query("PRAGMA quick_check").values() as unknown[][];
|
||||
if (checks.length !== 1 || checks[0]?.[0] !== "ok") {
|
||||
throw new Error(`${label} failed PRAGMA quick_check: ${JSON.stringify(checks)}`);
|
||||
}
|
||||
|
||||
const tables = new Set(
|
||||
(database
|
||||
.query("SELECT name FROM sqlite_master WHERE type = 'table'")
|
||||
.values() as string[][]).map(([name]) => name),
|
||||
);
|
||||
const summary: WalletSummary = {};
|
||||
for (const [name, query] of Object.entries(SUMMARY_QUERIES)) {
|
||||
const table = query.match(/FROM\s+(coco_cashu_\w+)/i)?.[1];
|
||||
summary[name] = table && tables.has(table) ? database.query(query).all() : [];
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Write a standalone SQLite snapshot containing committed WAL frames. */
|
||||
function snapshotDatabase(sourcePath: string, destinationPath: string): void {
|
||||
const source = new Database(sourcePath);
|
||||
@@ -114,45 +70,34 @@ export async function migrateLegacyWallet(
|
||||
): Promise<WalletMigrationResult> {
|
||||
const targetDir = options.walletDir || walletDir();
|
||||
const sourceDir = options.legacyDir || legacyCocodDir();
|
||||
const targetConfig = join(targetDir, "config.json");
|
||||
const targetDb = join(targetDir, "coco.db");
|
||||
const sourceConfig = join(sourceDir, "config.json");
|
||||
const sourceDb = join(sourceDir, "coco.db");
|
||||
const sourceWal = `${sourceDb}-wal`;
|
||||
const sourceShm = `${sourceDb}-shm`;
|
||||
const targetState = state(targetConfig, targetDb);
|
||||
const sourceState = state(sourceConfig, sourceDb);
|
||||
|
||||
// config.json is sufficient for a newly initialized wallet; coco.db is
|
||||
// created on first open. A database without its mnemonic is never usable.
|
||||
if (targetState === "initialized" && sourceState === "initialized") {
|
||||
// A prior migration may have committed successfully but failed to remove
|
||||
// its source files. Identical leftovers are safe; divergent wallets are not.
|
||||
const configsMatch = filesEqual(targetConfig, sourceConfig);
|
||||
const databasesMatch =
|
||||
!existsSync(targetDb) && !existsSync(sourceDb)
|
||||
? true
|
||||
: filesEqual(targetDb, sourceDb);
|
||||
if (configsMatch && databasesMatch) return { status: "already-current" };
|
||||
throw new Error(
|
||||
`Cannot migrate wallet: both ${targetDir} and ${sourceDir} contain different wallet data. ` +
|
||||
"Refusing to choose a mnemonic or merge wallet databases automatically.",
|
||||
);
|
||||
const classification = classifyWalletMigration(targetDir, sourceDir);
|
||||
switch (classification.kind) {
|
||||
case "fresh":
|
||||
return { status: "fresh" };
|
||||
case "already-current":
|
||||
return { status: "already-current" };
|
||||
case "migrate":
|
||||
break;
|
||||
case "conflict":
|
||||
throw new WalletMigrationConflictError(
|
||||
summarizeWalletDirectory(targetDir, "canonical"),
|
||||
summarizeWalletDirectory(sourceDir, "legacy"),
|
||||
);
|
||||
case "orphaned-sidecars":
|
||||
throw new Error(
|
||||
`Cannot migrate wallet: ${sourceDir} contains SQLite sidecar files without coco.db. ` +
|
||||
"Restore the matching main database before migration.",
|
||||
);
|
||||
case "database-only":
|
||||
throw new Error(
|
||||
`Cannot migrate wallet: ${targetDir} is ${classification.targetState} and ${sourceDir} is ${classification.sourceState}. ` +
|
||||
"A coco.db without config.json cannot be opened; restore the matching config first.",
|
||||
);
|
||||
}
|
||||
if (targetState === "initialized") return { status: "already-current" };
|
||||
if (!existsSync(sourceDb) && (existsSync(sourceWal) || existsSync(sourceShm))) {
|
||||
throw new Error(
|
||||
`Cannot migrate wallet: ${sourceDir} contains SQLite sidecar files without coco.db. ` +
|
||||
"Restore the matching main database before migration.",
|
||||
);
|
||||
}
|
||||
if (targetState === "database-only" || sourceState === "database-only") {
|
||||
throw new Error(
|
||||
`Cannot migrate wallet: ${targetDir} is ${targetState} and ${sourceDir} is ${sourceState}. ` +
|
||||
"A coco.db without config.json cannot be opened; restore the matching config first.",
|
||||
);
|
||||
}
|
||||
if (sourceState === "absent") return { status: "fresh" };
|
||||
|
||||
await options.assertLegacyStopped?.();
|
||||
const releaseLegacyLock = await options.acquireLegacyLock?.();
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
/**
|
||||
* Pure wallet-migration state detection, shared by the actual migration and by
|
||||
* the doctor diagnostics so the two cannot disagree about startup behavior.
|
||||
*/
|
||||
|
||||
export type WalletState = "absent" | "database-only" | "initialized";
|
||||
|
||||
export function walletState(configPath: string, dbPath: string): WalletState {
|
||||
const hasConfig = existsSync(configPath);
|
||||
const hasDb = existsSync(dbPath);
|
||||
if (hasConfig) return "initialized";
|
||||
if (hasDb) return "database-only";
|
||||
return "absent";
|
||||
}
|
||||
|
||||
export function filesEqual(left: string, right: string): boolean {
|
||||
if (!existsSync(left) || !existsSync(right)) return false;
|
||||
if (statSync(left).size !== statSync(right).size) return false;
|
||||
return readFileSync(left).equals(readFileSync(right));
|
||||
}
|
||||
|
||||
export type WalletMigrationClass =
|
||||
| { kind: "fresh" }
|
||||
| { kind: "already-current" }
|
||||
| { kind: "migrate" }
|
||||
| { kind: "conflict" }
|
||||
| { kind: "database-only"; targetState: WalletState; sourceState: WalletState }
|
||||
| { kind: "orphaned-sidecars" };
|
||||
|
||||
/**
|
||||
* Classify the two wallet locations exactly the way `migrateLegacyWallet`
|
||||
* decides what to do. This is the single source of truth for startup behavior;
|
||||
* `migrateLegacyWallet` maps it onto actions/errors and the doctor maps it onto
|
||||
* verdict text.
|
||||
*/
|
||||
export function classifyWalletMigration(
|
||||
targetDir: string,
|
||||
sourceDir: string,
|
||||
): WalletMigrationClass {
|
||||
const targetConfig = join(targetDir, "config.json");
|
||||
const targetDb = join(targetDir, "coco.db");
|
||||
const sourceConfig = join(sourceDir, "config.json");
|
||||
const sourceDb = join(sourceDir, "coco.db");
|
||||
const sourceWal = `${sourceDb}-wal`;
|
||||
const sourceShm = `${sourceDb}-shm`;
|
||||
|
||||
const targetState = walletState(targetConfig, targetDb);
|
||||
const sourceState = walletState(sourceConfig, sourceDb);
|
||||
|
||||
// config.json is sufficient for a newly initialized wallet; coco.db is
|
||||
// created on first open. A database without its mnemonic is never usable.
|
||||
if (targetState === "initialized" && sourceState === "initialized") {
|
||||
// A prior migration may have committed successfully but failed to remove
|
||||
// its source files. Identical leftovers are safe; divergent wallets are not.
|
||||
const configsMatch = filesEqual(targetConfig, sourceConfig);
|
||||
const databasesMatch =
|
||||
!existsSync(targetDb) && !existsSync(sourceDb)
|
||||
? true
|
||||
: filesEqual(targetDb, sourceDb);
|
||||
if (configsMatch && databasesMatch) return { kind: "already-current" };
|
||||
return { kind: "conflict" };
|
||||
}
|
||||
if (targetState === "initialized") return { kind: "already-current" };
|
||||
if (!existsSync(sourceDb) && (existsSync(sourceWal) || existsSync(sourceShm))) {
|
||||
return { kind: "orphaned-sidecars" };
|
||||
}
|
||||
if (targetState === "database-only" || sourceState === "database-only") {
|
||||
return { kind: "database-only", targetState, sourceState };
|
||||
}
|
||||
if (sourceState === "absent") return { kind: "fresh" };
|
||||
return { kind: "migrate" };
|
||||
}
|
||||
Reference in New Issue
Block a user