routstrd 0.2.21 → 0.3.0

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/src/cli.ts CHANGED
@@ -231,7 +231,8 @@ program
231
231
  "Mint URL to refund to (defaults to first mint in wallet)",
232
232
  )
233
233
  .option("-y, --yes", "Skip confirmation prompt", false)
234
- .action(async (options: { mintUrl?: string; yes: boolean }) => {
234
+ .option("--xcashu", "Refund xcashu tokens only (uses refundXcashuTokens)", false)
235
+ .action(async (options: { mintUrl?: string; yes: boolean; xcashu: boolean }) => {
235
236
  await ensureDaemonRunning();
236
237
 
237
238
  let mintUrl = options.mintUrl;
@@ -257,6 +258,36 @@ program
257
258
  }
258
259
 
259
260
  try {
261
+ if (options.xcashu) {
262
+ // xcashu path: only refund xcashu tokens
263
+ const result = await callDaemon("/refund/xcashu", {
264
+ method: "POST",
265
+ body: { mintUrl },
266
+ });
267
+
268
+ if (result.error) {
269
+ console.log(result.error);
270
+ process.exit(1);
271
+ }
272
+
273
+ const output = result.output as
274
+ | {
275
+ message: string;
276
+ results: Array<{ baseUrl: string; token: string; success: boolean; error?: string }>;
277
+ }
278
+ | undefined;
279
+
280
+ if (output) {
281
+ console.log(output.message);
282
+ console.log("\nResults:");
283
+ for (const r of output.results) {
284
+ const status = r.success ? "success" : `failed: ${r.error || "unknown"}`;
285
+ console.log(` - ${r.baseUrl}: ${status}`);
286
+ }
287
+ }
288
+ return;
289
+ }
290
+
260
291
  const result = await callDaemon("/refund", {
261
292
  method: "POST",
262
293
  body: { mintUrl },
@@ -1253,6 +1284,114 @@ walletMintsCmd
1253
1284
  });
1254
1285
  });
1255
1286
 
1287
+ // ── NWC (Nostr Wallet Connect) commands ─────────────────────────
1288
+
1289
+ const nwcCmd = program
1290
+ .command("nwc")
1291
+ .description("Manage NWC (Nostr Wallet Connect) integration");
1292
+
1293
+ nwcCmd
1294
+ .command("connect")
1295
+ .description("Connect to a Lightning wallet via NWC")
1296
+ .argument("[connection-string]", "NWC connection string (nostr+walletconnect://...)")
1297
+ .action(async (connectionString?: string) => {
1298
+ if (!connectionString) {
1299
+ // Interactive mode: prompt for connection string
1300
+ const rl = require("readline").createInterface({
1301
+ input: process.stdin,
1302
+ output: process.stdout,
1303
+ });
1304
+ connectionString = await new Promise<string>((resolve) => {
1305
+ rl.question("Paste your NWC connection string: ", (answer: string) => {
1306
+ rl.close();
1307
+ resolve(answer.trim());
1308
+ });
1309
+ });
1310
+ }
1311
+
1312
+ // Quick validation: must be nostr+walletconnect:// with a 64-char hex pubkey
1313
+ if (!/^nostr\+walletconnect:\/\/[0-9a-fA-F]{64}\?relay=/.test(connectionString)) {
1314
+ console.error("Invalid NWC connection string: expected nostr+walletconnect://<64-char-hex>?relay=...");
1315
+ process.exit(1);
1316
+ }
1317
+
1318
+ await handleDaemonCommand("/nwc/connect", {
1319
+ method: "POST",
1320
+ body: { connectionString },
1321
+ });
1322
+ });
1323
+
1324
+ nwcCmd
1325
+ .command("disconnect")
1326
+ .description("Disconnect from NWC wallet")
1327
+ .action(async () => {
1328
+ await handleDaemonCommand("/nwc/disconnect", {
1329
+ method: "POST",
1330
+ });
1331
+ });
1332
+
1333
+ nwcCmd
1334
+ .command("status")
1335
+ .description("Show NWC connection status and wallet info")
1336
+ .action(async () => {
1337
+ await handleDaemonCommand("/nwc/status");
1338
+ });
1339
+
1340
+ nwcCmd
1341
+ .command("fund <amount>")
1342
+ .description("Manually fund the Cashu wallet from the connected NWC wallet")
1343
+ .action(async (amount: string) => {
1344
+ const parsedAmount = parsePositiveIntOrExit(amount, "amount");
1345
+ await handleDaemonCommand("/nwc/fund", {
1346
+ method: "POST",
1347
+ body: { amount: parsedAmount },
1348
+ });
1349
+ });
1350
+
1351
+ const autoRefillCmd = nwcCmd
1352
+ .command("auto-refill")
1353
+ .description("Manage automatic wallet refill from NWC");
1354
+
1355
+ autoRefillCmd
1356
+ .command("on")
1357
+ .description("Enable auto-refill")
1358
+ .option(
1359
+ "--threshold <sats>",
1360
+ "Refill when Cashu balance drops below this many sats",
1361
+ "500",
1362
+ )
1363
+ .option("--amount <sats>", "Refill this many sats at a time", "1000")
1364
+ .option(
1365
+ "--cooldown <seconds>",
1366
+ "Minimum time between refills in seconds",
1367
+ "300",
1368
+ )
1369
+ .action(async (options: { threshold: string; amount: string; cooldown: string }) => {
1370
+ const threshold = parsePositiveIntOrExit(options.threshold, "threshold");
1371
+ const amount = parsePositiveIntOrExit(options.amount, "amount");
1372
+ const cooldownSec = parsePositiveIntOrExit(options.cooldown, "cooldown");
1373
+
1374
+ await handleDaemonCommand("/nwc/auto-refill", {
1375
+ method: "POST",
1376
+ body: {
1377
+ enabled: true,
1378
+ threshold,
1379
+ amount,
1380
+ cooldownMs: cooldownSec * 1000,
1381
+ },
1382
+ });
1383
+ });
1384
+
1385
+ autoRefillCmd
1386
+ .command("off")
1387
+ .description("Disable auto-refill")
1388
+ .action(async () => {
1389
+ await handleDaemonCommand("/nwc/auto-refill", {
1390
+ method: "POST",
1391
+ body: { enabled: false },
1392
+ });
1393
+ });
1394
+
1256
1395
  // Stop
1257
1396
  program
1258
1397
  .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
+ }
@@ -28,7 +28,7 @@ async function startDaemonUnlocked(
28
28
  const startupTimeoutMs = 10 * 60 * 1000;
29
29
 
30
30
  if (await isDaemonHealthy(port)) {
31
- logger.log(`Routstr daemon already running on http://localhost:${port}/v1`);
31
+ console.log(`Routstr daemon already running on http://localhost:${port}/v1`);
32
32
  return;
33
33
  }
34
34
 
@@ -43,8 +43,8 @@ async function startDaemonUnlocked(
43
43
  const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")}`;
44
44
 
45
45
  const proc = Bun.spawn(["sh", "-c", shellCmd], {
46
- stdout: "inherit",
47
- stderr: "inherit",
46
+ stdout: "ignore",
47
+ stderr: "ignore",
48
48
  stdin: "ignore",
49
49
  detached: true,
50
50
  });
@@ -67,7 +67,7 @@ async function startDaemonUnlocked(
67
67
  }
68
68
 
69
69
  if (await isDaemonHealthy(port)) {
70
- logger.log(`Routstr daemon started (PID: ${proc.pid}).`);
70
+ console.log(`Routstr daemon started (PID: ${proc.pid}).`);
71
71
  return;
72
72
  }
73
73
  }
@@ -84,7 +84,7 @@ export async function startDaemon(
84
84
  const startupTimeoutMs = 10 * 60 * 1000;
85
85
 
86
86
  if (await isDaemonHealthy(port)) {
87
- logger.log(`Routstr daemon already running on http://localhost:${port}/v1`);
87
+ console.log(`Routstr daemon already running on http://localhost:${port}/v1`);
88
88
  return;
89
89
  }
90
90
 
@@ -7,6 +7,28 @@ export const DB_PATH = `${CONFIG_DIR}/routstr.db`;
7
7
  export const CONFIG_FILE = `${CONFIG_DIR}/config.json`;
8
8
  export const LOGS_DIR = `${CONFIG_DIR}/logs`;
9
9
 
10
+ /** NWC auto-refill configuration */
11
+ export interface NwcAutoRefillConfig {
12
+ /** Whether auto-refill is enabled */
13
+ enabled: boolean;
14
+ /** Refill when Cashu balance drops below this many sats */
15
+ threshold: number;
16
+ /** Refill this many sats at a time */
17
+ amount: number;
18
+ /** Minimum time between refills in milliseconds */
19
+ cooldownMs: number;
20
+ }
21
+
22
+ /** NWC configuration section */
23
+ export interface NwcConfig {
24
+ /** NWC mode: "funding_source" = NWC funds the cocod Cashu wallet */
25
+ mode: "funding_source" | "standalone";
26
+ /** NWC connection string (nostr+walletconnect://...) */
27
+ connectionString?: string;
28
+ /** Auto-refill settings */
29
+ autoRefill?: NwcAutoRefillConfig;
30
+ }
31
+
10
32
  export interface RoutstrdConfig {
11
33
  port: number;
12
34
  provider: string | null;
@@ -16,6 +38,8 @@ export interface RoutstrdConfig {
16
38
  nsec?: string;
17
39
  /** Nostr hex pubkey for routstr review/model events (kind 38425/38423). */
18
40
  routstrPubkey?: string;
41
+ /** NWC integration configuration */
42
+ nwc?: NwcConfig;
19
43
  }
20
44
 
21
45
  export const DEFAULT_CONFIG: RoutstrdConfig = {
@@ -48,18 +48,18 @@ async function writeLog(level: string, ...args: unknown[]) {
48
48
 
49
49
  export const logger = {
50
50
  log: (...args: unknown[]) => {
51
- console.log(...args);
52
51
  writeLog("INFO", ...args);
53
52
  },
54
53
  debug: (...args: unknown[]) => {
55
54
  writeLog("DEBUG", ...args);
56
55
  },
56
+ warn: (...args: unknown[]) => {
57
+ writeLog("WARN", ...args);
58
+ },
57
59
  error: (...args: unknown[]) => {
58
- console.error(...args);
59
60
  writeLog("ERROR", ...args);
60
61
  },
61
62
  info: (...args: unknown[]) => {
62
- console.log(...args);
63
63
  writeLog("INFO", ...args);
64
64
  },
65
65
  };