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.
- package/README.md +22 -1
- package/SKILL.md +29 -4
- package/dist/daemon/index.js +93713 -72282
- package/dist/index.js +65080 -43216
- package/package.json +2 -2
- package/src/cli.ts +358 -23
- package/src/daemon/http/index.ts +318 -27
- package/src/daemon/http/request-path.ts +39 -0
- package/src/daemon/index.ts +1 -1
- package/src/daemon/models.ts +46 -11
- package/src/daemon/wallet/auto-refill.ts +20 -8
- package/src/daemon/wallet/cleanup.ts +16 -2
- package/src/daemon/wallet/coco-client.ts +921 -110
- package/src/daemon/wallet/index.ts +168 -50
- package/src/daemon/wallet/mint-quote-recovery.ts +119 -0
- package/src/daemon/wallet/recovery-probe.ts +312 -0
- package/src/daemon/wallet/recovery-work.ts +45 -0
- package/src/daemon/wallet/testing/fake-mint.ts +347 -0
- package/src/daemon/wallet/trusted-mints.ts +3 -3
- package/src/daemon/wallet/wallet-client.ts +206 -0
- package/src/integrations/pi.ts +53 -3
- package/src/utils/config.ts +3 -2
- package/src/utils/cooldowns.ts +134 -0
- package/src/utils/daemon-client.ts +53 -9
- package/src/utils/history.ts +9 -0
- package/src/utils/with-timeout.ts +21 -0
- package/src/daemon/wallet/cocod-client.ts +0 -505
|
@@ -5,7 +5,7 @@ import { normalizeMintUrl } from "@cashu/coco-core";
|
|
|
5
5
|
* always trusted, and a wallet is never allowed to point its default at a mint
|
|
6
6
|
* it could not fetch.
|
|
7
7
|
*/
|
|
8
|
-
export const DEFAULT_MINT_URL = "https://mint.
|
|
8
|
+
export const DEFAULT_MINT_URL = "https://mint.minibits.cash/Bitcoin";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Mints routstrd trusts out of the box. Every entry is added as a trusted mint
|
|
@@ -16,7 +16,7 @@ export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org";
|
|
|
16
16
|
*/
|
|
17
17
|
export const DEFAULT_TRUSTED_MINT_URLS: readonly string[] = [
|
|
18
18
|
DEFAULT_MINT_URL,
|
|
19
|
-
"https://mint.
|
|
19
|
+
"https://mint.cubabitcoin.org",
|
|
20
20
|
];
|
|
21
21
|
|
|
22
22
|
export interface TrustedMintSeeder {
|
|
@@ -84,4 +84,4 @@ export async function seedTrustedMints(
|
|
|
84
84
|
options.onError?.(`Could not add trusted mint ${mintUrl}`, error);
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
-
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { HistoryEntry } from "@cashu/coco-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Contract shared by every wallet implementation.
|
|
5
|
+
*
|
|
6
|
+
* The wallet runs in-process (`createCocoClient` in `./coco-client`); this
|
|
7
|
+
* module only describes what the rest of routstrd is allowed to ask of it.
|
|
8
|
+
* Historically this lived in a `cocod-client` module that also shipped an
|
|
9
|
+
* HTTP/unix-socket client for the external `cocod` binary. That client was
|
|
10
|
+
* removed once the in-process wallet replaced it — see `./migration` and the
|
|
11
|
+
* `legacyCocod*` path helpers in `./paths` for the migration-only code that
|
|
12
|
+
* still knows about external cocod installs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type WalletRuntimeState =
|
|
16
|
+
| "UNINITIALIZED"
|
|
17
|
+
| "LOCKED"
|
|
18
|
+
| "UNLOCKED"
|
|
19
|
+
| "RECOVERING"
|
|
20
|
+
| "ERROR";
|
|
21
|
+
|
|
22
|
+
/** Live progress for background wallet recovery started at daemon startup. */
|
|
23
|
+
export interface WalletRecoveryProgress {
|
|
24
|
+
state: "RECOVERING" | "UNLOCKED" | "ERROR";
|
|
25
|
+
/** Current recovery phase, e.g. "Mint recovery" or "done". */
|
|
26
|
+
phase: string;
|
|
27
|
+
pendingSends: number;
|
|
28
|
+
inflightProofs: number;
|
|
29
|
+
pendingMints: number;
|
|
30
|
+
/** Expired unpaid mint quotes failed locally without a mint round-trip. */
|
|
31
|
+
failedMintQuotes: number;
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** NPC (npubx.cash) Lightning address details for this wallet. */
|
|
36
|
+
export interface NpcAddress {
|
|
37
|
+
/** Full Lightning address, e.g. "alice@npubx.cash" (npub fallback when no username is set). */
|
|
38
|
+
address: string;
|
|
39
|
+
/** NPC username, when one has been claimed. */
|
|
40
|
+
name?: string;
|
|
41
|
+
/** Nostr hex pubkey of the NPC account (only available from the in-process wallet). */
|
|
42
|
+
pubkey?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Result of an NPC username claim attempt. */
|
|
46
|
+
export interface NpcUsernameResult {
|
|
47
|
+
success: boolean;
|
|
48
|
+
/** Present when NPC requires payment to claim the username. */
|
|
49
|
+
paymentRequest?: {
|
|
50
|
+
amount?: number;
|
|
51
|
+
mints?: string[];
|
|
52
|
+
[key: string]: unknown;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Options for the wallet cleanup command. */
|
|
57
|
+
export interface WalletCleanupOptions {
|
|
58
|
+
/** Only clean up operations for this mint URL. */
|
|
59
|
+
mintUrl?: string;
|
|
60
|
+
/** Minimum operation age in milliseconds (defaults to 7 days / 1 week). */
|
|
61
|
+
minAgeMs?: number;
|
|
62
|
+
/** Report what would be cleaned without applying changes. */
|
|
63
|
+
dryRun?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Fail expired mint quotes without confirming UNPAID with the mint. Only for
|
|
66
|
+
* operators who accept the risk of stranding a quote that was paid before
|
|
67
|
+
* its invoice expired; recovery is the safe default.
|
|
68
|
+
*/
|
|
69
|
+
force?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Summary of a wallet cleanup run. */
|
|
73
|
+
export interface WalletCleanupResult {
|
|
74
|
+
dryRun: boolean;
|
|
75
|
+
/** Expired quotes selected for checking; dry runs do not contact the mint. */
|
|
76
|
+
mintQuoteCandidates: number;
|
|
77
|
+
/** Number actually marked failed (always zero in a dry run). */
|
|
78
|
+
failedMintQuotes: number;
|
|
79
|
+
/** Expired quotes kept pending because they are paid/issued or unverified. */
|
|
80
|
+
leftForRecovery: number;
|
|
81
|
+
/** Number of stale pending send operations reclaimed. */
|
|
82
|
+
reclaimedSends: number;
|
|
83
|
+
/** Number of stale prepared melt operations cancelled. */
|
|
84
|
+
cancelledMelts: number;
|
|
85
|
+
/** Number of in-flight operations that were left untouched. */
|
|
86
|
+
skipped: number;
|
|
87
|
+
errors: Array<{ operationId: string; error: string }>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class WalletHttpError extends Error {
|
|
91
|
+
status: number;
|
|
92
|
+
|
|
93
|
+
constructor(status: number, message: string) {
|
|
94
|
+
super(message);
|
|
95
|
+
this.name = "WalletHttpError";
|
|
96
|
+
this.status = status;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Progress of a Lightning top-up created with `receiveBolt11`. */
|
|
101
|
+
export interface MintQuoteStatus {
|
|
102
|
+
operationId: string;
|
|
103
|
+
state: "pending" | "executing" | "finalized" | "failed";
|
|
104
|
+
/** Last quote state reported by the mint (UNPAID, PAID, ISSUED). */
|
|
105
|
+
mintState?: string;
|
|
106
|
+
amount: number;
|
|
107
|
+
mintUrl: string;
|
|
108
|
+
error?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Options for explicit PAID mint-quote recovery. */
|
|
112
|
+
export interface WalletMintQuoteRecoveryOptions {
|
|
113
|
+
/** Target only these operation ids (may include failed operations). */
|
|
114
|
+
operationIds?: string[];
|
|
115
|
+
/** Re-open failed operations instead of skipping them. */
|
|
116
|
+
includeFailed?: boolean;
|
|
117
|
+
/** Per-quote mint timeout in milliseconds. */
|
|
118
|
+
timeoutMs?: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Summary of a PAID mint-quote recovery run. */
|
|
122
|
+
export interface WalletMintQuoteRecoveryResult {
|
|
123
|
+
/** Operations whose quote state was checked with the mint. */
|
|
124
|
+
checked: number;
|
|
125
|
+
/** Operations whose paid sats were minted or restored. */
|
|
126
|
+
recovered: number;
|
|
127
|
+
/** Quotes the mint still reports UNPAID; left pending. */
|
|
128
|
+
waiting: number;
|
|
129
|
+
/** Quotes the mint can no longer issue. */
|
|
130
|
+
terminal: number;
|
|
131
|
+
/** Failed operations moved back to pending before checking. */
|
|
132
|
+
reopened: number;
|
|
133
|
+
/** Operations left to a later run (mint unreachable, budget spent, non-terminal). */
|
|
134
|
+
retryable: number;
|
|
135
|
+
/** Operations skipped because an earlier recovery of them is still running. */
|
|
136
|
+
busy: number;
|
|
137
|
+
errors: Array<{ operationId: string; error: string }>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Summary of a stuck-operation (send/melt/mint) recovery run. */
|
|
141
|
+
export interface WalletStuckOperationRecoveryResult {
|
|
142
|
+
/** Timed-out waits; the underlying operation remains tracked. */
|
|
143
|
+
timedOut: number;
|
|
144
|
+
/** Operations for which recovery was attempted (not necessarily completed). */
|
|
145
|
+
attempted: number;
|
|
146
|
+
/** Locked operations or unfinished work from another pass; retry later. */
|
|
147
|
+
busy: number;
|
|
148
|
+
/** Operations skipped for unreachable mints, shutdown, or pass budget exhaustion. */
|
|
149
|
+
skipped: number;
|
|
150
|
+
/** Operations at reachable mints whose recovery still failed. */
|
|
151
|
+
failed: number;
|
|
152
|
+
/** Unreachable mint URL -> number of operations skipped there. */
|
|
153
|
+
skippedMints: Record<string, number>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface WalletClient {
|
|
157
|
+
ping(): Promise<boolean>;
|
|
158
|
+
getStatus(): Promise<WalletRuntimeState>;
|
|
159
|
+
unlock(passphrase: string): Promise<string>;
|
|
160
|
+
getBalances(): Promise<Record<string, number>>;
|
|
161
|
+
receiveCashu(token: string): Promise<string>;
|
|
162
|
+
receiveBolt11(
|
|
163
|
+
amount: number,
|
|
164
|
+
mintUrl?: string,
|
|
165
|
+
): Promise<{ invoice: string; operationId?: string }>;
|
|
166
|
+
/** Progress of a top-up, when the wallet tracks mint operations. */
|
|
167
|
+
getMintQuote?(operationId: string): Promise<MintQuoteStatus | null>;
|
|
168
|
+
sendCashu(amount: number, mintUrl?: string): Promise<string>;
|
|
169
|
+
sendBolt11(invoice: string, mintUrl?: string): Promise<string>;
|
|
170
|
+
listMints(): Promise<string[]>;
|
|
171
|
+
addMint(url: string): Promise<string>;
|
|
172
|
+
getMintInfo(url: string): Promise<unknown>;
|
|
173
|
+
getDefaultMint(): Promise<string | null>;
|
|
174
|
+
setDefaultMint(url: string): Promise<string>;
|
|
175
|
+
/** Release resources held by in-process wallet implementations. */
|
|
176
|
+
dispose?(): Promise<void>;
|
|
177
|
+
getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]>;
|
|
178
|
+
/** Look up a single transaction by its history entry ID. */
|
|
179
|
+
getHistoryEntryById(id: string): Promise<HistoryEntry | null>;
|
|
180
|
+
/** NPC (npubx.cash) Lightning address for this wallet. */
|
|
181
|
+
getNpcAddress(): Promise<NpcAddress>;
|
|
182
|
+
/** Claim an NPC username; pass confirm=true to pay the claim fee from the wallet. */
|
|
183
|
+
setNpcUsername(username: string, confirm?: boolean): Promise<NpcUsernameResult>;
|
|
184
|
+
/** Manually trigger an NPC quote sync into the wallet. */
|
|
185
|
+
syncNpc(): Promise<void>;
|
|
186
|
+
/** Clear stuck pending/in-flight wallet operations that are safe to resolve. */
|
|
187
|
+
cleanupStuckOperations?(
|
|
188
|
+
options?: WalletCleanupOptions,
|
|
189
|
+
): Promise<WalletCleanupResult>;
|
|
190
|
+
/**
|
|
191
|
+
* Re-issue PAID mint quotes whose sats were never claimed, optionally
|
|
192
|
+
* targeting specific operations (including ones coco already failed).
|
|
193
|
+
*/
|
|
194
|
+
recoverMintQuotes?(
|
|
195
|
+
options?: WalletMintQuoteRecoveryOptions,
|
|
196
|
+
onProgress?: (message: string) => void,
|
|
197
|
+
): Promise<WalletMintQuoteRecoveryResult>;
|
|
198
|
+
/**
|
|
199
|
+
* Recover stuck send/melt/mint operations whose mints answer a
|
|
200
|
+
* reachability probe. Operations a live execute holds are reported busy,
|
|
201
|
+
* never driven. Receive stays startup-only (receive dedup classification).
|
|
202
|
+
*/
|
|
203
|
+
recoverStuckOperations?(): Promise<WalletStuckOperationRecoveryResult>;
|
|
204
|
+
/** Report background wallet recovery progress, when the wallet supports it. */
|
|
205
|
+
getRecoveryProgress?(): Promise<WalletRecoveryProgress>;
|
|
206
|
+
}
|
package/src/integrations/pi.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type ThinkingLevelMap = Partial<Record<PiThinkingLevel, string | null>>;
|
|
|
18
18
|
|
|
19
19
|
export type PiModelEntry = {
|
|
20
20
|
id: string;
|
|
21
|
+
api?: string;
|
|
21
22
|
contextWindow?: number;
|
|
22
23
|
name?: string;
|
|
23
24
|
input?: string[];
|
|
@@ -90,6 +91,14 @@ export function deriveThinkingFields(
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
const isDeepSeekModel = (id: string): boolean => id.startsWith("deepseek");
|
|
94
|
+
const isGptModel = (id: string): boolean => id.startsWith("gpt-");
|
|
95
|
+
/**
|
|
96
|
+
* Anthropic-served models (claude-opus-5.5, claude-sonnet-5, claude-fable-5.1,
|
|
97
|
+
* ...). routstr nodes proxy the Anthropic-native `messages` route, so pi's
|
|
98
|
+
* Anthropic transport can talk to the node's own endpoint instead of the
|
|
99
|
+
* OpenAI-shaped translation.
|
|
100
|
+
*/
|
|
101
|
+
const isClaudeModel = (id: string): boolean => id.startsWith("claude");
|
|
93
102
|
|
|
94
103
|
/** Project one daemon model onto a pi config entry. */
|
|
95
104
|
export function buildPiModelEntry(
|
|
@@ -113,6 +122,26 @@ export function buildPiModelEntry(
|
|
|
113
122
|
if (mods.includes("image")) input.push("image");
|
|
114
123
|
entry.input = input;
|
|
115
124
|
|
|
125
|
+
// Per-model transport pins. `api` is per-model while the provider `baseUrl`
|
|
126
|
+
// is shared, and the transports disagree about what a base URL means: the
|
|
127
|
+
// OpenAI SDKs append only their endpoint (`/chat/completions`, `/responses`)
|
|
128
|
+
// while Anthropic SDKs append `/v1/messages` themselves. The provider base
|
|
129
|
+
// URL is therefore the daemon ROOT (see installPiIntegration), which is the
|
|
130
|
+
// one spelling that resolves correctly for every transport:
|
|
131
|
+
// gpt-* -> {root}/responses -> `responses`
|
|
132
|
+
// claude* -> {root}/v1/messages -> `messages`
|
|
133
|
+
// other -> {root}/chat/completions -> `chat/completions`
|
|
134
|
+
// Pins override a curated value: a stale `anthropic-messages` left on a
|
|
135
|
+
// non-claude model picks an endpoint the daemon is not expecting, and the
|
|
136
|
+
// user cannot see from models.json which family needs which transport.
|
|
137
|
+
if (isGptModel(model.id)) {
|
|
138
|
+
entry.api = "openai-responses";
|
|
139
|
+
} else if (isClaudeModel(model.id)) {
|
|
140
|
+
entry.api = "anthropic-messages";
|
|
141
|
+
} else if (previous?.api !== undefined) {
|
|
142
|
+
entry.api = previous.api;
|
|
143
|
+
}
|
|
144
|
+
|
|
116
145
|
const derived = deriveThinkingFields(model);
|
|
117
146
|
if (derived) {
|
|
118
147
|
entry.reasoning = derived.reasoning;
|
|
@@ -154,17 +183,36 @@ type PiConfig = {
|
|
|
154
183
|
providers?: Record<string, PiProviderConfig>;
|
|
155
184
|
};
|
|
156
185
|
|
|
186
|
+
export type PiIntegrationDeps = {
|
|
187
|
+
callDaemon: typeof callDaemon;
|
|
188
|
+
getDaemonBaseUrl: typeof getDaemonBaseUrl;
|
|
189
|
+
};
|
|
190
|
+
|
|
157
191
|
export async function installPiIntegration(
|
|
158
192
|
config: RoutstrdConfig,
|
|
159
193
|
apiKey: string,
|
|
160
194
|
integrationConfig: IntegrationConfig,
|
|
195
|
+
// Injectable I/O so tests don't need mock.module, whose overrides leak
|
|
196
|
+
// across test files for the rest of the run under bun's runner.
|
|
197
|
+
deps: Partial<PiIntegrationDeps> = {},
|
|
161
198
|
): Promise<void> {
|
|
162
199
|
const { name, configPath } = integrationConfig;
|
|
200
|
+
const callDaemonFn = deps.callDaemon ?? callDaemon;
|
|
201
|
+
const getDaemonBaseUrlFn = deps.getDaemonBaseUrl ?? getDaemonBaseUrl;
|
|
163
202
|
|
|
164
203
|
console.log("\nInstalling routstr models in pi models.json...");
|
|
165
204
|
console.log(`Using API key for ${name}`);
|
|
166
205
|
|
|
167
|
-
|
|
206
|
+
// The daemon ROOT, deliberately without `/v1`. Every transport appends its
|
|
207
|
+
// own endpoint path, and only the Anthropic ones add a version prefix
|
|
208
|
+
// (`/v1/messages`); a base URL that already carries `/v1` therefore yields
|
|
209
|
+
// the doubled `/v1/v1/messages`, which routstr-core rejects with a 404 from
|
|
210
|
+
// every provider in the pool (it canonicalizes exactly one optional `v1/`).
|
|
211
|
+
// At the root, openai-completions -> `/chat/completions`, openai-responses
|
|
212
|
+
// -> `/responses` and anthropic-messages -> `/v1/messages` all land on an
|
|
213
|
+
// allowed route. getDaemonBaseUrl() strips any trailing slash, so no path
|
|
214
|
+
// can be double-slashed either.
|
|
215
|
+
const baseUrl = getDaemonBaseUrlFn(config);
|
|
168
216
|
|
|
169
217
|
let piConfig: PiConfig = {};
|
|
170
218
|
|
|
@@ -186,7 +234,7 @@ export async function installPiIntegration(
|
|
|
186
234
|
// Ensure directory exists
|
|
187
235
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
188
236
|
|
|
189
|
-
const data = await
|
|
237
|
+
const data = await callDaemonFn("/models");
|
|
190
238
|
const models = (data.output as { models: RoutstrModel[] } | undefined)?.models || [];
|
|
191
239
|
|
|
192
240
|
if (models.length === 0) {
|
|
@@ -198,7 +246,9 @@ export async function installPiIntegration(
|
|
|
198
246
|
// models.json is always a faithful projection of the daemon's state.
|
|
199
247
|
// Thinking fields are derived from the model's published reasoning allowlist;
|
|
200
248
|
// when the daemon has none, the user's hand-curated values are preserved.
|
|
201
|
-
// `compat` stays user-curated, except for the deepseek* pin applied below
|
|
249
|
+
// `compat` stays user-curated, except for the deepseek* pin applied below;
|
|
250
|
+
// `api` is pinned per family (see buildPiModelEntry), since the family
|
|
251
|
+
// decides which transport — and so which endpoint — the model is served by.
|
|
202
252
|
const existingModels = new Map<string, PiModelEntry>(
|
|
203
253
|
(piConfig.providers["routstr"]?.models ?? []).map((m) => [m.id, m]),
|
|
204
254
|
);
|
package/src/utils/config.ts
CHANGED
|
@@ -46,8 +46,9 @@ export interface RoutstrdConfig {
|
|
|
46
46
|
port: number;
|
|
47
47
|
host: string;
|
|
48
48
|
provider: string | null;
|
|
49
|
-
cocodPath: string | null;
|
|
50
49
|
mode?: "xcashu" | "apikeys";
|
|
50
|
+
/** Opt into SDK automatic model-path selection for DeepSeek V4.1 Flash. Disabled by default; requires daemon restart after changing. */
|
|
51
|
+
autoModelPath?: boolean;
|
|
51
52
|
/** Raw upstream request/response logging. Disabled by default because logs can contain sensitive prompts, outputs, and auth/payment headers. */
|
|
52
53
|
requestResponseLogging?: {
|
|
53
54
|
/** Enable raw request/response file logging. */
|
|
@@ -85,7 +86,7 @@ export const DEFAULT_CONFIG: RoutstrdConfig = {
|
|
|
85
86
|
port: 8008,
|
|
86
87
|
host: "127.0.0.1",
|
|
87
88
|
provider: null,
|
|
88
|
-
cocodPath: null,
|
|
89
89
|
mode: "apikeys",
|
|
90
|
+
autoModelPath: false,
|
|
90
91
|
maxTokens: 64000,
|
|
91
92
|
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cooldown reporting, shared by the daemon (`GET /cooldowns`) and the
|
|
3
|
+
* `routstrd cooldowns` CLI command.
|
|
4
|
+
*
|
|
5
|
+
* Cooldown state is owned by the SDK: its `ProviderManager` writes
|
|
6
|
+
* `providersOnCooldown` into the SdkStore, and every entry blocks routing for
|
|
7
|
+
* the length of the cooldown window (`getCooldownDurationMs()`, 210s today).
|
|
8
|
+
* An entry with a `modelId` cools down only that model on the provider; an
|
|
9
|
+
* entry without one cools down every model on the provider.
|
|
10
|
+
*
|
|
11
|
+
* The SDK prunes expired entries lazily (on the next routing decision), so a
|
|
12
|
+
* read of the persisted list can contain entries that already timed out.
|
|
13
|
+
* Reporting therefore filters by age and never mutates the store.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One persisted cooldown entry, as stored by the SDK. */
|
|
17
|
+
export interface StoredCooldownEntry {
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
/** Present for model-scoped entries; absent for provider-wide ones. */
|
|
20
|
+
modelId?: string;
|
|
21
|
+
/** When the cooldown started (ms since epoch). */
|
|
22
|
+
timestamp: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CooldownSummary {
|
|
26
|
+
baseUrl: string;
|
|
27
|
+
/** Model id for model-scoped cooldowns, `null` for provider-wide ones. */
|
|
28
|
+
modelId: string | null;
|
|
29
|
+
scope: "provider" | "model";
|
|
30
|
+
startedAt: number;
|
|
31
|
+
expiresAt: number;
|
|
32
|
+
remainingMs: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CooldownsOutput {
|
|
36
|
+
/** Server time the payload was built at (ms since epoch). */
|
|
37
|
+
now: number;
|
|
38
|
+
cooldownDurationMs: number;
|
|
39
|
+
/** Number of active cooldown entries (provider- and model-scoped). */
|
|
40
|
+
count: number;
|
|
41
|
+
/** Number of distinct providers with at least one active entry. */
|
|
42
|
+
providerCount: number;
|
|
43
|
+
cooldowns: CooldownSummary[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the `/cooldowns` payload: drop expired entries, tag each entry's
|
|
48
|
+
* scope, and compute when it lifts. Longest remaining cooldown comes first.
|
|
49
|
+
*/
|
|
50
|
+
export function buildCooldownsOutput(
|
|
51
|
+
entries: StoredCooldownEntry[],
|
|
52
|
+
cooldownDurationMs: number,
|
|
53
|
+
now: number = Date.now(),
|
|
54
|
+
): CooldownsOutput {
|
|
55
|
+
const cooldowns = entries
|
|
56
|
+
.filter((entry) => now - entry.timestamp < cooldownDurationMs)
|
|
57
|
+
.map((entry): CooldownSummary => {
|
|
58
|
+
const modelId = entry.modelId ?? null;
|
|
59
|
+
const expiresAt = entry.timestamp + cooldownDurationMs;
|
|
60
|
+
return {
|
|
61
|
+
baseUrl: entry.baseUrl,
|
|
62
|
+
modelId,
|
|
63
|
+
scope: modelId === null ? "provider" : "model",
|
|
64
|
+
startedAt: entry.timestamp,
|
|
65
|
+
expiresAt,
|
|
66
|
+
remainingMs: Math.max(0, expiresAt - now),
|
|
67
|
+
};
|
|
68
|
+
})
|
|
69
|
+
.sort(
|
|
70
|
+
(a, b) =>
|
|
71
|
+
b.remainingMs - a.remainingMs ||
|
|
72
|
+
a.baseUrl.localeCompare(b.baseUrl) ||
|
|
73
|
+
(a.modelId ?? "").localeCompare(b.modelId ?? ""),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
now,
|
|
78
|
+
cooldownDurationMs,
|
|
79
|
+
count: cooldowns.length,
|
|
80
|
+
providerCount: new Set(cooldowns.map((entry) => entry.baseUrl)).size,
|
|
81
|
+
cooldowns,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Format a remaining-time value for the terminal, e.g. `2m 05s` or `42s`. */
|
|
86
|
+
export function formatCooldownRemaining(ms: number): string {
|
|
87
|
+
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
|
|
88
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
89
|
+
const seconds = totalSeconds % 60;
|
|
90
|
+
return minutes > 0
|
|
91
|
+
? `${minutes}m ${String(seconds).padStart(2, "0")}s`
|
|
92
|
+
: `${seconds}s`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Render a `/cooldowns` payload for `routstrd cooldowns`. */
|
|
96
|
+
export function formatCooldowns(output: CooldownsOutput): string {
|
|
97
|
+
const windowSeconds = Math.round(output.cooldownDurationMs / 1000);
|
|
98
|
+
const heading = `Cooldowns (${windowSeconds}s window)`;
|
|
99
|
+
|
|
100
|
+
if (output.cooldowns.length === 0) {
|
|
101
|
+
return `${heading}\n\n Nothing is on cooldown right now.`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const scopeCell = (entry: CooldownSummary) =>
|
|
105
|
+
entry.scope === "provider" ? "PROVIDER" : "MODEL";
|
|
106
|
+
const urlWidth = Math.max(
|
|
107
|
+
...output.cooldowns.map((entry) => entry.baseUrl.length),
|
|
108
|
+
);
|
|
109
|
+
const modelWidth = Math.max(
|
|
110
|
+
0,
|
|
111
|
+
...output.cooldowns.map((entry) => (entry.modelId ?? "").length),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const lines = [
|
|
115
|
+
heading,
|
|
116
|
+
"",
|
|
117
|
+
` ${output.count} active across ${output.providerCount} ${
|
|
118
|
+
output.providerCount === 1 ? "provider" : "providers"
|
|
119
|
+
}:`,
|
|
120
|
+
"",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
for (const entry of output.cooldowns) {
|
|
124
|
+
const cells = [scopeCell(entry).padEnd("PROVIDER".length), entry.baseUrl.padEnd(urlWidth)];
|
|
125
|
+
if (entry.scope === "model") {
|
|
126
|
+
cells.push((entry.modelId ?? "").padEnd(modelWidth));
|
|
127
|
+
}
|
|
128
|
+
lines.push(
|
|
129
|
+
` ${cells.join(" ")} expires in ${formatCooldownRemaining(entry.remainingMs)}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return lines.join("\n");
|
|
134
|
+
}
|
|
@@ -56,6 +56,32 @@ class DaemonConnectionError extends Error {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Upper bound for a single daemon request, including response-body
|
|
61
|
+
* consumption. The daemon bounds its own NWC operations, so this only guards
|
|
62
|
+
* against a wedged server; without it a hung request would block the CLI
|
|
63
|
+
* forever.
|
|
64
|
+
*/
|
|
65
|
+
export const DAEMON_REQUEST_TIMEOUT_MS = 120_000;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Upper bound for value-moving wallet routes. A cashu melt/swap can
|
|
69
|
+
* legitimately run longer than {@link DAEMON_REQUEST_TIMEOUT_MS} (the mint has
|
|
70
|
+
* no request timeout in routstrd), so these get a more generous bound that
|
|
71
|
+
* still prevents an indefinite CLI hang.
|
|
72
|
+
*/
|
|
73
|
+
export const DAEMON_LONG_REQUEST_TIMEOUT_MS = 600_000;
|
|
74
|
+
|
|
75
|
+
/** Routes that may legitimately outlive the default request timeout. */
|
|
76
|
+
const LONG_RUNNING_ROUTES = ["/wallet/send/", "/wallet/receive/"];
|
|
77
|
+
|
|
78
|
+
function requestTimeoutMs(path: string): number {
|
|
79
|
+
const pathname = path.split("?")[0] ?? path;
|
|
80
|
+
return LONG_RUNNING_ROUTES.some((route) => pathname.startsWith(route))
|
|
81
|
+
? DAEMON_LONG_REQUEST_TIMEOUT_MS
|
|
82
|
+
: DAEMON_REQUEST_TIMEOUT_MS;
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
export function getDaemonBaseUrl(config: RoutstrdConfig): string {
|
|
60
86
|
if (config.daemonUrl) {
|
|
61
87
|
return config.daemonUrl.replace(/\/$/, "");
|
|
@@ -70,7 +96,7 @@ export function getAuthBaseUrl(config: RoutstrdConfig): string {
|
|
|
70
96
|
return getDaemonBaseUrl(config);
|
|
71
97
|
}
|
|
72
98
|
|
|
73
|
-
async function
|
|
99
|
+
export async function callDaemonUrl(
|
|
74
100
|
baseUrl: string,
|
|
75
101
|
path: string,
|
|
76
102
|
options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object },
|
|
@@ -99,23 +125,41 @@ async function _callUrl(
|
|
|
99
125
|
if (authorization) headers.set("Authorization", authorization);
|
|
100
126
|
if (bodyString) headers.set("Content-Type", "application/json");
|
|
101
127
|
|
|
128
|
+
const timeoutMs = requestTimeoutMs(path);
|
|
129
|
+
const timeoutError = () =>
|
|
130
|
+
new Error(
|
|
131
|
+
`Daemon request timed out after ${timeoutMs / 1000}s; ` +
|
|
132
|
+
"any payment outcome is unknown — check before retrying",
|
|
133
|
+
);
|
|
134
|
+
// The signal stays armed while the body is read, so a daemon that sends
|
|
135
|
+
// headers and then stalls the body cannot hang the CLI either. Aborting
|
|
136
|
+
// here never cancels the daemon's operation — see timeoutError's warning.
|
|
137
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
138
|
+
|
|
102
139
|
let response: Response;
|
|
103
140
|
try {
|
|
104
141
|
response = await fetch(url, {
|
|
105
142
|
method,
|
|
106
143
|
headers,
|
|
107
144
|
body: bodyString,
|
|
145
|
+
signal,
|
|
108
146
|
});
|
|
109
147
|
} catch (error) {
|
|
148
|
+
if (signal.aborted) throw timeoutError();
|
|
149
|
+
// Only connection failures qualify for alternate-host retries.
|
|
110
150
|
throw new DaemonConnectionError(error);
|
|
111
151
|
}
|
|
112
152
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
153
|
+
try {
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
const errorData = (await response.json()) as { error?: string };
|
|
156
|
+
throw new Error(errorData.error || `HTTP ${response.status}`);
|
|
157
|
+
}
|
|
158
|
+
return (await response.json()) as CommandResponse;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (signal.aborted) throw timeoutError();
|
|
161
|
+
throw error;
|
|
116
162
|
}
|
|
117
|
-
|
|
118
|
-
return response.json() as Promise<CommandResponse>;
|
|
119
163
|
}
|
|
120
164
|
|
|
121
165
|
async function callLocalDaemon(
|
|
@@ -127,7 +171,7 @@ async function callLocalDaemon(
|
|
|
127
171
|
let connectionError: DaemonConnectionError | undefined;
|
|
128
172
|
for (const baseUrl of localDaemonBaseUrls(config)) {
|
|
129
173
|
try {
|
|
130
|
-
return await
|
|
174
|
+
return await callDaemonUrl(baseUrl, path, options, config);
|
|
131
175
|
} catch (error) {
|
|
132
176
|
if (!(error instanceof DaemonConnectionError)) throw error;
|
|
133
177
|
connectionError = error;
|
|
@@ -142,7 +186,7 @@ export async function callDaemon(
|
|
|
142
186
|
): Promise<CommandResponse> {
|
|
143
187
|
const config = await loadConfig();
|
|
144
188
|
if (config.daemonUrl) {
|
|
145
|
-
return
|
|
189
|
+
return callDaemonUrl(getDaemonBaseUrl(config), path, options, config);
|
|
146
190
|
}
|
|
147
191
|
return callLocalDaemon(path, options, config);
|
|
148
192
|
}
|
|
@@ -157,7 +201,7 @@ export async function callAuth(
|
|
|
157
201
|
if (!config.authUrl && !config.daemonUrl) {
|
|
158
202
|
return callLocalDaemon(path, options, config);
|
|
159
203
|
}
|
|
160
|
-
return
|
|
204
|
+
return callDaemonUrl(getAuthBaseUrl(config), path, options, config);
|
|
161
205
|
}
|
|
162
206
|
|
|
163
207
|
export async function isDaemonRunning(): Promise<boolean> {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Transaction types understood by the wallet history type filter. */
|
|
2
|
+
export const HISTORY_ENTRY_TYPES = ["mint", "melt", "send", "receive"] as const;
|
|
3
|
+
|
|
4
|
+
export type HistoryEntryType = (typeof HISTORY_ENTRY_TYPES)[number];
|
|
5
|
+
|
|
6
|
+
/** True when `value` is a recognized history transaction type. */
|
|
7
|
+
export function isHistoryEntryType(value: string): value is HistoryEntryType {
|
|
8
|
+
return (HISTORY_ENTRY_TYPES as readonly string[]).includes(value);
|
|
9
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rejects when `timeoutMs` elapses before `promise` settles.
|
|
3
|
+
* This bounds the caller's wait; it does not cancel the underlying operation.
|
|
4
|
+
*
|
|
5
|
+
* Used to bound requests that may otherwise wait forever — notably NWC calls
|
|
6
|
+
* whose underlying library applies its own timeout only after a support/encryption
|
|
7
|
+
* handshake that can itself hang on a stale relay subscription.
|
|
8
|
+
*/
|
|
9
|
+
export function withTimeout<T>(
|
|
10
|
+
promise: Promise<T>,
|
|
11
|
+
timeoutMs: number,
|
|
12
|
+
message = "Operation timed out",
|
|
13
|
+
): Promise<T> {
|
|
14
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
15
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
16
|
+
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
17
|
+
});
|
|
18
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
19
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
20
|
+
});
|
|
21
|
+
}
|