routstrd 0.4.0 → 0.4.2

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.
@@ -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
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type { SdkStore } from "@routstr/sdk";
3
+ import { createModelService } from "./models";
4
+
5
+ /**
6
+ * `ensureProvidersBootstrapped` must mirror the review-disabled provider set
7
+ * (kind 38425) into the SdkStore. The SDK applies review disables to the
8
+ * discovery adapter during bootstrap, but `providers list` and per-model
9
+ * provider views read the store — if the store is not kept in sync, a fresh
10
+ * install reports "0 disabled" while routing silently excludes them.
11
+ */
12
+ describe("createModelService.ensureProvidersBootstrapped", () => {
13
+ function makeStore(initialBaseUrls: string[] = []) {
14
+ const disabledCalls: string[][] = [];
15
+ const state: Record<string, unknown> = {
16
+ baseUrlsList: initialBaseUrls,
17
+ setBaseUrlsList: (urls: string[]) => {
18
+ state.baseUrlsList = urls;
19
+ },
20
+ disabledProviders: [],
21
+ setDisabledProviders: (urls: string[]) => {
22
+ state.disabledProviders = urls;
23
+ disabledCalls.push(urls);
24
+ },
25
+ manuallyDisabledProviders: [],
26
+ manuallyEnabledProviders: [],
27
+ };
28
+
29
+ return {
30
+ store: { getState: () => state } as unknown as SdkStore,
31
+ state,
32
+ disabledCalls,
33
+ };
34
+ }
35
+
36
+ function makeModelManager(
37
+ providers: string[],
38
+ reviewedDisabled: string[] | null,
39
+ ) {
40
+ return {
41
+ bootstrapProviders: async () => providers,
42
+ syncReviewedProvidersFromNostr: async () => reviewedDisabled,
43
+ } as never;
44
+ }
45
+
46
+ it("mirrors review-disabled providers into the store after bootstrap", async () => {
47
+ const { store, state, disabledCalls } = makeStore();
48
+ const modelManager = makeModelManager(
49
+ ["https://ok.example/", "https://bad.example/"],
50
+ ["https://bad.example/"],
51
+ );
52
+ const service = createModelService(
53
+ modelManager as never,
54
+ {} as never,
55
+ store,
56
+ );
57
+
58
+ await service.ensureProvidersBootstrapped();
59
+
60
+ expect(disabledCalls).toEqual([["https://bad.example/"]]);
61
+ expect(state.disabledProviders).toEqual(["https://bad.example/"]);
62
+ });
63
+
64
+ it("leaves the store unchanged when review sync reports no change (null)", async () => {
65
+ const { store, state, disabledCalls } = makeStore(["https://ok.example/"]);
66
+ const modelManager = makeModelManager(
67
+ ["https://ok.example/", "https://new.example/"],
68
+ null,
69
+ );
70
+ const service = createModelService(
71
+ modelManager as never,
72
+ {} as never,
73
+ store,
74
+ );
75
+
76
+ await service.ensureProvidersBootstrapped();
77
+
78
+ expect(disabledCalls).toEqual([]);
79
+ expect(state.disabledProviders).toEqual([]);
80
+ });
81
+ });
@@ -89,7 +89,11 @@ export function createModelService(
89
89
 
90
90
  // Sync discovered providers into the store so `providers list` reflects
91
91
  // the same set that the model manager knows about.
92
- const { baseUrlsList, setBaseUrlsList } = store.getState();
92
+ const {
93
+ baseUrlsList,
94
+ setBaseUrlsList,
95
+ setDisabledProviders,
96
+ } = store.getState();
93
97
  const existing = new Set(baseUrlsList);
94
98
  const merged = [
95
99
  ...baseUrlsList,
@@ -102,6 +106,18 @@ export function createModelService(
102
106
  );
103
107
  }
104
108
 
109
+ // Mirror the review-disabled set (kind 38425) into the store. The SDK
110
+ // applies review disables to the discovery adapter during bootstrap,
111
+ // but `providers list` and the per-model provider views read the store,
112
+ // so without this a fresh install reports "0 disabled" while routing
113
+ // silently excludes the review-disabled providers.
114
+ const reviewedDisabled = await modelManager.syncReviewedProvidersFromNostr(
115
+ providers,
116
+ );
117
+ if (reviewedDisabled !== null) {
118
+ setDisabledProviders(reviewedDisabled);
119
+ }
120
+
105
121
  logger.log("Provider bootstrap complete.");
106
122
  })().catch((error) => {
107
123
  providerBootstrapPromise = null;
@@ -117,7 +133,7 @@ export function createModelService(
117
133
  ): Promise<ExposedModel[]> => {
118
134
  const routstr21ModelIds = Array.from(
119
135
  new Set(await modelManager.fetchRoutstr21Models(forceRefresh)),
120
- ).slice(0, 21);
136
+ );
121
137
 
122
138
  let discoveredModels: ExposedModel[];
123
139
  if (!forceRefresh) {
@@ -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
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Pure selection helpers for the wallet cleanup command.
3
+ *
4
+ * These helpers decide *which* stuck operations are safe to clear. The actual
5
+ * state transitions are applied by the in-process coco wallet client so that
6
+ * coco-core's operation services emit their normal events and release proof
7
+ * reservations. Keeping the selection logic here makes it easy to unit test
8
+ * without a wallet database or network access.
9
+ */
10
+
11
+ export interface MintCleanupCandidate {
12
+ id: string;
13
+ state: string;
14
+ /** Quote expiry in epoch seconds. `0` means unknown/not applicable. */
15
+ expiry: number;
16
+ /** Last update time in epoch milliseconds. */
17
+ updatedAt: number;
18
+ /**
19
+ * Last quote state observed from the mint (e.g. "PAID", "ISSUED").
20
+ * Unknown/undefined means no terminal observation has been recorded.
21
+ */
22
+ lastObservedRemoteState?: string;
23
+ }
24
+
25
+ export interface NonMintCleanupCandidate {
26
+ id: string;
27
+ state: string;
28
+ /** Last update time in epoch milliseconds. */
29
+ updatedAt: number;
30
+ }
31
+
32
+ export interface CleanupSelectionOptions<
33
+ TMint extends MintCleanupCandidate,
34
+ TSend extends NonMintCleanupCandidate,
35
+ TMelt extends NonMintCleanupCandidate,
36
+ > {
37
+ mints: TMint[];
38
+ sends: TSend[];
39
+ melts: TMelt[];
40
+ nowMs: number;
41
+ minAgeMs: number;
42
+ }
43
+
44
+ export interface CleanupSelection<
45
+ TMint extends MintCleanupCandidate,
46
+ TSend extends NonMintCleanupCandidate,
47
+ TMelt extends NonMintCleanupCandidate,
48
+ > {
49
+ mintsToFail: TMint[];
50
+ sendsToReclaim: TSend[];
51
+ meltsToCancel: TMelt[];
52
+ }
53
+
54
+ /**
55
+ * Select stuck operations that are old enough to be considered for clearing.
56
+ *
57
+ * - Pending mint quotes are candidates once their bolt11 quote has expired
58
+ * (an expired Lightning invoice can never be paid again) and the mint has
59
+ * not already reported it as PAID/ISSUED. A paid-but-unfinalized quote
60
+ * still has claimable proofs, so it must go through normal recovery instead
61
+ * of being failed locally.
62
+ *
63
+ * Note that expiry alone does not prove a quote was never paid: the payment
64
+ * can have happened before expiry while the daemon was down, leaving no
65
+ * local observation. Callers that fail quotes automatically at startup must
66
+ * therefore confirm UNPAID with the mint first (see
67
+ * settleExpiredMintQuotes in coco-client.ts); only the explicit,
68
+ * user-invoked cleanup command may fail candidates purely locally.
69
+ * - Pending sends are reclaimed (rolled back) only when they are older than
70
+ * `minAgeMs`, so we never roll back a token that a receiver might still
71
+ * legitimately claim.
72
+ * - Prepared melts are cancelled under the same age guard.
73
+ */
74
+ export function selectCleanupOperations<
75
+ TMint extends MintCleanupCandidate,
76
+ TSend extends NonMintCleanupCandidate,
77
+ TMelt extends NonMintCleanupCandidate,
78
+ >(
79
+ options: CleanupSelectionOptions<TMint, TSend, TMelt>,
80
+ ): CleanupSelection<TMint, TSend, TMelt> {
81
+ const { mints, sends, melts, nowMs, minAgeMs } = options;
82
+
83
+ // Expired quotes can never be paid again, but may have been paid before
84
+ // expiry without a local observation: this is a candidate set, and whether
85
+ // failing is safe without a mint round-trip depends on the caller (above).
86
+ const mintsToFail = mints.filter(
87
+ (op) =>
88
+ op.state === "pending" &&
89
+ op.expiry > 0 &&
90
+ op.expiry * 1000 <= nowMs &&
91
+ op.lastObservedRemoteState !== "PAID" &&
92
+ op.lastObservedRemoteState !== "ISSUED",
93
+ );
94
+
95
+ const sendsToReclaim = sends.filter(
96
+ (op) => op.state === "pending" && nowMs - op.updatedAt >= minAgeMs,
97
+ );
98
+
99
+ const meltsToCancel = melts.filter(
100
+ (op) => op.state === "prepared" && nowMs - op.updatedAt >= minAgeMs,
101
+ );
102
+
103
+ return { mintsToFail, sendsToReclaim, meltsToCancel };
104
+ }