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.
@@ -32,7 +32,25 @@ type SpawnDaemon = (
32
32
  env: Record<string, string>,
33
33
  ) => SpawnedProcess;
34
34
 
35
- export type CocodState = "UNINITIALIZED" | "LOCKED" | "UNLOCKED" | "ERROR";
35
+ export type CocodState =
36
+ | "UNINITIALIZED"
37
+ | "LOCKED"
38
+ | "UNLOCKED"
39
+ | "RECOVERING"
40
+ | "ERROR";
41
+
42
+ /** Live progress for background wallet recovery started at daemon startup. */
43
+ export interface WalletRecoveryProgress {
44
+ state: "RECOVERING" | "UNLOCKED" | "ERROR";
45
+ /** Current recovery phase, e.g. "Mint recovery" or "done". */
46
+ phase: string;
47
+ pendingSends: number;
48
+ inflightProofs: number;
49
+ pendingMints: number;
50
+ /** Expired unpaid mint quotes failed locally without a mint round-trip. */
51
+ failedMintQuotes: number;
52
+ error?: string;
53
+ }
36
54
 
37
55
  export type CocodBalanceOutput = Record<string, { sats?: number } | number>;
38
56
 
@@ -57,6 +75,30 @@ export interface NpcUsernameResult {
57
75
  };
58
76
  }
59
77
 
78
+ /** Options for the wallet cleanup command. */
79
+ export interface WalletCleanupOptions {
80
+ /** Only clean up operations for this mint URL. */
81
+ mintUrl?: string;
82
+ /** Minimum operation age in milliseconds (defaults to 7 days / 1 week). */
83
+ minAgeMs?: number;
84
+ /** Report what would be cleaned without applying changes. */
85
+ dryRun?: boolean;
86
+ }
87
+
88
+ /** Summary of a wallet cleanup run. */
89
+ export interface WalletCleanupResult {
90
+ dryRun: boolean;
91
+ /** Number of expired pending mint quotes marked as failed. */
92
+ failedMintQuotes: number;
93
+ /** Number of stale pending send operations reclaimed. */
94
+ reclaimedSends: number;
95
+ /** Number of stale prepared melt operations cancelled. */
96
+ cancelledMelts: number;
97
+ /** Number of in-flight operations that were left untouched. */
98
+ skipped: number;
99
+ errors: Array<{ operationId: string; error: string }>;
100
+ }
101
+
60
102
  export class CocodHttpError extends Error {
61
103
  status: number;
62
104
 
@@ -90,6 +132,12 @@ export interface CocodClient {
90
132
  setNpcUsername(username: string, confirm?: boolean): Promise<NpcUsernameResult>;
91
133
  /** Manually trigger an NPC quote sync into the wallet. */
92
134
  syncNpc(): Promise<void>;
135
+ /** Clear stuck pending/in-flight wallet operations that are safe to resolve. */
136
+ cleanupStuckOperations?(
137
+ options?: WalletCleanupOptions,
138
+ ): Promise<WalletCleanupResult>;
139
+ /** Report background wallet recovery progress, when the wallet supports it. */
140
+ getRecoveryProgress?(): Promise<WalletRecoveryProgress>;
93
141
  }
94
142
 
95
143
  export function resolveCocodExecutable(cocodPath?: string | null): string {
@@ -0,0 +1,378 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
4
+ import { tmpdir } from "os";
5
+ import { join } from "path";
6
+ import {
7
+ diagnoseWallets,
8
+ mnemonicFingerprint,
9
+ renderWalletDoctor,
10
+ summarizeWalletDirectory,
11
+ WalletMigrationConflictError,
12
+ } from "./diagnostics";
13
+
14
+ const roots: string[] = [];
15
+ function root(): string {
16
+ const path = mkdtempSync(join(tmpdir(), "routstrd-diagnostics-"));
17
+ roots.push(path);
18
+ return path;
19
+ }
20
+ afterEach(() => {
21
+ for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true });
22
+ });
23
+
24
+ const MNEMONIC_A =
25
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
26
+ const MNEMONIC_B =
27
+ "legal winner thank year wave sausage worth useful legal winner thank yellow";
28
+
29
+ function writeWalletConfig(dir: string, config: unknown): void {
30
+ mkdirSync(dir, { recursive: true });
31
+ writeFileSync(join(dir, "config.json"), JSON.stringify(config));
32
+ }
33
+
34
+ function writeFakeDbFile(dir: string): void {
35
+ mkdirSync(dir, { recursive: true });
36
+ writeFileSync(join(dir, "coco.db"), "not a real sqlite database");
37
+ }
38
+
39
+ function writeProofsDb(dir: string): void {
40
+ mkdirSync(dir, { recursive: true });
41
+ const db = new Database(join(dir, "coco.db"));
42
+ db.exec(`
43
+ CREATE TABLE coco_cashu_proofs (mintUrl TEXT, state TEXT, amount INTEGER);
44
+ INSERT INTO coco_cashu_proofs VALUES ('https://mint.example', 'ready', 100);
45
+ INSERT INTO coco_cashu_proofs VALUES ('https://mint.example', 'ready', 50);
46
+ INSERT INTO coco_cashu_proofs VALUES ('https://mint.example', 'pending', 30);
47
+ INSERT INTO coco_cashu_proofs VALUES ('https://other.example', 'spent', 20);
48
+ CREATE TABLE coco_cashu_mints (mintUrl TEXT, trusted INTEGER);
49
+ INSERT INTO coco_cashu_mints VALUES ('https://mint.example', 1);
50
+ INSERT INTO coco_cashu_mints VALUES ('https://other.example', 1);
51
+ INSERT INTO coco_cashu_mints VALUES ('https://third.example', 0);
52
+ `);
53
+ db.close();
54
+ }
55
+
56
+ describe("mnemonicFingerprint", () => {
57
+ it("is deterministic and normalizes whitespace", () => {
58
+ const base = mnemonicFingerprint(MNEMONIC_A);
59
+ expect(mnemonicFingerprint(MNEMONIC_A)).toBe(base);
60
+ expect(
61
+ mnemonicFingerprint(" abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about "),
62
+ ).toBe(base);
63
+ });
64
+
65
+ it("distinguishes different mnemonics and never returns the mnemonic", () => {
66
+ const a = mnemonicFingerprint(MNEMONIC_A);
67
+ const b = mnemonicFingerprint(MNEMONIC_B);
68
+ expect(a).not.toBe(b);
69
+ expect(a).not.toContain("abandon");
70
+ expect(b).not.toContain("legal");
71
+ });
72
+ });
73
+
74
+ describe("summarizeWalletDirectory", () => {
75
+ it("reports an empty directory", () => {
76
+ const dir = join(root(), "empty");
77
+ const diag = summarizeWalletDirectory(dir, "canonical");
78
+ expect(diag.config.exists).toBe(false);
79
+ expect(diag.db.exists).toBe(false);
80
+ });
81
+
82
+ it("summarizes a decrypted config with fingerprint and metadata", () => {
83
+ const dir = join(root(), "wallet");
84
+ writeWalletConfig(dir, {
85
+ version: 1,
86
+ mnemonic: MNEMONIC_A,
87
+ encrypted: false,
88
+ createdAt: "2026-08-20T14:03:22.000Z",
89
+ defaultMintUrl: "https://mint.example",
90
+ });
91
+ const diag = summarizeWalletDirectory(dir, "canonical");
92
+ expect(diag.config.exists).toBe(true);
93
+ expect(diag.config.fingerprint).toBe(mnemonicFingerprint(MNEMONIC_A));
94
+ expect(diag.config.hasMnemonic).toBe(true);
95
+ expect(diag.config.encrypted).toBe(false);
96
+ expect(diag.config.createdAt).toBe("2026-08-20T14:03:22.000Z");
97
+ expect(diag.config.defaultMintUrl).toBe("https://mint.example");
98
+ expect(diag.config.mtimeMs).toBeTypeOf("number");
99
+ });
100
+
101
+ it("does not derive a fingerprint for encrypted wallets", () => {
102
+ const dir = join(root(), "encrypted");
103
+ writeWalletConfig(dir, { mnemonic: MNEMONIC_A, encrypted: true });
104
+ const diag = summarizeWalletDirectory(dir, "canonical");
105
+ expect(diag.config.exists).toBe(true);
106
+ expect(diag.config.encrypted).toBe(true);
107
+ expect(diag.config.fingerprint).toBeUndefined();
108
+ });
109
+
110
+ it("degrades gracefully when config.json is malformed", () => {
111
+ const dir = join(root(), "bad-config");
112
+ mkdirSync(dir, { recursive: true });
113
+ writeFileSync(join(dir, "config.json"), "not json");
114
+ const diag = summarizeWalletDirectory(dir, "canonical");
115
+ expect(diag.config.exists).toBe(true);
116
+ expect(diag.config.error).toBeTruthy();
117
+ expect(diag.config.fingerprint).toBeUndefined();
118
+ });
119
+
120
+ it("summarizes a proof database without opening it read-write", () => {
121
+ const dir = join(root(), "with-db");
122
+ writeProofsDb(dir);
123
+ const diag = summarizeWalletDirectory(dir, "canonical");
124
+ expect(diag.db.exists).toBe(true);
125
+ expect(diag.db.summary).toBeDefined();
126
+ expect(diag.db.summary?.totalProofs).toBe(4);
127
+ expect(diag.db.summary?.totalAmount).toBe(200);
128
+ expect(diag.db.summary?.distinctMints).toBe(2);
129
+ expect(diag.db.summary?.mints).toHaveLength(3);
130
+ expect(diag.db.summary?.amountByState).toEqual({
131
+ ready: 150,
132
+ pending: 30,
133
+ spent: 20,
134
+ });
135
+ });
136
+ });
137
+
138
+ describe("WalletMigrationConflictError", () => {
139
+ it("renders a structured conflict message without leaking mnemonics", () => {
140
+ const targetDir = join(root(), "wallet");
141
+ const sourceDir = join(root(), ".cocod");
142
+ writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
143
+ writeWalletConfig(sourceDir, { mnemonic: MNEMONIC_B });
144
+
145
+ const err = new WalletMigrationConflictError(
146
+ summarizeWalletDirectory(targetDir, "canonical"),
147
+ summarizeWalletDirectory(sourceDir, "legacy"),
148
+ );
149
+ expect(err.target.dir).toBe(targetDir);
150
+ expect(err.source.dir).toBe(sourceDir);
151
+ expect(err.message).toContain(targetDir);
152
+ expect(err.message).toContain(sourceDir);
153
+ expect(err.message).toContain("routstrd wallet doctor");
154
+ expect(err.message).toContain("routstrd stop");
155
+ expect(err.message).not.toContain("abandon");
156
+ expect(err.message).not.toContain("legal winner");
157
+ });
158
+ });
159
+
160
+ describe("renderWalletDoctor", () => {
161
+ it("reports different mnemonics as a conflict with resolution steps", () => {
162
+ const targetDir = join(root(), "wallet");
163
+ const sourceDir = join(root(), ".cocod");
164
+ writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
165
+ writeWalletConfig(sourceDir, { mnemonic: MNEMONIC_B });
166
+
167
+ const report = renderWalletDoctor(
168
+ summarizeWalletDirectory(targetDir, "canonical"),
169
+ summarizeWalletDirectory(sourceDir, "legacy"),
170
+ );
171
+ expect(report).toContain("DIFFERENT mnemonics");
172
+ expect(report).toContain("routstrd stop");
173
+ expect(report).toContain("mv \"");
174
+ });
175
+
176
+ it("reports matching mnemonics and warns startup still refuses", () => {
177
+ const targetDir = join(root(), "wallet");
178
+ const sourceDir = join(root(), ".cocod");
179
+ writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
180
+ // Same mnemonic, but different bytes (extra metadata) — migration compares
181
+ // files, not mnemonics, so startup still refuses.
182
+ writeWalletConfig(sourceDir, {
183
+ mnemonic: MNEMONIC_A,
184
+ createdAt: "2026-01-01T00:00:00.000Z",
185
+ });
186
+
187
+ const report = renderWalletDoctor(
188
+ summarizeWalletDirectory(targetDir, "canonical"),
189
+ summarizeWalletDirectory(sourceDir, "legacy"),
190
+ );
191
+ expect(report).toContain("share the same mnemonic");
192
+ expect(report).toContain("startup still refuses");
193
+ expect(report).toContain("mv \"");
194
+ });
195
+
196
+ it("omits resolution steps when there is nothing to resolve", () => {
197
+ const targetDir = join(root(), "wallet");
198
+ const emptyDir = join(root(), ".cocod");
199
+ writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
200
+
201
+ const report = renderWalletDoctor(
202
+ summarizeWalletDirectory(targetDir, "canonical"),
203
+ summarizeWalletDirectory(emptyDir, "legacy"),
204
+ );
205
+ expect(report).toContain("no migration needed");
206
+ expect(report).not.toContain("routstrd stop");
207
+ expect(report).not.toContain("mv \"");
208
+ });
209
+
210
+ it("omits resolution steps for a fresh install", () => {
211
+ const report = renderWalletDoctor(
212
+ summarizeWalletDirectory(join(root(), "nope"), "canonical"),
213
+ summarizeWalletDirectory(join(root(), "alsonope"), "legacy"),
214
+ );
215
+ expect(report).toContain("fresh install");
216
+ expect(report).not.toContain("mv \"");
217
+ });
218
+
219
+ it("counts trusted mints from the mints table and formats amounts", () => {
220
+ const targetDir = join(root(), "wallet");
221
+ const sourceDir = join(root(), ".cocod");
222
+ writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
223
+ writeWalletConfig(sourceDir, { mnemonic: MNEMONIC_B });
224
+ writeProofsDb(sourceDir);
225
+
226
+ const report = renderWalletDoctor(
227
+ summarizeWalletDirectory(targetDir, "canonical"),
228
+ summarizeWalletDirectory(sourceDir, "legacy"),
229
+ );
230
+ // 3 registered mints, not the 2 mints that happen to hold proofs.
231
+ expect(report).toContain("3 mints");
232
+ });
233
+
234
+ it("formats large balances with thousands separators", () => {
235
+ const targetDir = join(root(), "wallet");
236
+ const sourceDir = join(root(), ".cocod");
237
+ writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
238
+ writeWalletConfig(sourceDir, { mnemonic: MNEMONIC_B });
239
+ mkdirSync(sourceDir, { recursive: true });
240
+ const db = new Database(join(sourceDir, "coco.db"));
241
+ db.exec(`
242
+ CREATE TABLE coco_cashu_proofs (mintUrl TEXT, state TEXT, amount INTEGER);
243
+ INSERT INTO coco_cashu_proofs VALUES ('https://mint.example', 'ready', 21000);
244
+ `);
245
+ db.close();
246
+
247
+ const report = renderWalletDoctor(
248
+ summarizeWalletDirectory(targetDir, "canonical"),
249
+ summarizeWalletDirectory(sourceDir, "legacy"),
250
+ );
251
+ expect(report).toContain("ready: 21,000 sats");
252
+ });
253
+ });
254
+
255
+ describe("diagnoseWallets", () => {
256
+ it("flags conflicting mnemonics as a conflict needing resolution", () => {
257
+ const targetDir = join(root(), "wallet");
258
+ const sourceDir = join(root(), ".cocod");
259
+ writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
260
+ writeWalletConfig(sourceDir, { mnemonic: MNEMONIC_B });
261
+
262
+ const verdict = diagnoseWallets(
263
+ summarizeWalletDirectory(targetDir, "canonical"),
264
+ summarizeWalletDirectory(sourceDir, "legacy"),
265
+ );
266
+ expect(verdict.conflict).toBe(true);
267
+ expect(verdict.showResolution).toBe(true);
268
+ });
269
+
270
+ it("treats a single existing wallet as no conflict", () => {
271
+ const targetDir = join(root(), "wallet");
272
+ writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
273
+
274
+ const verdict = diagnoseWallets(
275
+ summarizeWalletDirectory(targetDir, "canonical"),
276
+ summarizeWalletDirectory(join(root(), ".cocod"), "legacy"),
277
+ );
278
+ expect(verdict.conflict).toBe(false);
279
+ expect(verdict.showResolution).toBe(false);
280
+ });
281
+
282
+ it("flags a legacy database without config as an incomplete conflict", () => {
283
+ const sourceDir = join(root(), ".cocod");
284
+ writeProofsDb(sourceDir);
285
+
286
+ const verdict = diagnoseWallets(
287
+ summarizeWalletDirectory(join(root(), "wallet"), "canonical"),
288
+ summarizeWalletDirectory(sourceDir, "legacy"),
289
+ );
290
+ expect(verdict.conflict).toBe(true);
291
+ expect(verdict.text).toContain("incomplete");
292
+ });
293
+
294
+ it("treats a stray legacy database as already-current when the canonical wallet exists", () => {
295
+ const targetDir = join(root(), "wallet");
296
+ const sourceDir = join(root(), ".cocod");
297
+ writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
298
+ writeFakeDbFile(sourceDir);
299
+
300
+ const verdict = diagnoseWallets(
301
+ summarizeWalletDirectory(targetDir, "canonical"),
302
+ summarizeWalletDirectory(sourceDir, "legacy"),
303
+ );
304
+ expect(verdict.conflict).toBe(false);
305
+ expect(verdict.showResolution).toBe(false);
306
+ });
307
+
308
+ it("flags an incomplete canonical database as a conflict", () => {
309
+ const targetDir = join(root(), "wallet");
310
+ writeFakeDbFile(targetDir);
311
+
312
+ const verdict = diagnoseWallets(
313
+ summarizeWalletDirectory(targetDir, "canonical"),
314
+ summarizeWalletDirectory(join(root(), ".cocod"), "legacy"),
315
+ );
316
+ expect(verdict.conflict).toBe(true);
317
+ expect(verdict.showResolution).toBe(false);
318
+ });
319
+
320
+ it("flags orphaned legacy sidecars as a conflict", () => {
321
+ const sourceDir = join(root(), ".cocod");
322
+ mkdirSync(sourceDir, { recursive: true });
323
+ writeFileSync(join(sourceDir, "coco.db-wal"), "wal");
324
+ writeFileSync(join(sourceDir, "coco.db-shm"), "shm");
325
+
326
+ const verdict = diagnoseWallets(
327
+ summarizeWalletDirectory(join(root(), "wallet"), "canonical"),
328
+ summarizeWalletDirectory(sourceDir, "legacy"),
329
+ );
330
+ expect(verdict.conflict).toBe(true);
331
+ expect(verdict.showResolution).toBe(false);
332
+ });
333
+
334
+ it("treats byte-identical wallets as already-current", () => {
335
+ const targetDir = join(root(), "wallet");
336
+ const sourceDir = join(root(), ".cocod");
337
+ writeWalletConfig(targetDir, { mnemonic: MNEMONIC_A });
338
+ writeWalletConfig(sourceDir, { mnemonic: MNEMONIC_A });
339
+
340
+ const verdict = diagnoseWallets(
341
+ summarizeWalletDirectory(targetDir, "canonical"),
342
+ summarizeWalletDirectory(sourceDir, "legacy"),
343
+ );
344
+ expect(verdict.conflict).toBe(false);
345
+ expect(verdict.showResolution).toBe(false);
346
+ });
347
+
348
+ it("survives an unreadable wallet file without crashing", () => {
349
+ const targetDir = join(root(), "wallet");
350
+ const sourceDir = join(root(), ".cocod");
351
+ // Equal sizes force the classifier's filesEqual past the size check into
352
+ // raw reads, where the permission error would otherwise crash the doctor.
353
+ mkdirSync(targetDir, { recursive: true });
354
+ mkdirSync(sourceDir, { recursive: true });
355
+ writeFileSync(join(targetDir, "config.json"), `{"mnemonic":"${"a".repeat(50)}"}`);
356
+ const sourceConfig = join(sourceDir, "config.json");
357
+ writeFileSync(sourceConfig, `{"mnemonic":"${"b".repeat(50)}"}`);
358
+ chmodSync(sourceConfig, 0o000);
359
+
360
+ try {
361
+ const target = summarizeWalletDirectory(targetDir, "canonical");
362
+ const source = summarizeWalletDirectory(sourceDir, "legacy");
363
+ expect(source.config.error).toBeTruthy();
364
+
365
+ const verdict = diagnoseWallets(target, source);
366
+ expect(verdict.conflict).toBe(true);
367
+ expect(verdict.showResolution).toBe(true);
368
+ expect(verdict.text).toContain("could not be fully read");
369
+
370
+ // The full report must render too — this is the user-facing path.
371
+ const report = renderWalletDoctor(target, source);
372
+ expect(report).toContain("could not be fully read");
373
+ expect(report).toContain("mv \"");
374
+ } finally {
375
+ chmodSync(sourceConfig, 0o600);
376
+ }
377
+ });
378
+ });