routstrd 0.3.11 → 0.4.1

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.
Files changed (45) hide show
  1. package/COCO-2.0.0-MIGRATION-PLAN.md +1191 -0
  2. package/IMPLEMENTATION.md +253 -0
  3. package/README.md +46 -0
  4. package/SECURITY.md +23 -0
  5. package/SKILL.md +13 -3
  6. package/bun.lock +68 -41
  7. package/dist/daemon/index.js +48837 -11383
  8. package/dist/index.js +5027 -421
  9. package/package.json +8 -3
  10. package/src/cli.test.ts +55 -0
  11. package/src/cli.ts +601 -217
  12. package/src/daemon/args.ts +8 -1
  13. package/src/daemon/fatal-error.test.ts +104 -0
  14. package/src/daemon/fatal-error.ts +48 -0
  15. package/src/daemon/models.ts +119 -39
  16. package/src/daemon/wallet/auto-refill.ts +4 -6
  17. package/src/daemon/wallet/cleanup.test.ts +168 -0
  18. package/src/daemon/wallet/cleanup.ts +104 -0
  19. package/src/daemon/wallet/coco-client.npc.test.ts +252 -0
  20. package/src/daemon/wallet/coco-client.test.ts +669 -0
  21. package/src/daemon/wallet/coco-client.ts +1628 -0
  22. package/src/daemon/wallet/cocod-client.ts +135 -3
  23. package/src/daemon/wallet/diagnostics.test.ts +378 -0
  24. package/src/daemon/wallet/diagnostics.ts +471 -0
  25. package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
  26. package/src/daemon/wallet/migration.test.ts +171 -0
  27. package/src/daemon/wallet/migration.ts +154 -0
  28. package/src/daemon/wallet/paths.ts +33 -0
  29. package/src/daemon/wallet/receive-dedup.test.ts +238 -0
  30. package/src/daemon/wallet/receive-dedup.ts +384 -0
  31. package/src/daemon/wallet/wallet-state.ts +75 -0
  32. package/src/integrations/claudecode.ts +2 -1
  33. package/src/integrations/hermes.ts +46 -6
  34. package/src/integrations/openclaw.ts +3 -2
  35. package/src/integrations/opencode.ts +3 -2
  36. package/src/integrations/pi.ts +3 -2
  37. package/src/integrations/registry.ts +7 -5
  38. package/src/start-daemon.ts +189 -23
  39. package/src/utils/clients.ts +21 -0
  40. package/src/utils/config.ts +11 -0
  41. package/src/utils/daemon-client.ts +104 -25
  42. package/src/utils/logger.ts +42 -28
  43. package/tests/integrations/hermes.test.ts +42 -5
  44. package/tests/utils/daemon-client.test.ts +61 -0
  45. package/tests/wallet/short-keyset-token.test.ts +106 -0
@@ -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
+ }
@@ -0,0 +1,171 @@
1
+ import { afterEach, describe, expect, it, mock } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ rmSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from "fs";
13
+ import { tmpdir } from "os";
14
+ import { join } from "path";
15
+ import { migrateLegacyWallet } from "./migration";
16
+ import { WalletMigrationConflictError } from "./diagnostics";
17
+
18
+ const roots: string[] = [];
19
+ function root(): string {
20
+ const path = mkdtempSync(join(tmpdir(), "routstrd-migration-"));
21
+ roots.push(path);
22
+ return path;
23
+ }
24
+ afterEach(() => {
25
+ for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true });
26
+ });
27
+
28
+ describe("migrateLegacyWallet", () => {
29
+ it("snapshots committed WAL data and leaves process files behind", async () => {
30
+ const base = root();
31
+ const legacyDir = join(base, ".cocod");
32
+ const walletDir = join(base, ".routstrd", "wallet");
33
+ mkdirSync(legacyDir);
34
+ const config = JSON.stringify({
35
+ mnemonic: "seed words",
36
+ encrypted: false,
37
+ defaultMintUrl: "https://mint.example",
38
+ unknown: { preserved: true },
39
+ });
40
+ writeFileSync(join(legacyDir, "config.json"), config);
41
+ writeFileSync(join(legacyDir, "cocod.pid"), "123");
42
+ writeFileSync(join(legacyDir, "cocod.sock"), "not-a-real-socket");
43
+
44
+ const source = new Database(join(legacyDir, "coco.db"));
45
+ source.exec("PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0");
46
+ source.exec(`
47
+ CREATE TABLE coco_cashu_proofs (mintUrl TEXT, state TEXT, amount INTEGER);
48
+ CREATE TABLE coco_cashu_counters (mintUrl TEXT, keysetId TEXT, counter INTEGER);
49
+ CREATE TABLE coco_cashu_mint_operations (state TEXT);
50
+ CREATE TABLE coco_cashu_send_operations (state TEXT);
51
+ CREATE TABLE coco_cashu_melt_operations (state TEXT);
52
+ CREATE TABLE coco_cashu_mints (mintUrl TEXT, trusted INTEGER);
53
+ INSERT INTO coco_cashu_proofs VALUES ('https://mint.example', 'ready', 21);
54
+ INSERT INTO coco_cashu_counters VALUES ('https://mint.example', 'keyset', 3);
55
+ INSERT INTO coco_cashu_mints VALUES ('https://mint.example', 1);
56
+ `);
57
+ expect(existsSync(join(legacyDir, "coco.db-wal"))).toBe(true);
58
+
59
+ const assertStopped = mock(async () => {});
60
+ const releaseLock = mock(() => {});
61
+ const acquireLock = mock(() => releaseLock);
62
+ const result = await migrateLegacyWallet({
63
+ walletDir,
64
+ legacyDir,
65
+ assertLegacyStopped: assertStopped,
66
+ acquireLegacyLock: acquireLock,
67
+ });
68
+ source.close();
69
+
70
+ expect(result.status).toBe("migrated");
71
+ expect(assertStopped).toHaveBeenCalledTimes(2);
72
+ expect(acquireLock).toHaveBeenCalledTimes(1);
73
+ expect(releaseLock).toHaveBeenCalledTimes(1);
74
+ expect(readFileSync(join(walletDir, "config.json"), "utf8")).toBe(config);
75
+ const migrated = new Database(join(walletDir, "coco.db"), { readonly: true });
76
+ expect(migrated.query("PRAGMA quick_check").values()).toEqual([["ok"]]);
77
+ expect(migrated.query("SELECT state, amount FROM coco_cashu_proofs").values()).toEqual([
78
+ ["ready", 21],
79
+ ]);
80
+ expect(migrated.query("SELECT counter FROM coco_cashu_counters").get()).toEqual({
81
+ counter: 3,
82
+ });
83
+ migrated.close();
84
+ expect(existsSync(join(walletDir, "coco.db-wal"))).toBe(false);
85
+ expect(statSync(walletDir).mode & 0o777).toBe(0o700);
86
+ expect(statSync(join(walletDir, "config.json")).mode & 0o777).toBe(0o600);
87
+ expect(existsSync(join(walletDir, "cocod.pid"))).toBe(false);
88
+ expect(existsSync(join(walletDir, "cocod.sock"))).toBe(false);
89
+ expect(existsSync(join(legacyDir, "config.json"))).toBe(false);
90
+ expect(existsSync(join(legacyDir, "coco.db"))).toBe(false);
91
+ expect(existsSync(join(legacyDir, "cocod.pid"))).toBe(true);
92
+ expect(existsSync(join(legacyDir, "cocod.sock"))).toBe(true);
93
+ const archive = readdirSync(legacyDir).find((name) => name.startsWith("wallet-migrated-"));
94
+ expect(archive).toBeDefined();
95
+ expect(existsSync(join(legacyDir, archive!, "config.json"))).toBe(true);
96
+ expect(existsSync(join(legacyDir, archive!, "coco.db"))).toBe(true);
97
+ });
98
+
99
+ it("treats a config-only initialized wallet as migratable", async () => {
100
+ const base = root();
101
+ const legacyDir = join(base, ".cocod");
102
+ const walletDir = join(base, ".routstrd", "wallet");
103
+ mkdirSync(legacyDir);
104
+ writeFileSync(join(legacyDir, "config.json"), '{"mnemonic":"seed"}');
105
+
106
+ expect((await migrateLegacyWallet({ walletDir, legacyDir })).status).toBe("migrated");
107
+ expect(existsSync(join(walletDir, "config.json"))).toBe(true);
108
+ expect(existsSync(join(walletDir, "coco.db"))).toBe(false);
109
+ });
110
+
111
+ it("refuses a database without its mnemonic config", async () => {
112
+ const base = root();
113
+ const legacyDir = join(base, ".cocod");
114
+ mkdirSync(legacyDir);
115
+ writeFileSync(join(legacyDir, "coco.db"), "database");
116
+
117
+ await expect(migrateLegacyWallet({ walletDir: join(base, "wallet"), legacyDir })).rejects.toThrow(
118
+ "without config.json",
119
+ );
120
+ });
121
+
122
+ it("refuses orphaned SQLite sidecars", async () => {
123
+ const base = root();
124
+ const legacyDir = join(base, ".cocod");
125
+ mkdirSync(legacyDir);
126
+ writeFileSync(join(legacyDir, "coco.db-wal"), "orphaned WAL");
127
+
128
+ await expect(migrateLegacyWallet({ walletDir: join(base, "wallet"), legacyDir })).rejects.toThrow(
129
+ "sidecar files without coco.db",
130
+ );
131
+ });
132
+
133
+ it("refuses when both canonical and legacy configs exist", async () => {
134
+ const base = root();
135
+ const legacyDir = join(base, ".cocod");
136
+ const walletDir = join(base, ".routstrd", "wallet");
137
+ mkdirSync(legacyDir, { recursive: true });
138
+ mkdirSync(walletDir, { recursive: true });
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
+ );
147
+
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",
169
+ );
170
+ });
171
+ });