diff --git a/README.md b/README.md index 56173c9..af457d1 100644 --- a/README.md +++ b/README.md @@ -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 } } diff --git a/SKILL.md b/SKILL.md index 0302042..84a135b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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`) | diff --git a/docs/nwc-timeouts.md b/docs/nwc-timeouts.md new file mode 100644 index 0000000..b4a59db --- /dev/null +++ b/docs/nwc-timeouts.md @@ -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. diff --git a/src/cli.ts b/src/cli.ts index badd231..1f987a5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -305,10 +305,7 @@ async function initDaemon(integrationKey?: IntegrationKey): Promise { // 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}`); } diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index d961805..503d754 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -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; 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; } 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; 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, + client: Pick, 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`, diff --git a/src/daemon/http/providers.test.ts b/src/daemon/http/providers.test.ts index 21fdba2..d349eae 100644 --- a/src/daemon/http/providers.test.ts +++ b/src/daemon/http/providers.test.ts @@ -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" }); + }); +}); diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 3fb5689..afd3ae2 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -181,7 +181,6 @@ export async function runDaemon(argv: string[] = process.argv): Promise { }; const walletAdapter = await createWalletAdapter({ - cocodPath: config.cocodPath, walletClient, getAutoRefillConfig, nwcConnectionString: config.nwc?.connectionString, diff --git a/src/daemon/wallet/auto-refill.ts b/src/daemon/wallet/auto-refill.ts index 5f3fbae..7038c14 100644 --- a/src/daemon/wallet/auto-refill.ts +++ b/src/daemon/wallet/auto-refill.ts @@ -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( (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) { diff --git a/src/daemon/wallet/coco-client.npc.test.ts b/src/daemon/wallet/coco-client.npc.test.ts index b1ae0fc..9681c2b 100644 --- a/src/daemon/wallet/coco-client.npc.test.ts +++ b/src/daemon/wallet/coco-client.npc.test.ts @@ -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; - - 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).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"); - }); -}); diff --git a/src/daemon/wallet/coco-client.ts b/src/daemon/wallet/coco-client.ts index bbd1f84..48d366e 100644 --- a/src/daemon/wallet/coco-client.ts +++ b/src/daemon/wallet/coco-client.ts @@ -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(promise: Promise, timeoutMs: number): Promise { if (timeoutMs === Infinity) return promise; - let timer: ReturnType | undefined; - const timeout = new Promise((_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 { +): Promise { 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 { + async getStatus(): Promise { if (recoveryError) return "ERROR"; if (!recoveryDone) return "RECOVERING"; try { diff --git a/src/daemon/wallet/cocod-client.ts b/src/daemon/wallet/cocod-client.ts deleted file mode 100644 index 93d4d49..0000000 --- a/src/daemon/wallet/cocod-client.ts +++ /dev/null @@ -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 = { - output?: T; - error?: string; -}; - -type CocodFetch = ( - input: string | URL | Request, - init?: UnixRequestInit, -) => Promise; - -type SpawnedProcess = { - exited: Promise; - unref?: () => void; -}; - -type SpawnDaemon = ( - args: string[], - env: Record, -) => 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; - -/** 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; -} - -export interface CocodClient { - ping(): Promise; - getStatus(): Promise; - unlock(passphrase: string): Promise; - getBalances(): Promise>; - receiveCashu(token: string): Promise; - 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; - sendCashu(amount: number, mintUrl?: string): Promise; - sendBolt11(invoice: string, mintUrl?: string): Promise; - listMints(): Promise; - addMint(url: string): Promise; - getMintInfo(url: string): Promise; - getDefaultMint(): Promise; - setDefaultMint(url: string): Promise; - /** Release resources held by in-process wallet implementations. */ - dispose?(): Promise; - getHistory(offset?: number, limit?: number): Promise; - /** Look up a single transaction by its history entry ID. */ - getHistoryEntryById(id: string): Promise; - /** NPC (npubx.cash) Lightning address for this wallet. */ - getNpcAddress(): Promise; - /** Claim an NPC username; pass confirm=true to pay the claim fee from the wallet. */ - setNpcUsername(username: string, confirm?: boolean): Promise; - /** Manually trigger an NPC quote sync into the wallet. */ - syncNpc(): Promise; - /** Clear stuck pending/in-flight wallet operations that are safe to resolve. */ - cleanupStuckOperations?( - options?: WalletCleanupOptions, - ): Promise; - /** - * 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; - /** - * 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; - /** Report background wallet recovery progress, when the wallet supports it. */ - getRecoveryProgress?(): Promise; -} - -export function resolveCocodExecutable(cocodPath?: string | null): string { - const trimmed = cocodPath?.trim(); - return trimmed || "cocod"; -} - -export async function isCocodInstalled( - cocodPath?: string | null, -): Promise { - 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 { - 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 { - 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 | null = null; - - async function fetchJson( - path: string, - init: Omit = {}, - ): Promise> { - 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; - try { - data = JSON.parse(rawText) as CommandResponse; - } 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).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 { - try { - await fetchJson("/ping"); - return true; - } catch { - return false; - } - } - - async function startDaemon(): Promise { - 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 { - 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( - path: string, - init: Omit = {}, - ): Promise { - await ensureDaemonRunning(); - const response = await fetchJson(path, init); - return response.output as T; - } - - function post(path: string, body: Record): Promise { - return callDaemon(path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - } - - return { - async ping(): Promise { - return pingInternal(); - }, - async getStatus(): Promise { - return callDaemon("/status"); - }, - async unlock(passphrase: string): Promise { - return post("/unlock", { passphrase }); - }, - async getBalances(): Promise> { - const output = await callDaemon("/balance"); - return normalizeBalances(output); - }, - async receiveCashu(token: string): Promise { - logger.debug( - `[receiveCashu] Receiving Cashu token ${tokenFingerprint(token)}`, - ); - const message = await callDaemon("/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("/receive/bolt11", { amount, mintUrl }) }; - }, - async sendCashu(amount: number, mintUrl?: string): Promise { - return post("/send/cashu", { amount, mintUrl }); - }, - async sendBolt11(invoice: string, mintUrl?: string): Promise { - return post("/send/bolt11", { invoice, mintUrl }); - }, - async listMints(): Promise { - const output = await callDaemon("/mints/list"); - return parseMintList(output); - }, - async addMint(url: string): Promise { - return post("/mints/add", { url }); - }, - async getMintInfo(url: string): Promise { - return post("/mints/info", { url }); - }, - async getDefaultMint(): Promise { - return callDaemon("/mints/default"); - }, - async setDefaultMint(url: string): Promise { - return post("/mints/default", { url }); - }, - async getHistory(_offset?: number, _limit?: number): Promise { - return []; - }, - async getHistoryEntryById(_id: string): Promise { - return null; - }, - async getNpcAddress(): Promise { - const address = await callDaemon("/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 { - // 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 { - throw new CocodHttpError( - 501, - "Manual NPC sync is not supported by the legacy cocod client.", - ); - }, - }; -} diff --git a/src/daemon/wallet/index.nwc.test.ts b/src/daemon/wallet/index.nwc.test.ts new file mode 100644 index 0000000..3bbf064 --- /dev/null +++ b/src/daemon/wallet/index.nwc.test.ts @@ -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([["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 { + 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(() => {}); + 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(() => {}); + 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(() => {}); + 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; + +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((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((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"]); + }); +}); diff --git a/src/daemon/wallet/index.ts b/src/daemon/wallet/index.ts index a752c0a..8ac54c0 100644 --- a/src/daemon/wallet/index.ts +++ b/src/daemon/wallet/index.ts @@ -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, + client: Pick, 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 = {}; @@ -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( + label: string, + operation: (w: WalletConnect) => Promise, + ): Promise { + 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 { + 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 { 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 { diff --git a/src/daemon/wallet/wallet-client.ts b/src/daemon/wallet/wallet-client.ts new file mode 100644 index 0000000..2698b04 --- /dev/null +++ b/src/daemon/wallet/wallet-client.ts @@ -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; +} + +export interface WalletClient { + ping(): Promise; + getStatus(): Promise; + unlock(passphrase: string): Promise; + getBalances(): Promise>; + receiveCashu(token: string): Promise; + 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; + sendCashu(amount: number, mintUrl?: string): Promise; + sendBolt11(invoice: string, mintUrl?: string): Promise; + listMints(): Promise; + addMint(url: string): Promise; + getMintInfo(url: string): Promise; + getDefaultMint(): Promise; + setDefaultMint(url: string): Promise; + /** Release resources held by in-process wallet implementations. */ + dispose?(): Promise; + getHistory(offset?: number, limit?: number): Promise; + /** Look up a single transaction by its history entry ID. */ + getHistoryEntryById(id: string): Promise; + /** NPC (npubx.cash) Lightning address for this wallet. */ + getNpcAddress(): Promise; + /** Claim an NPC username; pass confirm=true to pay the claim fee from the wallet. */ + setNpcUsername(username: string, confirm?: boolean): Promise; + /** Manually trigger an NPC quote sync into the wallet. */ + syncNpc(): Promise; + /** Clear stuck pending/in-flight wallet operations that are safe to resolve. */ + cleanupStuckOperations?( + options?: WalletCleanupOptions, + ): Promise; + /** + * 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; + /** + * 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; + /** Report background wallet recovery progress, when the wallet supports it. */ + getRecoveryProgress?(): Promise; +} diff --git a/src/utils/config.ts b/src/utils/config.ts index 6ffa9d1..51e7315 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -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, diff --git a/src/utils/daemon-client.timeout.test.ts b/src/utils/daemon-client.timeout.test.ts new file mode 100644 index 0000000..43f829c --- /dev/null +++ b/src/utils/daemon-client.timeout.test.ts @@ -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"); + }); +}); diff --git a/src/utils/daemon-client.ts b/src/utils/daemon-client.ts index 7ae13ef..fe763f0 100644 --- a/src/utils/daemon-client.ts +++ b/src/utils/daemon-client.ts @@ -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; } 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 { 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 { diff --git a/src/utils/with-timeout.test.ts b/src/utils/with-timeout.test.ts new file mode 100644 index 0000000..88ab40d --- /dev/null +++ b/src/utils/with-timeout.test.ts @@ -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(() => {}); + 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"); + }); +}); diff --git a/src/utils/with-timeout.ts b/src/utils/with-timeout.ts new file mode 100644 index 0000000..8a0f143 --- /dev/null +++ b/src/utils/with-timeout.ts @@ -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( + promise: Promise, + timeoutMs: number, + message = "Operation timed out", +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); +} diff --git a/tests/daemon/config-store.scenario.ts b/tests/daemon/config-store.scenario.ts index 24b96cf..6ce8728 100644 --- a/tests/daemon/config-store.scenario.ts +++ b/tests/daemon/config-store.scenario.ts @@ -24,7 +24,6 @@ const baseConfig = { port: 8008, host: "127.0.0.1", provider: null, - cocodPath: null, }; function assert(cond: unknown, msg: string): void { diff --git a/tests/utils/daemon-client.test.ts b/tests/utils/daemon-client.test.ts index 761b416..88abbbc 100644 --- a/tests/utils/daemon-client.test.ts +++ b/tests/utils/daemon-client.test.ts @@ -11,7 +11,6 @@ function config(host: string): RoutstrdConfig { port: 8008, host, provider: null, - cocodPath: null, }; } diff --git a/tests/wallet/short-keyset-token.test.ts b/tests/wallet/short-keyset-token.test.ts index cc3ee30..6bf0aa1 100644 --- a/tests/wallet/short-keyset-token.test.ts +++ b/tests/wallet/short-keyset-token.test.ts @@ -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", () => {