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/COCO-2.0.0-MIGRATION-PLAN.md +1191 -0
- package/README.md +5 -0
- package/dist/daemon/index.js +1096 -143
- package/dist/index.js +572 -124
- package/package.json +1 -1
- package/src/cli.ts +160 -26
- package/src/daemon/fatal-error.test.ts +104 -0
- package/src/daemon/fatal-error.ts +48 -0
- package/src/daemon/wallet/auto-refill.ts +4 -6
- package/src/daemon/wallet/cleanup.test.ts +168 -0
- package/src/daemon/wallet/cleanup.ts +104 -0
- package/src/daemon/wallet/coco-client.test.ts +191 -30
- package/src/daemon/wallet/coco-client.ts +867 -74
- package/src/daemon/wallet/cocod-client.ts +49 -1
- package/src/daemon/wallet/diagnostics.test.ts +378 -0
- package/src/daemon/wallet/diagnostics.ts +471 -0
- package/src/daemon/wallet/migration.test.ts +30 -6
- package/src/daemon/wallet/migration.ts +30 -85
- package/src/daemon/wallet/receive-dedup.test.ts +238 -0
- package/src/daemon/wallet/receive-dedup.ts +384 -0
- package/src/daemon/wallet/wallet-state.ts +75 -0
- package/src/start-daemon.ts +21 -1
- package/tests/integrations/hermes.test.ts +42 -5
package/package.json
CHANGED
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
|
-
|
|
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
|
|
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
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|