Merge remote-tracking branch 'origin/main'

This commit is contained in:
redshift
2026-10-02 17:06:32 +08:00
22 changed files with 881 additions and 781 deletions
-1
View File
@@ -275,7 +275,6 @@ Configuration is stored in `~/.routstrd/config.json`:
"port": 8008,
"host": "127.0.0.1",
"provider": null,
"cocodPath": null,
"autoModelPath": false,
"autoRefresh": { "enabled": true }
}
-1
View File
@@ -488,7 +488,6 @@ Config file: `~/.routstrd/config.json`
| `port` | number | 8008 | Daemon HTTP port |
| `host` | string | `"127.0.0.1"` | Bind address |
| `provider` | string\|null | null | Default provider URL |
| `cocodPath` | string\|null | null | Custom path to a legacy cocod executable |
| `mode` | string | `"apikeys"` | Client mode (`apikeys` or `xcashu`) |
| `maxTokens` | number | 64000 | Completion budget applied when a client sets no output-token limit |
| `daemonUrl` | string | — | Remote daemon URL (set by `routstrd remote`) |
+13
View File
@@ -0,0 +1,13 @@
# NWC request timeouts
In `applesauce-wallet-connect@6.2.0`, encryption negotiation waits for a wallet-info event (kind 13194) before starting the response timeout. If a relay subscription stalls before that event arrives, the library request can remain pending indefinitely.
The wallet adapter adds an overall deadline: 15 seconds per read attempt and 45 seconds per payment. Reads can rebuild the relay connection and retry once. Payments are never automatically retried. The library still applies its own 30-second response timeout after negotiation; the 45-second deadline does not extend it.
Normal NIP-47 wallet errors do not rebuild the shared relay connection: a wallet error proves a response arrived, and rebuilding could interrupt unrelated payments. Transport failures and timeouts, including the library's own timeout, still trigger recovery.
Every CLI daemon request has a deadline covering headers and response-body consumption: 120 seconds by default, and 600 seconds for value-moving wallet routes (`/wallet/send/*`, `/wallet/receive/*`), whose mint operations can legitimately run longer. Aborting the CLI request never cancels the daemon-side operation; the longer bound only delays how soon the CLI reports the stall.
A payment timeout is an **unknown outcome**, not proof that no payment occurred. Promise deadlines do not cancel the underlying operation. Check the mint quote, wallet transactions, and Cashu balance before creating and paying another invoice.
The auto-refill loop starts when static configuration or a dynamic configuration getter is supplied, even without an NWC connection at startup. It reads the current wallet and configuration each cycle, so a later connection can activate refills without restarting the daemon.
+1 -4
View File
@@ -305,10 +305,7 @@ async function initDaemon(integrationKey?: IntegrationKey): Promise<void> {
// Create initial config (0600, atomic write)
if (!existsSync(CONFIG_FILE)) {
const config: RoutstrdConfig = {
...DEFAULT_CONFIG,
cocodPath: null,
};
const config: RoutstrdConfig = { ...DEFAULT_CONFIG };
saveDaemonConfig(config);
console.log(`Created config file: ${CONFIG_FILE}`);
}
+23 -23
View File
@@ -13,11 +13,11 @@ import type { HistoryEntry } from "@cashu/coco-core";
import { logger } from "../../utils/logger";
import { loadDaemonConfig, saveDaemonConfig } from "../config-store";
import {
CocodHttpError,
type CocodClient,
type CocodState,
WalletHttpError,
type WalletClient,
type WalletRuntimeState,
type WalletRecoveryProgress,
} from "../wallet/cocod-client";
} from "../wallet/wallet-client";
import { receiveCashuToken } from "../wallet";
import { getClientsFromStore } from "../../utils/clients";
import { getUsageSummary } from "./usage-summary";
@@ -51,7 +51,7 @@ type ClientMode = "xcashu" | "lazyrefund" | "apikeys";
type WalletStatusOutput = {
daemon: "running";
wallet: "connected" | "recovering" | "error";
walletState: CocodState;
walletState: WalletRuntimeState;
balances?: Record<string, number>;
mode: ClientMode;
error?: string;
@@ -62,7 +62,7 @@ type DaemonDeps = {
server: { close(cb?: () => void): void };
shutdown?: () => void;
store: any;
walletClient: CocodClient;
walletClient: WalletClient;
walletAdapter: any;
storageAdapter: any;
discoveryAdapter: any;
@@ -133,7 +133,7 @@ async function readJsonBody(
try {
return JSON.parse(bodyText) as Record<string, unknown>;
} catch {
throw new CocodHttpError(400, "Invalid JSON body.");
throw new WalletHttpError(400, "Invalid JSON body.");
}
}
@@ -201,7 +201,7 @@ function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function getWalletStateMessage(state: CocodState): string {
function getWalletStateMessage(state: WalletRuntimeState): string {
switch (state) {
case "LOCKED":
return "Wallet is locked. Unlock it before performing wallet operations.";
@@ -221,7 +221,7 @@ function respondWithError(
error: unknown,
fallbackStatus = 500,
): void {
if (error instanceof CocodHttpError) {
if (error instanceof WalletHttpError) {
sendJson(res, error.status, { error: error.message });
return;
}
@@ -254,7 +254,7 @@ function getRequiredStringField(
): string {
const value = requireStringField(body, field);
if (!value) {
throw new CocodHttpError(400, `Missing required '${field}' field.`);
throw new WalletHttpError(400, `Missing required '${field}' field.`);
}
return value;
}
@@ -273,7 +273,7 @@ function getRequiredPositiveNumberField(
return parsed;
}
}
throw new CocodHttpError(400, `Missing required '${field}' field.`);
throw new WalletHttpError(400, `Missing required '${field}' field.`);
}
function optionalStringField(
@@ -294,7 +294,7 @@ function optionalStringArrayField(
!Array.isArray(value) ||
value.some((item) => typeof item !== "string" || !item.trim())
) {
throw new CocodHttpError(400, `'${field}' must be an array of non-empty strings.`);
throw new WalletHttpError(400, `'${field}' must be an array of non-empty strings.`);
}
return value.map((item: string) => item.trim());
}
@@ -350,7 +350,7 @@ async function buildStatusOutput(
}
async function buildWalletDetails(deps: DaemonDeps): Promise<{
state: CocodState;
state: WalletRuntimeState;
ready: boolean;
balances?: Record<string, number>;
unit?: "sat";
@@ -412,7 +412,7 @@ const HISTORY_TYPE_SCAN_PAGE_SIZE = 200;
* scan pages until enough matches accumulate (or history is exhausted).
*/
export async function getHistoryByTypes(
client: Pick<CocodClient, "getHistory">,
client: Pick<WalletClient, "getHistory">,
types: string[],
offset: number,
limit: number,
@@ -436,7 +436,7 @@ export function createDaemonRequestHandler(deps: {
server: { close(cb?: () => void): void };
shutdown?: () => void;
store: any;
walletClient: CocodClient;
walletClient: WalletClient;
walletAdapter: any;
storageAdapter: any;
discoveryAdapter: any;
@@ -528,7 +528,7 @@ export function createDaemonRequestHandler(deps: {
if (req.method === "POST" && url.pathname === "/wallet/cleanup") {
await respond(res, async () => {
if (!deps.walletClient.cleanupStuckOperations) {
throw new CocodHttpError(
throw new WalletHttpError(
501,
"Wallet cleanup is not supported by this wallet client.",
);
@@ -552,7 +552,7 @@ export function createDaemonRequestHandler(deps: {
if (req.method === "POST" && url.pathname === "/wallet/recover") {
await respond(res, async () => {
if (!deps.walletClient.recoverMintQuotes) {
throw new CocodHttpError(
throw new WalletHttpError(
501,
"Mint quote recovery is not supported by this wallet client.",
);
@@ -561,7 +561,7 @@ export function createDaemonRequestHandler(deps: {
const body = await readJsonBody(req);
const operationIds = optionalStringArrayField(body, "operationIds");
if (body.includeFailed === true && !operationIds?.length) {
throw new CocodHttpError(
throw new WalletHttpError(
400,
"'includeFailed' requires non-empty 'operationIds'.",
);
@@ -571,7 +571,7 @@ export function createDaemonRequestHandler(deps: {
(typeof body.timeoutMs !== "number" ||
!Number.isFinite(body.timeoutMs) || body.timeoutMs <= 0)
) {
throw new CocodHttpError(
throw new WalletHttpError(
400,
"'timeoutMs' must be a positive finite number.",
);
@@ -589,7 +589,7 @@ export function createDaemonRequestHandler(deps: {
if (req.method === "POST" && url.pathname === "/wallet/recover/operations") {
await respond(res, async () => {
if (!deps.walletClient.recoverStuckOperations) {
throw new CocodHttpError(
throw new WalletHttpError(
501,
"Stuck operation recovery is not supported by this wallet client.",
);
@@ -629,7 +629,7 @@ export function createDaemonRequestHandler(deps: {
const operationId = decodeURIComponent(mintQuoteMatch[1]);
await respond(res, async () => {
const quote = await deps.walletClient.getMintQuote?.(operationId);
if (!quote) throw new CocodHttpError(404, "Mint quote not found");
if (!quote) throw new WalletHttpError(404, "Mint quote not found");
return { output: quote };
});
return;
@@ -773,12 +773,12 @@ export function createDaemonRequestHandler(deps: {
const amount = typeof pr.amount === "number" ? pr.amount : 0;
const mints = Array.isArray(pr.mints) ? pr.mints.join(", ") : "";
if (confirm) {
throw new CocodHttpError(
throw new WalletHttpError(
402,
`Failed to set username. Required amount: ${amount} SATS. Required mints: ${mints}`,
);
}
throw new CocodHttpError(
throw new WalletHttpError(
402,
`Payment required to set username: ${amount} SATS. ` +
`Use 'routstrd wallet npc username ${username} --confirm' to proceed`,
+11
View File
@@ -269,3 +269,14 @@ describe("POST /providers/nostr-sync", () => {
expect(res.status).toBe(400);
});
});
describe("wallet errors", () => {
it("maps a WalletHttpError onto its HTTP status", async () => {
const handler = createDaemonRequestHandler({
walletClient: { getMintQuote: async () => null },
} as any);
const res = await call(handler, "GET", "/wallet/receive/bolt11/missing");
expect(res.status).toBe(404);
expect(res.json()).toEqual({ error: "Mint quote not found" });
});
});
-1
View File
@@ -181,7 +181,6 @@ export async function runDaemon(argv: string[] = process.argv): Promise<void> {
};
const walletAdapter = await createWalletAdapter({
cocodPath: config.cocodPath,
walletClient,
getAutoRefillConfig,
nwcConnectionString: config.nwc?.connectionString,
+20 -8
View File
@@ -4,7 +4,7 @@
//
// Uses applesauce-wallet-connect (same approach as nwc_integration/pay_invoice.mts).
import type { CocodClient } from "./cocod-client";
import type { WalletClient } from "./wallet-client";
import type { WalletConnect } from "applesauce-wallet-connect";
import { logger } from "../../utils/logger";
@@ -33,10 +33,19 @@ function isFatalError(message: string): boolean {
}
export function startAutoRefillLoop(
cocod: CocodClient,
walletClient: WalletClient,
getWallet: () => WalletConnect | undefined,
getConfig: () => AutoRefillConfig | undefined,
intervalMs: number = 5000,
payInvoice: (
invoice: string,
) => Promise<{ preimage?: string; fees_paid?: number }> = (invoice) => {
const wallet = getWallet();
if (!wallet?.service) {
return Promise.reject(new Error("NWC not connected"));
}
return wallet.payInvoice(invoice);
},
): () => void {
let lastRefillAt = 0;
let lastAttemptAt = 0; // tracks last attempt (success or failure) for backoff
@@ -79,7 +88,7 @@ export function startAutoRefillLoop(
checkInProgress = true;
try {
const balances = await cocod.getBalances();
const balances = await walletClient.getBalances();
const totalBalance = Object.values(balances).reduce<number>(
(sum, b) => sum + (typeof b === "number" ? b : 0),
0,
@@ -94,17 +103,20 @@ export function startAutoRefillLoop(
`[auto-refill] Balance ${totalBalance} sats < threshold ${config.threshold}. Refilling ${config.amount} sats...`,
);
const mintUrl = await cocod.getDefaultMint();
const mintUrl = await walletClient.getDefaultMint();
if (!mintUrl) {
logger.error("[auto-refill] No default mint configured");
return;
}
// Step 1: Create a BOLT-11 invoice via cocod to fund the Cashu wallet
// Step 1: Create a BOLT-11 invoice via the wallet to fund the Cashu wallet
logger.log(
`[auto-refill] Creating BOLT-11 invoice for ${config.amount} sats via ${mintUrl}...`,
);
const { invoice } = await cocod.receiveBolt11(config.amount, mintUrl);
const { invoice } = await walletClient.receiveBolt11(
config.amount,
mintUrl,
);
// Step 2: Pay the invoice via NWC (applesauce)
logger.log(`[auto-refill] Paying invoice via NWC...`);
@@ -113,10 +125,10 @@ export function startAutoRefillLoop(
logger.log("[auto-refill] Wallet disconnected during refill check");
return;
}
const payment = await currentWallet.payInvoice(invoice);
const payment = await payInvoice(invoice);
// Step 3: The Cashu mint should automatically detect the paid invoice
// and issue tokens. We don't need to explicitly mint here; cocod
// and issue tokens. We don't need to explicitly mint here; the wallet
// handles this on its end when the mint sees the payment.
const preimage = payment.preimage;
if (preimage) {
-82
View File
@@ -39,7 +39,6 @@ mock.module("coco-cashu-plugin-npc", () => ({
}));
const { createCocoClient } = await import("./coco-client");
const { createCocodClient, CocodHttpError } = await import("./cocod-client");
const TEST_MNEMONIC =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
@@ -169,84 +168,3 @@ describe("in-process coco client NPC integration", () => {
}
});
});
describe("legacy cocod client NPC passthroughs", () => {
type FetchImpl = (
input: string | URL | Request,
init?: RequestInit & { unix: string },
) => Promise<Response>;
function legacyClient(handler: (path: string, body?: unknown) => Response) {
const requests: Array<{ path: string; body?: unknown }> = [];
const fetchImpl: FetchImpl = async (input, init) => {
const path = String(input).replace("http://localhost", "");
const body =
typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
if (path !== "/ping") requests.push({ path, body });
if (path === "/ping") {
return new Response(JSON.stringify({ output: "pong" }));
}
return handler(path, body);
};
const client = createCocodClient({
socketPath: "/tmp/routstrd-test-legacy-npc/cocod.sock",
fetchImpl,
});
return { client, requests };
}
it("fetches and parses a username NPC address", async () => {
const { client, requests } = legacyClient(
() => new Response(JSON.stringify({ output: "alice@npubx.cash" })),
);
const info = await client.getNpcAddress();
expect(info).toEqual({ address: "alice@npubx.cash", name: "alice" });
expect(requests[0]?.path).toBe("/npc/address");
});
it("omits the name for npub fallback addresses", async () => {
const { client } = legacyClient(
() =>
new Response(JSON.stringify({ output: "npub1abc123@npubx.cash" })),
);
const info = await client.getNpcAddress();
expect(info).toEqual({ address: "npub1abc123@npubx.cash" });
});
it("posts username claims with the confirm flag", async () => {
const { client, requests } = legacyClient(
() => new Response(JSON.stringify({ output: { success: true } })),
);
const result = await client.setNpcUsername("bob", true);
expect(result).toEqual({ success: true });
expect(requests[0]).toEqual({
path: "/npc/username",
body: { username: "bob", confirm: true },
});
});
it("preserves the 402 payment-required error from cocod", async () => {
const { client } = legacyClient(
() =>
new Response(
JSON.stringify({ error: "Payment required to set username" }),
{ status: 402 },
),
);
try {
await client.setNpcUsername("bob");
expect.unreachable("should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(CocodHttpError);
expect((error as InstanceType<typeof CocodHttpError>).status).toBe(402);
expect((error as Error).message).toContain("Payment required");
}
});
it("reports manual sync as unsupported", async () => {
const { client } = legacyClient(
() => new Response(JSON.stringify({ output: {} })),
);
await expect(client.syncNpc()).rejects.toThrow("not supported");
});
});
+7 -15
View File
@@ -1,3 +1,4 @@
import { withTimeout as withRequestTimeout } from "../../utils/with-timeout";
import { recoveryKey, trackRecovery, drainRecoveryWork, waitForRecoveryWork, createRecoveryDisposer, type RecoveryWork } from "./recovery-work";
import {
Manager,
@@ -29,14 +30,14 @@ import {
import { dirname, join } from "path";
import { mnemonicToSeedSync } from "@scure/bip39";
import type {
CocodClient,
CocodState,
WalletClient,
WalletRuntimeState,
NpcAddress,
NpcUsernameResult,
WalletCleanupOptions,
WalletCleanupResult,
WalletRecoveryProgress,
} from "./cocod-client";
} from "./wallet-client";
import { selectCleanupOperations, summarizeMintCleanup } from "./cleanup";
import {
classifyMintQuoteObservation,
@@ -733,16 +734,7 @@ const EXPIRED_MINT_OBSERVATION_DEADLINE_MS = 15_000;
/** Rejects when `timeoutMs` elapses before `promise` settles. */
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
if (timeoutMs === Infinity) return promise;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error("Timed out contacting mint")),
timeoutMs,
);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer !== undefined) clearTimeout(timer);
});
return withRequestTimeout(promise, timeoutMs, "Timed out contacting mint");
}
/** Structural subset of coco's Manager used by expired-quote settlement. */
@@ -1820,7 +1812,7 @@ export async function runWalletRecovery(
export async function createCocoClient(
options: CreateCocoClientOptions = {},
): Promise<CocodClient> {
): Promise<WalletClient> {
const configDir = options.walletDir || options.configDir || defaultWalletDir();
const configFile = join(configDir, "config.json");
const dbPath = join(configDir, "coco.db");
@@ -2156,7 +2148,7 @@ export async function createCocoClient(
}
},
async getStatus(): Promise<CocodState> {
async getStatus(): Promise<WalletRuntimeState> {
if (recoveryError) return "ERROR";
if (!recoveryDone) return "RECOVERING";
try {
-579
View File
@@ -1,579 +0,0 @@
import { existsSync } from "fs";
import { createHash } from "crypto";
import { isAbsolute } from "path";
import { logger } from "../../utils/logger";
import { withCrossProcessLock } from "../../utils/process-lock";
import type { HistoryEntry } from "@cashu/coco-core";
const DEFAULT_CONFIG_DIR =
process.env.COCOD_DIR || `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`;
const DEFAULT_SOCKET_PATH =
process.env.COCOD_SOCKET || `${DEFAULT_CONFIG_DIR}/cocod.sock`;
type UnixRequestInit = RequestInit & { unix: string };
type CommandResponse<T> = {
output?: T;
error?: string;
};
type CocodFetch = (
input: string | URL | Request,
init?: UnixRequestInit,
) => Promise<Response>;
type SpawnedProcess = {
exited: Promise<number>;
unref?: () => void;
};
type SpawnDaemon = (
args: string[],
env: Record<string, string>,
) => SpawnedProcess;
export type CocodState =
| "UNINITIALIZED"
| "LOCKED"
| "UNLOCKED"
| "RECOVERING"
| "ERROR";
/** Live progress for background wallet recovery started at daemon startup. */
export interface WalletRecoveryProgress {
state: "RECOVERING" | "UNLOCKED" | "ERROR";
/** Current recovery phase, e.g. "Mint recovery" or "done". */
phase: string;
pendingSends: number;
inflightProofs: number;
pendingMints: number;
/** Expired unpaid mint quotes failed locally without a mint round-trip. */
failedMintQuotes: number;
error?: string;
}
export type CocodBalanceOutput = Record<string, { sats?: number } | number>;
/** NPC (npubx.cash) Lightning address details for this wallet. */
export interface NpcAddress {
/** Full Lightning address, e.g. "alice@npubx.cash" (npub fallback when no username is set). */
address: string;
/** NPC username, when one has been claimed. */
name?: string;
/** Nostr hex pubkey of the NPC account (only available from the in-process wallet). */
pubkey?: string;
}
/** Result of an NPC username claim attempt. */
export interface NpcUsernameResult {
success: boolean;
/** Present when NPC requires payment to claim the username. */
paymentRequest?: {
amount?: number;
mints?: string[];
[key: string]: unknown;
};
}
/** Options for the wallet cleanup command. */
export interface WalletCleanupOptions {
/** Only clean up operations for this mint URL. */
mintUrl?: string;
/** Minimum operation age in milliseconds (defaults to 7 days / 1 week). */
minAgeMs?: number;
/** Report what would be cleaned without applying changes. */
dryRun?: boolean;
/**
* Fail expired mint quotes without confirming UNPAID with the mint. Only for
* operators who accept the risk of stranding a quote that was paid before
* its invoice expired; recovery is the safe default.
*/
force?: boolean;
}
/** Summary of a wallet cleanup run. */
export interface WalletCleanupResult {
dryRun: boolean;
/** Expired quotes selected for checking; dry runs do not contact the mint. */
mintQuoteCandidates: number;
/** Number actually marked failed (always zero in a dry run). */
failedMintQuotes: number;
/** Expired quotes kept pending because they are paid/issued or unverified. */
leftForRecovery: number;
/** Number of stale pending send operations reclaimed. */
reclaimedSends: number;
/** Number of stale prepared melt operations cancelled. */
cancelledMelts: number;
/** Number of in-flight operations that were left untouched. */
skipped: number;
errors: Array<{ operationId: string; error: string }>;
}
export class CocodHttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "CocodHttpError";
this.status = status;
}
}
/** Progress of a Lightning top-up created with `receiveBolt11`. */
export interface MintQuoteStatus {
operationId: string;
state: "pending" | "executing" | "finalized" | "failed";
/** Last quote state reported by the mint (UNPAID, PAID, ISSUED). */
mintState?: string;
amount: number;
mintUrl: string;
error?: string;
}
/** Options for explicit PAID mint-quote recovery. */
export interface WalletMintQuoteRecoveryOptions {
/** Target only these operation ids (may include failed operations). */
operationIds?: string[];
/** Re-open failed operations instead of skipping them. */
includeFailed?: boolean;
/** Per-quote mint timeout in milliseconds. */
timeoutMs?: number;
}
/** Summary of a PAID mint-quote recovery run. */
export interface WalletMintQuoteRecoveryResult {
/** Operations whose quote state was checked with the mint. */
checked: number;
/** Operations whose paid sats were minted or restored. */
recovered: number;
/** Quotes the mint still reports UNPAID; left pending. */
waiting: number;
/** Quotes the mint can no longer issue. */
terminal: number;
/** Failed operations moved back to pending before checking. */
reopened: number;
/** Operations left to a later run (mint unreachable, budget spent, non-terminal). */
retryable: number;
/** Operations skipped because an earlier recovery of them is still running. */
busy: number;
errors: Array<{ operationId: string; error: string }>;
}
/** Summary of a stuck-operation (send/melt/mint) recovery run. */
export interface WalletStuckOperationRecoveryResult {
/** Timed-out waits; the underlying operation remains tracked. */
timedOut: number;
/** Operations for which recovery was attempted (not necessarily completed). */
attempted: number;
/** Locked operations or unfinished work from another pass; retry later. */
busy: number;
/** Operations skipped for unreachable mints, shutdown, or pass budget exhaustion. */
skipped: number;
/** Operations at reachable mints whose recovery still failed. */
failed: number;
/** Unreachable mint URL -> number of operations skipped there. */
skippedMints: Record<string, number>;
}
export interface CocodClient {
ping(): Promise<boolean>;
getStatus(): Promise<CocodState>;
unlock(passphrase: string): Promise<string>;
getBalances(): Promise<Record<string, number>>;
receiveCashu(token: string): Promise<string>;
receiveBolt11(
amount: number,
mintUrl?: string,
): Promise<{ invoice: string; operationId?: string }>;
/** Progress of a top-up, when the wallet tracks mint operations. */
getMintQuote?(operationId: string): Promise<MintQuoteStatus | null>;
sendCashu(amount: number, mintUrl?: string): Promise<string>;
sendBolt11(invoice: string, mintUrl?: string): Promise<string>;
listMints(): Promise<string[]>;
addMint(url: string): Promise<string>;
getMintInfo(url: string): Promise<unknown>;
getDefaultMint(): Promise<string | null>;
setDefaultMint(url: string): Promise<string>;
/** Release resources held by in-process wallet implementations. */
dispose?(): Promise<void>;
getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]>;
/** Look up a single transaction by its history entry ID. */
getHistoryEntryById(id: string): Promise<HistoryEntry | null>;
/** NPC (npubx.cash) Lightning address for this wallet. */
getNpcAddress(): Promise<NpcAddress>;
/** Claim an NPC username; pass confirm=true to pay the claim fee from the wallet. */
setNpcUsername(username: string, confirm?: boolean): Promise<NpcUsernameResult>;
/** Manually trigger an NPC quote sync into the wallet. */
syncNpc(): Promise<void>;
/** Clear stuck pending/in-flight wallet operations that are safe to resolve. */
cleanupStuckOperations?(
options?: WalletCleanupOptions,
): Promise<WalletCleanupResult>;
/**
* Re-issue PAID mint quotes whose sats were never claimed, optionally
* targeting specific operations (including ones coco already failed).
*/
recoverMintQuotes?(
options?: WalletMintQuoteRecoveryOptions,
onProgress?: (message: string) => void,
): Promise<WalletMintQuoteRecoveryResult>;
/**
* Recover stuck send/melt/mint operations whose mints answer a
* reachability probe. Operations a live execute holds are reported busy,
* never driven. Receive stays startup-only (receive dedup classification).
*/
recoverStuckOperations?(): Promise<WalletStuckOperationRecoveryResult>;
/** Report background wallet recovery progress, when the wallet supports it. */
getRecoveryProgress?(): Promise<WalletRecoveryProgress>;
}
export function resolveCocodExecutable(cocodPath?: string | null): string {
const trimmed = cocodPath?.trim();
return trimmed || "cocod";
}
export async function isCocodInstalled(
cocodPath?: string | null,
): Promise<boolean> {
const executable = resolveCocodExecutable(cocodPath);
if (
isAbsolute(executable) ||
executable.includes("/") ||
executable.includes("\\")
) {
return existsSync(executable);
}
try {
const command = process.platform === "win32" ? "where.exe" : "which";
const proc = Bun.spawn({
cmd: [command, executable],
stdout: "ignore",
stderr: "ignore",
});
return (await proc.exited) === 0;
} catch {
return false;
}
}
export function normalizeBalances(
output: CocodBalanceOutput | undefined,
): Record<string, number> {
if (!output) return {};
return Object.fromEntries(
Object.entries(output).map(([mintUrl, value]) => {
if (typeof value === "number") {
return [mintUrl, value];
}
return [mintUrl, Number(value?.sats ?? 0)];
}),
);
}
function parseMintList(output: string | undefined): string[] {
return (output || "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function toErrorText(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (value === null || value === undefined) {
return "";
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function tokenFingerprint(token: string): string {
return createHash("sha256").update(token).digest("hex").slice(0, 12);
}
export function createCocodClient(
options: {
cocodPath?: string | null;
socketPath?: string;
fetchImpl?: CocodFetch;
spawnDaemon?: SpawnDaemon;
pollIntervalMs?: number;
startupTimeoutMs?: number;
} = {},
): CocodClient {
const executable = resolveCocodExecutable(options.cocodPath);
const socketPath = options.socketPath || DEFAULT_SOCKET_PATH;
const startupLockPath = `${socketPath}.startup.lock`;
const fetchImpl = options.fetchImpl || (fetch as CocodFetch);
const pollIntervalMs = options.pollIntervalMs ?? 100;
const startupTimeoutMs = options.startupTimeoutMs ?? 5000;
const spawnDaemon: SpawnDaemon =
options.spawnDaemon ||
((args, env) => {
const proc = Bun.spawn(args, {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
detached: true,
env,
});
proc.unref();
return proc;
});
let startPromise: Promise<void> | null = null;
async function fetchJson<T>(
path: string,
init: Omit<UnixRequestInit, "unix"> = {},
): Promise<CommandResponse<T>> {
const method = init.method || "GET";
const requestInit: UnixRequestInit = {
...init,
unix: socketPath,
};
const response = await fetchImpl(`http://localhost${path}`, requestInit);
const rawText = await response.text();
// logger.debug(
// `[fetchJson] ${method} ${path} status=${response.status} body=${rawText}`,
// );
if (!rawText.trim()) {
throw new CocodHttpError(
response.ok ? 502 : response.status,
`Empty response from cocod for ${method} ${path}`,
);
}
let data: CommandResponse<T>;
try {
data = JSON.parse(rawText) as CommandResponse<T>;
} catch {
throw new CocodHttpError(
response.ok ? 502 : response.status,
`Invalid JSON response from cocod for ${method} ${path}`,
);
}
if (!data || typeof data !== "object") {
throw new CocodHttpError(
response.ok ? 502 : response.status,
`Unexpected response shape from cocod for ${method} ${path}`,
);
}
const errorMessage = toErrorText((data as CommandResponse<T>).error);
if (errorMessage) {
throw new CocodHttpError(
response.ok ? 400 : response.status,
errorMessage,
);
}
if (!response.ok) {
throw new CocodHttpError(
response.status,
data.error || response.statusText || `HTTP ${response.status}`,
);
}
return data;
}
async function pingInternal(): Promise<boolean> {
try {
await fetchJson<string>("/ping");
return true;
} catch {
return false;
}
}
async function startDaemon(): Promise<void> {
const env = { ...process.env, COCOD_SOCKET: socketPath };
const proc = spawnDaemon([executable, "init"], env);
const maxPolls = Math.ceil(startupTimeoutMs / pollIntervalMs);
let exitCode: number | null = null;
void proc.exited.then((code) => {
exitCode = code;
});
for (let i = 0; i < maxPolls; i++) {
await delay(pollIntervalMs);
if (exitCode !== null && exitCode !== 0) {
throw new Error(`cocod init exited early with code ${exitCode}`);
}
if (await pingInternal()) {
logger.debug(`Connected to cocod daemon on ${socketPath}`);
return;
}
}
throw new Error(
`cocod failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds`,
);
}
async function ensureDaemonRunning(): Promise<void> {
if (await pingInternal()) {
return;
}
if (!startPromise) {
startPromise = withCrossProcessLock(
startupLockPath,
async () => {
if (await pingInternal()) {
return;
}
logger.debug(`Starting cocod daemon via ${executable} init...`);
await startDaemon();
},
{
acquireTimeoutMs: startupTimeoutMs + 30_000,
staleAfterMs: startupTimeoutMs + 30_000,
log: (message) => logger.debug(message),
},
).finally(() => {
startPromise = null;
});
}
await startPromise;
}
async function callDaemon<T>(
path: string,
init: Omit<UnixRequestInit, "unix"> = {},
): Promise<T> {
await ensureDaemonRunning();
const response = await fetchJson<T>(path, init);
return response.output as T;
}
function post<T>(path: string, body: Record<string, unknown>): Promise<T> {
return callDaemon<T>(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
return {
async ping(): Promise<boolean> {
return pingInternal();
},
async getStatus(): Promise<CocodState> {
return callDaemon<CocodState>("/status");
},
async unlock(passphrase: string): Promise<string> {
return post<string>("/unlock", { passphrase });
},
async getBalances(): Promise<Record<string, number>> {
const output = await callDaemon<CocodBalanceOutput>("/balance");
return normalizeBalances(output);
},
async receiveCashu(token: string): Promise<string> {
logger.debug(
`[receiveCashu] Receiving Cashu token ${tokenFingerprint(token)}`,
);
const message = await callDaemon<string>("/receive/cashu", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
if (typeof message !== "string" || !message.trim()) {
throw new CocodHttpError(
502,
"Unexpected response from cocod while receiving Cashu token.",
);
}
logger.debug(`[receiveCashu] Full response.output:`, message);
return message;
},
async receiveBolt11(amount: number, mintUrl?: string) {
return { invoice: await post<string>("/receive/bolt11", { amount, mintUrl }) };
},
async sendCashu(amount: number, mintUrl?: string): Promise<string> {
return post<string>("/send/cashu", { amount, mintUrl });
},
async sendBolt11(invoice: string, mintUrl?: string): Promise<string> {
return post<string>("/send/bolt11", { invoice, mintUrl });
},
async listMints(): Promise<string[]> {
const output = await callDaemon<string>("/mints/list");
return parseMintList(output);
},
async addMint(url: string): Promise<string> {
return post<string>("/mints/add", { url });
},
async getMintInfo(url: string): Promise<unknown> {
return post<unknown>("/mints/info", { url });
},
async getDefaultMint(): Promise<string | null> {
return callDaemon<string | null>("/mints/default");
},
async setDefaultMint(url: string): Promise<string> {
return post<string>("/mints/default", { url });
},
async getHistory(_offset?: number, _limit?: number): Promise<HistoryEntry[]> {
return [];
},
async getHistoryEntryById(_id: string): Promise<HistoryEntry | null> {
return null;
},
async getNpcAddress(): Promise<NpcAddress> {
const address = await callDaemon<string>("/npc/address");
if (typeof address !== "string" || !address.trim()) {
throw new CocodHttpError(
502,
"Unexpected response from cocod while fetching NPC address.",
);
}
const trimmed = address.trim();
const localPart = trimmed.split("@")[0];
return {
address: trimmed,
...(localPart && !localPart.startsWith("npub1")
? { name: localPart }
: {}),
};
},
async setNpcUsername(
username: string,
confirm?: boolean,
): Promise<NpcUsernameResult> {
// cocod answers 402 with a payment-required message when the claim fee
// has not been confirmed; fetchJson preserves that status on the thrown
// CocodHttpError, so callers can surface it unchanged.
const result = await post<{ success?: boolean }>("/npc/username", {
username,
confirm: confirm === true,
});
return { success: result?.success !== false };
},
async syncNpc(): Promise<void> {
throw new CocodHttpError(
501,
"Manual NPC sync is not supported by the legacy cocod client.",
);
},
};
}
+240
View File
@@ -0,0 +1,240 @@
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";
import { RestrictedError } from "applesauce-wallet-connect/helpers/error";
import type { WalletAdapterOptions } from "./index";
/**
* Regression tests for the NWC hang fixed in this change.
*
* `applesauce-wallet-connect` applies its request timeout only after it has
* negotiated encryption from the wallet's `kind:13194` info event. On a stale
* relay subscription that negotiation never completes, so `getInfo()` /
* `getBalance()` wait forever. The adapter must bound the wait, rebuild the
* relay connection, and retry once instead of hanging `routstrd nwc status`.
*/
type Behavior = "resolve" | "hang" | "restricted" | "library-timeout" | "deferred";
const state = {
infoQueue: [] as Behavior[],
balanceQueue: [] as Behavior[],
payQueue: [] as Behavior[],
createdInstances: 0,
pendingPayments: [] as (() => void)[],
paymentStarted: undefined as (() => void) | undefined,
closedPools: 0,
};
function next(queue: Behavior[]): Behavior {
return queue.shift() ?? "resolve";
}
class MockRelayPool {
relays = new Map<string, unknown>([["wss://relay.example", {}]]);
remove(url: string, _close?: boolean): void {
state.closedPools++;
this.relays.delete(url);
}
}
class MockWalletConnect {
service = "ab".repeat(32);
relays = ["wss://relay.example"];
constructor() {
state.createdInstances += 1;
}
static fromConnectURI(_uri: string): MockWalletConnect {
return new MockWalletConnect();
}
waitForService(): Promise<string> {
return Promise.resolve(this.service);
}
getInfo(): Promise<{
alias: string;
pubkey: string;
network: string;
methods: string[];
}> {
const behavior = next(state.infoQueue);
if (behavior === "hang") return new Promise<never>(() => {});
if (behavior === "restricted") return Promise.reject(new RestrictedError("restricted"));
if (behavior === "library-timeout") return Promise.reject(new Error("Timeout"));
return Promise.resolve({
alias: "Test Wallet",
pubkey: "cd".repeat(32),
network: "mainnet",
methods: ["get_balance", "get_info"],
});
}
getBalance(): Promise<{ balance: number }> {
const behavior = next(state.balanceQueue);
if (behavior === "hang") return new Promise<never>(() => {});
if (behavior === "restricted") return Promise.reject(new RestrictedError("restricted"));
return Promise.resolve({ balance: 123_000 });
}
payInvoice(
_invoice: string,
): Promise<{ preimage: string; fees_paid: number }> {
const behavior = next(state.payQueue);
if (behavior === "hang") return new Promise<never>(() => {});
if (behavior === "restricted") return Promise.reject(new RestrictedError("restricted"));
if (behavior === "library-timeout") return Promise.reject(new Error("Timeout"));
if (behavior === "deferred") {
return new Promise((resolve) => {
const closedAtStart = state.closedPools;
state.pendingPayments.push(() => {
// Closing the shared pool loses the original payment response.
if (state.closedPools === closedAtStart) {
resolve({ preimage: "00".repeat(32), fees_paid: 1000 });
}
});
state.paymentStarted?.();
});
}
return Promise.resolve({ preimage: "00".repeat(32), fees_paid: 1000 });
}
}
mock.module("applesauce-wallet-connect", () => ({
WalletConnect: MockWalletConnect,
}));
mock.module("applesauce-relay", () => ({ RelayPool: MockRelayPool }));
const { createWalletAdapter } = await import("./index");
/**
* Type of the injected wallet client, derived from the adapter options so this
* test keeps compiling when the legacy `CocodClient` is replaced (see #118).
*/
type WalletClientOption = NonNullable<WalletAdapterOptions["walletClient"]>;
function makeClient(): WalletClientOption {
return {
getBalances: async () => ({ "https://mint.example": 0 }),
getDefaultMint: async () => "https://mint.example",
receiveBolt11: async () => ({ invoice: "lnbc-test-invoice" }),
receiveCashu: async () => "ok",
} as unknown as WalletClientOption;
}
function makeAdapter(timeoutMs = 25) {
return createWalletAdapter({
walletClient: makeClient(),
nwcConnectionString:
"nostr+walletconnect://" +
"ab".repeat(32) +
"?relay=wss%3A%2F%2Frelay.example&secret=" +
"11".repeat(32),
nwcReadTimeoutMs: timeoutMs,
nwcPayTimeoutMs: timeoutMs,
});
}
beforeEach(() => {
state.infoQueue = [];
state.balanceQueue = [];
state.payQueue = [];
state.createdInstances = 0;
state.closedPools = 0;
state.pendingPayments = [];
state.paymentStarted = undefined;
});
afterAll(() => {
mock.restore();
});
describe("NWC status resilience", () => {
it("rebuilds a stale relay connection and still reports status", async () => {
state.infoQueue = ["hang"]; // first attempt stalls, retry succeeds
const adapter = await makeAdapter();
const status = await adapter.getNwcStatus();
expect(status.connected).toBe(true);
expect(status.alias).toBe("Test Wallet");
expect(status.balance).toBe(123);
// initial connection + one rebuild
expect(state.createdInstances).toBe(2);
});
it("returns a bounded error instead of hanging forever", async () => {
state.infoQueue = ["hang", "hang"]; // every attempt stalls
const adapter = await makeAdapter();
const startedAt = Date.now();
const status = await adapter.getNwcStatus();
expect(status.connected).toBe(false);
expect(status.error).toContain("timed out");
expect(Date.now() - startedAt).toBeLessThan(2_000);
});
it("bounds a hung invoice payment and surfaces the error", async () => {
state.payQueue = ["hang"];
const adapter = await makeAdapter();
const startedAt = Date.now();
const result = await adapter.fundFromNWC(2100);
expect(result.success).toBe(false);
expect(result.error).toContain("timed out");
expect(Date.now() - startedAt).toBeLessThan(2_000);
});
});
describe("NWC wallet errors versus transport stalls", () => {
it("does not retry or rebuild on a wallet read error", async () => {
state.infoQueue = ["restricted"];
const adapter = await makeAdapter();
expect((await adapter.getNwcStatus()).error).toBe("restricted");
expect(state.createdInstances).toBe(1);
expect(state.closedPools).toBe(0);
});
it("keeps an in-flight payment alive during a pay-only status check", async () => {
state.payQueue = ["deferred"];
state.balanceQueue = ["restricted"];
const adapter = await makeAdapter(500);
const started = new Promise<void>((resolve) => { state.paymentStarted = resolve; });
const payment = adapter.fundFromNWC(2100);
await started;
expect((await adapter.getNwcStatus()).connected).toBe(true);
expect(state.createdInstances).toBe(1);
state.pendingPayments[0]!();
expect((await payment).success).toBe(true);
expect(state.closedPools).toBe(0);
});
it("keeps a slow payment alive when an overlapping payment gets a wallet error", async () => {
state.payQueue = ["deferred", "restricted"];
const adapter = await makeAdapter(500);
const started = new Promise<void>((resolve) => { state.paymentStarted = resolve; });
const slow = adapter.fundFromNWC(2100);
await started;
expect((await adapter.fundFromNWC(2100)).error).toBe("restricted");
state.pendingPayments[0]!();
expect((await slow).success).toBe(true);
expect(state.createdInstances).toBe(1);
});
it("rebuilds and retries reads on the library's own timeout", async () => {
state.infoQueue = ["library-timeout"];
const adapter = await makeAdapter();
expect((await adapter.getNwcStatus()).connected).toBe(true);
expect(state.createdInstances).toBe(2);
});
it("rebuilds after the library's payment timeout without retrying payment", async () => {
state.payQueue = ["library-timeout", "restricted"];
const adapter = await makeAdapter();
expect((await adapter.fundFromNWC(2100)).error).toBe("Timeout");
expect(state.createdInstances).toBe(2);
expect(state.payQueue).toEqual(["restricted"]);
});
});
+168 -50
View File
@@ -1,11 +1,24 @@
import { getTokenMetadata } from "@cashu/cashu-ts";
import { InsufficientBalanceError } from "@routstr/sdk";
import { WalletConnect } from "applesauce-wallet-connect";
import { WalletBaseError } from "applesauce-wallet-connect/helpers/error";
import { RelayPool } from "applesauce-relay";
import { logger } from "../../utils/logger";
import { createCocodClient, type CocodClient } from "./cocod-client";
import { withTimeout } from "../../utils/with-timeout";
import type { WalletClient } from "./wallet-client";
import { startAutoRefillLoop, type AutoRefillConfig } from "./auto-refill";
/**
* NWC reads (get_info/get_balance) should answer in a couple of seconds. If
* they don't, the long-lived relay subscription is presumed stale and the
* connection is rebuilt before one retry.
*/
const NWC_READ_TIMEOUT_MS = 15_000;
/** Overall bound including encryption negotiation; replies have a library 30s timeout. */
const NWC_PAY_TIMEOUT_MS = 45_000;
type NwcPayment = { preimage?: string; fees_paid?: number };
export function decodeCashuTokenAmount(token: string): {
amount: number;
unit: "sat" | "msat";
@@ -21,7 +34,7 @@ export function decodeCashuTokenAmount(token: string): {
}
export async function receiveCashuToken(
client: Pick<CocodClient, "receiveCashu">,
client: Pick<WalletClient, "receiveCashu">,
token: string,
): Promise<{ message: string; amount: number; unit: "sat" | "msat" }> {
// Validate the token before handing it to a state-changing wallet call. This
@@ -33,10 +46,14 @@ export async function receiveCashuToken(
}
export interface WalletAdapterOptions {
cocodPath?: string | null;
walletClient?: CocodClient;
/** The in-process wallet engine. Required — construct it with `createCocoClient()`. */
walletClient: WalletClient;
/** NWC connection string for Lightning funding (uses applesauce-wallet-connect) */
nwcConnectionString?: string;
/** Override the NWC read timeout in milliseconds (test hook). */
nwcReadTimeoutMs?: number;
/** Override the NWC payment timeout in milliseconds (test hook). */
nwcPayTimeoutMs?: number;
/** Auto-refill configuration (static, for startup only) */
autoRefill?: AutoRefillConfig;
/**
@@ -48,10 +65,9 @@ export interface WalletAdapterOptions {
}
export async function createWalletAdapter(
options: WalletAdapterOptions = {},
options: WalletAdapterOptions,
) {
const client =
options.walletClient || createCocodClient({ cocodPath: options.cocodPath });
const client = options.walletClient;
let activeMintUrl: string | null = null;
let mintUnits: Record<string, "sat" | "msat"> = {};
@@ -82,19 +98,44 @@ export async function createWalletAdapter(
let wallet: WalletConnect | undefined;
let pool: RelayPool | undefined;
let nwcConnectionString = options.nwcConnectionString;
const nwcReadTimeoutMs = options.nwcReadTimeoutMs ?? NWC_READ_TIMEOUT_MS;
const nwcPayTimeoutMs = options.nwcPayTimeoutMs ?? NWC_PAY_TIMEOUT_MS;
// Getter for the current wallet instance (used by auto-refill loop)
const getWallet = (): WalletConnect | undefined => wallet;
if (options.nwcConnectionString) {
pool = new RelayPool();
wallet = WalletConnect.fromConnectURI(options.nwcConnectionString, { pool });
/** Close the active relay pool, if any. */
function closeNwcPool(): void {
if (pool) {
for (const [url] of pool.relays) {
pool.remove(url, true);
}
}
pool = undefined;
}
/**
* (Re)create the relay pool + WalletConnect client for a connection string.
* Shared by interactive connects and self-healing recovery so both paths use
* identical setup.
*/
function connectNwc(connectionString: string, reason: string): void {
const nextPool = new RelayPool();
const nextWallet = WalletConnect.fromConnectURI(connectionString, {
pool: nextPool,
});
pool = nextPool;
wallet = nextWallet;
nwcConnectionString = connectionString;
// Connect in background (non-blocking)
wallet.waitForService()
nextWallet
.waitForService()
.then(() => {
logger.log(
`[nwc] NWC wallet connected. Relay: ${wallet!.relays[0]}, Service: ${wallet!.service}`,
`[nwc] NWC wallet ${reason}. Relay: ${nextWallet.relays[0]}, Service: ${nextWallet.service}`,
);
})
.catch((err) => {
@@ -102,39 +143,97 @@ export async function createWalletAdapter(
});
}
/**
* Rebuild the NWC connection in place after a request stalled. applesauce's
* request timeout only covers the response stream, so a stale relay
* subscription can leave `getInfo`/`getBalance` pending forever while it
* negotiates encryption. Recreating the pool gives the next call a fresh
* subscription.
*/
function rebuildNwcConnection(reason: string): void {
if (!nwcConnectionString) return;
closeNwcPool();
wallet = undefined;
connectNwc(nwcConnectionString, reason);
}
/**
* Run an idempotent NWC read, bounding the wait and rebuilding the connection
* once if it stalls.
*/
async function nwcRead<T>(
label: string,
operation: (w: WalletConnect) => Promise<T>,
): Promise<T> {
const first = wallet;
if (!first?.service) {
throw new Error("NWC not connected");
}
try {
return await withTimeout(
operation(first),
nwcReadTimeoutMs,
`${label} timed out`,
);
} catch (error) {
// A normal NIP-47 error proves the wallet answered. Keep other calls alive.
if (error instanceof WalletBaseError || !nwcConnectionString) throw error;
logger.warn(
`[nwc] ${label} failed (${(error as Error).message}); rebuilding NWC connection and retrying`,
);
rebuildNwcConnection("reconnected after stall");
const retry = wallet;
if (!retry?.service) throw error;
return await withTimeout(
operation(retry),
nwcReadTimeoutMs,
`${label} timed out after reconnect`,
);
}
}
/**
* Pay a BOLT-11 invoice over NWC with a bounded wait. A timeout rebuilds the
* relay connection so later calls recover without a daemon restart. The
* payment is not retried here. A timeout is an unknown payment outcome,
* not proof of failure; callers must reconcile before trying a fresh invoice.
*/
async function payNwcInvoice(invoice: string): Promise<NwcPayment> {
const payer = wallet;
if (!payer?.service) throw new Error("NWC not connected");
try {
return await withTimeout(
payer.payInvoice(invoice),
nwcPayTimeoutMs,
"NWC payment timed out",
);
} catch (error) {
// Include the library's own timeout, but not normal wallet error replies.
if (!(error instanceof WalletBaseError)) {
rebuildNwcConnection("reconnected after payment stall");
}
throw error;
}
}
if (options.nwcConnectionString) {
connectNwc(options.nwcConnectionString, "connected");
}
const walletAdapter = {
async reconnect(connectionString?: string): Promise<void> {
logger.log(
`[nwc] Reconnecting NWC wallet... ${connectionString ? "new connection string provided" : "disconnecting"}`,
);
// 1. Close existing relay pool connections
if (pool) {
for (const [url] of pool.relays) {
pool.remove(url, true);
}
}
// 2. Update wallet reference
// Close existing relay pool connections and update the wallet reference
closeNwcPool();
wallet = undefined;
pool = undefined;
// 3. Create new wallet if connection string provided
if (connectionString) {
pool = new RelayPool();
wallet = WalletConnect.fromConnectURI(connectionString, { pool });
// Connect in background (non-blocking)
wallet.waitForService()
.then(() => {
logger.log(
`[nwc] NWC wallet reconnected. Relay: ${wallet!.relays[0]}, Service: ${wallet!.service}`,
);
})
.catch((err) => {
logger.error(`[nwc] NWC reconnection failed: ${err.message}`);
});
connectNwc(connectionString, "reconnected");
} else {
nwcConnectionString = undefined;
logger.log("[nwc] NWC wallet disconnected.");
}
},
@@ -192,9 +291,9 @@ export async function createWalletAdapter(
const { invoice } = await client.receiveBolt11(amount, mintUrl);
logger.log(`[nwc] Invoice: ${invoice}`);
// Step 3: Pay it via NWC
// Step 3: Pay it via NWC (bounded — a stale relay must not hang the CLI)
logger.log("[nwc] Paying invoice via NWC...");
const { preimage, fees_paid } = await wallet.payInvoice(invoice);
const { preimage, fees_paid } = await payNwcInvoice(invoice);
logger.log(`[nwc] ✅ Payment successful!`);
logger.log(`[nwc] Preimage: ${preimage}`);
if (fees_paid !== undefined) {
@@ -244,10 +343,10 @@ export async function createWalletAdapter(
}
try {
const info = await wallet.getInfo();
const info = await nwcRead("get_info", (w) => w.getInfo());
let balance: number | undefined;
try {
const bal = await wallet.getBalance();
const bal = await nwcRead("get_balance", (w) => w.getBalance());
balance = Math.floor(bal.balance / 1000); // msats → sats
} catch {
// Balance might not be available
@@ -326,21 +425,40 @@ export async function createWalletAdapter(
let stopAutoRefill: (() => void) | undefined;
/**
* Start the auto-refill loop if it is not already running. Safe to call more
* than once; it becomes a no-op after the first call.
*/
function ensureAutoRefillLoop(): void {
if (stopAutoRefill) return;
const getConfig = options.getAutoRefillConfig ?? (() => options.autoRefill);
stopAutoRefill = startAutoRefillLoop(
client,
getWallet,
getConfig,
5000,
payNwcInvoice,
);
}
const autoRefillConfig = options.getAutoRefillConfig
? options.getAutoRefillConfig()
: options.autoRefill;
if (autoRefillConfig && wallet) {
const getConfig = options.getAutoRefillConfig ?? (() => options.autoRefill);
stopAutoRefill = startAutoRefillLoop(client, getWallet, getConfig);
logger.log(
`[wallet] Auto-refill enabled: threshold=${autoRefillConfig.threshold} sats, amount=${autoRefillConfig.amount} sats, cooldown=${autoRefillConfig.cooldownMs / 60000} minutes`,
);
} else if (wallet && options.getAutoRefillConfig) {
// Wallet exists but auto-refill is not currently enabled.
// Start the loop anyway so it can pick up changes without a restart.
stopAutoRefill = startAutoRefillLoop(client, getWallet, options.getAutoRefillConfig);
logger.log("[wallet] Auto-refill loop started (currently disabled — enable via CLI to activate)");
if (options.getAutoRefillConfig || options.autoRefill) {
// Start the loop even when no wallet is connected yet: it reads the wallet
// and config fresh each cycle, so a later `nwc connect` activates refills
// without a daemon restart.
ensureAutoRefillLoop();
if (autoRefillConfig) {
logger.log(
`[wallet] Auto-refill enabled: threshold=${autoRefillConfig.threshold} sats, amount=${autoRefillConfig.amount} sats, cooldown=${autoRefillConfig.cooldownMs / 60000} minutes`,
);
} else {
logger.log(
"[wallet] Auto-refill loop started (currently disabled — enable via CLI to activate)",
);
}
}
try {
+206
View File
@@ -0,0 +1,206 @@
import type { HistoryEntry } from "@cashu/coco-core";
/**
* Contract shared by every wallet implementation.
*
* The wallet runs in-process (`createCocoClient` in `./coco-client`); this
* module only describes what the rest of routstrd is allowed to ask of it.
* Historically this lived in a `cocod-client` module that also shipped an
* HTTP/unix-socket client for the external `cocod` binary. That client was
* removed once the in-process wallet replaced it — see `./migration` and the
* `legacyCocod*` path helpers in `./paths` for the migration-only code that
* still knows about external cocod installs.
*/
export type WalletRuntimeState =
| "UNINITIALIZED"
| "LOCKED"
| "UNLOCKED"
| "RECOVERING"
| "ERROR";
/** Live progress for background wallet recovery started at daemon startup. */
export interface WalletRecoveryProgress {
state: "RECOVERING" | "UNLOCKED" | "ERROR";
/** Current recovery phase, e.g. "Mint recovery" or "done". */
phase: string;
pendingSends: number;
inflightProofs: number;
pendingMints: number;
/** Expired unpaid mint quotes failed locally without a mint round-trip. */
failedMintQuotes: number;
error?: string;
}
/** NPC (npubx.cash) Lightning address details for this wallet. */
export interface NpcAddress {
/** Full Lightning address, e.g. "alice@npubx.cash" (npub fallback when no username is set). */
address: string;
/** NPC username, when one has been claimed. */
name?: string;
/** Nostr hex pubkey of the NPC account (only available from the in-process wallet). */
pubkey?: string;
}
/** Result of an NPC username claim attempt. */
export interface NpcUsernameResult {
success: boolean;
/** Present when NPC requires payment to claim the username. */
paymentRequest?: {
amount?: number;
mints?: string[];
[key: string]: unknown;
};
}
/** Options for the wallet cleanup command. */
export interface WalletCleanupOptions {
/** Only clean up operations for this mint URL. */
mintUrl?: string;
/** Minimum operation age in milliseconds (defaults to 7 days / 1 week). */
minAgeMs?: number;
/** Report what would be cleaned without applying changes. */
dryRun?: boolean;
/**
* Fail expired mint quotes without confirming UNPAID with the mint. Only for
* operators who accept the risk of stranding a quote that was paid before
* its invoice expired; recovery is the safe default.
*/
force?: boolean;
}
/** Summary of a wallet cleanup run. */
export interface WalletCleanupResult {
dryRun: boolean;
/** Expired quotes selected for checking; dry runs do not contact the mint. */
mintQuoteCandidates: number;
/** Number actually marked failed (always zero in a dry run). */
failedMintQuotes: number;
/** Expired quotes kept pending because they are paid/issued or unverified. */
leftForRecovery: number;
/** Number of stale pending send operations reclaimed. */
reclaimedSends: number;
/** Number of stale prepared melt operations cancelled. */
cancelledMelts: number;
/** Number of in-flight operations that were left untouched. */
skipped: number;
errors: Array<{ operationId: string; error: string }>;
}
export class WalletHttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "WalletHttpError";
this.status = status;
}
}
/** Progress of a Lightning top-up created with `receiveBolt11`. */
export interface MintQuoteStatus {
operationId: string;
state: "pending" | "executing" | "finalized" | "failed";
/** Last quote state reported by the mint (UNPAID, PAID, ISSUED). */
mintState?: string;
amount: number;
mintUrl: string;
error?: string;
}
/** Options for explicit PAID mint-quote recovery. */
export interface WalletMintQuoteRecoveryOptions {
/** Target only these operation ids (may include failed operations). */
operationIds?: string[];
/** Re-open failed operations instead of skipping them. */
includeFailed?: boolean;
/** Per-quote mint timeout in milliseconds. */
timeoutMs?: number;
}
/** Summary of a PAID mint-quote recovery run. */
export interface WalletMintQuoteRecoveryResult {
/** Operations whose quote state was checked with the mint. */
checked: number;
/** Operations whose paid sats were minted or restored. */
recovered: number;
/** Quotes the mint still reports UNPAID; left pending. */
waiting: number;
/** Quotes the mint can no longer issue. */
terminal: number;
/** Failed operations moved back to pending before checking. */
reopened: number;
/** Operations left to a later run (mint unreachable, budget spent, non-terminal). */
retryable: number;
/** Operations skipped because an earlier recovery of them is still running. */
busy: number;
errors: Array<{ operationId: string; error: string }>;
}
/** Summary of a stuck-operation (send/melt/mint) recovery run. */
export interface WalletStuckOperationRecoveryResult {
/** Timed-out waits; the underlying operation remains tracked. */
timedOut: number;
/** Operations for which recovery was attempted (not necessarily completed). */
attempted: number;
/** Locked operations or unfinished work from another pass; retry later. */
busy: number;
/** Operations skipped for unreachable mints, shutdown, or pass budget exhaustion. */
skipped: number;
/** Operations at reachable mints whose recovery still failed. */
failed: number;
/** Unreachable mint URL -> number of operations skipped there. */
skippedMints: Record<string, number>;
}
export interface WalletClient {
ping(): Promise<boolean>;
getStatus(): Promise<WalletRuntimeState>;
unlock(passphrase: string): Promise<string>;
getBalances(): Promise<Record<string, number>>;
receiveCashu(token: string): Promise<string>;
receiveBolt11(
amount: number,
mintUrl?: string,
): Promise<{ invoice: string; operationId?: string }>;
/** Progress of a top-up, when the wallet tracks mint operations. */
getMintQuote?(operationId: string): Promise<MintQuoteStatus | null>;
sendCashu(amount: number, mintUrl?: string): Promise<string>;
sendBolt11(invoice: string, mintUrl?: string): Promise<string>;
listMints(): Promise<string[]>;
addMint(url: string): Promise<string>;
getMintInfo(url: string): Promise<unknown>;
getDefaultMint(): Promise<string | null>;
setDefaultMint(url: string): Promise<string>;
/** Release resources held by in-process wallet implementations. */
dispose?(): Promise<void>;
getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]>;
/** Look up a single transaction by its history entry ID. */
getHistoryEntryById(id: string): Promise<HistoryEntry | null>;
/** NPC (npubx.cash) Lightning address for this wallet. */
getNpcAddress(): Promise<NpcAddress>;
/** Claim an NPC username; pass confirm=true to pay the claim fee from the wallet. */
setNpcUsername(username: string, confirm?: boolean): Promise<NpcUsernameResult>;
/** Manually trigger an NPC quote sync into the wallet. */
syncNpc(): Promise<void>;
/** Clear stuck pending/in-flight wallet operations that are safe to resolve. */
cleanupStuckOperations?(
options?: WalletCleanupOptions,
): Promise<WalletCleanupResult>;
/**
* Re-issue PAID mint quotes whose sats were never claimed, optionally
* targeting specific operations (including ones coco already failed).
*/
recoverMintQuotes?(
options?: WalletMintQuoteRecoveryOptions,
onProgress?: (message: string) => void,
): Promise<WalletMintQuoteRecoveryResult>;
/**
* Recover stuck send/melt/mint operations whose mints answer a
* reachability probe. Operations a live execute holds are reported busy,
* never driven. Receive stays startup-only (receive dedup classification).
*/
recoverStuckOperations?(): Promise<WalletStuckOperationRecoveryResult>;
/** Report background wallet recovery progress, when the wallet supports it. */
getRecoveryProgress?(): Promise<WalletRecoveryProgress>;
}
-2
View File
@@ -46,7 +46,6 @@ export interface RoutstrdConfig {
port: number;
host: string;
provider: string | null;
cocodPath: string | null;
mode?: "xcashu" | "apikeys";
/** Opt into SDK automatic model-path selection for DeepSeek V4.1 Flash. Disabled by default; requires daemon restart after changing. */
autoModelPath?: boolean;
@@ -87,7 +86,6 @@ export const DEFAULT_CONFIG: RoutstrdConfig = {
port: 8008,
host: "127.0.0.1",
provider: null,
cocodPath: null,
mode: "apikeys",
autoModelPath: false,
maxTokens: 64000,
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
callDaemonUrl,
DAEMON_LONG_REQUEST_TIMEOUT_MS,
DAEMON_REQUEST_TIMEOUT_MS,
} from "./daemon-client";
import { DEFAULT_CONFIG } from "./config";
const originalFetch = globalThis.fetch;
const originalAbortTimeout = AbortSignal.timeout;
/** Deadline (ms) passed to AbortSignal.timeout by the last request. */
let requestedDeadlines: number[] = [];
afterEach(() => {
globalThis.fetch = originalFetch;
AbortSignal.timeout = originalAbortTimeout;
requestedDeadlines = [];
});
/**
* Record the requested deadline and arm a fast one instead, so tests do not
* wait out the real 120s/600s bounds.
*/
function shortenDeadline(): void {
AbortSignal.timeout = ((ms: number) => {
requestedDeadlines.push(ms);
return originalAbortTimeout(20);
}) as typeof AbortSignal.timeout;
}
function stalledResponse(status = 200): void {
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
const signal = init?.signal;
return new Response(new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"output":'));
signal?.addEventListener("abort", () => controller.error(signal.reason), { once: true });
},
}), { status });
}) as unknown as typeof fetch;
}
const request = (path = "/nwc/status") =>
callDaemonUrl("http://daemon.example", path, {}, DEFAULT_CONFIG);
describe("daemon request deadline", () => {
test("bounds the wait for response headers", async () => {
shortenDeadline();
globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) => new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true });
})) as unknown as typeof fetch;
await expect(request()).rejects.toThrow("Daemon request timed out");
});
for (const status of [200, 500]) {
test(`bounds a stalled ${status} response body`, async () => {
shortenDeadline();
stalledResponse(status);
await expect(request()).rejects.toThrow("payment outcome is unknown");
});
}
test("applies the default deadline to ordinary routes", async () => {
shortenDeadline();
globalThis.fetch = (async () => Response.json({ output: "ok" })) as unknown as typeof fetch;
expect(await request("/nwc/status")).toEqual({ output: "ok" });
expect(requestedDeadlines).toEqual([DAEMON_REQUEST_TIMEOUT_MS]);
});
for (const path of ["/wallet/send/bolt11", "/wallet/receive/cashu"]) {
test(`uses the long deadline for value-moving route ${path}`, async () => {
shortenDeadline();
globalThis.fetch = (async () => Response.json({ output: "paid" })) as unknown as typeof fetch;
expect(await request(path)).toEqual({ output: "paid" });
expect(requestedDeadlines).toEqual([DAEMON_LONG_REQUEST_TIMEOUT_MS]);
});
}
test("bounds a stalled body on a long-running route too", async () => {
shortenDeadline();
stalledResponse();
await expect(request("/wallet/send/bolt11")).rejects.toThrow("payment outcome is unknown");
expect(requestedDeadlines).toEqual([DAEMON_LONG_REQUEST_TIMEOUT_MS]);
});
test("preserves HTTP errors rather than labeling them connection failures", async () => {
globalThis.fetch = (async () => Response.json({ error: "restricted" }, { status: 403 })) as unknown as typeof fetch;
await expect(request()).rejects.toThrow("restricted");
});
});
+53 -9
View File
@@ -56,6 +56,32 @@ class DaemonConnectionError extends Error {
}
}
/**
* Upper bound for a single daemon request, including response-body
* consumption. The daemon bounds its own NWC operations, so this only guards
* against a wedged server; without it a hung request would block the CLI
* forever.
*/
export const DAEMON_REQUEST_TIMEOUT_MS = 120_000;
/**
* Upper bound for value-moving wallet routes. A cashu melt/swap can
* legitimately run longer than {@link DAEMON_REQUEST_TIMEOUT_MS} (the mint has
* no request timeout in routstrd), so these get a more generous bound that
* still prevents an indefinite CLI hang.
*/
export const DAEMON_LONG_REQUEST_TIMEOUT_MS = 600_000;
/** Routes that may legitimately outlive the default request timeout. */
const LONG_RUNNING_ROUTES = ["/wallet/send/", "/wallet/receive/"];
function requestTimeoutMs(path: string): number {
const pathname = path.split("?")[0] ?? path;
return LONG_RUNNING_ROUTES.some((route) => pathname.startsWith(route))
? DAEMON_LONG_REQUEST_TIMEOUT_MS
: DAEMON_REQUEST_TIMEOUT_MS;
}
export function getDaemonBaseUrl(config: RoutstrdConfig): string {
if (config.daemonUrl) {
return config.daemonUrl.replace(/\/$/, "");
@@ -70,7 +96,7 @@ export function getAuthBaseUrl(config: RoutstrdConfig): string {
return getDaemonBaseUrl(config);
}
async function _callUrl(
export async function callDaemonUrl(
baseUrl: string,
path: string,
options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object },
@@ -99,23 +125,41 @@ async function _callUrl(
if (authorization) headers.set("Authorization", authorization);
if (bodyString) headers.set("Content-Type", "application/json");
const timeoutMs = requestTimeoutMs(path);
const timeoutError = () =>
new Error(
`Daemon request timed out after ${timeoutMs / 1000}s; ` +
"any payment outcome is unknown — check before retrying",
);
// The signal stays armed while the body is read, so a daemon that sends
// headers and then stalls the body cannot hang the CLI either. Aborting
// here never cancels the daemon's operation — see timeoutError's warning.
const signal = AbortSignal.timeout(timeoutMs);
let response: Response;
try {
response = await fetch(url, {
method,
headers,
body: bodyString,
signal,
});
} catch (error) {
if (signal.aborted) throw timeoutError();
// Only connection failures qualify for alternate-host retries.
throw new DaemonConnectionError(error);
}
if (!response.ok) {
const errorData = (await response.json()) as { error?: string };
throw new Error(errorData.error || `HTTP ${response.status}`);
try {
if (!response.ok) {
const errorData = (await response.json()) as { error?: string };
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return (await response.json()) as CommandResponse;
} catch (error) {
if (signal.aborted) throw timeoutError();
throw error;
}
return response.json() as Promise<CommandResponse>;
}
async function callLocalDaemon(
@@ -127,7 +171,7 @@ async function callLocalDaemon(
let connectionError: DaemonConnectionError | undefined;
for (const baseUrl of localDaemonBaseUrls(config)) {
try {
return await _callUrl(baseUrl, path, options, config);
return await callDaemonUrl(baseUrl, path, options, config);
} catch (error) {
if (!(error instanceof DaemonConnectionError)) throw error;
connectionError = error;
@@ -142,7 +186,7 @@ export async function callDaemon(
): Promise<CommandResponse> {
const config = await loadConfig();
if (config.daemonUrl) {
return _callUrl(getDaemonBaseUrl(config), path, options, config);
return callDaemonUrl(getDaemonBaseUrl(config), path, options, config);
}
return callLocalDaemon(path, options, config);
}
@@ -157,7 +201,7 @@ export async function callAuth(
if (!config.authUrl && !config.daemonUrl) {
return callLocalDaemon(path, options, config);
}
return _callUrl(getAuthBaseUrl(config), path, options, config);
return callDaemonUrl(getAuthBaseUrl(config), path, options, config);
}
export async function isDaemonRunning(): Promise<boolean> {
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test";
import { withTimeout } from "./with-timeout";
describe("withTimeout", () => {
test("passes through a settled value", async () => {
await expect(withTimeout(Promise.resolve(42), 1000, "nope")).resolves.toBe(
42,
);
});
test("rejects with the provided message once the deadline elapses", async () => {
const never = new Promise<never>(() => {});
await expect(withTimeout(never, 20, "operation timed out")).rejects.toThrow(
"operation timed out",
);
});
test("propagates the original rejection", async () => {
await expect(
withTimeout(Promise.reject(new Error("boom")), 1000, "unused"),
).rejects.toThrow("boom");
});
});
+21
View File
@@ -0,0 +1,21 @@
/**
* Rejects when `timeoutMs` elapses before `promise` settles.
* This bounds the caller's wait; it does not cancel the underlying operation.
*
* Used to bound requests that may otherwise wait forever — notably NWC calls
* whose underlying library applies its own timeout only after a support/encryption
* handshake that can itself hang on a stale relay subscription.
*/
export function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
message = "Operation timed out",
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer !== undefined) clearTimeout(timer);
});
}
-1
View File
@@ -24,7 +24,6 @@ const baseConfig = {
port: 8008,
host: "127.0.0.1",
provider: null,
cocodPath: null,
};
function assert(cond: unknown, msg: string): void {
-1
View File
@@ -11,7 +11,6 @@ function config(host: string): RoutstrdConfig {
port: 8008,
host,
provider: null,
cocodPath: null,
};
}
+4 -4
View File
@@ -8,7 +8,7 @@ import {
createWalletAdapter,
decodeCashuTokenAmount,
} from "../../src/daemon/wallet";
import type { CocodClient } from "../../src/daemon/wallet/cocod-client";
import type { WalletClient } from "../../src/daemon/wallet/wallet-client";
// A full modern keyset ID with the same format as Minibits' post-migration
// active keyset. getEncodedToken stores only its first eight bytes in TokenV4.
@@ -38,11 +38,11 @@ function makeToken({
}
function makeWalletClient(
receiveCashu: CocodClient["receiveCashu"],
): CocodClient {
receiveCashu: WalletClient["receiveCashu"],
): WalletClient {
// createWalletAdapter is intentionally lazy; this receive-path test only
// needs the one capability exercised by receiveToken.
return { receiveCashu } as CocodClient;
return { receiveCashu } as WalletClient;
}
describe("short keyset TokenV4 compatibility", () => {