routstrd 0.2.22 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,15 @@
1
1
  services:
2
- bun:
2
+ routstrd:
3
3
  build: .
4
- container_name: routstrd-bun
5
- working_dir: /app
6
- volumes:
7
- - .:/app
8
- - bun_home:/root/.bun
4
+ container_name: routstrd
9
5
  stdin_open: true
10
6
  tty: true
7
+ volumes:
8
+ - routstrd_data:/data
9
+ ports:
10
+ - "8008:8008"
11
+ restart: unless-stopped
11
12
  command: /bin/bash
12
13
 
13
14
  volumes:
14
- bun_home:
15
+ routstrd_data:
package/new-task.md ADDED
@@ -0,0 +1,60 @@
1
+ # Hot-reload NWC connection (remove restart requirement)
2
+
3
+ ## Problem
4
+
5
+ `nwc connect` and `nwc disconnect` require a daemon restart to take effect. The
6
+ CLI writes the new connection string to disk, but the daemon never re-reads it.
7
+
8
+ ## Current flow
9
+
10
+ 1. **Startup** — `createWalletAdapter` reads `options.nwcConnectionString` and
11
+ creates a `WalletConnect` (relay pool + NWC wallet service) once:
12
+ ```ts
13
+ // src/daemon/wallet/index.ts:70-78
14
+ if (options.nwcConnectionString) {
15
+ wallet = WalletConnect.fromConnectURI(options.nwcConnectionString, { pool });
16
+ }
17
+ ```
18
+
19
+ 2. **CLI `nwc connect`** — HTTP handler at `POST /nwc/connect` only calls
20
+ `saveDaemonConfig(config)`. The running `WalletConnect` instance is untouched.
21
+
22
+ 3. **Result** — the old connection (or lack thereof) stays active until restart.
23
+
24
+ ## What needs to happen
25
+
26
+ Provide a way for the daemon to tear down the old `WalletConnect` and create a
27
+ new one from the updated connection string, triggered by the HTTP handler,
28
+ **without restarting the process**.
29
+
30
+ ### Key pieces
31
+
32
+ - **`src/daemon/wallet/index.ts`** — `createWalletAdapter` needs to expose a
33
+ `reconnect(connectionString: string)` method that:
34
+ - Closes the old relay pool / WalletConnect
35
+ - Creates a new `WalletConnect` from the new connection string
36
+ - Updates the adapter's internal `wallet` reference
37
+ - If connection string is empty/undefined, disconnects and sets `wallet` to
38
+ `undefined`
39
+
40
+ - **`src/daemon/http/index.ts`** — `POST /nwc/connect` and `POST /nwc/disconnect`
41
+ handlers should call `reconnect()` after saving config, and drop the "Restart
42
+ daemon" message.
43
+
44
+ - **`src/daemon/index.ts`** — may need to store the `reconnect` function and pass
45
+ it through `DaemonDeps` to the HTTP handler.
46
+
47
+ - **`src/cli.ts`** — remove `console.log("\nRun 'routstrd restart' to connect...")`
48
+ from the connect/disconnect commands.
49
+
50
+ ### Considerations
51
+
52
+ - The `WalletConnect` instance is referenced in the auto-refill loop (passed to
53
+ `startAutoRefillLoop`). After a reconnect, the auto-refill loop's captured
54
+ `wallet` reference would be stale. The loop already reads config from disk
55
+ each cycle, so it should also reference the latest `wallet` instance.
56
+ - Could make the auto-refill loop accept a `getWallet: () => WalletConnect |
57
+ undefined` getter, same pattern as the config getter.
58
+ - Relay pool cleanup — `applesauce-wallet-connect` / `applesauce-relay` may need
59
+ explicit `pool.close()` or `pool.destroy()` to free connections.
60
+ - Edge case: a refill is in progress when reconnect is triggered.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "routstrd",
3
- "version": "0.2.22",
3
+ "version": "0.3.1",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "private": false,
@@ -24,9 +24,10 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@cashu/cashu-ts": "^4.3.0",
27
- "@routstr/sdk": "^0.3.8",
27
+ "@routstr/sdk": "^0.3.9",
28
28
  "applesauce-core": "^5.1.0",
29
29
  "applesauce-relay": "^5.1.0",
30
+ "applesauce-wallet-connect": "^6.0.0",
30
31
  "commander": "^14.0.2",
31
32
  "nostr-tools": "^2.12.0",
32
33
  "qrcode": "^1.5.4",
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@ import { startDaemon } from "./start-daemon";
3
3
  import {
4
4
  handleDaemonCommand,
5
5
  callDaemon,
6
+ callAuth,
6
7
  ensureDaemonRunning,
7
8
  isDaemonRunning,
8
9
  loadConfig,
@@ -81,7 +82,7 @@ async function printLightningInvoice(invoice: string): Promise<void> {
81
82
  }
82
83
 
83
84
  async function installCocodOrExit(): Promise<void> {
84
- logger.log("cocod not found. Installing globally with bun...");
85
+ console.log("cocod not found. Installing globally with bun...");
85
86
 
86
87
  const installProc = Bun.spawn(
87
88
  ["bun", "install", "--global", "@routstr/cocod"],
@@ -93,13 +94,13 @@ async function installCocodOrExit(): Promise<void> {
93
94
 
94
95
  const installCode = await installProc.exited;
95
96
  if (installCode !== 0 || !(await isCocodInstalled())) {
96
- logger.error(
97
+ console.error(
97
98
  "Failed to install cocod. Please run 'bun install --global @routstr/cocod' manually.",
98
99
  );
99
100
  throw new Error("cocod installation failed");
100
101
  }
101
102
 
102
- logger.log("cocod installed successfully.");
103
+ console.log("cocod installed successfully.");
103
104
  }
104
105
 
105
106
  async function requireLocalDaemon(): Promise<void> {
@@ -113,12 +114,12 @@ async function requireLocalDaemon(): Promise<void> {
113
114
  }
114
115
 
115
116
  async function initDaemon(): Promise<void> {
116
- logger.log("Initializing routstrd...");
117
+ console.log("Initializing routstrd...");
117
118
 
118
119
  // Create config directory
119
120
  if (!existsSync(CONFIG_DIR)) {
120
121
  mkdirSync(CONFIG_DIR, { recursive: true });
121
- logger.log(`Created config directory: ${CONFIG_DIR}`);
122
+ console.log(`Created config directory: ${CONFIG_DIR}`);
122
123
  }
123
124
 
124
125
  // Create initial config
@@ -128,7 +129,7 @@ async function initDaemon(): Promise<void> {
128
129
  cocodPath: null,
129
130
  };
130
131
  await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
131
- logger.log(`Created config file: ${CONFIG_FILE}`);
132
+ console.log(`Created config file: ${CONFIG_FILE}`);
132
133
  }
133
134
 
134
135
  const config = await loadConfig();
@@ -190,30 +191,30 @@ async function initDaemon(): Promise<void> {
190
191
  const alreadyInitialized = combinedOutput.includes("already initialized");
191
192
 
192
193
  if (initCode !== 0 && !alreadyInitialized) {
193
- logger.error(
194
+ console.error(
194
195
  "Failed to initialize cocod. Please run 'cocod init' manually.",
195
196
  );
196
197
  return;
197
198
  }
198
199
 
199
200
  if (alreadyInitialized) {
200
- logger.log("cocod is already initialized.");
201
+ console.log("cocod is already initialized.");
201
202
  } else {
202
- logger.log("cocod initialized successfully.");
203
+ console.log("cocod initialized successfully.");
203
204
  }
204
205
 
205
206
  await startDaemon({ port: String(config.port || 8008) });
206
207
 
207
208
  await setupIntegration(config);
208
209
 
209
- logger.log("\nInitialization complete!");
210
- logger.log(
210
+ console.log("\nInitialization complete!");
211
+ console.log(
211
212
  "\n use 'routstrd receive <cashu-token>' or 'routstrd receive 2100' to top up your local wallet using Lightning!",
212
213
  );
213
- logger.log(
214
+ console.log(
214
215
  "\n full wallet commands still work too, e.g. 'routstrd wallet receive cashu <token>' and 'routstrd wallet receive bolt11 2100'.",
215
216
  );
216
- logger.log(
217
+ console.log(
217
218
  "\nTo ensure routstrd persists across system restarts, run: 'routstrd service install'",
218
219
  );
219
220
  }
@@ -231,7 +232,8 @@ program
231
232
  "Mint URL to refund to (defaults to first mint in wallet)",
232
233
  )
233
234
  .option("-y, --yes", "Skip confirmation prompt", false)
234
- .action(async (options: { mintUrl?: string; yes: boolean }) => {
235
+ .option("--xcashu", "Refund xcashu tokens only (uses refundXcashuTokens)", false)
236
+ .action(async (options: { mintUrl?: string; yes: boolean; xcashu: boolean }) => {
235
237
  await ensureDaemonRunning();
236
238
 
237
239
  let mintUrl = options.mintUrl;
@@ -257,6 +259,36 @@ program
257
259
  }
258
260
 
259
261
  try {
262
+ if (options.xcashu) {
263
+ // xcashu path: only refund xcashu tokens
264
+ const result = await callDaemon("/refund/xcashu", {
265
+ method: "POST",
266
+ body: { mintUrl },
267
+ });
268
+
269
+ if (result.error) {
270
+ console.log(result.error);
271
+ process.exit(1);
272
+ }
273
+
274
+ const output = result.output as
275
+ | {
276
+ message: string;
277
+ results: Array<{ baseUrl: string; token: string; success: boolean; error?: string }>;
278
+ }
279
+ | undefined;
280
+
281
+ if (output) {
282
+ console.log(output.message);
283
+ console.log("\nResults:");
284
+ for (const r of output.results) {
285
+ const status = r.success ? "success" : `failed: ${r.error || "unknown"}`;
286
+ console.log(` - ${r.baseUrl}: ${status}`);
287
+ }
288
+ }
289
+ return;
290
+ }
291
+
260
292
  const result = await callDaemon("/refund", {
261
293
  method: "POST",
262
294
  body: { mintUrl },
@@ -303,7 +335,8 @@ program
303
335
  program
304
336
  .command("remote <url>")
305
337
  .description("Configure a remote daemon URL")
306
- .action(async (url: string) => {
338
+ .option("--auth-url <authUrl>", "URL of the auth proxy for management commands (npubs, clients, usage)")
339
+ .action(async (url: string, options: { authUrl?: string }) => {
307
340
  try {
308
341
  new URL(url);
309
342
  } catch {
@@ -311,12 +344,24 @@ program
311
344
  process.exit(1);
312
345
  }
313
346
 
347
+ if (options.authUrl) {
348
+ try {
349
+ new URL(options.authUrl);
350
+ } catch {
351
+ console.error(`Invalid auth URL: ${options.authUrl}`);
352
+ process.exit(1);
353
+ }
354
+ }
355
+
314
356
  if (!existsSync(CONFIG_DIR)) {
315
357
  mkdirSync(CONFIG_DIR, { recursive: true });
316
358
  }
317
359
 
318
360
  const config = await loadConfig();
319
361
  const updates: Partial<RoutstrdConfig> = { daemonUrl: url };
362
+ if (options.authUrl) {
363
+ updates.authUrl = options.authUrl;
364
+ }
320
365
  let generatedNpub: string | undefined;
321
366
 
322
367
  if (!config.nsec) {
@@ -335,6 +380,9 @@ program
335
380
  await Bun.write(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2));
336
381
 
337
382
  console.log(`Remote daemon URL set to: ${url}`);
383
+ if (options.authUrl) {
384
+ console.log(`Auth proxy URL set to: ${options.authUrl}`);
385
+ }
338
386
  if (generatedNpub) {
339
387
  console.log(
340
388
  `\nA new Nostr identity has been generated for remote authentication.`,
@@ -635,7 +683,7 @@ program
635
683
  ? Math.min(requested, 1000)
636
684
  : 10;
637
685
 
638
- const result = await callDaemon(`/usage?limit=${limit}`);
686
+ const result = await callAuth(`/usage?limit=${limit}`);
639
687
  if (result.error) {
640
688
  console.log(result.error);
641
689
  process.exit(1);
@@ -851,7 +899,7 @@ npubsCmd
851
899
  await ensureDaemonRunning();
852
900
  const config = await loadConfig();
853
901
  const userNpub = getUserNpub(config);
854
- const result = await callDaemon("/npubs");
902
+ const result = await callAuth("/npubs");
855
903
  if (result.error) {
856
904
  console.log(result.error);
857
905
  process.exit(1);
@@ -894,7 +942,7 @@ npubsCmd
894
942
  );
895
943
  process.exit(1);
896
944
  }
897
- const result = await callDaemon("/npubs");
945
+ const result = await callAuth("/npubs");
898
946
  if (result.error) {
899
947
  console.log(result.error);
900
948
  process.exit(1);
@@ -912,7 +960,7 @@ npubsCmd
912
960
  console.error("Failed to normalize user npub.");
913
961
  process.exit(1);
914
962
  }
915
- const addResult = await callDaemon("/npubs", {
963
+ const addResult = await callAuth("/npubs", {
916
964
  method: "POST",
917
965
  body: { npub: npubFromPubkey(normalized) },
918
966
  });
@@ -946,7 +994,7 @@ npubsCmd
946
994
  process.exit(1);
947
995
  }
948
996
  const body: Record<string, string> = { npub: npubFromPubkey(normalized), role: options.role };
949
- const result = await callDaemon("/npubs", {
997
+ const result = await callAuth("/npubs", {
950
998
  method: "POST",
951
999
  body,
952
1000
  });
@@ -979,7 +1027,7 @@ npubsCmd
979
1027
  console.error("Invalid role. Expected 'admin' or 'user'.");
980
1028
  process.exit(1);
981
1029
  }
982
- const result = await callDaemon("/npubs", {
1030
+ const result = await callAuth("/npubs", {
983
1031
  method: "PATCH",
984
1032
  body: { npub: npubFromPubkey(normalized), role: options.role },
985
1033
  });
@@ -1008,7 +1056,7 @@ npubsCmd
1008
1056
  console.error("Invalid npub value. Use npub1... or 64-char hex pubkey.");
1009
1057
  process.exit(1);
1010
1058
  }
1011
- const result = await callDaemon(
1059
+ const result = await callAuth(
1012
1060
  `/npubs/${encodeURIComponent(npubFromPubkey(normalized))}`,
1013
1061
  {
1014
1062
  method: "DELETE",
@@ -1253,6 +1301,114 @@ walletMintsCmd
1253
1301
  });
1254
1302
  });
1255
1303
 
1304
+ // ── NWC (Nostr Wallet Connect) commands ─────────────────────────
1305
+
1306
+ const nwcCmd = program
1307
+ .command("nwc")
1308
+ .description("Manage NWC (Nostr Wallet Connect) integration");
1309
+
1310
+ nwcCmd
1311
+ .command("connect")
1312
+ .description("Connect to a Lightning wallet via NWC")
1313
+ .argument("[connection-string]", "NWC connection string (nostr+walletconnect://...)")
1314
+ .action(async (connectionString?: string) => {
1315
+ if (!connectionString) {
1316
+ // Interactive mode: prompt for connection string
1317
+ const rl = require("readline").createInterface({
1318
+ input: process.stdin,
1319
+ output: process.stdout,
1320
+ });
1321
+ connectionString = await new Promise<string>((resolve) => {
1322
+ rl.question("Paste your NWC connection string: ", (answer: string) => {
1323
+ rl.close();
1324
+ resolve(answer.trim());
1325
+ });
1326
+ });
1327
+ }
1328
+
1329
+ // Quick validation: must be nostr+walletconnect:// with a 64-char hex pubkey
1330
+ if (!/^nostr\+walletconnect:\/\/[0-9a-fA-F]{64}\?relay=/.test(connectionString)) {
1331
+ console.error("Invalid NWC connection string: expected nostr+walletconnect://<64-char-hex>?relay=...");
1332
+ process.exit(1);
1333
+ }
1334
+
1335
+ await handleDaemonCommand("/nwc/connect", {
1336
+ method: "POST",
1337
+ body: { connectionString },
1338
+ });
1339
+ });
1340
+
1341
+ nwcCmd
1342
+ .command("disconnect")
1343
+ .description("Disconnect from NWC wallet")
1344
+ .action(async () => {
1345
+ await handleDaemonCommand("/nwc/disconnect", {
1346
+ method: "POST",
1347
+ });
1348
+ });
1349
+
1350
+ nwcCmd
1351
+ .command("status")
1352
+ .description("Show NWC connection status and wallet info")
1353
+ .action(async () => {
1354
+ await handleDaemonCommand("/nwc/status");
1355
+ });
1356
+
1357
+ nwcCmd
1358
+ .command("fund <amount>")
1359
+ .description("Manually fund the Cashu wallet from the connected NWC wallet")
1360
+ .action(async (amount: string) => {
1361
+ const parsedAmount = parsePositiveIntOrExit(amount, "amount");
1362
+ await handleDaemonCommand("/nwc/fund", {
1363
+ method: "POST",
1364
+ body: { amount: parsedAmount },
1365
+ });
1366
+ });
1367
+
1368
+ const autoRefillCmd = nwcCmd
1369
+ .command("auto-refill")
1370
+ .description("Manage automatic wallet refill from NWC");
1371
+
1372
+ autoRefillCmd
1373
+ .command("on")
1374
+ .description("Enable auto-refill")
1375
+ .option(
1376
+ "--threshold <sats>",
1377
+ "Refill when Cashu balance drops below this many sats",
1378
+ "500",
1379
+ )
1380
+ .option("--amount <sats>", "Refill this many sats at a time", "1000")
1381
+ .option(
1382
+ "--cooldown <seconds>",
1383
+ "Minimum time between refills in seconds",
1384
+ "300",
1385
+ )
1386
+ .action(async (options: { threshold: string; amount: string; cooldown: string }) => {
1387
+ const threshold = parsePositiveIntOrExit(options.threshold, "threshold");
1388
+ const amount = parsePositiveIntOrExit(options.amount, "amount");
1389
+ const cooldownSec = parsePositiveIntOrExit(options.cooldown, "cooldown");
1390
+
1391
+ await handleDaemonCommand("/nwc/auto-refill", {
1392
+ method: "POST",
1393
+ body: {
1394
+ enabled: true,
1395
+ threshold,
1396
+ amount,
1397
+ cooldownMs: cooldownSec * 1000,
1398
+ },
1399
+ });
1400
+ });
1401
+
1402
+ autoRefillCmd
1403
+ .command("off")
1404
+ .description("Disable auto-refill")
1405
+ .action(async () => {
1406
+ await handleDaemonCommand("/nwc/auto-refill", {
1407
+ method: "POST",
1408
+ body: { enabled: false },
1409
+ });
1410
+ });
1411
+
1256
1412
  // Stop
1257
1413
  program
1258
1414
  .command("stop")
@@ -1,5 +1,5 @@
1
1
  import { mkdir } from "fs/promises";
2
- import { existsSync } from "fs";
2
+ import { existsSync, readFileSync } from "fs";
3
3
  import {
4
4
  CONFIG_DIR,
5
5
  CONFIG_FILE,
@@ -31,6 +31,18 @@ export async function loadDaemonConfig(): Promise<RoutstrdConfig> {
31
31
  return DEFAULT_CONFIG;
32
32
  }
33
33
 
34
+ export function loadDaemonConfigSync(): RoutstrdConfig {
35
+ try {
36
+ if (existsSync(CONFIG_FILE)) {
37
+ const content = readFileSync(CONFIG_FILE, "utf-8");
38
+ return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
39
+ }
40
+ } catch (error) {
41
+ logger.error("Failed to load config:", error);
42
+ }
43
+ return DEFAULT_CONFIG;
44
+ }
45
+
34
46
  export function saveDaemonConfig(config: RoutstrdConfig): void {
35
47
  Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
36
48
  }
@@ -0,0 +1,192 @@
1
+ // Auto-refill loop for routstrd
2
+ // Monitors Cocod Cashu balance and triggers NWC funding when below threshold.
3
+ // This runs server-side, so refills happen regardless of whether a frontend is open.
4
+ //
5
+ // Uses applesauce-wallet-connect (same approach as nwc_integration/pay_invoice.mts).
6
+
7
+ import type { CocodClient } from "./cocod-client";
8
+ import type { WalletConnect } from "applesauce-wallet-connect";
9
+ import { logger } from "../../utils/logger";
10
+
11
+ export interface AutoRefillConfig {
12
+ /** Minimum sats balance before triggering a refill */
13
+ threshold: number;
14
+ /** Amount of sats to refill each time */
15
+ amount: number;
16
+ /** Minimum time between refills (milliseconds) */
17
+ cooldownMs: number;
18
+ /** Maximum consecutive failures before stopping (0 = never stop on failures) */
19
+ maxConsecutiveFailures?: number;
20
+ }
21
+
22
+ // Errors that cannot be resolved by retrying — the user must intervene.
23
+ const FATAL_ERROR_PATTERNS = [
24
+ /insufficient (?:balance|funds)/i,
25
+ /invalid (?:credentials|auth)/i,
26
+ /forbidden/i,
27
+ /unauthorized/i,
28
+ /invoice.*expired/i,
29
+ ];
30
+
31
+ function isFatalError(message: string): boolean {
32
+ return FATAL_ERROR_PATTERNS.some((pattern) => pattern.test(message));
33
+ }
34
+
35
+ export function startAutoRefillLoop(
36
+ cocod: CocodClient,
37
+ getWallet: () => WalletConnect | undefined,
38
+ getConfig: () => AutoRefillConfig | undefined,
39
+ intervalMs: number = 5000,
40
+ ): () => void {
41
+ let lastRefillAt = 0;
42
+ let lastAttemptAt = 0; // tracks last attempt (success or failure) for backoff
43
+ let running = true;
44
+ let timeout: ReturnType<typeof setInterval> | null = null;
45
+ let checkInProgress = false;
46
+ let consecutiveFailures = 0;
47
+
48
+ async function checkAndRefill(): Promise<void> {
49
+ if (!running) return;
50
+ if (checkInProgress) return;
51
+ const wallet = getWallet();
52
+ if (!wallet?.service) {
53
+ // NWC not connected — nothing to do
54
+ return;
55
+ }
56
+
57
+ // Read config fresh each cycle so changes apply without restart
58
+ const config = getConfig();
59
+ if (!config) {
60
+ // Auto-refill disabled
61
+ return;
62
+ }
63
+
64
+ const now = Date.now();
65
+ if (now - lastRefillAt < config.cooldownMs) {
66
+ return;
67
+ }
68
+
69
+ checkInProgress = true;
70
+
71
+ // If we've been failing too much, back off rather than retrying at full speed.
72
+ // This prevents tight loops on transient errors.
73
+ const backoffInterval = Math.min(
74
+ intervalMs * Math.pow(2, consecutiveFailures),
75
+ 5 * 60 * 1000, // cap at 5 minutes
76
+ );
77
+ if (now - lastAttemptAt < backoffInterval) {
78
+ return;
79
+ }
80
+
81
+ try {
82
+ const balances = await cocod.getBalances();
83
+ const totalBalance = Object.values(balances).reduce<number>(
84
+ (sum, b) => sum + (typeof b === "number" ? b : 0),
85
+ 0,
86
+ );
87
+
88
+ if (totalBalance >= config.threshold) {
89
+ // Balance is sufficient
90
+ return;
91
+ }
92
+
93
+ logger.log(
94
+ `[auto-refill] Balance ${totalBalance} sats < threshold ${config.threshold}. Refilling ${config.amount} sats...`,
95
+ );
96
+
97
+ // Get active mint
98
+ const mints = await cocod.listMints();
99
+ const mintUrl = mints[0];
100
+ if (!mintUrl) {
101
+ logger.error("[auto-refill] No active mint configured");
102
+ return;
103
+ }
104
+
105
+ // Step 1: Create a BOLT-11 invoice via cocod to fund the Cashu wallet
106
+ logger.log(
107
+ `[auto-refill] Creating BOLT-11 invoice for ${config.amount} sats via ${mintUrl}...`,
108
+ );
109
+ const invoice = await cocod.receiveBolt11(config.amount, mintUrl);
110
+
111
+ // Step 2: Pay the invoice via NWC (applesauce)
112
+ logger.log(`[auto-refill] Paying invoice via NWC...`);
113
+ const currentWallet = getWallet();
114
+ if (!currentWallet?.service) {
115
+ logger.log("[auto-refill] Wallet disconnected during refill check");
116
+ return;
117
+ }
118
+ const payment = await currentWallet.payInvoice(invoice);
119
+
120
+ // Step 3: The Cashu mint should automatically detect the paid invoice
121
+ // and issue tokens. We don't need to explicitly mint here; cocod
122
+ // handles this on its end when the mint sees the payment.
123
+ const preimage = payment.preimage;
124
+ if (preimage) {
125
+ logger.log(
126
+ `[auto-refill] Successfully refilled ${config.amount} sats. Preimage: ${preimage.slice(0, 16)}...`,
127
+ );
128
+ } else {
129
+ logger.log(
130
+ `[auto-refill] Successfully refilled ${config.amount} sats (no preimage returned).`,
131
+ );
132
+ }
133
+ if (payment.fees_paid !== undefined) {
134
+ logger.log(`[auto-refill] Fees paid: ${payment.fees_paid} msats`);
135
+ }
136
+ lastRefillAt = now;
137
+ lastAttemptAt = now;
138
+ consecutiveFailures = 0; // reset on success
139
+ } catch (error) {
140
+ const message = error instanceof Error ? error.message : String(error);
141
+ lastAttemptAt = now; // track for backoff regardless of success/failure
142
+ consecutiveFailures++;
143
+
144
+ if (isFatalError(message)) {
145
+ // Cooldown for 10 minutes — the user likely needs to fund their NWC wallet.
146
+ // We don't stop permanently so it auto-recovers without a restart.
147
+ // Set lastRefillAt into the future so all time-gate checks block.
148
+ const FATAL_COOLDOWN_MS = 10 * 60 * 1000;
149
+ logger.error(
150
+ `[auto-refill] FATAL: ${message}. The funding wallet (NWC) cannot pay. ` +
151
+ `Cooling down for ${FATAL_COOLDOWN_MS / 60000} minutes. ` +
152
+ `Fund your NWC wallet and auto-refill will retry automatically.`,
153
+ );
154
+ lastRefillAt = now + FATAL_COOLDOWN_MS;
155
+ consecutiveFailures = 0; // reset — no point counting these as transient backoffs
156
+ checkInProgress = false;
157
+ return;
158
+ }
159
+
160
+ const maxFailures = config.maxConsecutiveFailures ?? 0;
161
+ if (maxFailures > 0 && consecutiveFailures >= maxFailures) {
162
+ logger.error(
163
+ `[auto-refill] Stopping after ${consecutiveFailures} consecutive failures (maxConsecutiveFailures=${maxFailures}). ` +
164
+ `Last error: ${message}`,
165
+ );
166
+ running = false;
167
+ return;
168
+ }
169
+
170
+ logger.error(
171
+ `[auto-refill] Error (attempt ${consecutiveFailures}, next in ${Math.round(backoffInterval / 1000)}s): ${message}`,
172
+ );
173
+ } finally {
174
+ checkInProgress = false;
175
+ }
176
+ }
177
+
178
+ // Check immediately on start
179
+ checkAndRefill();
180
+
181
+ // Then poll on interval
182
+ timeout = setInterval(checkAndRefill, intervalMs);
183
+
184
+ return () => {
185
+ running = false;
186
+ if (timeout) {
187
+ clearInterval(timeout);
188
+ timeout = null;
189
+ }
190
+ logger.log("[auto-refill] Stopped");
191
+ };
192
+ }