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.
@@ -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.cubabitcoin.org";
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.minibits.cash/Bitcoin",
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
+ }
@@ -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
- const baseUrl = `${getDaemonBaseUrl(config)}/v1`;
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 callDaemon("/models");
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
  );
@@ -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 _callUrl(
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
- if (!response.ok) {
114
- const errorData = (await response.json()) as { error?: string };
115
- throw new Error(errorData.error || `HTTP ${response.status}`);
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 _callUrl(baseUrl, path, options, config);
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 _callUrl(getDaemonBaseUrl(config), path, options, config);
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 _callUrl(getAuthBaseUrl(config), path, options, config);
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
+ }