routstrd 0.4.8 → 0.4.10

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/README.md +124 -5
  2. package/SKILL.md +341 -0
  3. package/dist/daemon/index.js +7995 -14602
  4. package/dist/index.js +155916 -14225
  5. package/package.json +9 -4
  6. package/src/cli.ts +279 -110
  7. package/src/daemon/args.ts +4 -5
  8. package/src/daemon/http/index.ts +37 -2
  9. package/src/daemon/index.ts +94 -33
  10. package/src/daemon/models.ts +13 -10
  11. package/src/daemon/wallet/auto-refill.ts +1 -1
  12. package/src/daemon/wallet/coco-client.ts +205 -3
  13. package/src/daemon/wallet/cocod-client.ts +19 -3
  14. package/src/daemon/wallet/index.ts +1 -1
  15. package/src/index.ts +9 -1
  16. package/src/runtime.ts +55 -0
  17. package/src/start-daemon.ts +4 -8
  18. package/src/tui/usage/app.ts +16 -2
  19. package/src/tui/usage/data.ts +110 -0
  20. package/src/tui/usage/render.ts +45 -20
  21. package/src/utils/clients.ts +74 -1
  22. package/src/utils/config.ts +14 -0
  23. package/src/utils/daemon-stop.ts +125 -0
  24. package/src/utils/standalone-update.ts +214 -0
  25. package/src/utils/update-checker.ts +81 -18
  26. package/src/version.ts +3 -0
  27. package/src/cli.test.ts +0 -192
  28. package/src/daemon/fatal-error.test.ts +0 -104
  29. package/src/daemon/http/usage-summary.test.ts +0 -272
  30. package/src/daemon/models.test.ts +0 -81
  31. package/src/daemon/wallet/cleanup.test.ts +0 -168
  32. package/src/daemon/wallet/coco-client.npc.test.ts +0 -252
  33. package/src/daemon/wallet/coco-client.test.ts +0 -669
  34. package/src/daemon/wallet/diagnostics.test.ts +0 -378
  35. package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
  36. package/src/daemon/wallet/migration.test.ts +0 -171
  37. package/src/daemon/wallet/receive-dedup.test.ts +0 -238
@@ -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 port =
13
- portFlagIndex !== -1
14
- ? Number.parseInt(argv[portFlagIndex + 1] || "8008", 10)
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;
@@ -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(amount, mintUrl);
492
- return { output: { invoice, amount, mintUrl } };
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 =
@@ -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
- installGlobalErrorHandlers();
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(process.argv);
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 REFRESH_INTERVAL_MS = 21 * 60 * 1000; // 21 mins
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 refreshInterval: ReturnType<typeof setInterval> | null = null;
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
- `Starting recurring model refresh job (every ${REFRESH_INTERVAL_MS / 1000 / 60 / 60} hours)`,
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
- if (refreshInterval) {
269
- clearInterval(refreshInterval);
270
- refreshInterval = null;
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
- logger.log("Running initial model refresh...");
386
- await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Initial");
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
- main().catch((error) => {
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).
@@ -87,22 +87,25 @@ export function createModelService(
87
87
  const providers = await modelManager.bootstrapProviders(false);
88
88
  logger.log(`Bootstrapped ${providers.length} providers`);
89
89
 
90
- // Sync discovered providers into the store so `providers list` reflects
91
- // the same set that the model manager knows about.
90
+ // Mirror discovery into the store so `providers list` reports the same
91
+ // set that the model manager polls and routes to. The list is
92
+ // *replaced*, not merged: an add-only merge keeps URLs discovery no
93
+ // longer reports (e.g. after a bootstrap regression or a provider
94
+ // unpublishing) visible to the CLI and to per-model views while
95
+ // routing silently ignores them.
92
96
  const {
93
97
  baseUrlsList,
94
98
  setBaseUrlsList,
95
99
  setDisabledProviders,
96
100
  } = store.getState();
97
- const existing = new Set(baseUrlsList);
98
- const merged = [
99
- ...baseUrlsList,
100
- ...providers.filter((url) => !existing.has(url)),
101
- ];
102
- if (merged.length !== baseUrlsList.length) {
103
- setBaseUrlsList(merged);
101
+ const known = new Set(baseUrlsList);
102
+ const inSync =
103
+ known.size === providers.length &&
104
+ providers.every((url) => known.has(url));
105
+ if (!inSync) {
106
+ setBaseUrlsList(providers);
104
107
  logger.log(
105
- `Synced ${merged.length - baseUrlsList.length} new provider(s) into store`,
108
+ `Synced ${providers.length} discovered provider(s) into store (was ${baseUrlsList.length})`,
106
109
  );
107
110
  }
108
111
 
@@ -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
- function defaultIsProcessRunning(pid: number): boolean {
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): Promise<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(amount: number, mintUrl?: string): Promise<string>;
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): Promise<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
- cli(process.argv);
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
+ }