routstrd 0.3.10 → 0.4.0

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 (37) hide show
  1. package/IMPLEMENTATION.md +253 -0
  2. package/README.md +45 -0
  3. package/SECURITY.md +23 -0
  4. package/SKILL.md +13 -3
  5. package/bun.lock +68 -41
  6. package/dist/daemon/index.js +47850 -11349
  7. package/dist/index.js +4730 -448
  8. package/package.json +8 -3
  9. package/src/cli.test.ts +55 -0
  10. package/src/cli.ts +502 -229
  11. package/src/daemon/args.ts +8 -1
  12. package/src/daemon/models.ts +119 -39
  13. package/src/daemon/wallet/coco-client.npc.test.ts +252 -0
  14. package/src/daemon/wallet/coco-client.test.ts +508 -0
  15. package/src/daemon/wallet/coco-client.ts +835 -0
  16. package/src/daemon/wallet/cocod-client.ts +86 -2
  17. package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
  18. package/src/daemon/wallet/migration.test.ts +147 -0
  19. package/src/daemon/wallet/migration.ts +209 -0
  20. package/src/daemon/wallet/paths.ts +33 -0
  21. package/src/integrations/claudecode.ts +2 -1
  22. package/src/integrations/hermes.ts +46 -6
  23. package/src/integrations/openclaw.ts +3 -2
  24. package/src/integrations/opencode.ts +3 -2
  25. package/src/integrations/pi.ts +3 -2
  26. package/src/integrations/registry.ts +7 -5
  27. package/src/start-daemon.ts +168 -22
  28. package/src/tui/usage/app.ts +41 -2
  29. package/src/tui/usage/render.ts +10 -3
  30. package/src/tui/usage/types.ts +6 -0
  31. package/src/utils/clients.ts +21 -0
  32. package/src/utils/config.ts +11 -0
  33. package/src/utils/daemon-client.ts +104 -25
  34. package/src/utils/logger.ts +42 -28
  35. package/src/utils/update-checker.ts +114 -0
  36. package/tests/utils/daemon-client.test.ts +61 -0
  37. package/tests/wallet/short-keyset-token.test.ts +106 -0
@@ -0,0 +1,835 @@
1
+ import {
2
+ initializeCoco,
3
+ getEncodedToken,
4
+ normalizeMintUrl,
5
+ } from "@cashu/coco-core";
6
+ import type {
7
+ HistoryEntry,
8
+ Logger as CocoLogger,
9
+ Plugin as CocoPlugin,
10
+ } from "@cashu/coco-core";
11
+ import { SqliteRepositories } from "@cashu/coco-sqlite-bun";
12
+ import { Database } from "bun:sqlite";
13
+ import { NPCPlugin, type PluginApi as NpcPluginApi } from "coco-cashu-plugin-npc";
14
+ import { privateKeyFromSeedWords } from "nostr-tools/nip06";
15
+ import { finalizeEvent, nip19, type EventTemplate } from "nostr-tools";
16
+ import {
17
+ closeSync,
18
+ existsSync,
19
+ mkdirSync,
20
+ openSync,
21
+ readFileSync,
22
+ renameSync,
23
+ unlinkSync,
24
+ writeFileSync,
25
+ } from "fs";
26
+ import { dirname, join } from "path";
27
+ import { mnemonicToSeedSync } from "@scure/bip39";
28
+ import type {
29
+ CocodClient,
30
+ CocodState,
31
+ NpcAddress,
32
+ NpcUsernameResult,
33
+ } from "./cocod-client";
34
+ import { cocoLogger, logger } from "../../utils/logger";
35
+ import {
36
+ legacyCocodPidPath,
37
+ legacyCocodSocketPath,
38
+ walletDir as defaultWalletDir,
39
+ walletPidPath as defaultWalletPidPath,
40
+ } from "./paths";
41
+
42
+ const NPC_DEFAULT_BASE_URL = "https://npubx.cash";
43
+
44
+ const STALE_SOCKET_ERROR_CODES = new Set([
45
+ "ECONNREFUSED",
46
+ "ENOENT",
47
+ // Bun's Unix-socket fetch error for an abandoned socket inode.
48
+ "FailedToOpenSocket",
49
+ ]);
50
+
51
+ type UnixRequestInit = RequestInit & { unix: string };
52
+ type LegacyCocodFetch = (
53
+ input: string | URL | Request,
54
+ init: UnixRequestInit,
55
+ ) => Promise<Response>;
56
+
57
+ export interface LegacyCocodGuardOptions {
58
+ socketPath?: string;
59
+ pidFilePath?: string;
60
+ pathExists?: (path: string) => boolean;
61
+ readFile?: (path: string) => string;
62
+ isProcessRunning?: (pid: number) => boolean;
63
+ /** PID owned by the caller's already-acquired legacy exclusion lock. */
64
+ ignorePid?: number;
65
+ fetchImpl?: LegacyCocodFetch;
66
+ timeoutMs?: number;
67
+ }
68
+
69
+ export interface LegacyCocodPidClaimOptions {
70
+ pidFilePath?: string;
71
+ pid?: number;
72
+ openExclusive?: (path: string) => number;
73
+ writePid?: (fd: number, pid: number) => void;
74
+ closeFile?: (fd: number) => void;
75
+ readFile?: (path: string) => string;
76
+ removeFile?: (path: string) => void;
77
+ isProcessRunning?: (pid: number) => boolean;
78
+ }
79
+
80
+ export interface LegacyCocodStopOptions {
81
+ socketPath?: string;
82
+ pidFilePath?: string;
83
+ pathExists?: (path: string) => boolean;
84
+ readFile?: (path: string) => string;
85
+ isProcessRunning?: (pid: number) => boolean;
86
+ fetchImpl?: LegacyCocodFetch;
87
+ killProcess?: (pid: number, signal: NodeJS.Signals) => void;
88
+ /** Total time to wait for cocod to exit after SIGTERM. */
89
+ timeoutMs?: number;
90
+ /** Interval between exit checks. */
91
+ pollIntervalMs?: number;
92
+ /** Timeout for identifying cocod through its Unix socket. */
93
+ socketTimeoutMs?: number;
94
+ }
95
+
96
+ interface CocodConfig {
97
+ mnemonic: string;
98
+ encrypted: boolean;
99
+ defaultMintUrl?: string;
100
+ }
101
+
102
+ const STARTUP_LOG_PREFIX = "[routstrd:start]";
103
+ export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org";
104
+
105
+ function startupProgress(message: string): void {
106
+ logger.info(message);
107
+ // The daemon is detached and stdout is captured by start-daemon.ts. The
108
+ // prefix lets the CLI surface only safe, user-facing startup progress while
109
+ // the full diagnostic stream remains in the normal log file.
110
+ console.log(`${STARTUP_LOG_PREFIX} ${message}`);
111
+ }
112
+
113
+ const SAFE_COCO_LOG_FIELDS = new Set([
114
+ "module",
115
+ "mintUrl",
116
+ "operationId",
117
+ "quoteId",
118
+ "state",
119
+ "count",
120
+ "total",
121
+ "filterCount",
122
+ "subId",
123
+ "initOperations",
124
+ "executingOperations",
125
+ "pendingOperations",
126
+ "rollingBackOperations",
127
+ "orphanedReservations",
128
+ ]);
129
+
130
+ function safeCocoMetadata(values: unknown[]): Record<string, unknown> {
131
+ const safe: Record<string, unknown> = {};
132
+ for (const value of values) {
133
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
134
+ for (const [key, fieldValue] of Object.entries(value)) {
135
+ if (SAFE_COCO_LOG_FIELDS.has(key)) safe[key] = fieldValue;
136
+ }
137
+ }
138
+ return safe;
139
+ }
140
+
141
+ function createCocoLogger(bindings: Record<string, unknown> = {}): CocoLogger {
142
+ const write = (
143
+ level: "error" | "warn" | "info" | "debug",
144
+ message: string,
145
+ meta: unknown[],
146
+ ) => {
147
+ // Coco diagnostics may contain proof secrets or encoded tokens. Keep only
148
+ // an explicit metadata allowlist; startup counts and operation IDs remain
149
+ // useful without copying wallet material into routstrd's logs. Written to
150
+ // ~/.routstrd/coco-logs/ so wallet-engine noise stays out of the main logs.
151
+ const metadata = safeCocoMetadata([bindings, ...meta]);
152
+ cocoLogger[level](
153
+ `[coco] ${message}`,
154
+ ...(Object.keys(metadata).length > 0 ? [metadata] : []),
155
+ );
156
+ };
157
+
158
+ return {
159
+ error: (message, ...meta) => write("error", message, meta),
160
+ warn: (message, ...meta) => write("warn", message, meta),
161
+ info: (message, ...meta) => write("info", message, meta),
162
+ debug: (message, ...meta) => write("debug", message, meta),
163
+ log: (level, message, ...meta) => write(level, message, meta),
164
+ child: (childBindings) =>
165
+ createCocoLogger({ ...bindings, ...childBindings }),
166
+ };
167
+ }
168
+
169
+ function loadConfig(configFile: string): CocodConfig {
170
+ if (!existsSync(configFile)) {
171
+ throw new Error(
172
+ `Config file not found at ${configFile}. Run 'routstrd onboard' first.`,
173
+ );
174
+ }
175
+ const config = JSON.parse(readFileSync(configFile, "utf-8")) as CocodConfig;
176
+ if (config.encrypted) {
177
+ throw new Error(
178
+ "Encrypted wallets are not supported yet. Please use an unencrypted wallet.",
179
+ );
180
+ }
181
+ return config;
182
+ }
183
+
184
+ function saveConfig(config: CocodConfig, configFile: string): void {
185
+ const temporaryFile = `${configFile}.${process.pid}.tmp`;
186
+ try {
187
+ writeFileSync(temporaryFile, JSON.stringify(config, null, 2), {
188
+ mode: 0o600,
189
+ flag: "wx",
190
+ });
191
+ renameSync(temporaryFile, configFile);
192
+ } catch (error) {
193
+ try {
194
+ unlinkSync(temporaryFile);
195
+ } catch {
196
+ // The temporary file may not have been created.
197
+ }
198
+ throw error;
199
+ }
200
+ }
201
+
202
+ function defaultIsProcessRunning(pid: number): boolean {
203
+ try {
204
+ process.kill(pid, 0);
205
+ return true;
206
+ } catch (error) {
207
+ return (error as NodeJS.ErrnoException).code === "EPERM";
208
+ }
209
+ }
210
+
211
+ function hasErrorCode(error: unknown, codes: Set<string>): boolean {
212
+ let current: unknown = error;
213
+ const visited = new Set<unknown>();
214
+
215
+ while (current && typeof current === "object" && !visited.has(current)) {
216
+ visited.add(current);
217
+ const candidate = current as { code?: unknown; cause?: unknown };
218
+ if (typeof candidate.code === "string" && codes.has(candidate.code)) {
219
+ return true;
220
+ }
221
+ current = candidate.cause;
222
+ }
223
+
224
+ return false;
225
+ }
226
+
227
+ /**
228
+ * Refuse to open coco.db while the legacy cocod daemon owns its Unix socket.
229
+ * Two independent wallet engines must never operate on the same proof database.
230
+ *
231
+ * A socket left behind after a crash is safe to ignore only when connecting
232
+ * fails with ENOENT or ECONNREFUSED. Other probe failures are treated as unsafe
233
+ * because they do not prove that cocod has stopped.
234
+ */
235
+ export async function assertLegacyCocodNotRunning(
236
+ options: LegacyCocodGuardOptions = {},
237
+ ): Promise<void> {
238
+ const socketPath = options.socketPath || legacyCocodSocketPath();
239
+ const pidFilePath = options.pidFilePath || legacyCocodPidPath();
240
+ const pathExists = options.pathExists || existsSync;
241
+ const readFile = options.readFile || ((path) => readFileSync(path, "utf-8"));
242
+ const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning;
243
+
244
+ const getRunningLegacyPid = (): number | null => {
245
+ if (!pathExists(pidFilePath)) return null;
246
+
247
+ try {
248
+ const pid = Number.parseInt(readFile(pidFilePath).trim(), 10);
249
+ return Number.isInteger(pid) &&
250
+ pid > 0 &&
251
+ pid !== options.ignorePid &&
252
+ isProcessRunning(pid)
253
+ ? pid
254
+ : null;
255
+ } catch {
256
+ // An unreadable or malformed PID file does not prove that cocod is alive;
257
+ // the socket probe below remains the authoritative fallback.
258
+ return null;
259
+ }
260
+ };
261
+
262
+ const runningPid = getRunningLegacyPid();
263
+ if (runningPid !== null) {
264
+ throw new Error(
265
+ `Legacy cocod daemon is still running with PID ${runningPid}. ` +
266
+ "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " +
267
+ `Run 'cocod stop' or 'kill ${runningPid}' and try again.`,
268
+ );
269
+ }
270
+
271
+ if (!pathExists(socketPath)) return;
272
+
273
+ const fetchImpl = options.fetchImpl || (fetch as LegacyCocodFetch);
274
+ const timeoutMs = options.timeoutMs ?? 1_000;
275
+
276
+ try {
277
+ const response = await fetchImpl("http://localhost/ping", {
278
+ unix: socketPath,
279
+ signal: AbortSignal.timeout(timeoutMs),
280
+ });
281
+ await response.body?.cancel();
282
+ } catch (error) {
283
+ if (hasErrorCode(error, STALE_SOCKET_ERROR_CODES)) {
284
+ // Recheck after the failed probe in case cocod started concurrently.
285
+ const newlyRunningPid = getRunningLegacyPid();
286
+ if (newlyRunningPid === null) {
287
+ logger.debug(`Ignoring stale legacy cocod socket at ${socketPath}`);
288
+ return;
289
+ }
290
+
291
+ throw new Error(
292
+ `Legacy cocod daemon is still running with PID ${newlyRunningPid}. ` +
293
+ "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " +
294
+ `Run 'cocod stop' or 'kill ${newlyRunningPid}' and try again.`,
295
+ { cause: error },
296
+ );
297
+ }
298
+
299
+ throw new Error(
300
+ `Cannot verify whether the legacy cocod daemon has stopped at ${socketPath}. ` +
301
+ "Refusing to open the wallet database to prevent concurrent access. " +
302
+ "Run 'cocod stop', verify the daemon has exited, and try again.",
303
+ { cause: error },
304
+ );
305
+ }
306
+
307
+ throw new Error(
308
+ `Legacy cocod daemon is still running at ${socketPath}. ` +
309
+ "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " +
310
+ "Run 'cocod stop' and try again.",
311
+ );
312
+ }
313
+
314
+ /**
315
+ * Gracefully stop a legacy cocod daemon that is still running, so the new
316
+ * in-process coco wallet can safely open the shared database.
317
+ *
318
+ * Sends SIGTERM to the PID recorded in cocod's PID file, then polls until the
319
+ * process exits and the PID file is removed (cocod cleans up both on graceful
320
+ * shutdown). On timeout it refuses rather than escalating to SIGKILL, because
321
+ * killing a wallet engine mid-proof-recovery risks corrupting coco.db — the
322
+ * exact failure the guard exists to prevent.
323
+ */
324
+ export async function stopLegacyCocod(
325
+ options: LegacyCocodStopOptions = {},
326
+ ): Promise<void> {
327
+ const socketPath = options.socketPath || legacyCocodSocketPath();
328
+ const pidFilePath = options.pidFilePath || legacyCocodPidPath();
329
+ const pathExists = options.pathExists || existsSync;
330
+ const readFile =
331
+ options.readFile || ((path: string) => readFileSync(path, "utf-8"));
332
+ const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning;
333
+ const fetchImpl = options.fetchImpl || (fetch as LegacyCocodFetch);
334
+ const killProcess =
335
+ options.killProcess || ((pid, signal) => process.kill(pid, signal));
336
+ const timeoutMs = options.timeoutMs ?? 30_000;
337
+ const pollIntervalMs = options.pollIntervalMs ?? 500;
338
+ const socketTimeoutMs = options.socketTimeoutMs ?? 1_000;
339
+
340
+ const readPid = (): number | null => {
341
+ if (!pathExists(pidFilePath)) return null;
342
+ try {
343
+ const pid = Number.parseInt(readFile(pidFilePath).trim(), 10);
344
+ return Number.isInteger(pid) && pid > 0 && isProcessRunning(pid)
345
+ ? pid
346
+ : null;
347
+ } catch {
348
+ return null;
349
+ }
350
+ };
351
+
352
+ const pid = readPid();
353
+ if (pid === null) {
354
+ logger.debug(
355
+ "stopLegacyCocod: no running legacy cocod found, nothing to stop.",
356
+ );
357
+ return;
358
+ }
359
+
360
+ // routstrd intentionally writes its own PID to cocod.pid while the in-process
361
+ // wallet is open. Never identify the owner from the shared PID file alone:
362
+ // only a process responding through cocod's Unix socket is safe to terminate.
363
+ if (!pathExists(socketPath)) {
364
+ logger.debug(
365
+ `PID ${pid} owns ${pidFilePath}, but no legacy cocod socket exists; leaving it running.`,
366
+ );
367
+ return;
368
+ }
369
+
370
+ try {
371
+ const response = await fetchImpl("http://localhost/ping", {
372
+ unix: socketPath,
373
+ signal: AbortSignal.timeout(socketTimeoutMs),
374
+ });
375
+ await response.body?.cancel();
376
+ } catch (error) {
377
+ if (hasErrorCode(error, STALE_SOCKET_ERROR_CODES)) {
378
+ logger.debug(
379
+ `PID ${pid} owns ${pidFilePath}, but the legacy cocod socket is stale; leaving it running.`,
380
+ );
381
+ return;
382
+ }
383
+
384
+ throw new Error(
385
+ `Cannot verify whether PID ${pid} is the legacy cocod daemon at ${socketPath}. ` +
386
+ "Refusing to stop an unidentified process.",
387
+ { cause: error },
388
+ );
389
+ }
390
+
391
+ logger.log(`Stopping legacy cocod daemon (PID ${pid})…`);
392
+ killProcess(pid, "SIGTERM");
393
+
394
+ const deadline = Date.now() + timeoutMs;
395
+ while (Date.now() < deadline) {
396
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
397
+ if (!isProcessRunning(pid) || readPid() !== pid) {
398
+ logger.log(`Legacy cocod daemon (PID ${pid}) stopped.`);
399
+ return;
400
+ }
401
+ }
402
+
403
+ throw new Error(
404
+ `Legacy cocod daemon (PID ${pid}) did not stop within ${Math.round(
405
+ timeoutMs / 1000,
406
+ )}s of SIGTERM. ` + `Run 'kill ${pid}' and try again.`,
407
+ );
408
+ }
409
+
410
+ /**
411
+ * Atomically claim cocod's PID file for the lifetime of the in-process wallet.
412
+ * Legacy cocod checks this same file before opening coco.db, so a live routstrd
413
+ * owner prevents cocod from starting after the initial socket/PID probe.
414
+ */
415
+ export function claimLegacyCocodPidFile(
416
+ options: LegacyCocodPidClaimOptions = {},
417
+ ): () => void {
418
+ return claimPidFile({
419
+ ...options,
420
+ pidFilePath: options.pidFilePath || legacyCocodPidPath(),
421
+ });
422
+ }
423
+
424
+ function claimPidFile(options: LegacyCocodPidClaimOptions & { pidFilePath: string }): () => void {
425
+ const pidFilePath = options.pidFilePath;
426
+ const pid = options.pid ?? process.pid;
427
+ const openExclusive =
428
+ options.openExclusive || ((path: string) => openSync(path, "wx", 0o600));
429
+ const writePid =
430
+ options.writePid ||
431
+ ((fd: number, ownerPid: number) => writeFileSync(fd, String(ownerPid)));
432
+ const closeFile = options.closeFile || closeSync;
433
+ const readFile =
434
+ options.readFile || ((path: string) => readFileSync(path, "utf-8"));
435
+ const removeFile = options.removeFile || unlinkSync;
436
+ const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning;
437
+
438
+ let fd: number;
439
+ try {
440
+ fd = openExclusive(pidFilePath);
441
+ } catch (error) {
442
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
443
+
444
+ // The earlier guard permits a dead PID file. Remove only a parseable,
445
+ // confirmed-dead owner; an empty/malformed file may belong to a process
446
+ // that has created the file but has not written its PID yet.
447
+ let stalePid: number;
448
+ try {
449
+ stalePid = Number.parseInt(readFile(pidFilePath).trim(), 10);
450
+ } catch {
451
+ throw new Error(
452
+ `Cannot claim the wallet process lock at ${pidFilePath}. ` +
453
+ "Another cocod or routstrd process may be starting. Stop it and try again.",
454
+ { cause: error },
455
+ );
456
+ }
457
+
458
+ if (
459
+ !Number.isInteger(stalePid) ||
460
+ stalePid <= 0 ||
461
+ isProcessRunning(stalePid)
462
+ ) {
463
+ throw new Error(
464
+ `Cannot claim the wallet process lock at ${pidFilePath}. ` +
465
+ "Another cocod or routstrd process may be starting. Stop it and try again.",
466
+ { cause: error },
467
+ );
468
+ }
469
+
470
+ try {
471
+ removeFile(pidFilePath);
472
+ fd = openExclusive(pidFilePath);
473
+ } catch (retryError) {
474
+ throw new Error(
475
+ `Cannot claim the wallet process lock at ${pidFilePath}. ` +
476
+ "Another cocod or routstrd process may be starting. Stop it and try again.",
477
+ { cause: retryError },
478
+ );
479
+ }
480
+ }
481
+
482
+ try {
483
+ writePid(fd, pid);
484
+ } catch (error) {
485
+ try {
486
+ removeFile(pidFilePath);
487
+ } catch {
488
+ // Preserve the original write failure.
489
+ }
490
+ throw error;
491
+ } finally {
492
+ closeFile(fd);
493
+ }
494
+
495
+ let released = false;
496
+ return () => {
497
+ if (released) return;
498
+ released = true;
499
+
500
+ try {
501
+ if (readFile(pidFilePath).trim() === String(pid)) {
502
+ removeFile(pidFilePath);
503
+ }
504
+ } catch (error) {
505
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
506
+ logger.warn(
507
+ `Failed to release wallet process lock at ${pidFilePath}:`,
508
+ error,
509
+ );
510
+ }
511
+ }
512
+ };
513
+ }
514
+
515
+ export interface CreateCocoClientOptions {
516
+ /** Override the canonical wallet data directory. */
517
+ walletDir?: string;
518
+ /** Deprecated alias retained for existing callers during migration. */
519
+ configDir?: string;
520
+ /** Override the in-process wallet lock path. */
521
+ walletPidPath?: string;
522
+ /** Override legacy external-cocod coordination paths. */
523
+ legacySocketPath?: string;
524
+ legacyPidPath?: string;
525
+ /** Set to false to skip NPC (npubx.cash) plugin registration. Default: true. */
526
+ enableNpc?: boolean;
527
+ /** NPC server base URL. Default: https://npubx.cash */
528
+ npcBaseUrl?: string;
529
+ }
530
+
531
+ export async function createCocoClient(
532
+ options: CreateCocoClientOptions = {},
533
+ ): Promise<CocodClient> {
534
+ const configDir = options.walletDir || options.configDir || defaultWalletDir();
535
+ const configFile = join(configDir, "config.json");
536
+ const dbPath = join(configDir, "coco.db");
537
+ const walletPidFile =
538
+ options.walletPidPath ||
539
+ (options.walletDir || options.configDir
540
+ ? join(configDir, "wallet.pid")
541
+ : defaultWalletPidPath());
542
+ const legacySocket = options.legacySocketPath || legacyCocodSocketPath();
543
+ const legacyPidFile = options.legacyPidPath || legacyCocodPidPath();
544
+ const npcBaseUrl = options.npcBaseUrl || NPC_DEFAULT_BASE_URL;
545
+ const npcAddressDomain = new URL(npcBaseUrl).host;
546
+
547
+ await assertLegacyCocodNotRunning({
548
+ socketPath: legacySocket,
549
+ pidFilePath: legacyPidFile,
550
+ });
551
+ // The canonical wallet directory is created by initialization/migration.
552
+ // Keep a legacy PID claim as an exclusion fence for old cocod binaries.
553
+ mkdirSync(dirname(legacyPidFile), { recursive: true, mode: 0o700 });
554
+ const releaseWalletPidClaim = claimPidFile({ pidFilePath: walletPidFile });
555
+ let releaseLegacyPidClaim: () => void;
556
+ try {
557
+ releaseLegacyPidClaim = claimLegacyCocodPidFile({
558
+ pidFilePath: legacyPidFile,
559
+ });
560
+ } catch (error) {
561
+ releaseWalletPidClaim();
562
+ throw error;
563
+ }
564
+
565
+ let database: Database | undefined;
566
+ let coco: Awaited<ReturnType<typeof initializeCoco>> | undefined;
567
+ let walletConfig = loadConfig(configFile);
568
+
569
+ try {
570
+ startupProgress("Opening Cashu wallet database...");
571
+
572
+ // Read and validate the existing cocod config during startup rather than
573
+ // deferring failure until coco-core first needs wallet key material.
574
+ const mnemonic = walletConfig.mnemonic;
575
+ const seed = mnemonicToSeedSync(mnemonic);
576
+ database = new Database(dbPath);
577
+ const repo = new SqliteRepositories({ database });
578
+ await repo.init();
579
+
580
+ const [pendingSends, inflightProofs, pendingMints] = await Promise.all([
581
+ repo.sendOperationRepository.getPending(),
582
+ repo.proofRepository.getInflightProofs(),
583
+ repo.mintOperationRepository.getPending(),
584
+ ]);
585
+ const recoveryCount =
586
+ pendingSends.length + inflightProofs.length + pendingMints.length;
587
+ if (recoveryCount > 0) {
588
+ startupProgress(
589
+ `Recovering wallet state: ${pendingSends.length} pending sends, ` +
590
+ `${inflightProofs.length} in-flight proofs, ${pendingMints.length} pending mints. ` +
591
+ "This may take a few minutes while Cashu mints are contacted.",
592
+ );
593
+ } else {
594
+ startupProgress("Initializing Cashu wallet...");
595
+ }
596
+
597
+ coco = await initializeCoco({
598
+ repo,
599
+ seedGetter: async () => seed,
600
+ logger: createCocoLogger(),
601
+ });
602
+
603
+ const trustedMints = await coco.mint.getAllTrustedMints();
604
+ const configuredDefault = walletConfig.defaultMintUrl;
605
+ const defaultMintUrl = normalizeMintUrl(
606
+ configuredDefault || trustedMints[0]?.mintUrl || DEFAULT_MINT_URL,
607
+ );
608
+
609
+ if (!trustedMints.some((mint) => mint.mintUrl === defaultMintUrl)) {
610
+ startupProgress(`Adding default mint: ${defaultMintUrl}`);
611
+ await coco.mint.addMint(defaultMintUrl, { trusted: true });
612
+ }
613
+
614
+ // Persist only after the mint was successfully fetched and trusted. A failed
615
+ // network request must not leave config pointing at an unusable default.
616
+ walletConfig.defaultMintUrl = defaultMintUrl;
617
+ if (configuredDefault !== defaultMintUrl) {
618
+ saveConfig(walletConfig, configFile);
619
+ }
620
+
621
+ if (options.enableNpc !== false) {
622
+ startupProgress("Registering NPC (npubx.cash) plugin...");
623
+ // NPC authenticates with a Nostr key derived from the same wallet seed
624
+ // (NIP-06). The signer only produces JWT auth events for the NPC
625
+ // server; it never signs anything that moves funds by itself.
626
+ const npcSecretKey = privateKeyFromSeedWords(mnemonic);
627
+ const npcSigner = async (template: EventTemplate) =>
628
+ finalizeEvent(template, npcSecretKey);
629
+ const npcPlugin = new NPCPlugin(npcBaseUrl, npcSigner, {
630
+ useWebsocket: true,
631
+ logger: createCocoLogger({ module: "npc" }),
632
+ });
633
+ // coco-cashu-plugin-npc implements the plugin contract from the
634
+ // coco-cashu-core package while routstrd runs the equivalent
635
+ // @cashu/coco-core build. The plugin host API is structurally identical
636
+ // in both (verified: mintService.addMintByUrl,
637
+ // mintOperationService.importQuote/getOperationByQuote), so this cast
638
+ // only bridges the duplicate package names, not a real API gap.
639
+ coco.use(npcPlugin as unknown as CocoPlugin);
640
+ }
641
+
642
+ startupProgress("Cashu wallet ready.");
643
+ } catch (error) {
644
+ database?.close();
645
+ releaseLegacyPidClaim();
646
+ releaseWalletPidClaim();
647
+ throw error;
648
+ }
649
+
650
+ const npcApi = (): NpcPluginApi => {
651
+ // The plugin augments coco-cashu-core's PluginExtensions; the equivalent
652
+ // registration lives on manager.ext here. Guard for enableNpc=false.
653
+ const api = coco
654
+ ? (coco.ext as { npc?: NpcPluginApi }).npc
655
+ : undefined;
656
+ if (!api) {
657
+ throw new Error("NPC plugin is not enabled for this wallet.");
658
+ }
659
+ return api;
660
+ };
661
+
662
+ let disposed = false;
663
+ return {
664
+ async ping(): Promise<boolean> {
665
+ try {
666
+ await coco.wallet.balances.total();
667
+ return true;
668
+ } catch {
669
+ return false;
670
+ }
671
+ },
672
+
673
+ async getStatus(): Promise<CocodState> {
674
+ try {
675
+ await coco.wallet.balances.total();
676
+ return "UNLOCKED";
677
+ } catch {
678
+ return "ERROR";
679
+ }
680
+ },
681
+
682
+ async unlock(_passphrase: string): Promise<string> {
683
+ // coco-core does not support passphrase locking.
684
+ // Wallet access is controlled by ~/.routstrd/wallet/config.json.
685
+ return "wallet does not require unlocking";
686
+ },
687
+
688
+ async getBalances(): Promise<Record<string, number>> {
689
+ const byMint = await coco.wallet.balances.byMint();
690
+ return Object.fromEntries(
691
+ Object.entries(byMint).map(([mintUrl, snapshot]) => [
692
+ mintUrl,
693
+ snapshot.spendable,
694
+ ]),
695
+ );
696
+ },
697
+
698
+ async receiveCashu(token: string): Promise<string> {
699
+ await coco.wallet.receive(token);
700
+ return "Token received successfully";
701
+ },
702
+
703
+ async receiveBolt11(amount: number, mintUrl?: string): Promise<string> {
704
+ const targetMint = mintUrl
705
+ ? normalizeMintUrl(mintUrl)
706
+ : walletConfig.defaultMintUrl;
707
+ if (!targetMint) {
708
+ throw new Error("No trusted mint available for Lightning invoice");
709
+ }
710
+ const op = await coco.ops.mint.prepare({
711
+ mintUrl: targetMint,
712
+ amount,
713
+ method: "bolt11",
714
+ });
715
+ if (!("request" in op)) {
716
+ throw new Error("mint prepare did not return a payment request");
717
+ }
718
+ return op.request as string;
719
+ },
720
+
721
+ async sendCashu(amount: number, mintUrl?: string): Promise<string> {
722
+ const targetMint = mintUrl
723
+ ? normalizeMintUrl(mintUrl)
724
+ : walletConfig.defaultMintUrl;
725
+ if (!targetMint) {
726
+ throw new Error("No trusted mint available for sending");
727
+ }
728
+ const prepared = await coco.ops.send.prepare({
729
+ mintUrl: targetMint,
730
+ amount,
731
+ });
732
+ const { token } = await coco.ops.send.execute(prepared.id);
733
+ return getEncodedToken(token);
734
+ },
735
+
736
+ async sendBolt11(invoice: string, mintUrl?: string): Promise<string> {
737
+ const targetMint = mintUrl
738
+ ? normalizeMintUrl(mintUrl)
739
+ : walletConfig.defaultMintUrl;
740
+ if (!targetMint) {
741
+ throw new Error("No trusted mint available for Lightning payment");
742
+ }
743
+ const prepared = await coco.ops.melt.prepare({
744
+ mintUrl: targetMint,
745
+ method: "bolt11",
746
+ methodData: { invoice },
747
+ });
748
+ await coco.ops.melt.execute(prepared.id);
749
+ return "Payment sent successfully";
750
+ },
751
+
752
+ async listMints(): Promise<string[]> {
753
+ const mints = await coco.mint.getAllTrustedMints();
754
+ return mints.map((m) => m.mintUrl);
755
+ },
756
+
757
+ async addMint(url: string): Promise<string> {
758
+ const mintUrl = normalizeMintUrl(url);
759
+ await coco.mint.addMint(mintUrl, { trusted: true });
760
+ return `Mint ${mintUrl} added successfully`;
761
+ },
762
+
763
+ async getMintInfo(url: string): Promise<unknown> {
764
+ return coco.mint.getMintInfo(normalizeMintUrl(url));
765
+ },
766
+
767
+ async getDefaultMint(): Promise<string | null> {
768
+ return walletConfig.defaultMintUrl || null;
769
+ },
770
+
771
+ async setDefaultMint(url: string): Promise<string> {
772
+ const mintUrl = normalizeMintUrl(url);
773
+ const trustedMints = await coco.mint.getAllTrustedMints();
774
+ if (!trustedMints.some((mint) => mint.mintUrl === mintUrl)) {
775
+ await coco.mint.addMint(mintUrl, { trusted: true });
776
+ }
777
+
778
+ walletConfig.defaultMintUrl = mintUrl;
779
+ saveConfig(walletConfig, configFile);
780
+ return `Default mint set to ${mintUrl}`;
781
+ },
782
+
783
+ async dispose(): Promise<void> {
784
+ if (disposed) return;
785
+ disposed = true;
786
+ try {
787
+ await coco.dispose();
788
+ } finally {
789
+ try {
790
+ database.close();
791
+ } finally {
792
+ releaseLegacyPidClaim();
793
+ releaseWalletPidClaim();
794
+ }
795
+ }
796
+ },
797
+
798
+ async getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]> {
799
+ return coco.history.getPaginatedHistory(offset, limit);
800
+ },
801
+
802
+ async getNpcAddress(): Promise<NpcAddress> {
803
+ const info = await npcApi().getInfo();
804
+ const name =
805
+ typeof info?.name === "string" && info.name.trim()
806
+ ? info.name.trim()
807
+ : undefined;
808
+ const localPart = name ?? nip19.npubEncode(info.pubkey);
809
+ return {
810
+ address: `${localPart}@${npcAddressDomain}`,
811
+ ...(name ? { name } : {}),
812
+ pubkey: info.pubkey,
813
+ };
814
+ },
815
+
816
+ async setNpcUsername(
817
+ username: string,
818
+ confirm?: boolean,
819
+ ): Promise<NpcUsernameResult> {
820
+ const result = await npcApi().setUsername(username, confirm === true);
821
+ if (result.success) {
822
+ return { success: true };
823
+ }
824
+ return {
825
+ success: false,
826
+ paymentRequest:
827
+ result.pr as NpcUsernameResult["paymentRequest"],
828
+ };
829
+ },
830
+
831
+ async syncNpc(): Promise<void> {
832
+ await npcApi().sync();
833
+ },
834
+ };
835
+ }