routstrd 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,471 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, statSync } from "fs";
3
+ import { Database } from "bun:sqlite";
4
+ import { join } from "path";
5
+ import {
6
+ classifyWalletMigration,
7
+ type WalletMigrationClass,
8
+ } from "./wallet-state";
9
+
10
+ /**
11
+ * Wallet diagnostics for the cocod → routstrd wallet-directory migration.
12
+ *
13
+ * The migration in `migration.ts` refuses to choose between two wallets when
14
+ * both `~/.routstrd/wallet` and `~/.cocod` contain different data. These
15
+ * helpers summarize each side with privacy-safe, decision-relevant signals
16
+ * (mnemonic fingerprint, timestamps, and database proof/amount summaries) so a
17
+ * user can tell which wallet is the real one without us ever printing secrets.
18
+ */
19
+
20
+ export type WalletRole = "canonical" | "legacy";
21
+
22
+ export type WalletSummary = Record<string, unknown[]>;
23
+
24
+ /** Queries shared with the migration snapshot verifier. */
25
+ export const SUMMARY_QUERIES: Record<string, string> = {
26
+ proofs:
27
+ "SELECT mintUrl, state, COUNT(*) count, COALESCE(SUM(amount), 0) amount FROM coco_cashu_proofs GROUP BY mintUrl, state ORDER BY mintUrl, state",
28
+ counters:
29
+ "SELECT mintUrl, keysetId, counter FROM coco_cashu_counters ORDER BY mintUrl, keysetId",
30
+ mintOperations:
31
+ "SELECT state, COUNT(*) count FROM coco_cashu_mint_operations GROUP BY state ORDER BY state",
32
+ sendOperations:
33
+ "SELECT state, COUNT(*) count FROM coco_cashu_send_operations GROUP BY state ORDER BY state",
34
+ meltOperations:
35
+ "SELECT state, COUNT(*) count FROM coco_cashu_melt_operations GROUP BY state ORDER BY state",
36
+ mints: "SELECT mintUrl, trusted FROM coco_cashu_mints ORDER BY mintUrl",
37
+ };
38
+
39
+ /**
40
+ * Verify a database is healthy and return a wallet summary. Throws on corrupt
41
+ * databases because the migration must never proceed over them.
42
+ */
43
+ export function verifyDatabase(database: Database, label: string): WalletSummary {
44
+ const checks = database.query("PRAGMA quick_check").values() as unknown[][];
45
+ if (checks.length !== 1 || checks[0]?.[0] !== "ok") {
46
+ throw new Error(`${label} failed PRAGMA quick_check: ${JSON.stringify(checks)}`);
47
+ }
48
+
49
+ const tables = new Set(
50
+ (database
51
+ .query("SELECT name FROM sqlite_master WHERE type = 'table'")
52
+ .values() as string[][]).map(([name]) => name),
53
+ );
54
+ const summary: WalletSummary = {};
55
+ for (const [name, query] of Object.entries(SUMMARY_QUERIES)) {
56
+ const table = query.match(/FROM\s+(coco_cashu_\w+)/i)?.[1];
57
+ summary[name] = table && tables.has(table) ? database.query(query).all() : [];
58
+ }
59
+ return summary;
60
+ }
61
+
62
+ export interface ProofSummaryRow {
63
+ mintUrl: string;
64
+ state: string;
65
+ count: number;
66
+ amount: number;
67
+ }
68
+
69
+ export interface DbDiagnosticSummary {
70
+ proofs: ProofSummaryRow[];
71
+ mints: { mintUrl: string; trusted: number }[];
72
+ totalProofs: number;
73
+ totalAmount: number;
74
+ amountByState: Record<string, number>;
75
+ distinctMints: number;
76
+ }
77
+
78
+ export interface WalletConfigDiagnostic {
79
+ exists: boolean;
80
+ path: string;
81
+ mtimeMs?: number;
82
+ error?: string;
83
+ fingerprint?: string;
84
+ encrypted?: boolean;
85
+ createdAt?: string;
86
+ version?: number;
87
+ defaultMintUrl?: string;
88
+ hasMnemonic?: boolean;
89
+ }
90
+
91
+ export interface WalletDbDiagnostic {
92
+ exists: boolean;
93
+ path: string;
94
+ mtimeMs?: number;
95
+ sizeBytes?: number;
96
+ error?: string;
97
+ summary?: DbDiagnosticSummary;
98
+ }
99
+
100
+ export interface WalletDiagnostic {
101
+ dir: string;
102
+ role: WalletRole;
103
+ config: WalletConfigDiagnostic;
104
+ db: WalletDbDiagnostic;
105
+ }
106
+
107
+ /** A one-way, truncated fingerprint of a mnemonic. Never returns the mnemonic. */
108
+ export function mnemonicFingerprint(mnemonic: string): string {
109
+ const normalized = mnemonic.trim().split(/\s+/).join(" ");
110
+ const hex = createHash("sha256").update(normalized).digest("hex");
111
+ return hex.slice(0, 16).match(/.{1,4}/g)?.join("-") ?? hex.slice(0, 16);
112
+ }
113
+
114
+ /** Read-only DB summary; never throws, degrades to an error string. */
115
+ export function summarizeDbReadonly(
116
+ dbPath: string,
117
+ ): { summary?: DbDiagnosticSummary; error?: string } {
118
+ let database: Database | undefined;
119
+ try {
120
+ database = new Database(dbPath, { readonly: true });
121
+ const raw = verifyDatabase(database, "Wallet database");
122
+
123
+ const proofs = (raw.proofs ?? []) as ProofSummaryRow[];
124
+ const mints = (raw.mints ?? []) as { mintUrl: string; trusted: number }[];
125
+ const amountByState: Record<string, number> = {};
126
+ let totalProofs = 0;
127
+ let totalAmount = 0;
128
+ for (const row of proofs) {
129
+ const count = typeof row.count === "number" ? row.count : 0;
130
+ const amount = typeof row.amount === "number" ? row.amount : 0;
131
+ totalProofs += count;
132
+ totalAmount += amount;
133
+ const state = row.state || "unknown";
134
+ amountByState[state] = (amountByState[state] ?? 0) + amount;
135
+ }
136
+
137
+ return {
138
+ summary: {
139
+ proofs,
140
+ mints,
141
+ totalProofs,
142
+ totalAmount,
143
+ amountByState,
144
+ distinctMints: new Set(proofs.map((row) => row.mintUrl).filter(Boolean)).size,
145
+ },
146
+ };
147
+ } catch (error) {
148
+ return { error: error instanceof Error ? error.message : String(error) };
149
+ } finally {
150
+ database?.close();
151
+ }
152
+ }
153
+
154
+ function summarizeConfig(configPath: string): WalletConfigDiagnostic {
155
+ const result: WalletConfigDiagnostic = { exists: false, path: configPath };
156
+ try {
157
+ if (!existsSync(configPath)) return result;
158
+ result.exists = true;
159
+ result.mtimeMs = statSync(configPath).mtimeMs;
160
+
161
+ const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
162
+ result.encrypted = parsed.encrypted === true;
163
+ if (typeof parsed.createdAt === "string") result.createdAt = parsed.createdAt;
164
+ if (typeof parsed.version === "number") result.version = parsed.version;
165
+ if (typeof parsed.defaultMintUrl === "string") {
166
+ result.defaultMintUrl = parsed.defaultMintUrl;
167
+ }
168
+ if (typeof parsed.mnemonic === "string") {
169
+ result.hasMnemonic = true;
170
+ if (!result.encrypted) result.fingerprint = mnemonicFingerprint(parsed.mnemonic);
171
+ }
172
+ } catch (error) {
173
+ result.error = error instanceof Error ? error.message : String(error);
174
+ }
175
+ return result;
176
+ }
177
+
178
+ function summarizeDb(dbPath: string): WalletDbDiagnostic {
179
+ const result: WalletDbDiagnostic = { exists: false, path: dbPath };
180
+ try {
181
+ if (!existsSync(dbPath)) return result;
182
+ result.exists = true;
183
+ const stats = statSync(dbPath);
184
+ result.mtimeMs = stats.mtimeMs;
185
+ result.sizeBytes = stats.size;
186
+
187
+ const { summary, error } = summarizeDbReadonly(dbPath);
188
+ if (summary) result.summary = summary;
189
+ if (error) result.error = error;
190
+ } catch (error) {
191
+ result.error = error instanceof Error ? error.message : String(error);
192
+ }
193
+ return result;
194
+ }
195
+
196
+ export function summarizeWalletDirectory(
197
+ dir: string,
198
+ role: WalletRole,
199
+ ): WalletDiagnostic {
200
+ return {
201
+ dir,
202
+ role,
203
+ config: summarizeConfig(join(dir, "config.json")),
204
+ db: summarizeDb(join(dir, "coco.db")),
205
+ };
206
+ }
207
+
208
+ export class WalletMigrationConflictError extends Error {
209
+ readonly target: WalletDiagnostic;
210
+ readonly source: WalletDiagnostic;
211
+
212
+ constructor(target: WalletDiagnostic, source: WalletDiagnostic) {
213
+ super(renderWalletConflict(target, source));
214
+ this.name = "WalletMigrationConflictError";
215
+ this.target = target;
216
+ this.source = source;
217
+ }
218
+ }
219
+
220
+ function timeAgo(ms?: number): string | undefined {
221
+ if (ms === undefined || !Number.isFinite(ms)) return undefined;
222
+ const elapsed = Date.now() - ms;
223
+ if (elapsed < 0) return undefined;
224
+ const seconds = Math.floor(elapsed / 1000);
225
+ if (seconds < 5) return "just now";
226
+ if (seconds < 60) return `${seconds}s ago`;
227
+ const minutes = Math.floor(seconds / 60);
228
+ if (minutes < 60) return `${minutes}m ago`;
229
+ const hours = Math.floor(minutes / 60);
230
+ if (hours < 48) return `${hours}h ago`;
231
+ const days = Math.floor(hours / 24);
232
+ if (days < 60) return `${days}d ago`;
233
+ const months = Math.floor(days / 30);
234
+ if (months < 24) return `${months}mo ago`;
235
+ return `${Math.floor(days / 365)}y ago`;
236
+ }
237
+
238
+ function modifiedDescription(config: WalletConfigDiagnostic): string {
239
+ if (config.createdAt) return `created ${config.createdAt}`;
240
+ const ago = timeAgo(config.mtimeMs);
241
+ if (ago) return `modified ${ago}`;
242
+ return "modified time unknown";
243
+ }
244
+
245
+ function roleLabel(role: WalletRole): string {
246
+ return role === "canonical" ? "current routstrd wallet" : "legacy cocod wallet";
247
+ }
248
+
249
+ function configLine(config: WalletConfigDiagnostic): string {
250
+ if (!config.exists) return "config.json absent";
251
+ if (config.error) return `config.json present (unreadable: ${config.error})`;
252
+ const parts: string[] = ["config.json present"];
253
+ if (config.fingerprint) parts.push(`fingerprint ${config.fingerprint}`);
254
+ else if (config.encrypted) parts.push("encrypted (mnemonic not readable)");
255
+ else parts.push("no mnemonic found");
256
+ parts.push(modifiedDescription(config));
257
+ if (config.defaultMintUrl) parts.push(`default mint ${config.defaultMintUrl}`);
258
+ return parts.join(" · ");
259
+ }
260
+
261
+ function formatNumber(value: number): string {
262
+ return value.toLocaleString("en-US");
263
+ }
264
+
265
+ function amountByStateLine(summary: DbDiagnosticSummary): string {
266
+ const parts = Object.entries(summary.amountByState).filter(([, amount]) => amount > 0);
267
+ if (parts.length === 0) return "0 sats";
268
+ return parts.map(([state, amount]) => `${state}: ${formatNumber(amount)} sats`).join(", ");
269
+ }
270
+
271
+ function dbLine(db: WalletDbDiagnostic): string {
272
+ if (!db.exists) return "coco.db absent";
273
+ if (db.error && !db.summary) return `coco.db present (unreadable: ${db.error})`;
274
+ const parts: string[] = ["coco.db present"];
275
+ if (db.summary) {
276
+ const { totalProofs, mints, distinctMints } = db.summary;
277
+ // The mints table is the wallet's mint registry; fall back to mints seen
278
+ // in proofs for databases old enough to lack the registry table.
279
+ const mintCount = mints.length > 0 ? mints.length : distinctMints;
280
+ parts.push(`${formatNumber(totalProofs)} proof${totalProofs === 1 ? "" : "s"}`);
281
+ parts.push(amountByStateLine(db.summary));
282
+ parts.push(`${mintCount} mint${mintCount === 1 ? "" : "s"}`);
283
+ }
284
+ return parts.join(" · ");
285
+ }
286
+
287
+ function block(diag: WalletDiagnostic, letter: string): string {
288
+ return [
289
+ ` ${letter}) ${diag.dir} (${roleLabel(diag.role)})`,
290
+ ` ${configLine(diag.config)}`,
291
+ ` ${dbLine(diag.db)}`,
292
+ ].join("\n");
293
+ }
294
+
295
+ function moveAsideCommands(target: WalletDiagnostic, source: WalletDiagnostic): string {
296
+ return [
297
+ " • If B is your wallet (keep the legacy wallet):",
298
+ ` mv "${target.dir}" "${target.dir}.old"`,
299
+ "",
300
+ " • If A is your wallet (keep the normal routstrd wallet):",
301
+ ` mv "${source.dir}" "${source.dir}.old"`,
302
+ ].join("\n");
303
+ }
304
+
305
+ export function renderResolutionSteps(
306
+ target: WalletDiagnostic,
307
+ source: WalletDiagnostic,
308
+ ): string {
309
+ return [
310
+ "To resolve, decide which wallet is yours, move the other one aside, and run",
311
+ "'routstrd onboard' (or 'routstrd start') again. Stop the daemon first:",
312
+ "",
313
+ " routstrd stop",
314
+ "",
315
+ "Then:",
316
+ "",
317
+ moveAsideCommands(target, source),
318
+ ].join("\n");
319
+ }
320
+
321
+ export function renderWalletConflict(
322
+ target: WalletDiagnostic,
323
+ source: WalletDiagnostic,
324
+ ): string {
325
+ return [
326
+ "Cannot migrate wallet: two different wallets were found, and routstrd will",
327
+ "not choose a mnemonic or merge databases automatically — the wrong choice",
328
+ "could lose access to your funds.",
329
+ "",
330
+ block(target, "A"),
331
+ block(source, "B"),
332
+ "",
333
+ "No files were changed.",
334
+ "",
335
+ renderResolutionSteps(target, source),
336
+ "",
337
+ "For a full comparison and safe cleanup, run: routstrd wallet doctor",
338
+ ].join("\n");
339
+ }
340
+
341
+ export interface WalletVerdict {
342
+ /** One-line human verdict for the doctor report. */
343
+ text: string;
344
+ /** Whether the mv-aside resolution steps apply to this state. */
345
+ showResolution: boolean;
346
+ /** Whether startup would (or may) refuse to migrate in this state. */
347
+ conflict: boolean;
348
+ }
349
+
350
+ function conflictVerdict(
351
+ target: WalletDiagnostic,
352
+ source: WalletDiagnostic,
353
+ ): WalletVerdict {
354
+ const unreadable =
355
+ !!target.config.error ||
356
+ !!source.config.error ||
357
+ !!target.db.error ||
358
+ !!source.db.error;
359
+ if (unreadable) {
360
+ return {
361
+ text: "Verdict: both wallets exist but one or both could not be fully read; startup will refuse to migrate. See the details above.",
362
+ showResolution: true,
363
+ conflict: true,
364
+ };
365
+ }
366
+ if (target.config.fingerprint && source.config.fingerprint) {
367
+ if (target.config.fingerprint === source.config.fingerprint) {
368
+ return {
369
+ 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.",
370
+ showResolution: true,
371
+ conflict: true,
372
+ };
373
+ }
374
+ return {
375
+ text: "Verdict: the two wallets have DIFFERENT mnemonics. Startup will refuse to migrate.",
376
+ showResolution: true,
377
+ conflict: true,
378
+ };
379
+ }
380
+ return {
381
+ text: "Verdict: both wallets exist but their mnemonics cannot be compared (encrypted or missing). Startup will refuse to migrate unless the files are identical.",
382
+ showResolution: true,
383
+ conflict: true,
384
+ };
385
+ }
386
+
387
+ function verdictFromClassification(
388
+ target: WalletDiagnostic,
389
+ source: WalletDiagnostic,
390
+ classification: WalletMigrationClass,
391
+ ): WalletVerdict {
392
+ switch (classification.kind) {
393
+ case "fresh":
394
+ return {
395
+ text: "Verdict: no wallet found in either location (fresh install).",
396
+ showResolution: false,
397
+ conflict: false,
398
+ };
399
+ case "already-current":
400
+ return {
401
+ text: "Verdict: the current routstrd wallet is authoritative; no migration needed.",
402
+ showResolution: false,
403
+ conflict: false,
404
+ };
405
+ case "migrate":
406
+ return {
407
+ text: "Verdict: only the legacy cocod wallet exists; it will be migrated on next startup.",
408
+ showResolution: false,
409
+ conflict: false,
410
+ };
411
+ case "conflict":
412
+ return conflictVerdict(target, source);
413
+ case "database-only":
414
+ return {
415
+ text: "Verdict: a wallet is incomplete (coco.db without config.json). Startup will refuse to migrate. See the details above.",
416
+ showResolution: false,
417
+ conflict: true,
418
+ };
419
+ case "orphaned-sidecars":
420
+ return {
421
+ text: "Verdict: the legacy wallet has SQLite sidecar files without coco.db. Startup will refuse to migrate. Restore the matching database first.",
422
+ showResolution: false,
423
+ conflict: true,
424
+ };
425
+ }
426
+ }
427
+
428
+ /** Map the shared migration classification onto a human doctor verdict. */
429
+ export function diagnoseWallets(
430
+ target: WalletDiagnostic,
431
+ source: WalletDiagnostic,
432
+ ): WalletVerdict {
433
+ let classification: WalletMigrationClass;
434
+ try {
435
+ classification = classifyWalletMigration(target.dir, source.dir);
436
+ } catch {
437
+ // classifyWalletMigration does raw reads (filesEqual) that can throw on
438
+ // unreadable or concurrently-removed files. The doctor exists to diagnose
439
+ // broken states, so it must never crash on one. Fail safe: treat the
440
+ // state as a conflict — startup hits the same read error and refuses.
441
+ if (target.config.error || source.config.error || target.db.error || source.db.error) {
442
+ return conflictVerdict(target, source);
443
+ }
444
+ return {
445
+ text: "Verdict: the wallets could not be fully compared (read error); startup will refuse to migrate. See the details above.",
446
+ showResolution: true,
447
+ conflict: true,
448
+ };
449
+ }
450
+ return verdictFromClassification(target, source, classification);
451
+ }
452
+
453
+ export function renderWalletDoctor(
454
+ target: WalletDiagnostic,
455
+ source: WalletDiagnostic,
456
+ ): string {
457
+ const verdict = diagnoseWallets(target, source);
458
+ const lines = [
459
+ "Routstr wallet diagnostic",
460
+ "========================",
461
+ "",
462
+ block(target, "A"),
463
+ block(source, "B"),
464
+ "",
465
+ verdict.text,
466
+ ];
467
+ if (verdict.showResolution) {
468
+ lines.push("", renderResolutionSteps(target, source));
469
+ }
470
+ return lines.join("\n");
471
+ }
@@ -13,6 +13,7 @@ import {
13
13
  import { tmpdir } from "os";
14
14
  import { join } from "path";
15
15
  import { migrateLegacyWallet } from "./migration";
16
+ import { WalletMigrationConflictError } from "./diagnostics";
16
17
 
17
18
  const roots: string[] = [];
18
19
  function root(): string {
@@ -135,13 +136,36 @@ describe("migrateLegacyWallet", () => {
135
136
  const walletDir = join(base, ".routstrd", "wallet");
136
137
  mkdirSync(legacyDir, { recursive: true });
137
138
  mkdirSync(walletDir, { recursive: true });
138
- writeFileSync(join(legacyDir, "config.json"), "legacy");
139
- writeFileSync(join(walletDir, "config.json"), "current");
139
+ writeFileSync(
140
+ join(legacyDir, "config.json"),
141
+ JSON.stringify({ mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" }),
142
+ );
143
+ writeFileSync(
144
+ join(walletDir, "config.json"),
145
+ JSON.stringify({ mnemonic: "legal winner thank year wave sausage worth useful legal winner thank yellow" }),
146
+ );
140
147
 
141
- await expect(migrateLegacyWallet({ walletDir, legacyDir })).rejects.toThrow(
142
- "both",
148
+ let thrown: unknown;
149
+ try {
150
+ await migrateLegacyWallet({ walletDir, legacyDir });
151
+ } catch (error) {
152
+ thrown = error;
153
+ }
154
+
155
+ expect(thrown).toBeInstanceOf(WalletMigrationConflictError);
156
+ const err = thrown as WalletMigrationConflictError;
157
+ expect(err.target.dir).toBe(walletDir);
158
+ expect(err.source.dir).toBe(legacyDir);
159
+ expect(err.message).toContain("two different wallets were found");
160
+ expect(err.message).toContain(walletDir);
161
+ expect(err.message).toContain(legacyDir);
162
+ expect(err.message).not.toContain("abandon");
163
+ expect(err.message).not.toContain("legal winner");
164
+ expect(readFileSync(join(walletDir, "config.json"), "utf8")).toContain(
165
+ "legal winner",
166
+ );
167
+ expect(readFileSync(join(legacyDir, "config.json"), "utf8")).toContain(
168
+ "abandon abandon",
143
169
  );
144
- expect(readFileSync(join(walletDir, "config.json"), "utf8")).toBe("current");
145
- expect(readFileSync(join(legacyDir, "config.json"), "utf8")).toBe("legacy");
146
170
  });
147
171
  });
@@ -3,7 +3,6 @@ import {
3
3
  copyFileSync,
4
4
  existsSync,
5
5
  mkdirSync,
6
- readFileSync,
7
6
  renameSync,
8
7
  rmSync,
9
8
  statSync,
@@ -11,6 +10,13 @@ import {
11
10
  import { Database } from "bun:sqlite";
12
11
  import { basename, dirname, join } from "path";
13
12
  import { legacyCocodDir, walletDir } from "./paths";
13
+ import {
14
+ summarizeWalletDirectory,
15
+ verifyDatabase,
16
+ WalletMigrationConflictError,
17
+ type WalletSummary,
18
+ } from "./diagnostics";
19
+ import { classifyWalletMigration } from "./wallet-state";
14
20
 
15
21
  export type WalletMigrationResult =
16
22
  | { status: "fresh" }
@@ -26,60 +32,10 @@ export interface WalletMigrationOptions {
26
32
  acquireLegacyLock?: () => (() => void) | Promise<() => void>;
27
33
  }
28
34
 
29
- type WalletState = "absent" | "database-only" | "initialized";
30
-
31
- function state(configPath: string, dbPath: string): WalletState {
32
- const hasConfig = existsSync(configPath);
33
- const hasDb = existsSync(dbPath);
34
- if (hasConfig) return "initialized";
35
- if (hasDb) return "database-only";
36
- return "absent";
37
- }
38
-
39
- function filesEqual(left: string, right: string): boolean {
40
- if (!existsSync(left) || !existsSync(right)) return false;
41
- if (statSync(left).size !== statSync(right).size) return false;
42
- return readFileSync(left).equals(readFileSync(right));
43
- }
44
-
45
35
  function sqlString(value: string): string {
46
36
  return `'${value.replaceAll("'", "''")}'`;
47
37
  }
48
38
 
49
- type WalletSummary = Record<string, unknown[]>;
50
- const SUMMARY_QUERIES: Record<string, string> = {
51
- proofs:
52
- "SELECT mintUrl, state, COUNT(*) count, COALESCE(SUM(amount), 0) amount FROM coco_cashu_proofs GROUP BY mintUrl, state ORDER BY mintUrl, state",
53
- counters:
54
- "SELECT mintUrl, keysetId, counter FROM coco_cashu_counters ORDER BY mintUrl, keysetId",
55
- mintOperations:
56
- "SELECT state, COUNT(*) count FROM coco_cashu_mint_operations GROUP BY state ORDER BY state",
57
- sendOperations:
58
- "SELECT state, COUNT(*) count FROM coco_cashu_send_operations GROUP BY state ORDER BY state",
59
- meltOperations:
60
- "SELECT state, COUNT(*) count FROM coco_cashu_melt_operations GROUP BY state ORDER BY state",
61
- mints: "SELECT mintUrl, trusted FROM coco_cashu_mints ORDER BY mintUrl",
62
- };
63
-
64
- function verifyDatabase(database: Database, label: string): WalletSummary {
65
- const checks = database.query("PRAGMA quick_check").values() as unknown[][];
66
- if (checks.length !== 1 || checks[0]?.[0] !== "ok") {
67
- throw new Error(`${label} failed PRAGMA quick_check: ${JSON.stringify(checks)}`);
68
- }
69
-
70
- const tables = new Set(
71
- (database
72
- .query("SELECT name FROM sqlite_master WHERE type = 'table'")
73
- .values() as string[][]).map(([name]) => name),
74
- );
75
- const summary: WalletSummary = {};
76
- for (const [name, query] of Object.entries(SUMMARY_QUERIES)) {
77
- const table = query.match(/FROM\s+(coco_cashu_\w+)/i)?.[1];
78
- summary[name] = table && tables.has(table) ? database.query(query).all() : [];
79
- }
80
- return summary;
81
- }
82
-
83
39
  /** Write a standalone SQLite snapshot containing committed WAL frames. */
84
40
  function snapshotDatabase(sourcePath: string, destinationPath: string): void {
85
41
  const source = new Database(sourcePath);
@@ -114,45 +70,34 @@ export async function migrateLegacyWallet(
114
70
  ): Promise<WalletMigrationResult> {
115
71
  const targetDir = options.walletDir || walletDir();
116
72
  const sourceDir = options.legacyDir || legacyCocodDir();
117
- const targetConfig = join(targetDir, "config.json");
118
- const targetDb = join(targetDir, "coco.db");
119
73
  const sourceConfig = join(sourceDir, "config.json");
120
74
  const sourceDb = join(sourceDir, "coco.db");
121
75
  const sourceWal = `${sourceDb}-wal`;
122
76
  const sourceShm = `${sourceDb}-shm`;
123
- const targetState = state(targetConfig, targetDb);
124
- const sourceState = state(sourceConfig, sourceDb);
125
-
126
- // config.json is sufficient for a newly initialized wallet; coco.db is
127
- // created on first open. A database without its mnemonic is never usable.
128
- if (targetState === "initialized" && sourceState === "initialized") {
129
- // A prior migration may have committed successfully but failed to remove
130
- // its source files. Identical leftovers are safe; divergent wallets are not.
131
- const configsMatch = filesEqual(targetConfig, sourceConfig);
132
- const databasesMatch =
133
- !existsSync(targetDb) && !existsSync(sourceDb)
134
- ? true
135
- : filesEqual(targetDb, sourceDb);
136
- if (configsMatch && databasesMatch) return { status: "already-current" };
137
- throw new Error(
138
- `Cannot migrate wallet: both ${targetDir} and ${sourceDir} contain different wallet data. ` +
139
- "Refusing to choose a mnemonic or merge wallet databases automatically.",
140
- );
141
- }
142
- if (targetState === "initialized") return { status: "already-current" };
143
- if (!existsSync(sourceDb) && (existsSync(sourceWal) || existsSync(sourceShm))) {
144
- throw new Error(
145
- `Cannot migrate wallet: ${sourceDir} contains SQLite sidecar files without coco.db. ` +
146
- "Restore the matching main database before migration.",
147
- );
148
- }
149
- if (targetState === "database-only" || sourceState === "database-only") {
150
- throw new Error(
151
- `Cannot migrate wallet: ${targetDir} is ${targetState} and ${sourceDir} is ${sourceState}. ` +
152
- "A coco.db without config.json cannot be opened; restore the matching config first.",
153
- );
77
+ const classification = classifyWalletMigration(targetDir, sourceDir);
78
+ switch (classification.kind) {
79
+ case "fresh":
80
+ return { status: "fresh" };
81
+ case "already-current":
82
+ return { status: "already-current" };
83
+ case "migrate":
84
+ break;
85
+ case "conflict":
86
+ throw new WalletMigrationConflictError(
87
+ summarizeWalletDirectory(targetDir, "canonical"),
88
+ summarizeWalletDirectory(sourceDir, "legacy"),
89
+ );
90
+ case "orphaned-sidecars":
91
+ throw new Error(
92
+ `Cannot migrate wallet: ${sourceDir} contains SQLite sidecar files without coco.db. ` +
93
+ "Restore the matching main database before migration.",
94
+ );
95
+ case "database-only":
96
+ throw new Error(
97
+ `Cannot migrate wallet: ${targetDir} is ${classification.targetState} and ${sourceDir} is ${classification.sourceState}. ` +
98
+ "A coco.db without config.json cannot be opened; restore the matching config first.",
99
+ );
154
100
  }
155
- if (sourceState === "absent") return { status: "fresh" };
156
101
 
157
102
  await options.assertLegacyStopped?.();
158
103
  const releaseLegacyLock = await options.acquireLegacyLock?.();