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/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 31cae7c..f79b479 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"; @@ -50,7 +50,7 @@ type ClientMode = "xcashu" | "lazyrefund" | "apikeys"; type WalletStatusOutput = { daemon: "running"; wallet: "connected" | "recovering" | "error"; - walletState: CocodState; + walletState: WalletRuntimeState; balances?: Record; mode: ClientMode; error?: string; @@ -61,7 +61,7 @@ type DaemonDeps = { server: { close(cb?: () => void): void }; shutdown?: () => void; store: any; - walletClient: CocodClient; + walletClient: WalletClient; walletAdapter: any; storageAdapter: any; discoveryAdapter: any; @@ -132,7 +132,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."); } } @@ -200,7 +200,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."; @@ -220,7 +220,7 @@ function respondWithError( error: unknown, fallbackStatus = 500, ): void { - if (error instanceof CocodHttpError) { + if (error instanceof WalletHttpError) { sendJson(res, error.status, { error: error.message }); return; } @@ -253,7 +253,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; } @@ -272,7 +272,7 @@ function getRequiredPositiveNumberField( return parsed; } } - throw new CocodHttpError(400, `Missing required '${field}' field.`); + throw new WalletHttpError(400, `Missing required '${field}' field.`); } function optionalStringField( @@ -293,7 +293,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()); } @@ -349,7 +349,7 @@ async function buildStatusOutput( } async function buildWalletDetails(deps: DaemonDeps): Promise<{ - state: CocodState; + state: WalletRuntimeState; ready: boolean; balances?: Record; unit?: "sat"; @@ -411,7 +411,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, @@ -435,7 +435,7 @@ export function createDaemonRequestHandler(deps: { server: { close(cb?: () => void): void }; shutdown?: () => void; store: any; - walletClient: CocodClient; + walletClient: WalletClient; walletAdapter: any; storageAdapter: any; discoveryAdapter: any; @@ -514,7 +514,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.", ); @@ -538,7 +538,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.", ); @@ -547,7 +547,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'.", ); @@ -557,7 +557,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.", ); @@ -575,7 +575,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.", ); @@ -615,7 +615,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; @@ -759,12 +759,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/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 39e9ea5..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,7 +33,7 @@ function isFatalError(message: string): boolean { } export function startAutoRefillLoop( - cocod: CocodClient, + walletClient: WalletClient, getWallet: () => WalletConnect | undefined, getConfig: () => AutoRefillConfig | undefined, intervalMs: number = 5000, @@ -88,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, @@ -103,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...`); @@ -125,7 +128,7 @@ export function startAutoRefillLoop( 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 869bcb1..48d366e 100644 --- a/src/daemon/wallet/coco-client.ts +++ b/src/daemon/wallet/coco-client.ts @@ -30,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, @@ -1812,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"); @@ -2148,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.ts b/src/daemon/wallet/index.ts index 4a7d2fe..8ac54c0 100644 --- a/src/daemon/wallet/index.ts +++ b/src/daemon/wallet/index.ts @@ -5,7 +5,7 @@ import { WalletBaseError } from "applesauce-wallet-connect/helpers/error"; import { RelayPool } from "applesauce-relay"; import { logger } from "../../utils/logger"; import { withTimeout } from "../../utils/with-timeout"; -import { createCocodClient, type CocodClient } from "./cocod-client"; +import type { WalletClient } from "./wallet-client"; import { startAutoRefillLoop, type AutoRefillConfig } from "./auto-refill"; /** @@ -34,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 @@ -46,8 +46,8 @@ 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). */ @@ -65,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 = {}; 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/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", () => {