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,1628 @@
1
+ import {
2
+ Manager,
3
+ getEncodedToken,
4
+ normalizeMintUrl,
5
+ } from "@cashu/coco-core";
6
+ import type {
7
+ HistoryEntry,
8
+ ReceiveOperation,
9
+ Logger as CocoLogger,
10
+ Plugin as CocoPlugin,
11
+ } from "@cashu/coco-core";
12
+ import { SqliteRepositories } from "@cashu/coco-sqlite-bun";
13
+ import { Database } from "bun:sqlite";
14
+ import { NPCPlugin, type PluginApi as NpcPluginApi } from "coco-cashu-plugin-npc";
15
+ import { privateKeyFromSeedWords } from "nostr-tools/nip06";
16
+ import { finalizeEvent, nip19, type EventTemplate } from "nostr-tools";
17
+ import {
18
+ closeSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ openSync,
22
+ readFileSync,
23
+ renameSync,
24
+ unlinkSync,
25
+ writeFileSync,
26
+ } from "fs";
27
+ import { dirname, join } from "path";
28
+ import { mnemonicToSeedSync } from "@scure/bip39";
29
+ import type {
30
+ CocodClient,
31
+ CocodState,
32
+ NpcAddress,
33
+ NpcUsernameResult,
34
+ WalletCleanupOptions,
35
+ WalletCleanupResult,
36
+ WalletRecoveryProgress,
37
+ } from "./cocod-client";
38
+ import { selectCleanupOperations } from "./cleanup";
39
+ import {
40
+ clearInterruptedReceiveReservations,
41
+ deleteReceiveTokenReservation,
42
+ getReceiveReconcileBackup,
43
+ initReceiveDedupSchema,
44
+ listProcessingReceiveTokens,
45
+ receiveInputFingerprint,
46
+ reconcileExecutingReceives,
47
+ releaseReceiveToken,
48
+ reserveReceiveToken,
49
+ setReceiveReconcileBackup,
50
+ updateReceiveToken,
51
+ type ReceiveReconcileSource,
52
+ } from "./receive-dedup";
53
+ import { cocoLogger, logger } from "../../utils/logger";
54
+ import {
55
+ legacyCocodPidPath,
56
+ legacyCocodSocketPath,
57
+ walletDir as defaultWalletDir,
58
+ walletPidPath as defaultWalletPidPath,
59
+ } from "./paths";
60
+
61
+ const NPC_DEFAULT_BASE_URL = "https://npubx.cash";
62
+
63
+ const STALE_SOCKET_ERROR_CODES = new Set([
64
+ "ECONNREFUSED",
65
+ "ENOENT",
66
+ // Bun's Unix-socket fetch error for an abandoned socket inode.
67
+ "FailedToOpenSocket",
68
+ ]);
69
+
70
+ type UnixRequestInit = RequestInit & { unix: string };
71
+ type LegacyCocodFetch = (
72
+ input: string | URL | Request,
73
+ init: UnixRequestInit,
74
+ ) => Promise<Response>;
75
+
76
+ export interface LegacyCocodGuardOptions {
77
+ socketPath?: string;
78
+ pathExists?: (path: string) => boolean;
79
+ fetchImpl?: LegacyCocodFetch;
80
+ timeoutMs?: number;
81
+ }
82
+
83
+ export interface LegacyCocodPidClaimOptions {
84
+ pidFilePath?: string;
85
+ pid?: number;
86
+ /** Human-readable lock name used in contention errors. */
87
+ label?: string;
88
+ openExclusive?: (path: string) => number;
89
+ writePid?: (fd: number, pid: number) => void;
90
+ closeFile?: (fd: number) => void;
91
+ readFile?: (path: string) => string;
92
+ removeFile?: (path: string) => void;
93
+ isProcessRunning?: (pid: number) => boolean;
94
+ }
95
+
96
+ export interface LegacyCocodStopOptions {
97
+ socketPath?: string;
98
+ pidFilePath?: string;
99
+ pathExists?: (path: string) => boolean;
100
+ readFile?: (path: string) => string;
101
+ isProcessRunning?: (pid: number) => boolean;
102
+ fetchImpl?: LegacyCocodFetch;
103
+ killProcess?: (pid: number, signal: NodeJS.Signals) => void;
104
+ /** Total time to wait for cocod to exit after SIGTERM. */
105
+ timeoutMs?: number;
106
+ /** Interval between exit checks. */
107
+ pollIntervalMs?: number;
108
+ /** Timeout for identifying cocod through its Unix socket. */
109
+ socketTimeoutMs?: number;
110
+ }
111
+
112
+ interface CocodConfig {
113
+ mnemonic: string;
114
+ encrypted: boolean;
115
+ defaultMintUrl?: string;
116
+ }
117
+
118
+ const STARTUP_LOG_PREFIX = "[routstrd:start]";
119
+ export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org";
120
+
121
+ function startupProgress(message: string): void {
122
+ logger.info(message);
123
+ // The daemon is detached and stdout is captured by start-daemon.ts. The
124
+ // prefix lets the CLI surface only safe, user-facing startup progress while
125
+ // the full diagnostic stream remains in the normal log file.
126
+ console.log(`${STARTUP_LOG_PREFIX} ${message}`);
127
+ }
128
+
129
+ // Set only while the background wallet recovery sweeps are running. While set,
130
+ // coco logger messages that indicate a stalled/failed per-mint check are
131
+ // forwarded to the startup stream (see createCocoLogger).
132
+ let surfacingRecoveryProgress = false;
133
+
134
+ // These fire once per operation when a mint is unreachable or otherwise fails
135
+ // to reconcile. They are the only per-mint signal coco emits, and they happen
136
+ // exactly when recovery is slow.
137
+ const RECOVERY_STALL_MARKERS = new Map<string, string>([
138
+ ["SendOperationService\u0000Could not reach mint for recovery, will retry later", "Send recovery: mint unreachable"],
139
+ ["ReceiveOperationService\u0000Could not reach mint for receive recovery, will retry later", "Receive recovery: mint unreachable"],
140
+ ["MintOperationService\u0000Failed to reconcile stale pending mint operation", "Mint recovery: pending operation check failed"],
141
+ ]);
142
+
143
+ const SAFE_COCO_LOG_FIELDS = new Set([
144
+ "module",
145
+ "mintUrl",
146
+ "operationId",
147
+ "quoteId",
148
+ "state",
149
+ "count",
150
+ "total",
151
+ "filterCount",
152
+ "subId",
153
+ "initOperations",
154
+ "executingOperations",
155
+ "pendingOperations",
156
+ "rollingBackOperations",
157
+ "orphanedReservations",
158
+ ]);
159
+
160
+ function safeCocoMetadata(values: unknown[]): Record<string, unknown> {
161
+ const safe: Record<string, unknown> = {};
162
+ for (const value of values) {
163
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
164
+ for (const [key, fieldValue] of Object.entries(value)) {
165
+ if (SAFE_COCO_LOG_FIELDS.has(key)) safe[key] = fieldValue;
166
+ }
167
+ }
168
+ return safe;
169
+ }
170
+
171
+ function createCocoLogger(bindings: Record<string, unknown> = {}): CocoLogger {
172
+ const write = (
173
+ level: "error" | "warn" | "info" | "debug",
174
+ message: string,
175
+ meta: unknown[],
176
+ ) => {
177
+ // Coco diagnostics may contain proof secrets or encoded tokens. Keep only
178
+ // an explicit metadata allowlist; startup counts and operation IDs remain
179
+ // useful without copying wallet material into routstrd's logs. Written to
180
+ // ~/.routstrd/coco-logs/ so wallet-engine noise stays out of the main logs.
181
+ const metadata = safeCocoMetadata([bindings, ...meta]);
182
+
183
+ if (surfacingRecoveryProgress) {
184
+ const module = typeof bindings.module === "string" ? bindings.module : undefined;
185
+ if (module) {
186
+ const stall = RECOVERY_STALL_MARKERS.get(`${module}\u0000${message}`);
187
+ if (stall) {
188
+ const mintUrl =
189
+ typeof metadata.mintUrl === "string" ? metadata.mintUrl : undefined;
190
+ startupProgress(mintUrl ? `${stall} (${mintUrl})` : stall);
191
+ }
192
+ }
193
+ }
194
+
195
+ cocoLogger[level](
196
+ `[coco] ${message}`,
197
+ ...(Object.keys(metadata).length > 0 ? [metadata] : []),
198
+ );
199
+ };
200
+
201
+ return {
202
+ error: (message, ...meta) => write("error", message, meta),
203
+ warn: (message, ...meta) => write("warn", message, meta),
204
+ info: (message, ...meta) => write("info", message, meta),
205
+ debug: (message, ...meta) => write("debug", message, meta),
206
+ log: (level, message, ...meta) => write(level, message, meta),
207
+ child: (childBindings) =>
208
+ createCocoLogger({ ...bindings, ...childBindings }),
209
+ };
210
+ }
211
+
212
+ function loadConfig(configFile: string): CocodConfig {
213
+ if (!existsSync(configFile)) {
214
+ throw new Error(
215
+ `Config file not found at ${configFile}. Run 'routstrd onboard' first.`,
216
+ );
217
+ }
218
+ const config = JSON.parse(readFileSync(configFile, "utf-8")) as CocodConfig;
219
+ if (config.encrypted) {
220
+ throw new Error(
221
+ "Encrypted wallets are not supported yet. Please use an unencrypted wallet.",
222
+ );
223
+ }
224
+ return config;
225
+ }
226
+
227
+ function saveConfig(config: CocodConfig, configFile: string): void {
228
+ const temporaryFile = `${configFile}.${process.pid}.tmp`;
229
+ try {
230
+ writeFileSync(temporaryFile, JSON.stringify(config, null, 2), {
231
+ mode: 0o600,
232
+ flag: "wx",
233
+ });
234
+ renameSync(temporaryFile, configFile);
235
+ } catch (error) {
236
+ try {
237
+ unlinkSync(temporaryFile);
238
+ } catch {
239
+ // The temporary file may not have been created.
240
+ }
241
+ throw error;
242
+ }
243
+ }
244
+
245
+ export function isZombieProcess(
246
+ pid: number,
247
+ readFile: (path: string) => string = (path) =>
248
+ readFileSync(path, "utf-8"),
249
+ ): boolean {
250
+ try {
251
+ // Linux exposes zombie state as the character following the final `)` in
252
+ // /proc/<pid>/stat. Use the final parenthesis because process names may
253
+ // themselves contain spaces or parentheses. Other platforms simply fall
254
+ // back to process.kill(pid, 0) below.
255
+ const stat = readFile(`/proc/${pid}/stat`);
256
+ const commandEnd = stat.lastIndexOf(")");
257
+ return commandEnd >= 0 && stat.charAt(commandEnd + 2) === "Z";
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
263
+ function defaultIsProcessRunning(pid: number): boolean {
264
+ try {
265
+ process.kill(pid, 0);
266
+ } catch (error) {
267
+ return (error as NodeJS.ErrnoException).code === "EPERM";
268
+ }
269
+
270
+ // kill(pid, 0) also succeeds for dead-but-unreaped processes. Zombies hold
271
+ // no database or socket resources, so treating them as dead allows stale
272
+ // wallet locks to be reclaimed (notably under non-reaping Docker PID 1s).
273
+ return !isZombieProcess(pid);
274
+ }
275
+
276
+ function hasErrorCode(error: unknown, codes: Set<string>): boolean {
277
+ let current: unknown = error;
278
+ const visited = new Set<unknown>();
279
+
280
+ while (current && typeof current === "object" && !visited.has(current)) {
281
+ visited.add(current);
282
+ const candidate = current as { code?: unknown; cause?: unknown };
283
+ if (typeof candidate.code === "string" && codes.has(candidate.code)) {
284
+ return true;
285
+ }
286
+ current = candidate.cause;
287
+ }
288
+
289
+ return false;
290
+ }
291
+
292
+ /**
293
+ * Refuse to open coco.db while a daemon answers on legacy cocod's Unix socket.
294
+ * Two independent wallet engines must never operate on the same proof database.
295
+ *
296
+ * cocod.pid is deliberately shared: routstrd writes its own PID there while
297
+ * the in-process wallet is open, fencing old cocod binaries from starting. A
298
+ * live PID in that file therefore cannot identify cocod. Socket responsiveness
299
+ * is the authoritative identity check; the atomic PID-file claim below closes
300
+ * the race when cocod is still starting and has not opened its socket yet.
301
+ *
302
+ * A socket left behind after a crash is safe to ignore only when connecting
303
+ * fails with ENOENT or ECONNREFUSED. Other probe failures are treated as unsafe
304
+ * because they do not prove that cocod has stopped.
305
+ */
306
+ export async function assertLegacyCocodNotRunning(
307
+ options: LegacyCocodGuardOptions = {},
308
+ ): Promise<void> {
309
+ const socketPath = options.socketPath || legacyCocodSocketPath();
310
+ const pathExists = options.pathExists || existsSync;
311
+
312
+ if (!pathExists(socketPath)) return;
313
+
314
+ const fetchImpl = options.fetchImpl || (fetch as LegacyCocodFetch);
315
+ const timeoutMs = options.timeoutMs ?? 1_000;
316
+
317
+ try {
318
+ const response = await fetchImpl("http://localhost/ping", {
319
+ unix: socketPath,
320
+ signal: AbortSignal.timeout(timeoutMs),
321
+ });
322
+ await response.body?.cancel();
323
+ } catch (error) {
324
+ if (hasErrorCode(error, STALE_SOCKET_ERROR_CODES)) {
325
+ logger.debug(`Ignoring stale legacy cocod socket at ${socketPath}`);
326
+ return;
327
+ }
328
+
329
+ throw new Error(
330
+ `Cannot verify whether the legacy cocod daemon has stopped at ${socketPath}. ` +
331
+ "Refusing to open the wallet database to prevent concurrent access. " +
332
+ "Run 'cocod stop', verify the daemon has exited, and try again.",
333
+ { cause: error },
334
+ );
335
+ }
336
+
337
+ throw new Error(
338
+ `Legacy cocod daemon is still running at ${socketPath}. ` +
339
+ "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " +
340
+ "Run 'cocod stop' and try again.",
341
+ );
342
+ }
343
+
344
+ /**
345
+ * Gracefully stop a legacy cocod daemon that is still running, so the new
346
+ * in-process coco wallet can safely open the shared database.
347
+ *
348
+ * Sends SIGTERM to the PID recorded in cocod's PID file, then polls until the
349
+ * process exits and the PID file is removed (cocod cleans up both on graceful
350
+ * shutdown). On timeout it refuses rather than escalating to SIGKILL, because
351
+ * killing a wallet engine mid-proof-recovery risks corrupting coco.db — the
352
+ * exact failure the guard exists to prevent.
353
+ */
354
+ export async function stopLegacyCocod(
355
+ options: LegacyCocodStopOptions = {},
356
+ ): Promise<void> {
357
+ const socketPath = options.socketPath || legacyCocodSocketPath();
358
+ const pidFilePath = options.pidFilePath || legacyCocodPidPath();
359
+ const pathExists = options.pathExists || existsSync;
360
+ const readFile =
361
+ options.readFile || ((path: string) => readFileSync(path, "utf-8"));
362
+ const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning;
363
+ const fetchImpl = options.fetchImpl || (fetch as LegacyCocodFetch);
364
+ const killProcess =
365
+ options.killProcess || ((pid, signal) => process.kill(pid, signal));
366
+ const timeoutMs = options.timeoutMs ?? 30_000;
367
+ const pollIntervalMs = options.pollIntervalMs ?? 500;
368
+ const socketTimeoutMs = options.socketTimeoutMs ?? 1_000;
369
+
370
+ const readPid = (): number | null => {
371
+ if (!pathExists(pidFilePath)) return null;
372
+ try {
373
+ const pid = Number.parseInt(readFile(pidFilePath).trim(), 10);
374
+ return Number.isInteger(pid) && pid > 0 && isProcessRunning(pid)
375
+ ? pid
376
+ : null;
377
+ } catch {
378
+ return null;
379
+ }
380
+ };
381
+
382
+ const pid = readPid();
383
+ if (pid === null) {
384
+ logger.debug(
385
+ "stopLegacyCocod: no running legacy cocod found, nothing to stop.",
386
+ );
387
+ return;
388
+ }
389
+
390
+ // routstrd intentionally writes its own PID to cocod.pid while the in-process
391
+ // wallet is open. Never identify the owner from the shared PID file alone:
392
+ // only a process responding through cocod's Unix socket is safe to terminate.
393
+ if (!pathExists(socketPath)) {
394
+ logger.debug(
395
+ `PID ${pid} owns ${pidFilePath}, but no legacy cocod socket exists; leaving it running.`,
396
+ );
397
+ return;
398
+ }
399
+
400
+ try {
401
+ const response = await fetchImpl("http://localhost/ping", {
402
+ unix: socketPath,
403
+ signal: AbortSignal.timeout(socketTimeoutMs),
404
+ });
405
+ await response.body?.cancel();
406
+ } catch (error) {
407
+ if (hasErrorCode(error, STALE_SOCKET_ERROR_CODES)) {
408
+ logger.debug(
409
+ `PID ${pid} owns ${pidFilePath}, but the legacy cocod socket is stale; leaving it running.`,
410
+ );
411
+ return;
412
+ }
413
+
414
+ throw new Error(
415
+ `Cannot verify whether PID ${pid} is the legacy cocod daemon at ${socketPath}. ` +
416
+ "Refusing to stop an unidentified process.",
417
+ { cause: error },
418
+ );
419
+ }
420
+
421
+ logger.log(`Stopping legacy cocod daemon (PID ${pid})…`);
422
+ killProcess(pid, "SIGTERM");
423
+
424
+ const deadline = Date.now() + timeoutMs;
425
+ while (Date.now() < deadline) {
426
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
427
+ if (!isProcessRunning(pid) || readPid() !== pid) {
428
+ logger.log(`Legacy cocod daemon (PID ${pid}) stopped.`);
429
+ return;
430
+ }
431
+ }
432
+
433
+ throw new Error(
434
+ `Legacy cocod daemon (PID ${pid}) did not stop within ${Math.round(
435
+ timeoutMs / 1000,
436
+ )}s of SIGTERM. ` + `Run 'kill ${pid}' and try again.`,
437
+ );
438
+ }
439
+
440
+ /**
441
+ * Atomically claim cocod's PID file for the lifetime of the in-process wallet.
442
+ * Legacy cocod checks this same file before opening coco.db, so a live routstrd
443
+ * owner prevents cocod from starting after the initial socket/PID probe.
444
+ */
445
+ export function claimLegacyCocodPidFile(
446
+ options: LegacyCocodPidClaimOptions = {},
447
+ ): () => void {
448
+ return claimPidFile({
449
+ ...options,
450
+ pidFilePath: options.pidFilePath || legacyCocodPidPath(),
451
+ label: options.label || "legacy cocod exclusion lock",
452
+ });
453
+ }
454
+
455
+ function claimPidFile(options: LegacyCocodPidClaimOptions & { pidFilePath: string }): () => void {
456
+ const pidFilePath = options.pidFilePath;
457
+ const pid = options.pid ?? process.pid;
458
+ const label = options.label || "wallet process lock";
459
+ const openExclusive =
460
+ options.openExclusive || ((path: string) => openSync(path, "wx", 0o600));
461
+ const writePid =
462
+ options.writePid ||
463
+ ((fd: number, ownerPid: number) => writeFileSync(fd, String(ownerPid)));
464
+ const closeFile = options.closeFile || closeSync;
465
+ const readFile =
466
+ options.readFile || ((path: string) => readFileSync(path, "utf-8"));
467
+ const removeFile = options.removeFile || unlinkSync;
468
+ const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning;
469
+
470
+ let fd: number;
471
+ try {
472
+ fd = openExclusive(pidFilePath);
473
+ } catch (error) {
474
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
475
+
476
+ // The earlier guard permits a dead PID file. Remove only a parseable,
477
+ // confirmed-dead owner; an empty/malformed file may belong to a process
478
+ // that has created the file but has not written its PID yet.
479
+ let stalePid: number;
480
+ try {
481
+ stalePid = Number.parseInt(readFile(pidFilePath).trim(), 10);
482
+ } catch {
483
+ throw new Error(
484
+ `Cannot claim the ${label} at ${pidFilePath}. ` +
485
+ "Another cocod or routstrd process may be starting. Stop it and try again.",
486
+ { cause: error },
487
+ );
488
+ }
489
+
490
+ if (
491
+ !Number.isInteger(stalePid) ||
492
+ stalePid <= 0 ||
493
+ isProcessRunning(stalePid)
494
+ ) {
495
+ const ownerMessage =
496
+ Number.isInteger(stalePid) && stalePid > 0
497
+ ? `PID ${stalePid} is still running and holds it. ` +
498
+ `Stop that process first ('routstrd stop' or 'kill ${stalePid}').`
499
+ : "Another cocod or routstrd process may be starting. Stop it and try again.";
500
+ throw new Error(
501
+ `Cannot claim the ${label} at ${pidFilePath}: ${ownerMessage}`,
502
+ { cause: error },
503
+ );
504
+ }
505
+
506
+ try {
507
+ removeFile(pidFilePath);
508
+ fd = openExclusive(pidFilePath);
509
+ } catch (retryError) {
510
+ throw new Error(
511
+ `Cannot claim the ${label} at ${pidFilePath}. ` +
512
+ "Another cocod or routstrd process may be starting. Stop it and try again.",
513
+ { cause: retryError },
514
+ );
515
+ }
516
+ }
517
+
518
+ try {
519
+ writePid(fd, pid);
520
+ } catch (error) {
521
+ try {
522
+ removeFile(pidFilePath);
523
+ } catch {
524
+ // Preserve the original write failure.
525
+ }
526
+ throw error;
527
+ } finally {
528
+ closeFile(fd);
529
+ }
530
+
531
+ let released = false;
532
+ const release = () => {
533
+ if (released) return;
534
+ released = true;
535
+ process.removeListener("exit", release);
536
+
537
+ try {
538
+ if (readFile(pidFilePath).trim() === String(pid)) {
539
+ removeFile(pidFilePath);
540
+ }
541
+ } catch (error) {
542
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
543
+ logger.warn(
544
+ `Failed to release ${label} at ${pidFilePath}:`,
545
+ error,
546
+ );
547
+ }
548
+ }
549
+ };
550
+
551
+ // process.exit() and natural shutdown still run synchronous exit handlers.
552
+ // This prevents migration or startup failures from stranding our PID files.
553
+ process.once("exit", release);
554
+ return release;
555
+ }
556
+
557
+ /**
558
+ * Minimal structural view of coco-core's MintOperationService.
559
+ * The service is private on the exported Manager class, so the in-process
560
+ * client reaches it through this narrow cast. Both methods reload the latest
561
+ * persisted row before mutating anything, so a bare operation id is enough.
562
+ */
563
+ interface MintOperationServiceCleanup {
564
+ failPendingOperation(
565
+ op: { id: string },
566
+ terminalFailure: { reason: string; retryable?: boolean; observedAt: number },
567
+ ): Promise<unknown>;
568
+ /**
569
+ * Ask the mint for a pending quote's current state and persist the
570
+ * observation. "waiting" means the mint still reports the quote as unpaid.
571
+ */
572
+ observePendingOperation(
573
+ operationId: string,
574
+ ): Promise<{ category: "waiting" | "ready" | "completed" | "terminal" }>;
575
+ }
576
+
577
+ export interface CreateCocoClientOptions {
578
+ /** Override the canonical wallet data directory. */
579
+ walletDir?: string;
580
+ /** Deprecated alias retained for existing callers during migration. */
581
+ configDir?: string;
582
+ /** Override the in-process wallet lock path. */
583
+ walletPidPath?: string;
584
+ /** Override legacy external-cocod coordination paths. */
585
+ legacySocketPath?: string;
586
+ legacyPidPath?: string;
587
+ /** Set to false to skip NPC (npubx.cash) plugin registration. Default: true. */
588
+ enableNpc?: boolean;
589
+ /** NPC server base URL. Default: https://npubx.cash */
590
+ npcBaseUrl?: string;
591
+ }
592
+
593
+ /**
594
+ * Build a usable coco Manager without running the blocking recovery sweeps.
595
+ *
596
+ * This replicates `initializeCoco()` up to (but not including) the send/melt/
597
+ * receive/mint recovery passes, so the daemon can serve wallet reads while
598
+ * recovery proceeds in the background.
599
+ */
600
+ function constructCocoManager(
601
+ repo: SqliteRepositories,
602
+ seed: Uint8Array,
603
+ ): Manager {
604
+ return new Manager(repo, async () => seed, createCocoLogger());
605
+ }
606
+
607
+ async function enableCocoManager(coco: Manager): Promise<void> {
608
+ await coco.initPlugins();
609
+ await coco.reconcileLegacyMintQuotes();
610
+ await coco.enableMintOperationWatcher();
611
+ await coco.enableProofStateWatcher();
612
+ await coco.enableMintOperationProcessor();
613
+ }
614
+
615
+ /**
616
+ * Shared wall-clock budget for checking expired mint quotes with their mints
617
+ * during background recovery. coco-core issues mint requests without a
618
+ * timeout, so a hung mint could otherwise stall this phase (and with it the
619
+ * recovery promise that gates value-moving operations) far longer than this.
620
+ */
621
+ const EXPIRED_MINT_OBSERVATION_DEADLINE_MS = 15_000;
622
+
623
+ /** Rejects when `timeoutMs` elapses before `promise` settles. */
624
+ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
625
+ let timer: ReturnType<typeof setTimeout> | undefined;
626
+ const timeout = new Promise<never>((_resolve, reject) => {
627
+ timer = setTimeout(
628
+ () => reject(new Error("Timed out contacting mint")),
629
+ timeoutMs,
630
+ );
631
+ });
632
+ return Promise.race([promise, timeout]).finally(() => {
633
+ if (timer !== undefined) clearTimeout(timer);
634
+ });
635
+ }
636
+
637
+ /** Structural subset of coco's Manager used by expired-quote settlement. */
638
+ export interface ExpiredMintQuoteSource {
639
+ ops: {
640
+ mint: {
641
+ listPending(): Promise<
642
+ Array<{
643
+ id: string;
644
+ mintUrl: string;
645
+ quoteId?: string;
646
+ state: string;
647
+ /** Quote expiry in epoch seconds. */
648
+ expiry: number;
649
+ updatedAt: number;
650
+ lastObservedRemoteState?: string;
651
+ }>
652
+ >;
653
+ };
654
+ };
655
+ mintOperationService: MintOperationServiceCleanup;
656
+ }
657
+
658
+ export interface ExpiredMintSettlement {
659
+ /** Quotes their mint confirmed as UNPAID, failed locally. */
660
+ failed: number;
661
+ /** Quotes observed as PAID/ISSUED, left for mint recovery to finalize. */
662
+ leftForRecovery: number;
663
+ /** Quotes whose mint could not be checked in time, left pending. */
664
+ unobserved: number;
665
+ }
666
+
667
+ /**
668
+ * Settle expired pending mint quotes before the mint recovery sweep runs.
669
+ *
670
+ * An expired bolt11 invoice can never be paid again, so a quote the mint
671
+ * still reports as UNPAID is guaranteed never to be issued and is failed
672
+ * locally. That local fail is what keeps coco-core's mint recovery sweep
673
+ * quick: the sweep treats UNPAID as "waiting" and would otherwise re-contact
674
+ * every dead quote's mint on every startup.
675
+ *
676
+ * The observation round is what makes the local fail safe: a quote can have
677
+ * been paid before expiry while the daemon was down, leaving no local
678
+ * observation behind. Failing such a quote without asking the mint would
679
+ * strand the paid funds, because failed operations are skipped by recovery.
680
+ * Asking the mint first closes that hole: PAID/ISSUED quotes are left for
681
+ * the sweep to finalize, and quotes whose mint is unreachable or too slow
682
+ * are left pending so a later startup can still recover them.
683
+ */
684
+ export async function settleExpiredMintQuotes(
685
+ source: ExpiredMintQuoteSource,
686
+ nowMs: number,
687
+ deadlineMs: number = EXPIRED_MINT_OBSERVATION_DEADLINE_MS,
688
+ ): Promise<ExpiredMintSettlement> {
689
+ const pendingMints = await source.ops.mint.listPending();
690
+ const selection = selectCleanupOperations({
691
+ mints: pendingMints,
692
+ sends: [],
693
+ melts: [],
694
+ nowMs,
695
+ minAgeMs: 0,
696
+ });
697
+
698
+ const settlement: ExpiredMintSettlement = {
699
+ failed: 0,
700
+ leftForRecovery: 0,
701
+ unobserved: 0,
702
+ };
703
+ const candidates = selection.mintsToFail;
704
+ if (candidates.length === 0) return settlement;
705
+
706
+ const startedAt = Date.now();
707
+ for (const op of candidates) {
708
+ const remainingMs = deadlineMs - (Date.now() - startedAt);
709
+ if (remainingMs <= 0) {
710
+ const skipped =
711
+ candidates.length -
712
+ settlement.failed -
713
+ settlement.leftForRecovery -
714
+ settlement.unobserved;
715
+ settlement.unobserved += skipped;
716
+ startupProgress(
717
+ `Expired mint quote check budget exhausted; ${skipped} quote(s) left for mint recovery.`,
718
+ );
719
+ break;
720
+ }
721
+
722
+ try {
723
+ const result = await withTimeout(
724
+ source.mintOperationService.observePendingOperation(op.id),
725
+ remainingMs,
726
+ );
727
+ if (result.category === "waiting") {
728
+ // The mint confirms the expired quote is still unpaid: it can never
729
+ // be issued now, so failing it locally cannot strand funds.
730
+ await source.mintOperationService.failPendingOperation(
731
+ { id: op.id },
732
+ {
733
+ reason: "Expired mint quote confirmed unpaid by mint",
734
+ retryable: false,
735
+ observedAt: Date.now(),
736
+ },
737
+ );
738
+ settlement.failed++;
739
+ } else {
740
+ // PAID/ISSUED (or terminally failed) at the mint: normal recovery
741
+ // must see this quote so paid proofs get claimed.
742
+ settlement.leftForRecovery++;
743
+ const observed =
744
+ result.category === "ready"
745
+ ? "was paid at the mint"
746
+ : result.category === "completed"
747
+ ? "was already issued at the mint"
748
+ : "failed terminally at the mint";
749
+ startupProgress(
750
+ `Expired mint quote ${op.quoteId ?? op.id} at ${op.mintUrl} ${observed}; leaving it for mint recovery.`,
751
+ );
752
+ }
753
+ } catch (error) {
754
+ // Mint unreachable, too slow, or the quote unknown to it: leave the
755
+ // operation pending so a later startup can still recover it.
756
+ settlement.unobserved++;
757
+ logger.warn("Could not check expired mint quote; leaving it pending", {
758
+ operationId: op.id,
759
+ mintUrl: op.mintUrl,
760
+ error: error instanceof Error ? error.message : String(error),
761
+ });
762
+ }
763
+ }
764
+
765
+ return settlement;
766
+ }
767
+
768
+ interface ReceiveRecoveryInternals {
769
+ receiveOperationService: {
770
+ checkProofStatesWithMint(
771
+ mintUrl: string,
772
+ proofs: Array<{ secret: string }>,
773
+ ): Promise<Array<{ state: string }>>;
774
+ hasSavedOutputs(operation: unknown): Promise<boolean>;
775
+ markAsRolledBack(operation: unknown, error: string): Promise<unknown>;
776
+ };
777
+ mintAdapter: {
778
+ getCashuMint(mintUrl: string): {
779
+ restore(input: { outputs: Array<{ amount: number; id: string; B_: string }> }): Promise<{
780
+ outputs: Array<{ B_: string }>;
781
+ }>;
782
+ };
783
+ };
784
+ }
785
+
786
+ async function reconcileDuplicateReceiveOperations(
787
+ coco: Manager,
788
+ repo: SqliteRepositories,
789
+ ): Promise<Awaited<ReturnType<typeof reconcileExecutingReceives>>> {
790
+ const internals = coco as unknown as ReceiveRecoveryInternals;
791
+ const unavailableMints = new Set<string>();
792
+ const deadline = Date.now() + 45_000;
793
+ const ensureMintBudget = (mintUrl: string): void => {
794
+ if (Date.now() >= deadline) throw new Error("Receive cleanup time budget exhausted");
795
+ if (unavailableMints.has(mintUrl)) throw new Error("Mint already failed receive cleanup");
796
+ };
797
+ const markMintFailure = (mintUrl: string, error: unknown): never => {
798
+ unavailableMints.add(mintUrl);
799
+ throw error;
800
+ };
801
+ const source: ReceiveReconcileSource = {
802
+ listExecuting: () => repo.receiveOperationRepository.getByState("executing"),
803
+ checkProofStates: async (operation) => {
804
+ ensureMintBudget(operation.mintUrl);
805
+ try {
806
+ return await withTimeout(
807
+ internals.receiveOperationService.checkProofStatesWithMint(
808
+ operation.mintUrl,
809
+ operation.inputProofs,
810
+ ),
811
+ Math.min(15_000, Math.max(1, deadline - Date.now())),
812
+ );
813
+ } catch (error) {
814
+ return markMintFailure(operation.mintUrl, error);
815
+ }
816
+ },
817
+ restoreOutputs: async (mintUrl, outputs) => {
818
+ ensureMintBudget(mintUrl);
819
+ // Probe deterministic outputs in bounded batches. The Cashu restore
820
+ // response echoes only blinded messages with stored signatures, which
821
+ // identifies the owning operation. Coco later performs the real proof
822
+ // recovery for the retained operation.
823
+ const restored: Array<{ B_: string }> = [];
824
+ const mint = internals.mintAdapter.getCashuMint(mintUrl);
825
+ for (let index = 0; index < outputs.length; index += 300) {
826
+ try {
827
+ const response = await withTimeout(
828
+ mint.restore({ outputs: outputs.slice(index, index + 300) }),
829
+ Math.min(15_000, Math.max(1, deadline - Date.now())),
830
+ );
831
+ restored.push(...response.outputs);
832
+ } catch (error) {
833
+ return markMintFailure(mintUrl, error);
834
+ }
835
+ }
836
+ return restored;
837
+ },
838
+ hasSavedOutputs: (operation) =>
839
+ internals.receiveOperationService.hasSavedOutputs(operation),
840
+ rollBack: async (operation, reason) => {
841
+ await internals.receiveOperationService.markAsRolledBack(operation, reason);
842
+ },
843
+ };
844
+ return reconcileExecutingReceives(source);
845
+ }
846
+
847
+ interface RecoveryPhaseProgress {
848
+ phase: string;
849
+ failedMintQuotes: number;
850
+ }
851
+
852
+ /**
853
+ * Run the wallet recovery sweeps in order, reporting phase changes.
854
+ *
855
+ * Expired mint quotes are settled first: quotes their mint confirms as unpaid
856
+ * are failed locally so `recoverPendingMintOperations()` skips them, while
857
+ * paid/issued and unreachable-mint quotes stay pending for the sweep.
858
+ */
859
+ async function runWalletRecovery(
860
+ coco: Manager,
861
+ onProgress: (progress: RecoveryPhaseProgress) => void,
862
+ receiveOperationIds?: string[],
863
+ ): Promise<void> {
864
+ surfacingRecoveryProgress = true;
865
+ let failedMintQuotes = 0;
866
+ try {
867
+ onProgress({ phase: "Settling expired mint quotes", failedMintQuotes });
868
+ const settlement = await settleExpiredMintQuotes(
869
+ {
870
+ ops: coco.ops,
871
+ mintOperationService: (
872
+ coco as unknown as {
873
+ mintOperationService: MintOperationServiceCleanup;
874
+ }
875
+ ).mintOperationService,
876
+ },
877
+ Date.now(),
878
+ );
879
+ failedMintQuotes = settlement.failed;
880
+ if (settlement.leftForRecovery > 0 || settlement.unobserved > 0) {
881
+ startupProgress(
882
+ `Expired mint quotes: ${settlement.failed} failed locally, ` +
883
+ `${settlement.leftForRecovery} paid/issued (kept for recovery), ` +
884
+ `${settlement.unobserved} unverifiable (kept pending).`,
885
+ );
886
+ }
887
+ onProgress({ phase: "Settled expired mint quotes", failedMintQuotes });
888
+
889
+ onProgress({ phase: "Send recovery", failedMintQuotes });
890
+ await coco.ops.send.recovery.run();
891
+
892
+ onProgress({ phase: "Melt recovery", failedMintQuotes });
893
+ await coco.ops.melt.recovery.run();
894
+
895
+ onProgress({ phase: "Receive recovery", failedMintQuotes });
896
+ if (receiveOperationIds) {
897
+ // The pre-check already classified every executing receive by unique
898
+ // input set. Recover only the conclusive retained operations; unresolved
899
+ // groups stay untouched instead of falling back to Coco 1's expensive
900
+ // per-row sweep on this startup.
901
+ for (const operationId of receiveOperationIds) {
902
+ try {
903
+ await withTimeout(coco.ops.receive.refresh(operationId), 15_000);
904
+ } catch (error) {
905
+ logger.warn("Targeted receive recovery did not complete", {
906
+ operationId,
907
+ error: error instanceof Error ? error.message : String(error),
908
+ });
909
+ }
910
+ }
911
+ } else {
912
+ await coco.ops.receive.recovery.run();
913
+ }
914
+
915
+ onProgress({ phase: "Mint recovery", failedMintQuotes });
916
+ await coco.recoverPendingMintOperations();
917
+
918
+ onProgress({ phase: "done", failedMintQuotes });
919
+ } finally {
920
+ surfacingRecoveryProgress = false;
921
+ }
922
+ }
923
+
924
+ export async function createCocoClient(
925
+ options: CreateCocoClientOptions = {},
926
+ ): Promise<CocodClient> {
927
+ const configDir = options.walletDir || options.configDir || defaultWalletDir();
928
+ const configFile = join(configDir, "config.json");
929
+ const dbPath = join(configDir, "coco.db");
930
+ const walletPidFile =
931
+ options.walletPidPath ||
932
+ (options.walletDir || options.configDir
933
+ ? join(configDir, "wallet.pid")
934
+ : defaultWalletPidPath());
935
+ const legacySocket = options.legacySocketPath || legacyCocodSocketPath();
936
+ const legacyPidFile = options.legacyPidPath || legacyCocodPidPath();
937
+ const npcBaseUrl = options.npcBaseUrl || NPC_DEFAULT_BASE_URL;
938
+ const npcAddressDomain = new URL(npcBaseUrl).host;
939
+
940
+ await assertLegacyCocodNotRunning({ socketPath: legacySocket });
941
+ // The canonical wallet directory is created by initialization/migration.
942
+ // Keep a legacy PID claim as an exclusion fence for old cocod binaries.
943
+ mkdirSync(dirname(legacyPidFile), { recursive: true, mode: 0o700 });
944
+ const releaseWalletPidClaim = claimPidFile({
945
+ pidFilePath: walletPidFile,
946
+ label: "routstrd wallet lock",
947
+ });
948
+ let releaseLegacyPidClaim: () => void;
949
+ try {
950
+ releaseLegacyPidClaim = claimLegacyCocodPidFile({
951
+ pidFilePath: legacyPidFile,
952
+ });
953
+ } catch (error) {
954
+ releaseWalletPidClaim();
955
+ throw error;
956
+ }
957
+
958
+ let database: Database | undefined;
959
+ let coco: Manager | undefined;
960
+ let findFinalizedReceiveSibling: (
961
+ operation: ReceiveOperation | null,
962
+ ) => Promise<string | null> = async () => null;
963
+ let walletConfig = loadConfig(configFile);
964
+
965
+ let recoveryPhase = "queued";
966
+ let recoveryFailedMintQuotes = 0;
967
+ let recoveryDone = false;
968
+ let recoveryError: string | undefined;
969
+ const recoveryCounts = {
970
+ pendingSends: 0,
971
+ inflightProofs: 0,
972
+ pendingMints: 0,
973
+ };
974
+ let recoveryResolve: (() => void) | undefined;
975
+ const recoveryPromise = new Promise<void>((resolve) => {
976
+ recoveryResolve = resolve;
977
+ });
978
+
979
+ try {
980
+ startupProgress("Opening Cashu wallet database...");
981
+
982
+ // Read and validate the existing cocod config during startup rather than
983
+ // deferring failure until coco-core first needs wallet key material.
984
+ const mnemonic = walletConfig.mnemonic;
985
+ const seed = mnemonicToSeedSync(mnemonic);
986
+ database = new Database(dbPath);
987
+ const repo = new SqliteRepositories({ database });
988
+ await repo.init();
989
+ initReceiveDedupSchema(database);
990
+ const interruptedReservations = clearInterruptedReceiveReservations(database);
991
+ if (interruptedReservations > 0) {
992
+ logger.warn("Cleared interrupted receive reservations with no Coco operation", {
993
+ count: interruptedReservations,
994
+ });
995
+ }
996
+
997
+ const [pendingSends, inflightProofs, pendingMints] = await Promise.all([
998
+ repo.sendOperationRepository.getPending(),
999
+ repo.proofRepository.getInflightProofs(),
1000
+ repo.mintOperationRepository.getPending(),
1001
+ ]);
1002
+ recoveryCounts.pendingSends = pendingSends.length;
1003
+ recoveryCounts.inflightProofs = inflightProofs.length;
1004
+ recoveryCounts.pendingMints = pendingMints.length;
1005
+ const recoveryCount =
1006
+ recoveryCounts.pendingSends +
1007
+ recoveryCounts.inflightProofs +
1008
+ recoveryCounts.pendingMints;
1009
+
1010
+ if (recoveryCount > 0) {
1011
+ startupProgress(
1012
+ `Recovering wallet state in background: ${recoveryCounts.pendingSends} pending sends, ` +
1013
+ `${recoveryCounts.inflightProofs} in-flight proofs, ${recoveryCounts.pendingMints} pending mints.`,
1014
+ );
1015
+ } else {
1016
+ startupProgress("Initializing Cashu wallet...");
1017
+ }
1018
+
1019
+ // Construct Coco so the pre-recovery checker can reuse its mint adapter,
1020
+ // but do not enable watchers/processors until the backup and cleanup finish.
1021
+ coco = constructCocoManager(repo, seed);
1022
+
1023
+ const executingReceives = await repo.receiveOperationRepository.getByState("executing");
1024
+ let receiveRecoveryOperationIds: string[] | undefined;
1025
+ if (executingReceives.length > 0) {
1026
+ startupProgress(
1027
+ `Checking ${executingReceives.length} unfinished Cashu receive operation(s) for duplicates...`,
1028
+ );
1029
+ const recordedBackup = getReceiveReconcileBackup(database);
1030
+ if (!recordedBackup || !existsSync(recordedBackup)) {
1031
+ const backupPath = `${dbPath}.pre-receive-reconcile-${Date.now()}`;
1032
+ database.exec(`VACUUM INTO '${backupPath.replaceAll("'", "''")}'`);
1033
+ setReceiveReconcileBackup(database, backupPath);
1034
+ startupProgress(`Created wallet backup before receive cleanup: ${backupPath}`);
1035
+ }
1036
+ const receiveReconcile = await reconcileDuplicateReceiveOperations(coco, repo);
1037
+ receiveRecoveryOperationIds = receiveReconcile.recoveryOperationIds;
1038
+ startupProgress(
1039
+ `Receive cleanup: ${receiveReconcile.executing} operation(s), ` +
1040
+ `${receiveReconcile.uniqueGroups} unique input set(s), ` +
1041
+ `${receiveReconcile.rolledBack} stale duplicate(s) retired, ` +
1042
+ `${receiveReconcile.unresolved} unresolved.`,
1043
+ );
1044
+ }
1045
+
1046
+ await enableCocoManager(coco);
1047
+ const openDatabase = database;
1048
+
1049
+ findFinalizedReceiveSibling = async (
1050
+ operation: ReceiveOperation | null,
1051
+ ): Promise<string | null> => {
1052
+ if (!operation) return null;
1053
+ const fingerprint = receiveInputFingerprint(operation);
1054
+ const siblings = await repo.receiveOperationRepository.getByMintUrl(operation.mintUrl);
1055
+ return (
1056
+ siblings.find(
1057
+ (candidate) =>
1058
+ candidate.state === "finalized" &&
1059
+ receiveInputFingerprint(candidate) === fingerprint,
1060
+ )?.id ?? null
1061
+ );
1062
+ };
1063
+
1064
+ const syncReceiveReservations = async (): Promise<void> => {
1065
+ for (const reservation of listProcessingReceiveTokens(openDatabase)) {
1066
+ if (!reservation.operationId) continue;
1067
+ try {
1068
+ const operation = await coco!.ops.receive.get(reservation.operationId);
1069
+ if (operation?.state === "finalized") {
1070
+ updateReceiveToken(openDatabase, reservation.tokenHash, {
1071
+ state: "succeeded",
1072
+ operationId: operation.id,
1073
+ });
1074
+ } else if (operation?.state === "rolled_back") {
1075
+ const finalizedSibling = await findFinalizedReceiveSibling(operation);
1076
+ updateReceiveToken(openDatabase, reservation.tokenHash, finalizedSibling
1077
+ ? {
1078
+ state: "succeeded",
1079
+ operationId: finalizedSibling,
1080
+ }
1081
+ : {
1082
+ state: "failed",
1083
+ operationId: operation.id,
1084
+ error: operation.error || "Token receive was rolled back",
1085
+ });
1086
+ } else if (operation?.state === "prepared") {
1087
+ // A crash before execute had no mint side effect. Cancel the stale
1088
+ // prepared operation and permit a fresh exact-token attempt.
1089
+ await coco!.ops.receive.cancel(
1090
+ operation.id,
1091
+ "Cancelled interrupted receive before execution",
1092
+ );
1093
+ deleteReceiveTokenReservation(openDatabase, reservation.tokenHash);
1094
+ } else if (!operation) {
1095
+ deleteReceiveTokenReservation(openDatabase, reservation.tokenHash);
1096
+ }
1097
+ } catch (error) {
1098
+ // One damaged/stale reservation must never block daemon startup or
1099
+ // make every wallet write fail after recovery.
1100
+ logger.warn("Could not reconcile receive token reservation", {
1101
+ operationId: reservation.operationId,
1102
+ error: error instanceof Error ? error.message : String(error),
1103
+ });
1104
+ }
1105
+ }
1106
+ };
1107
+ await syncReceiveReservations();
1108
+
1109
+ const trustedMints = await coco.mint.getAllTrustedMints();
1110
+ const configuredDefault = walletConfig.defaultMintUrl;
1111
+ const defaultMintUrl = normalizeMintUrl(
1112
+ configuredDefault || trustedMints[0]?.mintUrl || DEFAULT_MINT_URL,
1113
+ );
1114
+
1115
+ if (!trustedMints.some((mint) => mint.mintUrl === defaultMintUrl)) {
1116
+ startupProgress(`Adding default mint: ${defaultMintUrl}`);
1117
+ await coco.mint.addMint(defaultMintUrl, { trusted: true });
1118
+ }
1119
+
1120
+ // Persist only after the mint was successfully fetched and trusted. A failed
1121
+ // network request must not leave config pointing at an unusable default.
1122
+ walletConfig.defaultMintUrl = defaultMintUrl;
1123
+ if (configuredDefault !== defaultMintUrl) {
1124
+ saveConfig(walletConfig, configFile);
1125
+ }
1126
+
1127
+ if (options.enableNpc !== false) {
1128
+ startupProgress("Registering NPC (npubx.cash) plugin...");
1129
+ // NPC authenticates with a Nostr key derived from the same wallet seed
1130
+ // (NIP-06). The signer only produces JWT auth events for the NPC
1131
+ // server; it never signs anything that moves funds by itself.
1132
+ const npcSecretKey = privateKeyFromSeedWords(mnemonic);
1133
+ const npcSigner = async (template: EventTemplate) =>
1134
+ finalizeEvent(template, npcSecretKey);
1135
+ const npcPlugin = new NPCPlugin(npcBaseUrl, npcSigner, {
1136
+ useWebsocket: true,
1137
+ logger: createCocoLogger({ module: "npc" }),
1138
+ });
1139
+ // coco-cashu-plugin-npc implements the plugin contract from the
1140
+ // coco-cashu-core package while routstrd runs the equivalent
1141
+ // @cashu/coco-core build. The plugin host API is structurally identical
1142
+ // in both (verified: mintService.addMintByUrl,
1143
+ // mintOperationService.importQuote/getOperationByQuote), so this cast
1144
+ // only bridges the duplicate package names, not a real API gap.
1145
+ coco.use(npcPlugin as unknown as CocoPlugin);
1146
+ }
1147
+
1148
+ startupProgress("Cashu wallet ready.");
1149
+
1150
+ // Recovery runs in the background so the daemon can serve wallet reads
1151
+ // immediately. Value-moving operations await the same promise below.
1152
+ runWalletRecovery(
1153
+ coco,
1154
+ (progress) => {
1155
+ recoveryPhase = progress.phase;
1156
+ recoveryFailedMintQuotes = progress.failedMintQuotes;
1157
+ if (progress.phase !== "done") {
1158
+ startupProgress(`Wallet recovery: ${progress.phase}...`);
1159
+ }
1160
+ },
1161
+ receiveRecoveryOperationIds,
1162
+ )
1163
+ .then(async () => {
1164
+ await syncReceiveReservations();
1165
+ recoveryDone = true;
1166
+ recoveryPhase = "done";
1167
+ recoveryResolve?.();
1168
+ startupProgress("Wallet recovery complete.");
1169
+ })
1170
+ .catch((error) => {
1171
+ recoveryDone = true;
1172
+ recoveryPhase = "error";
1173
+ recoveryError = error instanceof Error ? error.message : String(error);
1174
+ recoveryResolve?.();
1175
+ startupProgress(`Wallet recovery failed: ${recoveryError}`);
1176
+ });
1177
+ } catch (error) {
1178
+ database?.close();
1179
+ releaseLegacyPidClaim();
1180
+ releaseWalletPidClaim();
1181
+ throw error;
1182
+ }
1183
+
1184
+ const npcApi = (): NpcPluginApi => {
1185
+ // The plugin augments coco-cashu-core's PluginExtensions; the equivalent
1186
+ // registration lives on manager.ext here. Guard for enableNpc=false.
1187
+ const api = coco
1188
+ ? (coco.ext as { npc?: NpcPluginApi }).npc
1189
+ : undefined;
1190
+ if (!api) {
1191
+ throw new Error("NPC plugin is not enabled for this wallet.");
1192
+ }
1193
+ return api;
1194
+ };
1195
+
1196
+ let disposed = false;
1197
+
1198
+ /**
1199
+ * Block a value-moving operation until background recovery has settled.
1200
+ * Reads stay ungated so the daemon can report balances/status immediately.
1201
+ */
1202
+ const waitForRecovery = async (): Promise<void> => {
1203
+ if (!recoveryDone) await recoveryPromise;
1204
+ if (recoveryError) {
1205
+ throw new Error(`Wallet is not ready: ${recoveryError}`);
1206
+ }
1207
+ };
1208
+
1209
+ return {
1210
+ async ping(): Promise<boolean> {
1211
+ try {
1212
+ await coco.wallet.balances.total();
1213
+ return true;
1214
+ } catch {
1215
+ return false;
1216
+ }
1217
+ },
1218
+
1219
+ async getStatus(): Promise<CocodState> {
1220
+ if (recoveryError) return "ERROR";
1221
+ if (!recoveryDone) return "RECOVERING";
1222
+ try {
1223
+ await coco.wallet.balances.total();
1224
+ return "UNLOCKED";
1225
+ } catch {
1226
+ return "ERROR";
1227
+ }
1228
+ },
1229
+
1230
+ async getRecoveryProgress(): Promise<WalletRecoveryProgress> {
1231
+ return {
1232
+ state: recoveryError ? "ERROR" : recoveryDone ? "UNLOCKED" : "RECOVERING",
1233
+ phase: recoveryPhase,
1234
+ pendingSends: recoveryCounts.pendingSends,
1235
+ inflightProofs: recoveryCounts.inflightProofs,
1236
+ pendingMints: recoveryCounts.pendingMints,
1237
+ failedMintQuotes: recoveryFailedMintQuotes,
1238
+ ...(recoveryError ? { error: recoveryError } : {}),
1239
+ };
1240
+ },
1241
+
1242
+ async unlock(_passphrase: string): Promise<string> {
1243
+ // coco-core does not support passphrase locking.
1244
+ // Wallet access is controlled by ~/.routstrd/wallet/config.json.
1245
+ return "wallet does not require unlocking";
1246
+ },
1247
+
1248
+ async getBalances(): Promise<Record<string, number>> {
1249
+ const byMint = await coco.wallet.balances.byMint();
1250
+ return Object.fromEntries(
1251
+ Object.entries(byMint).map(([mintUrl, snapshot]) => [
1252
+ mintUrl,
1253
+ snapshot.spendable,
1254
+ ]),
1255
+ );
1256
+ },
1257
+
1258
+ async receiveCashu(token: string): Promise<string> {
1259
+ const reservation = reserveReceiveToken(database, token);
1260
+ if (!reservation.acquired) {
1261
+ if (reservation.existing?.state === "succeeded") {
1262
+ return "Token already received successfully";
1263
+ }
1264
+ if (
1265
+ reservation.existing?.state === "processing" &&
1266
+ reservation.existing.operationId
1267
+ ) {
1268
+ // A prior request may have stopped after the mint call became
1269
+ // uncertain. Re-drive that one Coco operation instead of creating a
1270
+ // duplicate. Refresh is idempotent and uses its stored output data.
1271
+ try {
1272
+ const operation = await withTimeout(
1273
+ coco.ops.receive.refresh(reservation.existing.operationId),
1274
+ 15_000,
1275
+ );
1276
+ if (operation.state === "finalized") {
1277
+ updateReceiveToken(database, reservation.tokenHash, {
1278
+ state: "succeeded",
1279
+ operationId: operation.id,
1280
+ });
1281
+ return "Token received successfully";
1282
+ }
1283
+ if (operation.state === "rolled_back") {
1284
+ const finalizedSibling = await findFinalizedReceiveSibling(operation);
1285
+ if (finalizedSibling) {
1286
+ updateReceiveToken(database, reservation.tokenHash, {
1287
+ state: "succeeded",
1288
+ operationId: finalizedSibling,
1289
+ });
1290
+ return "Token already received successfully";
1291
+ }
1292
+ updateReceiveToken(database, reservation.tokenHash, {
1293
+ state: "failed",
1294
+ operationId: operation.id,
1295
+ error: operation.error || "Token receive was rolled back",
1296
+ });
1297
+ throw new Error(operation.error || "Token receive was rolled back");
1298
+ }
1299
+ } catch (error) {
1300
+ const latest = await coco.ops.receive.get(reservation.existing.operationId);
1301
+ if (latest?.state === "finalized") {
1302
+ updateReceiveToken(database, reservation.tokenHash, {
1303
+ state: "succeeded",
1304
+ operationId: latest.id,
1305
+ });
1306
+ return "Token received successfully";
1307
+ }
1308
+ throw error;
1309
+ }
1310
+ throw new Error("Token receive is still unresolved");
1311
+ }
1312
+ if (reservation.existing?.state === "processing") {
1313
+ throw new Error("Token receive is already in progress");
1314
+ }
1315
+ throw new Error(reservation.existing?.error || "Token receive previously failed");
1316
+ }
1317
+
1318
+ let preparedOperationId: string | undefined;
1319
+ try {
1320
+ await waitForRecovery();
1321
+ const prepared = await coco.ops.receive.prepare({ token });
1322
+ preparedOperationId = prepared.id;
1323
+ updateReceiveToken(database, reservation.tokenHash, {
1324
+ state: "processing",
1325
+ operationId: prepared.id,
1326
+ });
1327
+ await coco.ops.receive.execute(prepared.id);
1328
+ updateReceiveToken(database, reservation.tokenHash, {
1329
+ state: "succeeded",
1330
+ operationId: prepared.id,
1331
+ });
1332
+ return "Token received successfully";
1333
+ } catch (error) {
1334
+ const message = error instanceof Error ? error.message : String(error);
1335
+ if (preparedOperationId) {
1336
+ let latest: Awaited<ReturnType<typeof coco.ops.receive.get>> = null;
1337
+ try {
1338
+ latest = await coco.ops.receive.get(preparedOperationId);
1339
+ } catch (lookupError) {
1340
+ logger.warn("Could not inspect failed receive operation", {
1341
+ operationId: preparedOperationId,
1342
+ error:
1343
+ lookupError instanceof Error ? lookupError.message : String(lookupError),
1344
+ });
1345
+ }
1346
+ if (latest?.state === "finalized") {
1347
+ updateReceiveToken(database, reservation.tokenHash, {
1348
+ state: "succeeded",
1349
+ operationId: preparedOperationId,
1350
+ });
1351
+ return "Token received successfully";
1352
+ }
1353
+ if (latest?.state === "executing") {
1354
+ updateReceiveToken(database, reservation.tokenHash, {
1355
+ state: "processing",
1356
+ operationId: preparedOperationId,
1357
+ error: message,
1358
+ });
1359
+ } else if (latest?.state === "rolled_back") {
1360
+ const finalizedSibling = await findFinalizedReceiveSibling(latest);
1361
+ if (finalizedSibling) {
1362
+ updateReceiveToken(database, reservation.tokenHash, {
1363
+ state: "succeeded",
1364
+ operationId: finalizedSibling,
1365
+ });
1366
+ return "Token already received successfully";
1367
+ }
1368
+ updateReceiveToken(database, reservation.tokenHash, {
1369
+ state: "failed",
1370
+ operationId: preparedOperationId,
1371
+ error: latest.error || message,
1372
+ });
1373
+ } else {
1374
+ // A prepared or missing operation had no known mint side effect.
1375
+ deleteReceiveTokenReservation(database, reservation.tokenHash);
1376
+ }
1377
+ } else {
1378
+ // Decode/validation failed before coco created an operation. Do not
1379
+ // permanently reserve malformed input or transient mint-fetch errors.
1380
+ releaseReceiveToken(database, reservation.tokenHash);
1381
+ }
1382
+ throw error;
1383
+ }
1384
+ },
1385
+
1386
+ async receiveBolt11(amount: number, mintUrl?: string): Promise<string> {
1387
+ await waitForRecovery();
1388
+ const targetMint = mintUrl
1389
+ ? normalizeMintUrl(mintUrl)
1390
+ : walletConfig.defaultMintUrl;
1391
+ if (!targetMint) {
1392
+ throw new Error("No trusted mint available for Lightning invoice");
1393
+ }
1394
+ const op = await coco.ops.mint.prepare({
1395
+ mintUrl: targetMint,
1396
+ amount,
1397
+ method: "bolt11",
1398
+ });
1399
+ if (!("request" in op)) {
1400
+ throw new Error("mint prepare did not return a payment request");
1401
+ }
1402
+ return op.request as string;
1403
+ },
1404
+
1405
+ async sendCashu(amount: number, mintUrl?: string): Promise<string> {
1406
+ await waitForRecovery();
1407
+ const targetMint = mintUrl
1408
+ ? normalizeMintUrl(mintUrl)
1409
+ : walletConfig.defaultMintUrl;
1410
+ if (!targetMint) {
1411
+ throw new Error("No trusted mint available for sending");
1412
+ }
1413
+ const prepared = await coco.ops.send.prepare({
1414
+ mintUrl: targetMint,
1415
+ amount,
1416
+ });
1417
+ const { token } = await coco.ops.send.execute(prepared.id);
1418
+ return getEncodedToken(token);
1419
+ },
1420
+
1421
+ async sendBolt11(invoice: string, mintUrl?: string): Promise<string> {
1422
+ await waitForRecovery();
1423
+ const targetMint = mintUrl
1424
+ ? normalizeMintUrl(mintUrl)
1425
+ : walletConfig.defaultMintUrl;
1426
+ if (!targetMint) {
1427
+ throw new Error("No trusted mint available for Lightning payment");
1428
+ }
1429
+ const prepared = await coco.ops.melt.prepare({
1430
+ mintUrl: targetMint,
1431
+ method: "bolt11",
1432
+ methodData: { invoice },
1433
+ });
1434
+ await coco.ops.melt.execute(prepared.id);
1435
+ return "Payment sent successfully";
1436
+ },
1437
+
1438
+ async listMints(): Promise<string[]> {
1439
+ const mints = await coco.mint.getAllTrustedMints();
1440
+ return mints.map((m) => m.mintUrl);
1441
+ },
1442
+
1443
+ async addMint(url: string): Promise<string> {
1444
+ await waitForRecovery();
1445
+ const mintUrl = normalizeMintUrl(url);
1446
+ await coco.mint.addMint(mintUrl, { trusted: true });
1447
+ return `Mint ${mintUrl} added successfully`;
1448
+ },
1449
+
1450
+ async getMintInfo(url: string): Promise<unknown> {
1451
+ return coco.mint.getMintInfo(normalizeMintUrl(url));
1452
+ },
1453
+
1454
+ async getDefaultMint(): Promise<string | null> {
1455
+ return walletConfig.defaultMintUrl || null;
1456
+ },
1457
+
1458
+ async setDefaultMint(url: string): Promise<string> {
1459
+ await waitForRecovery();
1460
+ const mintUrl = normalizeMintUrl(url);
1461
+ const trustedMints = await coco.mint.getAllTrustedMints();
1462
+ if (!trustedMints.some((mint) => mint.mintUrl === mintUrl)) {
1463
+ await coco.mint.addMint(mintUrl, { trusted: true });
1464
+ }
1465
+
1466
+ walletConfig.defaultMintUrl = mintUrl;
1467
+ saveConfig(walletConfig, configFile);
1468
+ return `Default mint set to ${mintUrl}`;
1469
+ },
1470
+
1471
+ async dispose(): Promise<void> {
1472
+ if (disposed) return;
1473
+ disposed = true;
1474
+ try {
1475
+ // Let any in-flight recovery settle before closing the database from
1476
+ // underneath it. The recovery promise resolves on success or failure.
1477
+ await recoveryPromise;
1478
+ await coco.dispose();
1479
+ } finally {
1480
+ try {
1481
+ database.close();
1482
+ } finally {
1483
+ releaseLegacyPidClaim();
1484
+ releaseWalletPidClaim();
1485
+ }
1486
+ }
1487
+ },
1488
+
1489
+ async getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]> {
1490
+ return coco.history.getPaginatedHistory(offset, limit);
1491
+ },
1492
+
1493
+ async getNpcAddress(): Promise<NpcAddress> {
1494
+ const info = await npcApi().getInfo();
1495
+ const name =
1496
+ typeof info?.name === "string" && info.name.trim()
1497
+ ? info.name.trim()
1498
+ : undefined;
1499
+ const localPart = name ?? nip19.npubEncode(info.pubkey);
1500
+ return {
1501
+ address: `${localPart}@${npcAddressDomain}`,
1502
+ ...(name ? { name } : {}),
1503
+ pubkey: info.pubkey,
1504
+ };
1505
+ },
1506
+
1507
+ async setNpcUsername(
1508
+ username: string,
1509
+ confirm?: boolean,
1510
+ ): Promise<NpcUsernameResult> {
1511
+ await waitForRecovery();
1512
+ const result = await npcApi().setUsername(username, confirm === true);
1513
+ if (result.success) {
1514
+ return { success: true };
1515
+ }
1516
+ return {
1517
+ success: false,
1518
+ paymentRequest:
1519
+ result.pr as NpcUsernameResult["paymentRequest"],
1520
+ };
1521
+ },
1522
+
1523
+ async syncNpc(): Promise<void> {
1524
+ await waitForRecovery();
1525
+ await npcApi().sync();
1526
+ },
1527
+
1528
+ async cleanupStuckOperations(
1529
+ options: WalletCleanupOptions = {},
1530
+ ): Promise<WalletCleanupResult> {
1531
+ await waitForRecovery();
1532
+ const minAgeMs = options.minAgeMs ?? 7 * 24 * 60 * 60 * 1000;
1533
+ const dryRun = options.dryRun === true;
1534
+ const nowMs = Date.now();
1535
+
1536
+ const [pendingMints, inFlightSends, preparedMelts] = await Promise.all([
1537
+ coco.ops.mint.listPending(),
1538
+ coco.ops.send.listInFlight(),
1539
+ coco.ops.melt.listPrepared(),
1540
+ ]);
1541
+
1542
+ const filteredMints = options.mintUrl
1543
+ ? pendingMints.filter((op) => op.mintUrl === options.mintUrl)
1544
+ : pendingMints;
1545
+ const filteredSends = options.mintUrl
1546
+ ? inFlightSends.filter((op) => op.mintUrl === options.mintUrl)
1547
+ : inFlightSends;
1548
+ const filteredMelts = options.mintUrl
1549
+ ? preparedMelts.filter((op) => op.mintUrl === options.mintUrl)
1550
+ : preparedMelts;
1551
+
1552
+ const selection = selectCleanupOperations({
1553
+ mints: filteredMints,
1554
+ sends: filteredSends,
1555
+ melts: filteredMelts,
1556
+ nowMs,
1557
+ minAgeMs,
1558
+ });
1559
+
1560
+ const errors: WalletCleanupResult["errors"] = [];
1561
+
1562
+ if (!dryRun) {
1563
+ const mintService = (
1564
+ coco as unknown as {
1565
+ mintOperationService: MintOperationServiceCleanup;
1566
+ }
1567
+ ).mintOperationService;
1568
+
1569
+ for (const op of selection.mintsToFail) {
1570
+ try {
1571
+ await mintService.failPendingOperation(
1572
+ { id: op.id },
1573
+ {
1574
+ reason: "Expired unpaid mint quote cleaned up by routstrd",
1575
+ retryable: false,
1576
+ observedAt: nowMs,
1577
+ },
1578
+ );
1579
+ } catch (error) {
1580
+ errors.push({
1581
+ operationId: op.id,
1582
+ error: error instanceof Error ? error.message : String(error),
1583
+ });
1584
+ }
1585
+ }
1586
+
1587
+ for (const op of selection.sendsToReclaim) {
1588
+ try {
1589
+ await coco.ops.send.reclaim(op.id);
1590
+ } catch (error) {
1591
+ errors.push({
1592
+ operationId: op.id,
1593
+ error: error instanceof Error ? error.message : String(error),
1594
+ });
1595
+ }
1596
+ }
1597
+
1598
+ for (const op of selection.meltsToCancel) {
1599
+ try {
1600
+ await coco.ops.melt.cancel(op.id, "Cancelled by wallet cleanup");
1601
+ } catch (error) {
1602
+ errors.push({
1603
+ operationId: op.id,
1604
+ error: error instanceof Error ? error.message : String(error),
1605
+ });
1606
+ }
1607
+ }
1608
+ }
1609
+
1610
+ const actedOn =
1611
+ selection.mintsToFail.length +
1612
+ selection.sendsToReclaim.length +
1613
+ selection.meltsToCancel.length;
1614
+ const skipped =
1615
+ filteredMints.length + filteredSends.length + filteredMelts.length -
1616
+ actedOn;
1617
+
1618
+ return {
1619
+ dryRun,
1620
+ failedMintQuotes: selection.mintsToFail.length,
1621
+ reclaimedSends: selection.sendsToReclaim.length,
1622
+ cancelledMelts: selection.meltsToCancel.length,
1623
+ skipped,
1624
+ errors,
1625
+ };
1626
+ },
1627
+ };
1628
+ }