routstrd 0.4.7 → 0.4.9
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/README.md +96 -6
- package/SKILL.md +341 -0
- package/dist/daemon/index.js +31092 -31824
- package/dist/index.js +162637 -14155
- package/package.json +9 -4
- package/src/cli.ts +279 -110
- package/src/daemon/args.ts +4 -5
- package/src/daemon/http/index.ts +37 -2
- package/src/daemon/index.ts +94 -33
- package/src/daemon/wallet/auto-refill.ts +1 -1
- package/src/daemon/wallet/coco-client.ts +205 -3
- package/src/daemon/wallet/cocod-client.ts +19 -3
- package/src/daemon/wallet/index.ts +1 -1
- package/src/index.ts +9 -1
- package/src/runtime.ts +55 -0
- package/src/start-daemon.ts +4 -8
- package/src/utils/clients.ts +74 -1
- package/src/utils/config.ts +14 -0
- package/src/utils/daemon-stop.ts +125 -0
- package/src/utils/standalone-update.ts +214 -0
- package/src/utils/update-checker.ts +81 -18
- package/src/version.ts +3 -0
- package/src/cli.test.ts +0 -192
- package/src/daemon/fatal-error.test.ts +0 -104
- package/src/daemon/http/usage-summary.test.ts +0 -272
- package/src/daemon/models.test.ts +0 -81
- package/src/daemon/wallet/cleanup.test.ts +0 -168
- package/src/daemon/wallet/coco-client.npc.test.ts +0 -252
- package/src/daemon/wallet/coco-client.test.ts +0 -669
- package/src/daemon/wallet/diagnostics.test.ts +0 -378
- package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
- package/src/daemon/wallet/migration.test.ts +0 -171
- package/src/daemon/wallet/receive-dedup.test.ts +0 -238
package/src/daemon/args.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export function parseArgs(argv: string[]): {
|
|
2
|
-
port: number;
|
|
2
|
+
port: number | null;
|
|
3
3
|
host: string | null;
|
|
4
4
|
provider: string | null;
|
|
5
5
|
} {
|
|
@@ -9,10 +9,9 @@ export function parseArgs(argv: string[]): {
|
|
|
9
9
|
(arg) => arg === "--provider" || arg === "-p",
|
|
10
10
|
);
|
|
11
11
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
: 8008;
|
|
12
|
+
const portValue = portFlagIndex !== -1 ? argv[portFlagIndex + 1] : undefined;
|
|
13
|
+
const parsedPort = portValue ? Number.parseInt(portValue, 10) : Number.NaN;
|
|
14
|
+
const port = Number.isInteger(parsedPort) ? parsedPort : null;
|
|
16
15
|
|
|
17
16
|
const hostValue =
|
|
18
17
|
hostFlagIndex !== -1 ? argv[hostFlagIndex + 1] : undefined;
|
package/src/daemon/http/index.ts
CHANGED
|
@@ -488,8 +488,22 @@ export function createDaemonRequestHandler(deps: {
|
|
|
488
488
|
const body = await readJsonBody(req);
|
|
489
489
|
const amount = getRequiredPositiveNumberField(body, "amount");
|
|
490
490
|
const mintUrl = optionalStringField(body, "mintUrl");
|
|
491
|
-
const invoice = await deps.walletClient.receiveBolt11(
|
|
492
|
-
|
|
491
|
+
const { invoice, operationId } = await deps.walletClient.receiveBolt11(
|
|
492
|
+
amount,
|
|
493
|
+
mintUrl,
|
|
494
|
+
);
|
|
495
|
+
return { output: { invoice, operationId, amount, mintUrl } };
|
|
496
|
+
});
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const mintQuoteMatch = url.pathname.match(/^\/wallet\/receive\/bolt11\/([^/]+)$/);
|
|
501
|
+
if (req.method === "GET" && mintQuoteMatch?.[1]) {
|
|
502
|
+
const operationId = decodeURIComponent(mintQuoteMatch[1]);
|
|
503
|
+
await respond(res, async () => {
|
|
504
|
+
const quote = await deps.walletClient.getMintQuote?.(operationId);
|
|
505
|
+
if (!quote) throw new CocodHttpError(404, "Mint quote not found");
|
|
506
|
+
return { output: quote };
|
|
493
507
|
});
|
|
494
508
|
return;
|
|
495
509
|
}
|
|
@@ -785,6 +799,27 @@ export function createDaemonRequestHandler(deps: {
|
|
|
785
799
|
return;
|
|
786
800
|
}
|
|
787
801
|
|
|
802
|
+
// Scheduled refresh job (Nostr events, models, client integrations).
|
|
803
|
+
// The job re-reads this on every tick, so no daemon restart is needed.
|
|
804
|
+
if (req.method === "POST" && url.pathname === "/settings/auto-refresh") {
|
|
805
|
+
await respond(res, async () => {
|
|
806
|
+
const body = await readJsonBody(req);
|
|
807
|
+
const enabled = body.enabled === true || body.enabled === "true";
|
|
808
|
+
|
|
809
|
+
const config = await loadDaemonConfig();
|
|
810
|
+
config.autoRefresh = { ...config.autoRefresh, enabled };
|
|
811
|
+
saveDaemonConfig(config);
|
|
812
|
+
|
|
813
|
+
return {
|
|
814
|
+
output: {
|
|
815
|
+
message: `Automatic refresh ${enabled ? "enabled" : "disabled"}.`,
|
|
816
|
+
autoRefresh: config.autoRefresh,
|
|
817
|
+
},
|
|
818
|
+
};
|
|
819
|
+
});
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
788
823
|
if (req.method === "GET" && url.pathname === "/models") {
|
|
789
824
|
try {
|
|
790
825
|
const forceRefresh =
|
package/src/daemon/index.ts
CHANGED
|
@@ -74,9 +74,8 @@ import { installGlobalErrorHandlers } from "./fatal-error";
|
|
|
74
74
|
// the process silently. Uncaught exceptions are fatal: the process state can
|
|
75
75
|
// no longer be trusted, so the daemon logs and exits for its supervisor to
|
|
76
76
|
// restart (see fatal-error.ts).
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
async function main(): Promise<void> {
|
|
77
|
+
export async function runDaemon(argv: string[] = process.argv): Promise<void> {
|
|
78
|
+
installGlobalErrorHandlers();
|
|
80
79
|
// Install signal handlers before migration and wallet startup. If a signal
|
|
81
80
|
// arrives before the full shutdown path is wired, process.exit() still runs
|
|
82
81
|
// the synchronous PID-lock exit hooks registered by claimPidFile.
|
|
@@ -89,10 +88,10 @@ async function main(): Promise<void> {
|
|
|
89
88
|
process.once("SIGTERM", shutdownForSignal);
|
|
90
89
|
|
|
91
90
|
startupProgress("Loading configuration...");
|
|
92
|
-
const args = parseArgs(
|
|
91
|
+
const args = parseArgs(argv);
|
|
93
92
|
const config = await loadDaemonConfig();
|
|
94
93
|
|
|
95
|
-
const port = args.port;
|
|
94
|
+
const port = args.port ?? config.port ?? 8008;
|
|
96
95
|
const host = args.host || config.host || "127.0.0.1";
|
|
97
96
|
const provider = args.provider || config.provider;
|
|
98
97
|
const requestResponseLogDir =
|
|
@@ -235,39 +234,94 @@ async function main(): Promise<void> {
|
|
|
235
234
|
// Ignore
|
|
236
235
|
}
|
|
237
236
|
|
|
238
|
-
const
|
|
237
|
+
const DEFAULT_REFRESH_INTERVAL_MS = 21 * 60 * 1000; // 21 mins
|
|
238
|
+
// While the job is disabled we keep a light poll running so `clients
|
|
239
|
+
// --enable-automatic-refresh` takes effect without a daemon restart.
|
|
240
|
+
const DISABLED_REFRESH_POLL_MS = 60 * 1000;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Read autoRefresh from disk on every tick (like the NWC auto-refill
|
|
244
|
+
* getter) so CLI/config changes apply immediately without a restart.
|
|
245
|
+
*/
|
|
246
|
+
const readAutoRefreshSettings = (): {
|
|
247
|
+
enabled: boolean;
|
|
248
|
+
intervalMs: number;
|
|
249
|
+
} => {
|
|
250
|
+
const autoRefresh = loadDaemonConfigSync().autoRefresh;
|
|
251
|
+
const intervalMs =
|
|
252
|
+
typeof autoRefresh?.intervalMs === "number" && autoRefresh.intervalMs > 0
|
|
253
|
+
? autoRefresh.intervalMs
|
|
254
|
+
: DEFAULT_REFRESH_INTERVAL_MS;
|
|
255
|
+
return { enabled: autoRefresh?.enabled !== false, intervalMs };
|
|
256
|
+
};
|
|
239
257
|
|
|
240
258
|
// Recurring job to refresh routstr21 models
|
|
241
|
-
let
|
|
259
|
+
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
260
|
+
let refreshJobActive = false;
|
|
261
|
+
let disabledNoticeLogged = false;
|
|
262
|
+
|
|
263
|
+
const runScheduledRefresh = async (): Promise<void> => {
|
|
264
|
+
logger.log("Running scheduled Nostr event refresh...");
|
|
265
|
+
try {
|
|
266
|
+
await modelManager.refreshNostrEvents();
|
|
267
|
+
} catch (error) {
|
|
268
|
+
logger.error("Scheduled Nostr event refresh failed:", error);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
logger.log("Running scheduled model refresh...");
|
|
272
|
+
try {
|
|
273
|
+
await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Scheduled");
|
|
274
|
+
} catch (error) {
|
|
275
|
+
logger.error("Scheduled model refresh failed:", error);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const scheduleNextRefresh = (): void => {
|
|
280
|
+
if (!refreshJobActive) return;
|
|
281
|
+
|
|
282
|
+
const { enabled, intervalMs } = readAutoRefreshSettings();
|
|
283
|
+
if (!enabled) {
|
|
284
|
+
if (!disabledNoticeLogged) {
|
|
285
|
+
logger.log(
|
|
286
|
+
"Scheduled refresh job is disabled (autoRefresh.enabled=false). Polling for re-enable every 60s.",
|
|
287
|
+
);
|
|
288
|
+
disabledNoticeLogged = true;
|
|
289
|
+
}
|
|
290
|
+
refreshTimer = setTimeout(
|
|
291
|
+
scheduleNextRefresh,
|
|
292
|
+
Math.min(intervalMs, DISABLED_REFRESH_POLL_MS),
|
|
293
|
+
);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (disabledNoticeLogged) {
|
|
298
|
+
logger.log("Scheduled refresh job re-enabled.");
|
|
299
|
+
disabledNoticeLogged = false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
refreshTimer = setTimeout(() => {
|
|
303
|
+
void runScheduledRefresh()
|
|
304
|
+
.catch((error) => logger.error("Model refresh interval escaped:", error))
|
|
305
|
+
.finally(() => scheduleNextRefresh());
|
|
306
|
+
}, intervalMs);
|
|
307
|
+
};
|
|
242
308
|
|
|
243
309
|
const startModelRefreshJob = () => {
|
|
310
|
+
refreshJobActive = true;
|
|
311
|
+
const { enabled, intervalMs } = readAutoRefreshSettings();
|
|
244
312
|
logger.log(
|
|
245
|
-
|
|
313
|
+
enabled
|
|
314
|
+
? `Starting recurring model refresh job (every ${Math.round(intervalMs / 60_000)} minutes)`
|
|
315
|
+
: "Recurring model refresh job is disabled (autoRefresh.enabled=false).",
|
|
246
316
|
);
|
|
247
|
-
|
|
248
|
-
refreshInterval = setInterval(() => {
|
|
249
|
-
(async () => {
|
|
250
|
-
logger.log("Running scheduled Nostr event refresh...");
|
|
251
|
-
try {
|
|
252
|
-
await modelManager.refreshNostrEvents();
|
|
253
|
-
} catch (error) {
|
|
254
|
-
logger.error("Scheduled Nostr event refresh failed:", error);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
logger.log("Running scheduled model refresh...");
|
|
258
|
-
try {
|
|
259
|
-
await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Scheduled");
|
|
260
|
-
} catch (error) {
|
|
261
|
-
logger.error("Scheduled model refresh failed:", error);
|
|
262
|
-
}
|
|
263
|
-
})().catch((error) => logger.error("Model refresh interval escaped:", error));
|
|
264
|
-
}, REFRESH_INTERVAL_MS);
|
|
317
|
+
scheduleNextRefresh();
|
|
265
318
|
};
|
|
266
319
|
|
|
267
320
|
const stopModelRefreshJob = () => {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
321
|
+
refreshJobActive = false;
|
|
322
|
+
if (refreshTimer) {
|
|
323
|
+
clearTimeout(refreshTimer);
|
|
324
|
+
refreshTimer = null;
|
|
271
325
|
logger.log("Stopped recurring model refresh job.");
|
|
272
326
|
}
|
|
273
327
|
};
|
|
@@ -381,9 +435,16 @@ async function main(): Promise<void> {
|
|
|
381
435
|
|
|
382
436
|
startModelRefreshJob();
|
|
383
437
|
startRefundJob();
|
|
384
|
-
// Run an immediate refresh to populate models right away
|
|
385
|
-
|
|
386
|
-
|
|
438
|
+
// Run an immediate refresh to populate models right away. Client
|
|
439
|
+
// integrations are skipped when the scheduled job is disabled, so a
|
|
440
|
+
// restart does not overwrite hand-edited client configs.
|
|
441
|
+
if (readAutoRefreshSettings().enabled) {
|
|
442
|
+
logger.log("Running initial model refresh...");
|
|
443
|
+
await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Initial");
|
|
444
|
+
} else {
|
|
445
|
+
logger.log("Running initial model refresh (client integrations skipped)...");
|
|
446
|
+
await getRoutstr21Models(true);
|
|
447
|
+
}
|
|
387
448
|
})
|
|
388
449
|
.catch((error) => {
|
|
389
450
|
logger.error("Initial model refresh failed:", error);
|
|
@@ -395,7 +456,7 @@ async function main(): Promise<void> {
|
|
|
395
456
|
}
|
|
396
457
|
|
|
397
458
|
if (import.meta.main) {
|
|
398
|
-
|
|
459
|
+
runDaemon().catch((error) => {
|
|
399
460
|
logger.error("Failed to start Routstr daemon:", error);
|
|
400
461
|
// Also write to stderr so the spawning CLI can surface the real error
|
|
401
462
|
// (stdout/stderr are redirected to debug.log by start-daemon.ts).
|
|
@@ -104,7 +104,7 @@ export function startAutoRefillLoop(
|
|
|
104
104
|
logger.log(
|
|
105
105
|
`[auto-refill] Creating BOLT-11 invoice for ${config.amount} sats via ${mintUrl}...`,
|
|
106
106
|
);
|
|
107
|
-
const invoice = await cocod.receiveBolt11(config.amount, mintUrl);
|
|
107
|
+
const { invoice } = await cocod.receiveBolt11(config.amount, mintUrl);
|
|
108
108
|
|
|
109
109
|
// Step 2: Pay the invoice via NWC (applesauce)
|
|
110
110
|
logger.log(`[auto-refill] Paying invoice via NWC...`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Manager,
|
|
3
|
+
OperationInProgressError,
|
|
3
4
|
getEncodedToken,
|
|
4
5
|
normalizeMintUrl,
|
|
5
6
|
} from "@cashu/coco-core";
|
|
@@ -146,6 +147,9 @@ const SAFE_COCO_LOG_FIELDS = new Set([
|
|
|
146
147
|
"operationId",
|
|
147
148
|
"quoteId",
|
|
148
149
|
"state",
|
|
150
|
+
"code",
|
|
151
|
+
"detail",
|
|
152
|
+
"status",
|
|
149
153
|
"count",
|
|
150
154
|
"total",
|
|
151
155
|
"filterCount",
|
|
@@ -260,7 +264,8 @@ export function isZombieProcess(
|
|
|
260
264
|
}
|
|
261
265
|
}
|
|
262
266
|
|
|
263
|
-
|
|
267
|
+
/** Test whether a PID is alive; dead-but-unreaped zombies count as dead. */
|
|
268
|
+
export function defaultIsProcessRunning(pid: number): boolean {
|
|
264
269
|
try {
|
|
265
270
|
process.kill(pid, 0);
|
|
266
271
|
} catch (error) {
|
|
@@ -765,6 +770,181 @@ export async function settleExpiredMintQuotes(
|
|
|
765
770
|
return settlement;
|
|
766
771
|
}
|
|
767
772
|
|
|
773
|
+
const PENDING_MINT_SWEEP_INTERVAL_MS = 15_000;
|
|
774
|
+
/** Per-quote wait inside a sweep, so one stalled mint cannot starve the rest. */
|
|
775
|
+
const PENDING_MINT_CHECK_TIMEOUT_MS = 10_000;
|
|
776
|
+
const PENDING_MINT_SWEEP_DEADLINE_MS = 30_000;
|
|
777
|
+
const PENDING_MINT_STOP_DRAIN_MS = 10_000;
|
|
778
|
+
|
|
779
|
+
export interface PendingMintQuoteSource {
|
|
780
|
+
ops: { mint: Pick<Manager["ops"]["mint"], "listPending" | "refresh" | "get"> };
|
|
781
|
+
wallet: { balances: Pick<Manager["wallet"]["balances"], "byMint"> };
|
|
782
|
+
mintOperationService: Pick<MintOperationServiceCleanup, "failPendingOperation">;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
export interface PendingMintSweepState {
|
|
786
|
+
/** Refreshes that outlived their wait; skipped until they settle. */
|
|
787
|
+
outstanding: Map<string, Promise<unknown>>;
|
|
788
|
+
/** Next sweep starts after this operation. */
|
|
789
|
+
after?: string;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export interface PendingMintSweepOptions {
|
|
793
|
+
deadlineMs?: number;
|
|
794
|
+
checkTimeoutMs?: number;
|
|
795
|
+
state?: PendingMintSweepState;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
type PendingMintOutcome = "unreachable" | "other";
|
|
799
|
+
type PendingMintOp = Awaited<ReturnType<PendingMintQuoteSource["ops"]["mint"]["listPending"]>>[number];
|
|
800
|
+
|
|
801
|
+
/** Startup mint recovery, run while up: coco's live subscriptions can stop. */
|
|
802
|
+
export async function settlePendingMintQuotes(
|
|
803
|
+
source: PendingMintQuoteSource,
|
|
804
|
+
nowMs: number,
|
|
805
|
+
options: PendingMintSweepOptions = {},
|
|
806
|
+
): Promise<{ unreachable: number }> {
|
|
807
|
+
const deadlineMs = options.deadlineMs ?? PENDING_MINT_SWEEP_DEADLINE_MS;
|
|
808
|
+
const checkTimeoutMs = options.checkTimeoutMs ?? PENDING_MINT_CHECK_TIMEOUT_MS;
|
|
809
|
+
const state: PendingMintSweepState = options.state ?? { outstanding: new Map() };
|
|
810
|
+
let unreachable = 0;
|
|
811
|
+
|
|
812
|
+
const pending = await source.ops.mint.listPending();
|
|
813
|
+
const resumeAt = pending.findIndex((op) => op.id === state.after) + 1;
|
|
814
|
+
const ordered = [...pending.slice(resumeAt), ...pending.slice(0, resumeAt)];
|
|
815
|
+
const startedAt = Date.now();
|
|
816
|
+
for (const op of ordered) {
|
|
817
|
+
const remainingMs = deadlineMs - (Date.now() - startedAt);
|
|
818
|
+
if (state.outstanding.has(op.id) || remainingMs <= 0) {
|
|
819
|
+
unreachable++;
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
state.after = op.id;
|
|
823
|
+
// Report on the refresh itself so a late result is still logged.
|
|
824
|
+
const settled = source.ops.mint
|
|
825
|
+
.refresh(op.id)
|
|
826
|
+
.then(
|
|
827
|
+
(result) => reportPendingMintRefresh(source, op, result, nowMs),
|
|
828
|
+
(error) => reportPendingMintRefreshError(source, op, error),
|
|
829
|
+
)
|
|
830
|
+
.finally(() => state.outstanding.delete(op.id));
|
|
831
|
+
state.outstanding.set(op.id, settled);
|
|
832
|
+
try {
|
|
833
|
+
const outcome = await withTimeout(settled, Math.min(checkTimeoutMs, remainingMs));
|
|
834
|
+
if (outcome === "unreachable") unreachable++;
|
|
835
|
+
} catch {
|
|
836
|
+
unreachable++;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return { unreachable };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function reportPendingMintRefresh(
|
|
843
|
+
source: PendingMintQuoteSource,
|
|
844
|
+
op: PendingMintOp,
|
|
845
|
+
result: Awaited<ReturnType<PendingMintQuoteSource["ops"]["mint"]["refresh"]>>,
|
|
846
|
+
nowMs: number,
|
|
847
|
+
): Promise<PendingMintOutcome> {
|
|
848
|
+
const quote = `Mint quote ${op.quoteId} at ${op.mintUrl}`;
|
|
849
|
+
if (result.state === "finalized") {
|
|
850
|
+
if (result.error) {
|
|
851
|
+
// Already issued, proofs not restored: not a credit.
|
|
852
|
+
logger.warn(`${quote}: ${result.error}`);
|
|
853
|
+
return "other";
|
|
854
|
+
}
|
|
855
|
+
const balance = (await source.wallet.balances.byMint())[op.mintUrl]?.spendable;
|
|
856
|
+
logger.log(
|
|
857
|
+
`${quote}: paid, ${op.amount} sat minted` +
|
|
858
|
+
(balance === undefined ? "" : `, balance now ${balance} sat`),
|
|
859
|
+
);
|
|
860
|
+
return "other";
|
|
861
|
+
}
|
|
862
|
+
if (result.state === "failed") {
|
|
863
|
+
logger.warn(`${quote}: failed at the mint: ${result.error ?? "unknown reason"}`);
|
|
864
|
+
return "other";
|
|
865
|
+
}
|
|
866
|
+
// Paid-before-expiry is only known to the mint, so expired quotes are
|
|
867
|
+
// checked too; confirmed UNPAID after expiry is failed locally, as at startup.
|
|
868
|
+
const expired = op.expiry > 0 && op.expiry * 1000 <= nowMs;
|
|
869
|
+
if (expired && result.state === "pending" && result.lastObservedRemoteState === "UNPAID") {
|
|
870
|
+
await source.mintOperationService.failPendingOperation(
|
|
871
|
+
{ id: op.id },
|
|
872
|
+
{
|
|
873
|
+
reason: "Expired mint quote confirmed unpaid by mint",
|
|
874
|
+
retryable: false,
|
|
875
|
+
observedAt: Date.now(),
|
|
876
|
+
},
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
return "other";
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function reportPendingMintRefreshError(
|
|
883
|
+
source: PendingMintQuoteSource,
|
|
884
|
+
op: PendingMintOp,
|
|
885
|
+
error: unknown,
|
|
886
|
+
): Promise<PendingMintOutcome> {
|
|
887
|
+
// coco's own watcher is minting this quote right now; let it finish.
|
|
888
|
+
if (error instanceof OperationInProgressError) return "other";
|
|
889
|
+
// A failed mint attempt rejects after coco moved the operation back to pending.
|
|
890
|
+
const current = await source.ops.mint.get(op.id);
|
|
891
|
+
if (current?.state === "pending" && current.lastObservedRemoteState === "PAID") {
|
|
892
|
+
if (op.lastObservedRemoteState !== "PAID" || current.error !== op.error) {
|
|
893
|
+
logger.warn(
|
|
894
|
+
`Mint quote ${op.quoteId} at ${op.mintUrl}: paid (${op.amount} sat) but proofs not minted yet: ${current.error ?? String(error)}; will retry`,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
return "other";
|
|
898
|
+
}
|
|
899
|
+
return "unreachable";
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Stopping waits for the running sweep, then briefly for stragglers. Anything
|
|
904
|
+
* still running after that fails against the closed database and is picked
|
|
905
|
+
* up by startup recovery.
|
|
906
|
+
*/
|
|
907
|
+
function startPendingMintSweep(source: PendingMintQuoteSource): () => Promise<void> {
|
|
908
|
+
let stopped = false;
|
|
909
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
910
|
+
let inFlight: Promise<void> = Promise.resolve();
|
|
911
|
+
let unreachableBefore = 0;
|
|
912
|
+
const state: PendingMintSweepState = { outstanding: new Map() };
|
|
913
|
+
|
|
914
|
+
const tick = async () => {
|
|
915
|
+
if (stopped) return;
|
|
916
|
+
inFlight = settlePendingMintQuotes(source, Date.now(), { state }).then(
|
|
917
|
+
({ unreachable }) => {
|
|
918
|
+
// Report a mint becoming unreachable, or reachable again, once.
|
|
919
|
+
if (unreachable > 0 && unreachableBefore === 0) {
|
|
920
|
+
logger.warn(`Could not check ${unreachable} pending mint quote(s); will keep retrying`);
|
|
921
|
+
} else if (unreachable === 0 && unreachableBefore > 0) {
|
|
922
|
+
logger.log("Pending mint quote checks are reaching the mint again");
|
|
923
|
+
}
|
|
924
|
+
unreachableBefore = unreachable;
|
|
925
|
+
},
|
|
926
|
+
(error: unknown) => {
|
|
927
|
+
logger.warn(
|
|
928
|
+
`Pending mint quote sweep failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
929
|
+
);
|
|
930
|
+
},
|
|
931
|
+
);
|
|
932
|
+
await inFlight;
|
|
933
|
+
if (!stopped) timer = setTimeout(tick, PENDING_MINT_SWEEP_INTERVAL_MS);
|
|
934
|
+
};
|
|
935
|
+
timer = setTimeout(tick, PENDING_MINT_SWEEP_INTERVAL_MS);
|
|
936
|
+
|
|
937
|
+
return async () => {
|
|
938
|
+
stopped = true;
|
|
939
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
940
|
+
await inFlight;
|
|
941
|
+
await withTimeout(
|
|
942
|
+
Promise.allSettled(state.outstanding.values()),
|
|
943
|
+
PENDING_MINT_STOP_DRAIN_MS,
|
|
944
|
+
).catch(() => {});
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
768
948
|
interface ReceiveRecoveryInternals {
|
|
769
949
|
receiveOperationService: {
|
|
770
950
|
checkProofStatesWithMint(
|
|
@@ -972,6 +1152,7 @@ export async function createCocoClient(
|
|
|
972
1152
|
pendingMints: 0,
|
|
973
1153
|
};
|
|
974
1154
|
let recoveryResolve: (() => void) | undefined;
|
|
1155
|
+
let stopPendingMintSweep: (() => Promise<void>) | undefined;
|
|
975
1156
|
const recoveryPromise = new Promise<void>((resolve) => {
|
|
976
1157
|
recoveryResolve = resolve;
|
|
977
1158
|
});
|
|
@@ -1166,6 +1347,13 @@ export async function createCocoClient(
|
|
|
1166
1347
|
recoveryPhase = "done";
|
|
1167
1348
|
recoveryResolve?.();
|
|
1168
1349
|
startupProgress("Wallet recovery complete.");
|
|
1350
|
+
stopPendingMintSweep = startPendingMintSweep({
|
|
1351
|
+
ops: coco!.ops,
|
|
1352
|
+
wallet: coco!.wallet,
|
|
1353
|
+
mintOperationService: (
|
|
1354
|
+
coco as unknown as { mintOperationService: MintOperationServiceCleanup }
|
|
1355
|
+
).mintOperationService,
|
|
1356
|
+
});
|
|
1169
1357
|
})
|
|
1170
1358
|
.catch((error) => {
|
|
1171
1359
|
recoveryDone = true;
|
|
@@ -1383,7 +1571,7 @@ export async function createCocoClient(
|
|
|
1383
1571
|
}
|
|
1384
1572
|
},
|
|
1385
1573
|
|
|
1386
|
-
async receiveBolt11(amount: number, mintUrl?: string)
|
|
1574
|
+
async receiveBolt11(amount: number, mintUrl?: string) {
|
|
1387
1575
|
await waitForRecovery();
|
|
1388
1576
|
const targetMint = mintUrl
|
|
1389
1577
|
? normalizeMintUrl(mintUrl)
|
|
@@ -1399,7 +1587,20 @@ export async function createCocoClient(
|
|
|
1399
1587
|
if (!("request" in op)) {
|
|
1400
1588
|
throw new Error("mint prepare did not return a payment request");
|
|
1401
1589
|
}
|
|
1402
|
-
return op.request as string;
|
|
1590
|
+
return { invoice: op.request as string, operationId: op.id };
|
|
1591
|
+
},
|
|
1592
|
+
|
|
1593
|
+
async getMintQuote(operationId: string) {
|
|
1594
|
+
const op = await coco.ops.mint.get(operationId);
|
|
1595
|
+
if (!op || op.state === "init") return null;
|
|
1596
|
+
return {
|
|
1597
|
+
operationId: op.id,
|
|
1598
|
+
state: op.state,
|
|
1599
|
+
mintState: op.lastObservedRemoteState,
|
|
1600
|
+
amount: op.amount,
|
|
1601
|
+
mintUrl: op.mintUrl,
|
|
1602
|
+
error: op.error,
|
|
1603
|
+
};
|
|
1403
1604
|
},
|
|
1404
1605
|
|
|
1405
1606
|
async sendCashu(amount: number, mintUrl?: string): Promise<string> {
|
|
@@ -1475,6 +1676,7 @@ export async function createCocoClient(
|
|
|
1475
1676
|
// Let any in-flight recovery settle before closing the database from
|
|
1476
1677
|
// underneath it. The recovery promise resolves on success or failure.
|
|
1477
1678
|
await recoveryPromise;
|
|
1679
|
+
await stopPendingMintSweep?.();
|
|
1478
1680
|
await coco.dispose();
|
|
1479
1681
|
} finally {
|
|
1480
1682
|
try {
|
|
@@ -109,13 +109,29 @@ export class CocodHttpError extends Error {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/** Progress of a Lightning top-up created with `receiveBolt11`. */
|
|
113
|
+
export interface MintQuoteStatus {
|
|
114
|
+
operationId: string;
|
|
115
|
+
state: "pending" | "executing" | "finalized" | "failed";
|
|
116
|
+
/** Last quote state reported by the mint (UNPAID, PAID, ISSUED). */
|
|
117
|
+
mintState?: string;
|
|
118
|
+
amount: number;
|
|
119
|
+
mintUrl: string;
|
|
120
|
+
error?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
112
123
|
export interface CocodClient {
|
|
113
124
|
ping(): Promise<boolean>;
|
|
114
125
|
getStatus(): Promise<CocodState>;
|
|
115
126
|
unlock(passphrase: string): Promise<string>;
|
|
116
127
|
getBalances(): Promise<Record<string, number>>;
|
|
117
128
|
receiveCashu(token: string): Promise<string>;
|
|
118
|
-
receiveBolt11(
|
|
129
|
+
receiveBolt11(
|
|
130
|
+
amount: number,
|
|
131
|
+
mintUrl?: string,
|
|
132
|
+
): Promise<{ invoice: string; operationId?: string }>;
|
|
133
|
+
/** Progress of a top-up, when the wallet tracks mint operations. */
|
|
134
|
+
getMintQuote?(operationId: string): Promise<MintQuoteStatus | null>;
|
|
119
135
|
sendCashu(amount: number, mintUrl?: string): Promise<string>;
|
|
120
136
|
sendBolt11(invoice: string, mintUrl?: string): Promise<string>;
|
|
121
137
|
listMints(): Promise<string[]>;
|
|
@@ -421,8 +437,8 @@ export function createCocodClient(
|
|
|
421
437
|
logger.debug(`[receiveCashu] Full response.output:`, message);
|
|
422
438
|
return message;
|
|
423
439
|
},
|
|
424
|
-
async receiveBolt11(amount: number, mintUrl?: string)
|
|
425
|
-
return post<string>("/receive/bolt11", { amount, mintUrl });
|
|
440
|
+
async receiveBolt11(amount: number, mintUrl?: string) {
|
|
441
|
+
return { invoice: await post<string>("/receive/bolt11", { amount, mintUrl }) };
|
|
426
442
|
},
|
|
427
443
|
async sendCashu(amount: number, mintUrl?: string): Promise<string> {
|
|
428
444
|
return post<string>("/send/cashu", { amount, mintUrl });
|
|
@@ -189,7 +189,7 @@ export async function createWalletAdapter(
|
|
|
189
189
|
|
|
190
190
|
// Step 2: Create a BOLT-11 invoice via cocod
|
|
191
191
|
logger.log(`[nwc] Creating ${amount}-sat Lightning invoice via cocod...`);
|
|
192
|
-
const invoice = await client.receiveBolt11(amount, mintUrl);
|
|
192
|
+
const { invoice } = await client.receiveBolt11(amount, mintUrl);
|
|
193
193
|
logger.log(`[nwc] Invoice: ${invoice}`);
|
|
194
194
|
|
|
195
195
|
// Step 3: Pay it via NWC
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { cli } from "./cli";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
try {
|
|
5
|
+
await cli(process.argv);
|
|
6
|
+
} catch (error) {
|
|
7
|
+
console.error(
|
|
8
|
+
"routstrd command failed:",
|
|
9
|
+
error instanceof Error ? error.message : error,
|
|
10
|
+
);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const DAEMON_COMMAND = "daemon";
|
|
2
|
+
|
|
3
|
+
type BunRuntime = {
|
|
4
|
+
isStandaloneExecutable?: boolean;
|
|
5
|
+
main: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function isStandaloneExecutable(
|
|
9
|
+
runtime: BunRuntime = Bun as unknown as BunRuntime,
|
|
10
|
+
): boolean {
|
|
11
|
+
return (
|
|
12
|
+
runtime.isStandaloneExecutable === true ||
|
|
13
|
+
runtime.main.startsWith("/$bunfs/")
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type RuntimeExecutable = {
|
|
18
|
+
standalone: boolean;
|
|
19
|
+
execPath: string;
|
|
20
|
+
main: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function daemonSpawnCommand(
|
|
24
|
+
args: string[],
|
|
25
|
+
runtime: RuntimeExecutable = {
|
|
26
|
+
standalone: isStandaloneExecutable(),
|
|
27
|
+
execPath: process.execPath,
|
|
28
|
+
main: Bun.main,
|
|
29
|
+
},
|
|
30
|
+
): string[] {
|
|
31
|
+
return runtime.standalone
|
|
32
|
+
? [runtime.execPath, DAEMON_COMMAND, ...args]
|
|
33
|
+
: [runtime.execPath, runtime.main, DAEMON_COMMAND, ...args];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function pm2DaemonArgs(
|
|
37
|
+
runtime: RuntimeExecutable = {
|
|
38
|
+
standalone: isStandaloneExecutable(),
|
|
39
|
+
execPath: process.execPath,
|
|
40
|
+
main: Bun.main,
|
|
41
|
+
},
|
|
42
|
+
): string[] {
|
|
43
|
+
const entrypoint = runtime.standalone ? runtime.execPath : runtime.main;
|
|
44
|
+
const interpreter = runtime.standalone ? "none" : runtime.execPath;
|
|
45
|
+
return [
|
|
46
|
+
"start",
|
|
47
|
+
entrypoint,
|
|
48
|
+
"--name",
|
|
49
|
+
"routstrd",
|
|
50
|
+
"--interpreter",
|
|
51
|
+
interpreter,
|
|
52
|
+
"--",
|
|
53
|
+
DAEMON_COMMAND,
|
|
54
|
+
];
|
|
55
|
+
}
|
package/src/start-daemon.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { logger } from "./utils/logger";
|
|
|
9
9
|
import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
|
|
10
10
|
import { withCrossProcessLock } from "./utils/process-lock";
|
|
11
11
|
import { urlHosts } from "./utils/daemon-client";
|
|
12
|
-
import {
|
|
12
|
+
import { daemonSpawnCommand } from "./runtime";
|
|
13
13
|
|
|
14
14
|
const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
|
|
15
15
|
const DEBUG_LOG_PATH = `${CONFIG_DIR}/debug.log`;
|
|
@@ -88,7 +88,8 @@ function printStartupProgress(offset: number): {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
/** Format an elapsed duration as "45s" or "2m 5s". */
|
|
92
|
+
export function formatElapsed(elapsedMs: number): string {
|
|
92
93
|
const seconds = Math.floor(elapsedMs / 1000);
|
|
93
94
|
if (seconds < 60) return `${seconds}s`;
|
|
94
95
|
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
@@ -151,17 +152,12 @@ async function startDaemonUnlocked(
|
|
|
151
152
|
args.push("--provider", options.provider);
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
let daemonScript = fileURLToPath(new URL("./daemon/index.js", import.meta.url));
|
|
155
|
-
if (!existsSync(daemonScript)) {
|
|
156
|
-
daemonScript = fileURLToPath(new URL("./daemon/index.ts", import.meta.url));
|
|
157
|
-
}
|
|
158
|
-
|
|
159
155
|
const debugLogOffset = existsSync(DEBUG_LOG_PATH)
|
|
160
156
|
? fileSize(DEBUG_LOG_PATH)
|
|
161
157
|
: 0;
|
|
162
158
|
const debugLogFd = openSync(DEBUG_LOG_PATH, "a");
|
|
163
159
|
|
|
164
|
-
const proc = Bun.spawn(
|
|
160
|
+
const proc = Bun.spawn(daemonSpawnCommand(args), {
|
|
165
161
|
stdout: debugLogFd,
|
|
166
162
|
stderr: debugLogFd,
|
|
167
163
|
stdin: "ignore",
|