routstrd 0.4.11 → 0.4.12

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.
@@ -1,11 +1,24 @@
1
1
  import { getTokenMetadata } from "@cashu/cashu-ts";
2
2
  import { InsufficientBalanceError } from "@routstr/sdk";
3
3
  import { WalletConnect } from "applesauce-wallet-connect";
4
+ import { WalletBaseError } from "applesauce-wallet-connect/helpers/error";
4
5
  import { RelayPool } from "applesauce-relay";
5
6
  import { logger } from "../../utils/logger";
6
- import { createCocodClient, type CocodClient } from "./cocod-client";
7
+ import { withTimeout } from "../../utils/with-timeout";
8
+ import type { WalletClient } from "./wallet-client";
7
9
  import { startAutoRefillLoop, type AutoRefillConfig } from "./auto-refill";
8
10
 
11
+ /**
12
+ * NWC reads (get_info/get_balance) should answer in a couple of seconds. If
13
+ * they don't, the long-lived relay subscription is presumed stale and the
14
+ * connection is rebuilt before one retry.
15
+ */
16
+ const NWC_READ_TIMEOUT_MS = 15_000;
17
+ /** Overall bound including encryption negotiation; replies have a library 30s timeout. */
18
+ const NWC_PAY_TIMEOUT_MS = 45_000;
19
+
20
+ type NwcPayment = { preimage?: string; fees_paid?: number };
21
+
9
22
  export function decodeCashuTokenAmount(token: string): {
10
23
  amount: number;
11
24
  unit: "sat" | "msat";
@@ -21,7 +34,7 @@ export function decodeCashuTokenAmount(token: string): {
21
34
  }
22
35
 
23
36
  export async function receiveCashuToken(
24
- client: Pick<CocodClient, "receiveCashu">,
37
+ client: Pick<WalletClient, "receiveCashu">,
25
38
  token: string,
26
39
  ): Promise<{ message: string; amount: number; unit: "sat" | "msat" }> {
27
40
  // Validate the token before handing it to a state-changing wallet call. This
@@ -33,10 +46,14 @@ export async function receiveCashuToken(
33
46
  }
34
47
 
35
48
  export interface WalletAdapterOptions {
36
- cocodPath?: string | null;
37
- walletClient?: CocodClient;
49
+ /** The in-process wallet engine. Required — construct it with `createCocoClient()`. */
50
+ walletClient: WalletClient;
38
51
  /** NWC connection string for Lightning funding (uses applesauce-wallet-connect) */
39
52
  nwcConnectionString?: string;
53
+ /** Override the NWC read timeout in milliseconds (test hook). */
54
+ nwcReadTimeoutMs?: number;
55
+ /** Override the NWC payment timeout in milliseconds (test hook). */
56
+ nwcPayTimeoutMs?: number;
40
57
  /** Auto-refill configuration (static, for startup only) */
41
58
  autoRefill?: AutoRefillConfig;
42
59
  /**
@@ -48,10 +65,9 @@ export interface WalletAdapterOptions {
48
65
  }
49
66
 
50
67
  export async function createWalletAdapter(
51
- options: WalletAdapterOptions = {},
68
+ options: WalletAdapterOptions,
52
69
  ) {
53
- const client =
54
- options.walletClient || createCocodClient({ cocodPath: options.cocodPath });
70
+ const client = options.walletClient;
55
71
  let activeMintUrl: string | null = null;
56
72
  let mintUnits: Record<string, "sat" | "msat"> = {};
57
73
 
@@ -82,19 +98,44 @@ export async function createWalletAdapter(
82
98
 
83
99
  let wallet: WalletConnect | undefined;
84
100
  let pool: RelayPool | undefined;
101
+ let nwcConnectionString = options.nwcConnectionString;
102
+ const nwcReadTimeoutMs = options.nwcReadTimeoutMs ?? NWC_READ_TIMEOUT_MS;
103
+ const nwcPayTimeoutMs = options.nwcPayTimeoutMs ?? NWC_PAY_TIMEOUT_MS;
85
104
 
86
105
  // Getter for the current wallet instance (used by auto-refill loop)
87
106
  const getWallet = (): WalletConnect | undefined => wallet;
88
107
 
89
- if (options.nwcConnectionString) {
90
- pool = new RelayPool();
91
- wallet = WalletConnect.fromConnectURI(options.nwcConnectionString, { pool });
108
+ /** Close the active relay pool, if any. */
109
+ function closeNwcPool(): void {
110
+ if (pool) {
111
+ for (const [url] of pool.relays) {
112
+ pool.remove(url, true);
113
+ }
114
+ }
115
+ pool = undefined;
116
+ }
117
+
118
+ /**
119
+ * (Re)create the relay pool + WalletConnect client for a connection string.
120
+ * Shared by interactive connects and self-healing recovery so both paths use
121
+ * identical setup.
122
+ */
123
+ function connectNwc(connectionString: string, reason: string): void {
124
+ const nextPool = new RelayPool();
125
+ const nextWallet = WalletConnect.fromConnectURI(connectionString, {
126
+ pool: nextPool,
127
+ });
128
+
129
+ pool = nextPool;
130
+ wallet = nextWallet;
131
+ nwcConnectionString = connectionString;
92
132
 
93
133
  // Connect in background (non-blocking)
94
- wallet.waitForService()
134
+ nextWallet
135
+ .waitForService()
95
136
  .then(() => {
96
137
  logger.log(
97
- `[nwc] NWC wallet connected. Relay: ${wallet!.relays[0]}, Service: ${wallet!.service}`,
138
+ `[nwc] NWC wallet ${reason}. Relay: ${nextWallet.relays[0]}, Service: ${nextWallet.service}`,
98
139
  );
99
140
  })
100
141
  .catch((err) => {
@@ -102,39 +143,97 @@ export async function createWalletAdapter(
102
143
  });
103
144
  }
104
145
 
146
+ /**
147
+ * Rebuild the NWC connection in place after a request stalled. applesauce's
148
+ * request timeout only covers the response stream, so a stale relay
149
+ * subscription can leave `getInfo`/`getBalance` pending forever while it
150
+ * negotiates encryption. Recreating the pool gives the next call a fresh
151
+ * subscription.
152
+ */
153
+ function rebuildNwcConnection(reason: string): void {
154
+ if (!nwcConnectionString) return;
155
+ closeNwcPool();
156
+ wallet = undefined;
157
+ connectNwc(nwcConnectionString, reason);
158
+ }
159
+
160
+ /**
161
+ * Run an idempotent NWC read, bounding the wait and rebuilding the connection
162
+ * once if it stalls.
163
+ */
164
+ async function nwcRead<T>(
165
+ label: string,
166
+ operation: (w: WalletConnect) => Promise<T>,
167
+ ): Promise<T> {
168
+ const first = wallet;
169
+ if (!first?.service) {
170
+ throw new Error("NWC not connected");
171
+ }
172
+ try {
173
+ return await withTimeout(
174
+ operation(first),
175
+ nwcReadTimeoutMs,
176
+ `${label} timed out`,
177
+ );
178
+ } catch (error) {
179
+ // A normal NIP-47 error proves the wallet answered. Keep other calls alive.
180
+ if (error instanceof WalletBaseError || !nwcConnectionString) throw error;
181
+ logger.warn(
182
+ `[nwc] ${label} failed (${(error as Error).message}); rebuilding NWC connection and retrying`,
183
+ );
184
+ rebuildNwcConnection("reconnected after stall");
185
+ const retry = wallet;
186
+ if (!retry?.service) throw error;
187
+ return await withTimeout(
188
+ operation(retry),
189
+ nwcReadTimeoutMs,
190
+ `${label} timed out after reconnect`,
191
+ );
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Pay a BOLT-11 invoice over NWC with a bounded wait. A timeout rebuilds the
197
+ * relay connection so later calls recover without a daemon restart. The
198
+ * payment is not retried here. A timeout is an unknown payment outcome,
199
+ * not proof of failure; callers must reconcile before trying a fresh invoice.
200
+ */
201
+ async function payNwcInvoice(invoice: string): Promise<NwcPayment> {
202
+ const payer = wallet;
203
+ if (!payer?.service) throw new Error("NWC not connected");
204
+ try {
205
+ return await withTimeout(
206
+ payer.payInvoice(invoice),
207
+ nwcPayTimeoutMs,
208
+ "NWC payment timed out",
209
+ );
210
+ } catch (error) {
211
+ // Include the library's own timeout, but not normal wallet error replies.
212
+ if (!(error instanceof WalletBaseError)) {
213
+ rebuildNwcConnection("reconnected after payment stall");
214
+ }
215
+ throw error;
216
+ }
217
+ }
218
+
219
+ if (options.nwcConnectionString) {
220
+ connectNwc(options.nwcConnectionString, "connected");
221
+ }
222
+
105
223
  const walletAdapter = {
106
224
  async reconnect(connectionString?: string): Promise<void> {
107
225
  logger.log(
108
226
  `[nwc] Reconnecting NWC wallet... ${connectionString ? "new connection string provided" : "disconnecting"}`,
109
227
  );
110
228
 
111
- // 1. Close existing relay pool connections
112
- if (pool) {
113
- for (const [url] of pool.relays) {
114
- pool.remove(url, true);
115
- }
116
- }
117
-
118
- // 2. Update wallet reference
229
+ // Close existing relay pool connections and update the wallet reference
230
+ closeNwcPool();
119
231
  wallet = undefined;
120
- pool = undefined;
121
232
 
122
- // 3. Create new wallet if connection string provided
123
233
  if (connectionString) {
124
- pool = new RelayPool();
125
- wallet = WalletConnect.fromConnectURI(connectionString, { pool });
126
-
127
- // Connect in background (non-blocking)
128
- wallet.waitForService()
129
- .then(() => {
130
- logger.log(
131
- `[nwc] NWC wallet reconnected. Relay: ${wallet!.relays[0]}, Service: ${wallet!.service}`,
132
- );
133
- })
134
- .catch((err) => {
135
- logger.error(`[nwc] NWC reconnection failed: ${err.message}`);
136
- });
234
+ connectNwc(connectionString, "reconnected");
137
235
  } else {
236
+ nwcConnectionString = undefined;
138
237
  logger.log("[nwc] NWC wallet disconnected.");
139
238
  }
140
239
  },
@@ -192,9 +291,9 @@ export async function createWalletAdapter(
192
291
  const { invoice } = await client.receiveBolt11(amount, mintUrl);
193
292
  logger.log(`[nwc] Invoice: ${invoice}`);
194
293
 
195
- // Step 3: Pay it via NWC
294
+ // Step 3: Pay it via NWC (bounded — a stale relay must not hang the CLI)
196
295
  logger.log("[nwc] Paying invoice via NWC...");
197
- const { preimage, fees_paid } = await wallet.payInvoice(invoice);
296
+ const { preimage, fees_paid } = await payNwcInvoice(invoice);
198
297
  logger.log(`[nwc] ✅ Payment successful!`);
199
298
  logger.log(`[nwc] Preimage: ${preimage}`);
200
299
  if (fees_paid !== undefined) {
@@ -244,10 +343,10 @@ export async function createWalletAdapter(
244
343
  }
245
344
 
246
345
  try {
247
- const info = await wallet.getInfo();
346
+ const info = await nwcRead("get_info", (w) => w.getInfo());
248
347
  let balance: number | undefined;
249
348
  try {
250
- const bal = await wallet.getBalance();
349
+ const bal = await nwcRead("get_balance", (w) => w.getBalance());
251
350
  balance = Math.floor(bal.balance / 1000); // msats → sats
252
351
  } catch {
253
352
  // Balance might not be available
@@ -326,21 +425,40 @@ export async function createWalletAdapter(
326
425
 
327
426
  let stopAutoRefill: (() => void) | undefined;
328
427
 
428
+ /**
429
+ * Start the auto-refill loop if it is not already running. Safe to call more
430
+ * than once; it becomes a no-op after the first call.
431
+ */
432
+ function ensureAutoRefillLoop(): void {
433
+ if (stopAutoRefill) return;
434
+ const getConfig = options.getAutoRefillConfig ?? (() => options.autoRefill);
435
+ stopAutoRefill = startAutoRefillLoop(
436
+ client,
437
+ getWallet,
438
+ getConfig,
439
+ 5000,
440
+ payNwcInvoice,
441
+ );
442
+ }
443
+
329
444
  const autoRefillConfig = options.getAutoRefillConfig
330
445
  ? options.getAutoRefillConfig()
331
446
  : options.autoRefill;
332
447
 
333
- if (autoRefillConfig && wallet) {
334
- const getConfig = options.getAutoRefillConfig ?? (() => options.autoRefill);
335
- stopAutoRefill = startAutoRefillLoop(client, getWallet, getConfig);
336
- logger.log(
337
- `[wallet] Auto-refill enabled: threshold=${autoRefillConfig.threshold} sats, amount=${autoRefillConfig.amount} sats, cooldown=${autoRefillConfig.cooldownMs / 60000} minutes`,
338
- );
339
- } else if (wallet && options.getAutoRefillConfig) {
340
- // Wallet exists but auto-refill is not currently enabled.
341
- // Start the loop anyway so it can pick up changes without a restart.
342
- stopAutoRefill = startAutoRefillLoop(client, getWallet, options.getAutoRefillConfig);
343
- logger.log("[wallet] Auto-refill loop started (currently disabled — enable via CLI to activate)");
448
+ if (options.getAutoRefillConfig || options.autoRefill) {
449
+ // Start the loop even when no wallet is connected yet: it reads the wallet
450
+ // and config fresh each cycle, so a later `nwc connect` activates refills
451
+ // without a daemon restart.
452
+ ensureAutoRefillLoop();
453
+ if (autoRefillConfig) {
454
+ logger.log(
455
+ `[wallet] Auto-refill enabled: threshold=${autoRefillConfig.threshold} sats, amount=${autoRefillConfig.amount} sats, cooldown=${autoRefillConfig.cooldownMs / 60000} minutes`,
456
+ );
457
+ } else {
458
+ logger.log(
459
+ "[wallet] Auto-refill loop started (currently disabled — enable via CLI to activate)",
460
+ );
461
+ }
344
462
  }
345
463
 
346
464
  try {
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Pure helpers for PAID mint-quote recovery.
3
+ *
4
+ * A mint quote can be PAID at the mint while its local operation is still
5
+ * `pending` (the Lightning payment landed before expiry while the daemon was
6
+ * down, so no local observation was ever recorded) or even terminally
7
+ * `failed` (coco gives up when the mint refuses to sign, for example after the
8
+ * invoice expiry). Claimability still depends on the mint accepting issuance.
9
+ * This feature retries the stored outputs or restores their signatures; it
10
+ * does not regenerate outputs rejected by the mint (for example an inactive
11
+ * keyset). coco already reconciles pending paid quotes at startup and in the
12
+ * periodic sweep. The new capability is operator-targeted recovery, including
13
+ * explicitly reopening failed operations, alongside safer cleanup.
14
+ *
15
+ * Recovery asks the mint what it thinks, then retries issuance or restore. These helpers decide *what* to do from a remote observation; the
16
+ * actual state transitions are applied by the in-process coco wallet client
17
+ * so coco-core's operation services emit their normal events and release
18
+ * proof reservations. Keeping the decisions here makes them unit testable
19
+ * without a wallet database or network access.
20
+ */
21
+
22
+ /** Subset of coco's mint operation rows that recovery needs. */
23
+ export interface MintQuoteRecoveryCandidate {
24
+ id: string;
25
+ mintUrl: string;
26
+ quoteId?: string;
27
+ state: string;
28
+ /** Quote amount in sats. */
29
+ amount: number;
30
+ /** Quote expiry in epoch seconds. `0` means unknown/not applicable. */
31
+ expiry: number;
32
+ /** Last quote state observed from the mint (UNPAID, PAID, ISSUED). */
33
+ lastObservedRemoteState?: string;
34
+ error?: string;
35
+ }
36
+
37
+ /** Coco's classification of a fresh remote quote check. */
38
+ export type PendingMintCheckCategory =
39
+ | "waiting"
40
+ | "ready"
41
+ | "completed"
42
+ | "terminal";
43
+
44
+ /** What recovery should do with a quote after checking it with the mint. */
45
+ export type MintQuoteRecoveryDecision =
46
+ | { action: "finalize"; observedRemoteState: "PAID" | "ISSUED" }
47
+ | { action: "waiting" }
48
+ | { action: "terminal" };
49
+
50
+ /**
51
+ * Map a remote quote check onto a recovery action.
52
+ *
53
+ * - `ready` means the mint reports the quote PAID but never issued: submit the
54
+ * operation's stored outputs to claim the sats.
55
+ * - `completed` means the mint already issued it: recover the signatures
56
+ * (NUT-09) instead of minting again.
57
+ * - `waiting` means the mint still reports the quote UNPAID: nothing is
58
+ * claimable, so leave the operation alone.
59
+ * - `terminal` means the quote can no longer be issued (for example the mint
60
+ * refused an expired quote). coco persists that verdict as a failed
61
+ * operation, so recovery must report it rather than treat it as progress.
62
+ */
63
+ export function classifyMintQuoteObservation(
64
+ category: PendingMintCheckCategory,
65
+ ): MintQuoteRecoveryDecision {
66
+ switch (category) {
67
+ case "ready":
68
+ return { action: "finalize", observedRemoteState: "PAID" };
69
+ case "completed":
70
+ return { action: "finalize", observedRemoteState: "ISSUED" };
71
+ case "waiting":
72
+ return { action: "waiting" };
73
+ case "terminal":
74
+ return { action: "terminal" };
75
+ }
76
+ }
77
+
78
+ export interface MintQuoteRecoverySelectionOptions<
79
+ T extends MintQuoteRecoveryCandidate,
80
+ > {
81
+ mints: T[];
82
+ /**
83
+ * Also consider terminally failed operations. Off by default: re-opening a
84
+ * failed operation is a mutation, so only an explicit user-invoked recovery
85
+ * may do it. Startup recovery must never resurrect quotes on its own.
86
+ */
87
+ includeFailed?: boolean;
88
+ }
89
+
90
+ export interface MintQuoteRecoverySelection<
91
+ T extends MintQuoteRecoveryCandidate,
92
+ > {
93
+ /** Pending (or executing) operations that need a fresh mint observation. */
94
+ pending: T[];
95
+ /** Failed operations the caller may re-open and retry. */
96
+ failed: T[];
97
+ }
98
+
99
+ /**
100
+ * Split operations into those recovery should check and those that were
101
+ * already given up on.
102
+ *
103
+ * Every `pending` operation is selected: only the mint knows whether an
104
+ * expired quote was paid before the local invoice ran out. `executing`
105
+ * operations are recovered too, since a crash mid-mint leaves outputs that
106
+ * may already be signed.
107
+ */
108
+ export function selectMintQuotesForRecovery<
109
+ T extends MintQuoteRecoveryCandidate,
110
+ >(options: MintQuoteRecoverySelectionOptions<T>): MintQuoteRecoverySelection<T> {
111
+ const { mints, includeFailed = false } = options;
112
+ const pending = mints.filter(
113
+ (op) => op.state === "pending" || op.state === "executing",
114
+ );
115
+ const failed = includeFailed
116
+ ? mints.filter((op) => op.state === "failed")
117
+ : [];
118
+ return { pending, failed };
119
+ }