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.
- package/.claude/settings.local.json +10 -0
- package/Dockerfile +8 -0
- package/bun.lock +165 -9
- package/dist/daemon/index.js +42891 -20990
- package/dist/index.js +417 -169
- package/docker-compose.yml +8 -7
- package/new-task.md +60 -0
- package/package.json +3 -2
- package/src/cli.ts +178 -22
- package/src/daemon/config-store.ts +13 -1
- package/src/daemon/wallet/auto-refill.ts +192 -0
- package/src/integrations/claudecode.ts +9 -10
- package/src/integrations/hermes.ts +10 -11
- package/src/integrations/openclaw.ts +5 -6
- package/src/integrations/opencode.ts +5 -6
- package/src/integrations/pi.ts +13 -9
- package/src/integrations/registry.ts +1 -0
- package/src/utils/clients.ts +4 -3
- package/src/utils/config.ts +27 -0
- package/src/utils/daemon-client.ts +32 -5
package/docker-compose.yml
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
services:
|
|
2
|
-
|
|
2
|
+
routstrd:
|
|
3
3
|
build: .
|
|
4
|
-
container_name: routstrd
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
+
console.log("cocod is already initialized.");
|
|
201
202
|
} else {
|
|
202
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|