diff --git a/README.md b/README.md index f776e15..14295f1 100644 --- a/README.md +++ b/README.md @@ -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`: diff --git a/src/cli.ts b/src/cli.ts index 3c365ca..54d4767 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 ") .description("Unlock the wallet") diff --git a/src/daemon/wallet/diagnostics.test.ts b/src/daemon/wallet/diagnostics.test.ts new file mode 100644 index 0000000..8964cca --- /dev/null +++ b/src/daemon/wallet/diagnostics.test.ts @@ -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); + } + }); +}); diff --git a/src/daemon/wallet/diagnostics.ts b/src/daemon/wallet/diagnostics.ts new file mode 100644 index 0000000..c79e083 --- /dev/null +++ b/src/daemon/wallet/diagnostics.ts @@ -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; + +/** Queries shared with the migration snapshot verifier. */ +export const SUMMARY_QUERIES: Record = { + 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; + 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 = {}; + 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; + 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"); +} \ No newline at end of file diff --git a/src/daemon/wallet/migration.test.ts b/src/daemon/wallet/migration.test.ts index b053bcd..b8d838c 100644 --- a/src/daemon/wallet/migration.test.ts +++ b/src/daemon/wallet/migration.test.ts @@ -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"); }); }); diff --git a/src/daemon/wallet/migration.ts b/src/daemon/wallet/migration.ts index e4e5bc2..43fe083 100644 --- a/src/daemon/wallet/migration.ts +++ b/src/daemon/wallet/migration.ts @@ -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; -const SUMMARY_QUERIES: Record = { - 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 { 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?.(); diff --git a/src/daemon/wallet/wallet-state.ts b/src/daemon/wallet/wallet-state.ts new file mode 100644 index 0000000..e636c27 --- /dev/null +++ b/src/daemon/wallet/wallet-state.ts @@ -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" }; +}