routstrd 0.4.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "routstrd",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "private": false,
package/src/cli.ts CHANGED
@@ -35,6 +35,13 @@ import {
35
35
  } from "./daemon/wallet/coco-client";
36
36
  import { migrateLegacyWallet } from "./daemon/wallet/migration";
37
37
  import {
38
+ diagnoseWallets,
39
+ renderWalletDoctor,
40
+ summarizeWalletDirectory,
41
+ WalletMigrationConflictError,
42
+ } from "./daemon/wallet/diagnostics";
43
+ import {
44
+ legacyCocodDir,
38
45
  legacyCocodPidPath,
39
46
  legacyCocodSocketPath,
40
47
  walletDir as defaultWalletDir,
@@ -242,27 +249,19 @@ async function initDaemon(): Promise<void> {
242
249
  console.log(`Database will be stored at: ${DB_PATH}`);
243
250
  await stopLegacyCocod();
244
251
 
245
- let migrationLockOwner: number | undefined;
246
252
  const migration = await migrateLegacyWallet({
247
253
  assertLegacyStopped: () =>
248
254
  assertLegacyCocodNotRunning({
249
255
  socketPath: legacyCocodSocketPath(),
250
- pidFilePath: legacyCocodPidPath(),
251
- ignorePid: migrationLockOwner,
252
256
  }),
253
257
  acquireLegacyLock: () => {
254
258
  mkdirSync(dirname(legacyCocodPidPath()), {
255
259
  recursive: true,
256
260
  mode: 0o700,
257
261
  });
258
- const release = claimLegacyCocodPidFile({
262
+ return claimLegacyCocodPidFile({
259
263
  pidFilePath: legacyCocodPidPath(),
260
264
  });
261
- migrationLockOwner = process.pid;
262
- return () => {
263
- migrationLockOwner = undefined;
264
- release();
265
- };
266
265
  },
267
266
  });
268
267
  if (migration.status === "migrated") {
@@ -349,7 +348,7 @@ program
349
348
  .description("Refund pending tokens and API keys to a specified mint")
350
349
  .option(
351
350
  "-m, --mint-url <mintUrl>",
352
- "Mint URL to refund to (defaults to first mint in wallet)",
351
+ "Mint URL to refund to (defaults to active wallet mint)",
353
352
  )
354
353
  .option("-y, --yes", "Skip confirmation prompt", false)
355
354
  .option("--xcashu", "Refund xcashu tokens only (uses refundXcashuTokens)", false)
@@ -363,18 +362,19 @@ program
363
362
  console.log(balanceResult.error);
364
363
  process.exit(1);
365
364
  }
366
- const balances = (
367
- balanceResult.output as
368
- | {
369
- balances?: Record<string, number>;
370
- }
371
- | undefined
372
- )?.balances;
373
- if (!balances || Object.keys(balances).length === 0) {
365
+ const output = balanceResult.output as
366
+ | {
367
+ balances?: Record<string, number>;
368
+ activeMint?: string;
369
+ }
370
+ | undefined;
371
+ mintUrl =
372
+ output?.activeMint ??
373
+ (output?.balances ? Object.keys(output.balances)[0] : undefined);
374
+ if (!mintUrl) {
374
375
  console.log("No mint URLs found in wallet balance");
375
376
  process.exit(1);
376
377
  }
377
- mintUrl = Object.keys(balances)[0];
378
378
  console.log(`Using mint URL: ${mintUrl}`);
379
379
  }
380
380
 
@@ -578,7 +578,17 @@ program
578
578
  )
579
579
  .action(async () => {
580
580
  await requireLocalDaemon();
581
- await initDaemon();
581
+ try {
582
+ await initDaemon();
583
+ } catch (error) {
584
+ // An expected, user-actionable refusal — print the structured message
585
+ // without Bun's unhandled-rejection source snippet and stack trace.
586
+ if (error instanceof WalletMigrationConflictError) {
587
+ console.error(error.message);
588
+ process.exit(1);
589
+ }
590
+ throw error;
591
+ }
582
592
  });
583
593
 
584
594
  // Start - start the background daemon
@@ -592,11 +602,18 @@ program
592
602
  await requireLocalDaemon();
593
603
  const config = await loadConfig();
594
604
  await stopLegacyCocod();
595
- await startDaemon({
596
- port: options.port || String(config.port || 8008),
597
- host: options.host || config.host || undefined,
598
- provider: options.provider,
599
- });
605
+ try {
606
+ await startDaemon({
607
+ port: options.port || String(config.port || 8008),
608
+ host: options.host || config.host || undefined,
609
+ provider: options.provider,
610
+ });
611
+ } catch (error) {
612
+ // startDaemon embeds the failed daemon's own output in the message
613
+ // (including the wallet-conflict report), so print it without a stack.
614
+ console.error(error instanceof Error ? error.message : error);
615
+ process.exit(1);
616
+ }
600
617
  });
601
618
 
602
619
  // Status - check daemon status
@@ -641,7 +658,7 @@ program
641
658
  )
642
659
  .option(
643
660
  "--mint-url <url>",
644
- "Mint URL to refund the deleted API key balance to (defaults to first mint in wallet)",
661
+ "Mint URL to refund the deleted API key balance to (defaults to active wallet mint)",
645
662
  )
646
663
  .action(async (options: { apiKeys: boolean; deleteApiKeys?: string; mintUrl?: string }) => {
647
664
  await ensureDaemonRunning();
@@ -1531,6 +1548,16 @@ walletCmd
1531
1548
  await handleDaemonCommand("/wallet/status");
1532
1549
  });
1533
1550
 
1551
+ walletCmd
1552
+ .command("doctor")
1553
+ .description("Diagnose conflicting wallets (current routstrd wallet vs legacy cocod)")
1554
+ .action(async () => {
1555
+ const target = summarizeWalletDirectory(defaultWalletDir(), "canonical");
1556
+ const source = summarizeWalletDirectory(legacyCocodDir(), "legacy");
1557
+ console.log(renderWalletDoctor(target, source));
1558
+ if (diagnoseWallets(target, source).conflict) process.exit(1);
1559
+ });
1560
+
1534
1561
  walletCmd
1535
1562
  .command("unlock <passphrase>")
1536
1563
  .description("Unlock the wallet")
@@ -1548,6 +1575,113 @@ walletCmd
1548
1575
  await handleDaemonCommand("/wallet/balance");
1549
1576
  });
1550
1577
 
1578
+ walletCmd
1579
+ .command("cleanup")
1580
+ .description("Clear stuck pending/in-flight wallet operations")
1581
+ .option("--mint-url <url>", "Only clean up operations for this mint URL")
1582
+ .option(
1583
+ "--min-age <hours>",
1584
+ "Minimum age for reclaiming sends/cancelling melts, in hours (default: 168, one week; expired mint quotes are always failed)",
1585
+ "168",
1586
+ )
1587
+ .option("--dry-run", "Report what would be cleaned without applying changes", false)
1588
+ .option("-y, --yes", "Skip confirmation prompt", false)
1589
+ .action(
1590
+ async (options: {
1591
+ mintUrl?: string;
1592
+ minAge: string;
1593
+ dryRun: boolean;
1594
+ yes: boolean;
1595
+ }) => {
1596
+ const minAgeHours = Number.parseFloat(options.minAge);
1597
+ if (!Number.isFinite(minAgeHours) || minAgeHours < 0) {
1598
+ console.error(`Invalid --min-age value: ${options.minAge}`);
1599
+ process.exit(1);
1600
+ }
1601
+
1602
+ if (!options.dryRun && !options.yes) {
1603
+ const rl = require("readline").createInterface({
1604
+ input: process.stdin,
1605
+ output: process.stdout,
1606
+ });
1607
+ const answer = await new Promise<string>((resolve) => {
1608
+ rl.question(
1609
+ "This will fail expired mint quotes, reclaim old pending sends, and cancel prepared melts. Continue? [y/N] ",
1610
+ (value: string) => {
1611
+ rl.close();
1612
+ resolve(value.trim().toLowerCase());
1613
+ },
1614
+ );
1615
+ });
1616
+ if (answer !== "y" && answer !== "yes") {
1617
+ console.log("Aborted.");
1618
+ return;
1619
+ }
1620
+ }
1621
+
1622
+ try {
1623
+ await ensureDaemonRunning();
1624
+
1625
+ const result = await callDaemon("/wallet/cleanup", {
1626
+ method: "POST",
1627
+ body: {
1628
+ mintUrl: options.mintUrl,
1629
+ minAgeMs: Math.round(minAgeHours * 60 * 60 * 1000),
1630
+ dryRun: options.dryRun === true,
1631
+ },
1632
+ });
1633
+
1634
+ if (result.error) {
1635
+ console.log(result.error);
1636
+ process.exit(1);
1637
+ }
1638
+
1639
+ const output = result.output as
1640
+ | {
1641
+ dryRun?: boolean;
1642
+ failedMintQuotes?: number;
1643
+ reclaimedSends?: number;
1644
+ cancelledMelts?: number;
1645
+ skipped?: number;
1646
+ errors?: Array<{ operationId: string; error: string }>;
1647
+ }
1648
+ | undefined;
1649
+
1650
+ if (output) {
1651
+ const prefix = output.dryRun ? "Would clean up:" : "Cleaned up:";
1652
+ console.log(prefix);
1653
+ console.log(
1654
+ ` Expired mint quotes failed: ${output.failedMintQuotes ?? 0}`,
1655
+ );
1656
+ console.log(` Pending sends reclaimed: ${output.reclaimedSends ?? 0}`);
1657
+ console.log(
1658
+ ` Prepared melts cancelled: ${output.cancelledMelts ?? 0}`,
1659
+ );
1660
+ console.log(
1661
+ ` Skipped (still recent or already terminal): ${output.skipped ?? 0}`,
1662
+ );
1663
+ if (output.errors && output.errors.length > 0) {
1664
+ console.log("\nErrors:");
1665
+ for (const e of output.errors) {
1666
+ console.log(` - ${e.operationId}: ${e.error}`);
1667
+ }
1668
+ }
1669
+ }
1670
+ } catch (error) {
1671
+ const message = (error as Error).message;
1672
+ if (
1673
+ message?.includes("fetch failed") ||
1674
+ message?.includes("Connection refused")
1675
+ ) {
1676
+ console.error("Daemon is not running");
1677
+ process.exit(1);
1678
+ }
1679
+ console.error(message);
1680
+ process.exit(1);
1681
+ }
1682
+ },
1683
+ );
1684
+
1551
1685
  const walletReceiveCmd = walletCmd
1552
1686
  .command("receive")
1553
1687
  .description("Wallet receive operations");
@@ -0,0 +1,104 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { unlinkSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { exitOnUncaughtException } from "./fatal-error";
6
+
7
+ const fatalErrorModule = JSON.stringify(join(import.meta.dir, "fatal-error.ts"));
8
+
9
+ const fixturePaths: string[] = [];
10
+
11
+ afterEach(() => {
12
+ while (fixturePaths.length > 0) {
13
+ try {
14
+ unlinkSync(fixturePaths.pop()!);
15
+ } catch {
16
+ // Best effort cleanup of temp fixtures.
17
+ }
18
+ }
19
+ });
20
+
21
+ /** Run a fixture script in a real subprocess so process.exit is for real. */
22
+ async function runFixture(source: string): Promise<{
23
+ exitCode: number;
24
+ stdout: string;
25
+ stderr: string;
26
+ }> {
27
+ const scriptPath = join(
28
+ tmpdir(),
29
+ `routstrd-fatal-error-fixture-${crypto.randomUUID()}.ts`,
30
+ );
31
+ fixturePaths.push(scriptPath);
32
+ await Bun.write(scriptPath, source);
33
+ const proc = Bun.spawn(["bun", scriptPath], {
34
+ stdout: "pipe",
35
+ stderr: "pipe",
36
+ });
37
+ const [stdout, stderr] = await Promise.all([
38
+ new Response(proc.stdout).text(),
39
+ new Response(proc.stderr).text(),
40
+ ]);
41
+ const exitCode = await proc.exited;
42
+ return { exitCode, stdout, stderr };
43
+ }
44
+
45
+ describe("exitOnUncaughtException", () => {
46
+ test("reports the error and exits with code 1", () => {
47
+ let exitCode: number | undefined;
48
+ const stderrLines: string[] = [];
49
+ const originalConsoleError = console.error;
50
+ console.error = (...args: unknown[]) => {
51
+ stderrLines.push(args.map(String).join(" "));
52
+ };
53
+ try {
54
+ exitOnUncaughtException(new Error("boom"), (code) => {
55
+ exitCode = code;
56
+ });
57
+ } finally {
58
+ console.error = originalConsoleError;
59
+ }
60
+ expect(exitCode).toBe(1);
61
+ expect(stderrLines.join("\n")).toContain("boom");
62
+ });
63
+
64
+ test("handles non-Error thrown values", () => {
65
+ let exitCode: number | undefined;
66
+ const originalConsoleError = console.error;
67
+ console.error = () => {};
68
+ try {
69
+ exitOnUncaughtException("string failure", (code) => {
70
+ exitCode = code;
71
+ });
72
+ } finally {
73
+ console.error = originalConsoleError;
74
+ }
75
+ expect(exitCode).toBe(1);
76
+ });
77
+ });
78
+
79
+ describe("installGlobalErrorHandlers (subprocess)", () => {
80
+ test("an uncaught exception exits the process with code 1", async () => {
81
+ const { exitCode, stderr } = await runFixture(`
82
+ import { installGlobalErrorHandlers } from ${fatalErrorModule};
83
+ installGlobalErrorHandlers();
84
+ setTimeout(() => {
85
+ throw new Error("boom-uncaught");
86
+ }, 10);
87
+ `);
88
+ expect(exitCode).toBe(1);
89
+ expect(stderr).toContain("boom-uncaught");
90
+ });
91
+
92
+ test("an unhandled rejection is logged without exiting", async () => {
93
+ const { exitCode, stdout } = await runFixture(`
94
+ import { installGlobalErrorHandlers } from ${fatalErrorModule};
95
+ installGlobalErrorHandlers();
96
+ Promise.reject(new Error("boom-rejection"));
97
+ setTimeout(() => {
98
+ console.log("STILL-ALIVE");
99
+ }, 50);
100
+ `);
101
+ expect(exitCode).toBe(0);
102
+ expect(stdout).toContain("STILL-ALIVE");
103
+ });
104
+ });
@@ -0,0 +1,48 @@
1
+ import { logger } from "../utils/logger";
2
+
3
+ /**
4
+ * Report an unrecoverable error, then exit.
5
+ *
6
+ * The daemon runs detached under a supervisor (pm2 via `routstrd service
7
+ * install`, or the spawning CLI). An uncaught exception means the call stack
8
+ * unwound unexpectedly mid-operation and process state can no longer be
9
+ * trusted. Swallowing it once left a daemon alive with nothing listening
10
+ * while it held both wallet PID locks, so every later start failed on the
11
+ * lock and liveness-only supervisors never restarted it. Exiting lets the
12
+ * supervisor bring up a healthy process instead.
13
+ *
14
+ * The file logger writes synchronously, so the record survives the exit.
15
+ * The error is mirrored to stderr because the daemon's stdout/stderr are
16
+ * redirected to debug.log, which is what the CLI surfaces when the daemon
17
+ * exits early and what pm2 captures. process.exit() runs the synchronous
18
+ * PID-lock exit hooks registered by claimPidFile, releasing the wallet
19
+ * locks on the way out.
20
+ *
21
+ * `exit` is injectable for tests.
22
+ */
23
+ export function exitOnUncaughtException(
24
+ error: unknown,
25
+ exit: (code: number) => void = (code) => process.exit(code),
26
+ ): void {
27
+ logger.error("UNCAUGHT EXCEPTION:", error);
28
+ // Full error object (not just .message): this path means a bug, so the
29
+ // stack is the most useful thing to have in debug.log.
30
+ console.error("UNCAUGHT EXCEPTION:", error);
31
+ exit(1);
32
+ }
33
+
34
+ /**
35
+ * Install the process-level error handlers. Called once at daemon module
36
+ * load, before main() runs.
37
+ *
38
+ * Only uncaught exceptions are fatal. An unhandled rejection means a
39
+ * promise's failure went unobserved; the stack was not unwound and the
40
+ * process is still in a consistent state, so it is logged and the daemon
41
+ * keeps serving.
42
+ */
43
+ export function installGlobalErrorHandlers(): void {
44
+ process.on("uncaughtException", (error) => exitOnUncaughtException(error));
45
+ process.on("unhandledRejection", (reason) => {
46
+ logger.error("UNHANDLED REJECTION:", reason);
47
+ });
48
+ }
@@ -66,8 +66,6 @@ export function startAutoRefillLoop(
66
66
  return;
67
67
  }
68
68
 
69
- checkInProgress = true;
70
-
71
69
  // If we've been failing too much, back off rather than retrying at full speed.
72
70
  // This prevents tight loops on transient errors.
73
71
  const backoffInterval = Math.min(
@@ -78,6 +76,8 @@ export function startAutoRefillLoop(
78
76
  return;
79
77
  }
80
78
 
79
+ checkInProgress = true;
80
+
81
81
  try {
82
82
  const balances = await cocod.getBalances();
83
83
  const totalBalance = Object.values(balances).reduce<number>(
@@ -94,11 +94,9 @@ export function startAutoRefillLoop(
94
94
  `[auto-refill] Balance ${totalBalance} sats < threshold ${config.threshold}. Refilling ${config.amount} sats...`,
95
95
  );
96
96
 
97
- // Get active mint
98
- const mints = await cocod.listMints();
99
- const mintUrl = mints[0];
97
+ const mintUrl = await cocod.getDefaultMint();
100
98
  if (!mintUrl) {
101
- logger.error("[auto-refill] No active mint configured");
99
+ logger.error("[auto-refill] No default mint configured");
102
100
  return;
103
101
  }
104
102
 
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { selectCleanupOperations } from "./cleanup";
3
+
4
+ const NOW_MS = 1_800_000_000_000;
5
+ const DAY_MS = 24 * 60 * 60 * 1000;
6
+
7
+ function mint(overrides: Record<string, unknown>) {
8
+ return {
9
+ id: "mint-1",
10
+ state: "pending",
11
+ expiry: NOW_MS / 1000 - 1000, // expired
12
+ updatedAt: NOW_MS - 2 * DAY_MS,
13
+ lastObservedRemoteState: undefined,
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ function send(overrides: Record<string, unknown>) {
19
+ return {
20
+ id: "send-1",
21
+ state: "pending",
22
+ updatedAt: NOW_MS - 2 * DAY_MS,
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ function melt(overrides: Record<string, unknown>) {
28
+ return {
29
+ id: "melt-1",
30
+ state: "prepared",
31
+ updatedAt: NOW_MS - 2 * DAY_MS,
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ describe("selectCleanupOperations", () => {
37
+ it("selects expired pending mint quotes that are old enough", () => {
38
+ const result = selectCleanupOperations({
39
+ mints: [mint({ id: "a" })],
40
+ sends: [],
41
+ melts: [],
42
+ nowMs: NOW_MS,
43
+ minAgeMs: DAY_MS,
44
+ });
45
+
46
+ expect(result.mintsToFail.map((op) => op.id)).toEqual(["a"]);
47
+ });
48
+
49
+ it("ignores mint quotes that have not expired", () => {
50
+ const result = selectCleanupOperations({
51
+ mints: [mint({ id: "a", expiry: NOW_MS / 1000 + 1000 })],
52
+ sends: [],
53
+ melts: [],
54
+ nowMs: NOW_MS,
55
+ minAgeMs: DAY_MS,
56
+ });
57
+
58
+ expect(result.mintsToFail).toEqual([]);
59
+ });
60
+
61
+ it("ignores expired mint quotes already observed as PAID", () => {
62
+ const result = selectCleanupOperations({
63
+ mints: [mint({ id: "a", lastObservedRemoteState: "PAID" })],
64
+ sends: [],
65
+ melts: [],
66
+ nowMs: NOW_MS,
67
+ minAgeMs: DAY_MS,
68
+ });
69
+
70
+ expect(result.mintsToFail).toEqual([]);
71
+ });
72
+
73
+ it("ignores expired mint quotes already observed as ISSUED", () => {
74
+ const result = selectCleanupOperations({
75
+ mints: [mint({ id: "a", lastObservedRemoteState: "ISSUED" })],
76
+ sends: [],
77
+ melts: [],
78
+ nowMs: NOW_MS,
79
+ minAgeMs: DAY_MS,
80
+ });
81
+
82
+ expect(result.mintsToFail).toEqual([]);
83
+ });
84
+
85
+ it("ignores mint quotes without an expiry", () => {
86
+ const result = selectCleanupOperations({
87
+ mints: [mint({ id: "a", expiry: 0 })],
88
+ sends: [],
89
+ melts: [],
90
+ nowMs: NOW_MS,
91
+ minAgeMs: DAY_MS,
92
+ });
93
+
94
+ expect(result.mintsToFail).toEqual([]);
95
+ });
96
+
97
+ it("selects expired mint quotes even when the watcher recently touched them", () => {
98
+ const result = selectCleanupOperations({
99
+ mints: [mint({ id: "a", updatedAt: NOW_MS - 60_000 })],
100
+ sends: [],
101
+ melts: [],
102
+ nowMs: NOW_MS,
103
+ minAgeMs: DAY_MS,
104
+ });
105
+
106
+ expect(result.mintsToFail.map((op) => op.id)).toEqual(["a"]);
107
+ });
108
+
109
+ it("selects stale pending sends for reclaim", () => {
110
+ const result = selectCleanupOperations({
111
+ mints: [],
112
+ sends: [send({ id: "s" })],
113
+ melts: [],
114
+ nowMs: NOW_MS,
115
+ minAgeMs: DAY_MS,
116
+ });
117
+
118
+ expect(result.sendsToReclaim.map((op) => op.id)).toEqual(["s"]);
119
+ });
120
+
121
+ it("ignores recent pending sends", () => {
122
+ const result = selectCleanupOperations({
123
+ mints: [],
124
+ sends: [send({ id: "s", updatedAt: NOW_MS - 60_000 })],
125
+ melts: [],
126
+ nowMs: NOW_MS,
127
+ minAgeMs: DAY_MS,
128
+ });
129
+
130
+ expect(result.sendsToReclaim).toEqual([]);
131
+ });
132
+
133
+ it("ignores non-pending sends even when old", () => {
134
+ const result = selectCleanupOperations({
135
+ mints: [],
136
+ sends: [send({ id: "s", state: "executing" })],
137
+ melts: [],
138
+ nowMs: NOW_MS,
139
+ minAgeMs: DAY_MS,
140
+ });
141
+
142
+ expect(result.sendsToReclaim).toEqual([]);
143
+ });
144
+
145
+ it("selects stale prepared melts for cancellation", () => {
146
+ const result = selectCleanupOperations({
147
+ mints: [],
148
+ sends: [],
149
+ melts: [melt({ id: "m" })],
150
+ nowMs: NOW_MS,
151
+ minAgeMs: DAY_MS,
152
+ });
153
+
154
+ expect(result.meltsToCancel.map((op) => op.id)).toEqual(["m"]);
155
+ });
156
+
157
+ it("ignores non-prepared melts", () => {
158
+ const result = selectCleanupOperations({
159
+ mints: [],
160
+ sends: [],
161
+ melts: [melt({ id: "m", state: "pending" })],
162
+ nowMs: NOW_MS,
163
+ minAgeMs: DAY_MS,
164
+ });
165
+
166
+ expect(result.meltsToCancel).toEqual([]);
167
+ });
168
+ });