diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 7f701b6..08ce8e9 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -1287,7 +1287,7 @@ export function createDaemonRequestHandler(deps: { storageAdapter: deps.storageAdapter, discoveryAdapter: deps.discoveryAdapter, modelManager: deps.modelManager, - debugLevel: "DEBUG", + debugLevel: "WARN", mode: deps.mode, usageTrackingDriver: deps.usageTrackingDriver, sdkStore: deps.store, diff --git a/src/daemon/index.ts b/src/daemon/index.ts index d26cda0..7a389f5 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -20,6 +20,31 @@ import { } from "../utils/config"; import { logger } from "../utils/logger"; +// ── Console noise filter ───────────────────────────────────────── +// The @routstr/sdk's SSE inspector does unconditional console.log("[routstr:sse] +// chunk:", ...) for every single streaming token. This produced a 195 MB log +// file that filled swap and caused silent OOM crashes. The SDK's own +// debugLevel flag only governs its _logger_ calls, not these raw console.log +// statements, so we intercept console.log here and drop the per-token SSE +// spam while preserving everything else (including real errors and the +// crash/shutdown markers written via process.stderr). +// +// Set ROUTSTRD_VERBOSE_SSE=1 to restore the full per-token logging (useful for +// deep SDK debugging, but it will grow the log file fast). +const _origConsoleLog = console.log.bind(console); +const _verboseSse = process.env.ROUTSTRD_VERBOSE_SSE === "1"; +console.log = (...args: unknown[]) => { + if (_verboseSse) { + _origConsoleLog(...args); + return; + } + const first = args[0]; + if (typeof first === "string" && first.startsWith("[routstr:sse]")) { + return; // suppress per-token SSE spam + } + _origConsoleLog(...args); +}; + function makeSdkLogger(prefix?: string): SdkLogger { const tag = prefix ? `[${prefix}]` : undefined; @@ -50,15 +75,78 @@ import { FileRequestResponseLogSink } from "./request-response-log-sink"; import { refreshModelsAndIntegrations } from "../integrations"; import { RoutstrClient } from "@routstr/sdk"; -// Global error handlers — the daemon is spawned detached with stdout/stderr -// redirected to a file, so without these, uncaught async errors would kill -// the process silently. Log to the file logger before exiting. +// ── Global error & shutdown handlers ───────────────────────────── +// The daemon is spawned detached with stdout/stderr redirected to a file, +// so without these handlers, uncaught async errors and external signals +// (SIGTERM/OOM-killer) would kill the process silently — leaving no trace +// in the structured logs. These handlers write a clear crash/shutdown marker +// (with timestamp + stack trace) to the file logger AND to stderr, so the +// cause is visible in both ~/.routstrd/logs/YYYY-MM-DD.log and the +// stdout/stderr capture file (debug.log). +// +// The daemon is a long-lived server: we do NOT exit on uncaughtException / +// unhandledRejection (a single broken request should not take down the whole +// process). We log loudly and keep running. External kill signals (SIGTERM, +// SIGINT) and process.exit() are logged as a final marker so you can always +// see exactly when and why the process stopped. + +const SHUTDOWN_MARKER = + "══════════════════════════════════════════════════"; + +function logToStderr(msg: string): void { + try { + process.stderr.write(`${msg}\n`); + } catch { + // stderr may be closed during final shutdown — ignore. + } +} + process.on("uncaughtException", (error) => { - logger.error("UNCAUGHT EXCEPTION:", error); + const msg = `${SHUTDOWN_MARKER}\n[CRASH] uncaughtException at ${new Date().toISOString()}\n${error.stack || error.message || String(error)}\n${SHUTDOWN_MARKER}`; + logger.error(msg); + logToStderr(msg); + // Do NOT exit — the daemon is a server; one bad request should not kill it. + // The error is logged prominently so it can be diagnosed. }); process.on("unhandledRejection", (reason) => { - logger.error("UNHANDLED REJECTION:", reason); + const detail = + reason instanceof Error + ? `${reason.stack || reason.message}` + : String(reason); + const msg = `[CRASH] unhandledRejection at ${new Date().toISOString()}\n${detail}`; + logger.error(msg); + logToStderr(msg); + // Do NOT exit — same rationale as uncaughtException. +}); + +// Graceful shutdown signal handlers. These fire when the process is killed +// externally (systemctl stop, OOM-killer, kill , Ctrl+C). Without them +// the process just vanishes and the logs show nothing after the last request. +const shutdown = (signal: string) => { + const msg = `[SHUTDOWN] received ${signal} at ${new Date().toISOString()}, pid=${process.pid}`; + logger.log(msg); + logToStderr(msg); + // Give the file logger a moment to flush, then exit. + setTimeout(() => process.exit(0), 200); +}; + +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); + +// Final marker on exit — records the exit code so you can distinguish a +// clean shutdown (code 0) from a crash (code 1) when reading logs. +process.on("exit", (code) => { + const msg = `[EXIT] code=${code} at ${new Date().toISOString()}, pid=${process.pid}`; + // The file logger uses async fs/promises — it may not flush during exit, + // so also write to stderr (which is sync) as a guaranteed record. + logToStderr(msg); + try { + // Best-effort sync write to stderr is already done above; the async logger + // may or may not flush, but the stderr capture file will have it. + } catch { + // ignore + } }); async function main(): Promise { @@ -144,7 +232,7 @@ async function main(): Promise { requestResponseLogSink, }, ); - installMintFallbackTopUp(routeClient, walletClient, walletAdapter, daemonSdkLogger); + installMintFallbackTopUp(routeClient, walletClient, walletAdapter, daemonSdkLogger, provider); const refundClient = new RoutstrClient( walletAdapter, diff --git a/src/daemon/wallet/index.ts b/src/daemon/wallet/index.ts index caac7c1..42e993e 100644 --- a/src/daemon/wallet/index.ts +++ b/src/daemon/wallet/index.ts @@ -356,6 +356,28 @@ export async function createWalletAdapter( } }, + /** Pay an externally-created BOLT-11 invoice via NWC. */ + async payBolt11(bolt11: string): Promise<{ + success: boolean; + preimage?: string; + error?: string; + }> { + if (!wallet || !wallet.service) { + return { success: false, error: "NWC not connected" }; + } + if (!bolt11 || typeof bolt11 !== "string" || !bolt11.startsWith("lnbc")) { + return { success: false, error: "Invalid bolt11 invoice (must start with 'lnbc')" }; + } + try { + const { preimage } = await wallet.payInvoice(bolt11); + return { success: true, preimage }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`[nwc] payBolt11 failed: ${message}`); + return { success: false, error: message }; + } + }, + /** Get the current auto-refill config, re-reading from the getter if available */ getAutoRefillConfig(): AutoRefillConfig | undefined { return options.getAutoRefillConfig?.() ?? options.autoRefill; diff --git a/src/daemon/wallet/mint-fallback.ts b/src/daemon/wallet/mint-fallback.ts index 8c3672b..a95e1eb 100644 --- a/src/daemon/wallet/mint-fallback.ts +++ b/src/daemon/wallet/mint-fallback.ts @@ -21,7 +21,11 @@ function uniqueMintUrls(mints: Array): string[] { function inspectForMintUnreachable(value: unknown): boolean { if (typeof value === "string") { const trimmed = value.trim(); - if (/mint[_-]unreachable/i.test(trimmed) || /mint\b.*\bunreachable/i.test(trimmed)) return true; + if (/mint[_-]unreachable/i.test(trimmed) || /mint[_-]rate[_-]limited/i.test(trimmed) || /mint\b.*\bunreachable/i.test(trimmed)) return true; + // Wallet-empty fallback: when the local wallet has no proofs, sendToken + // throws "Not enough proofs to send" — treat this like an unreachable + // mint so the fallback to the next configured mint kicks in. + if (/not enough proofs/i.test(trimmed)) return true; if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) { diff --git a/src/daemon/wallet/sdk-mint-fallback.ts b/src/daemon/wallet/sdk-mint-fallback.ts index 13d6bf3..35e68ac 100644 --- a/src/daemon/wallet/sdk-mint-fallback.ts +++ b/src/daemon/wallet/sdk-mint-fallback.ts @@ -1,9 +1,10 @@ import type { CocodClient } from "./cocod-client"; -import { isMintUnreachableError } from "./mint-fallback"; +import { isMintUnreachableError, receiveBolt11WithMintFallback } from "./mint-fallback"; type LoggerLike = { log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; }; type TopUpOptions = { @@ -30,10 +31,171 @@ type RoutstrClientLike = { type WalletAdapterLike = { getBalances(): Promise>; + /** NWC auto-fund: creates invoice, pays it via Lightning, returns result */ + fundFromNWC?(amount: number): Promise<{ + success: boolean; + invoice: string; + preimage?: string; + error?: string; + }>; + /** NWC connection status check */ + getNwcStatus?(): Promise<{ + connected: boolean; + alias?: string; + pubkey?: string; + network?: string; + methods?: string[]; + balance?: number; + error?: string; + }>; + /** Pay an externally-created BOLT-11 invoice via NWC */ + payBolt11?(bolt11: string): Promise<{ success: boolean; preimage?: string; error?: string }>; }; const PATCH_MARKER = Symbol.for("routstrd.mintFallbackTopUpPatched"); const ERROR_RETRY_PATCH_MARKER = Symbol.for("routstrd.mintFallbackErrorRetryPatched"); +const CREATE_TOKEN_PATCH_MARKER = Symbol.for("routstrd.mintCreateTokenFallbackPatched"); + +// ── Routstr-core Lightning invoice integration ────────────────── +const ROUTSTR_CORE_INVOICE_POLL_MS = 5000; + +/** POST /lightning/invoice on the upstream routstr-core provider. */ +async function createInvoiceViaRoutstrCore( + upstreamProviderUrl: string, + amountSats: number, + apiKey?: string, +): Promise<{ invoice_id: string; bolt11: string } | null> { + // ── Input validation ────────────────────────────────────── + // Prevent SSRF: only allow HTTPS URLs (or localhost for dev) + let parsedUrl: URL; + try { + parsedUrl = new URL(upstreamProviderUrl); + } catch { + throw new Error(`Invalid provider URL: ${upstreamProviderUrl.slice(0, 50)}`); + } + const isLocalhost = parsedUrl.hostname === "localhost" || parsedUrl.hostname === "127.0.0.1"; + if (parsedUrl.protocol !== "https:" && !isLocalhost) { + throw new Error(`Provider URL must be HTTPS (got ${parsedUrl.protocol})`); + } + + // Amount validation — routstr-core enforces 1 < amount <= 1_000_000 + if (!amountSats || amountSats <= 0) { + throw new Error(`Invalid amount: ${amountSats} (must be > 0)`); + } + if (amountSats > 1_000_000) { + throw new Error(`Amount ${amountSats} exceeds routstr-core max (1_000_000 sats)`); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const headers: Record = { "Content-Type": "application/json" }; + const body: Record = { amount_sats: amountSats, purpose: "create" }; + // If we have an existing API key for this provider, topup it instead of creating a new one. + if (apiKey) { + body.purpose = "topup"; + headers["Authorization"] = `Bearer ${apiKey}`; + } + const resp = await fetch(`${parsedUrl.origin}/lightning/invoice`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`HTTP ${resp.status}: ${text.slice(0, 200)}`); + } + const data = (await resp.json()) as { invoice_id?: string; bolt11?: string }; + if (!data.invoice_id || typeof data.invoice_id !== "string" || data.invoice_id.length === 0) { + throw new Error("Missing or empty invoice_id in routstr-core response"); + } + if (!data.bolt11 || typeof data.bolt11 !== "string" || !data.bolt11.startsWith("lnbc")) { + throw new Error("Missing or invalid bolt11 (must start with 'lnbc') in routstr-core response"); + } + return { invoice_id: data.invoice_id, bolt11: data.bolt11 }; + } finally { + clearTimeout(timeout); + } +} + +/** + * Try to pay a Lightning invoice via NWC. Returns true if paid. + * Used for BOTH local-wallet and routstr-core invoices. + */ +async function payInvoiceViaNwc( + walletAdapter: WalletAdapterLike, + bolt11: string, + amountSats: number, + logger: LoggerLike, +): Promise { + if (!walletAdapter.getNwcStatus || typeof walletAdapter.payBolt11 !== "function") { + return false; + } + try { + const nwcStatus = await walletAdapter.getNwcStatus(); + if (!nwcStatus.connected) { + return false; + } + logger.log(`[wallet] NWC connected — attempting to pay Lightning invoice (${amountSats} sats)...`); + // Use the direct pay path (payBolt11) — the wallet adapter wraps the + // underlying NWC wallet's payInvoice call for externally-created invoices. + const result = await walletAdapter.payBolt11!(bolt11); + if (result.success) { + logger.log( + `[wallet] ✅ NWC paid invoice! Preimage: ${result.preimage?.slice(0, 16) ?? "N/A"}...`, + ); + return true; + } + logger.error(`[wallet] NWC pay failed: ${result.error || "unknown error"}`); + return false; + } catch (nwcError) { + logger.error( + `[wallet] NWC pay error: ${nwcError instanceof Error ? nwcError.message : String(nwcError)}`, + ); + return false; + } +} + +/** Non-blocking poller: watches a routstr-core invoice until paid/expired. */ +function pollRoutstrCoreInvoice( + upstreamProviderUrl: string, + invoiceId: string, + amountSats: number, + logger: LoggerLike, +): void { + let polls = 0; + const maxPolls = 84; // ~7 min at 5s + const interval = setInterval(async () => { + polls++; + try { + const resp = await fetch( + `${upstreamProviderUrl}/lightning/invoice/${invoiceId}/status`, + { signal: AbortSignal.timeout(10_000) }, + ); + if (!resp.ok) return; + const data = (await resp.json()) as { status?: string; api_key?: string }; + if (data.status === "paid") { + clearInterval(interval); + logger.log( + `[wallet] \u2705 Routstr-core invoice ${invoiceId} PAID! ${amountSats} sats credited via routstr-core.` + + (data.api_key ? ` API key: ${data.api_key.slice(0, 8)}...` : ""), + ); + return; + } + if (data.status === "expired") { + clearInterval(interval); + logger.warn(`[wallet] Routstr-core invoice ${invoiceId} EXPIRED.`); + return; + } + } catch { /* transient */ } + if (polls >= maxPolls) { + clearInterval(interval); + logger.warn(`[wallet] Stopped polling routstr-core invoice ${invoiceId} after ${polls} attempts.`); + } + }, ROUTSTR_CORE_INVOICE_POLL_MS); +} +// ── End routstr-core helpers ──────────────────────────────────── function uniqueMintUrls(mints: Array): string[] { const seen = new Set(); @@ -73,13 +235,176 @@ async function getTopUpMintCandidates( ]); } +export function installCreateProviderTokenFallback( + client: RoutstrClientLike, + walletClient: CocodClient, + walletAdapter: WalletAdapterLike, + logger: LoggerLike, + upstreamProviderUrl?: string, +): void { + const balanceManager = client.getBalanceManager() as BalanceManagerLike & { + createProviderToken?: (options: TopUpOptions) => Promise; + [CREATE_TOKEN_PATCH_MARKER]?: boolean; + }; + if (balanceManager[CREATE_TOKEN_PATCH_MARKER] || typeof balanceManager.createProviderToken !== "function") return; + + const originalCreateProviderToken = balanceManager.createProviderToken.bind(balanceManager); + + balanceManager.createProviderToken = async (options: TopUpOptions): Promise => { + const candidates = await getTopUpMintCandidates( + options.mintUrl, + walletClient, + walletAdapter, + ); + + let lastResult: TopUpResult | undefined; + for (const [index, mintUrl] of candidates.entries()) { + if (index > 0) { + logger.log( + `[wallet] Retrying createProviderToken with fallback mint ${mintUrl} (${index + 1}/${candidates.length})...`, + ); + } + + const result = await originalCreateProviderToken({ ...options, mintUrl }); + if (result.success) { + return result; + } + + // Retry the next mint only on wallet-empty / mint-unreachable errors. + // Other errors (insufficient balance, invalid amount, network) are not + // mint-specific and should propagate immediately. + if (!isMintUnreachableError(result.error) && !isMintUnreachableError(result.message)) { + return result; + } + + lastResult = result; + logger.warn( + `[wallet] createProviderToken: mint ${mintUrl} failed (out of proofs / unreachable).` + + (index + 1 < candidates.length ? " Trying next configured mint." : " No fallback mints left."), + ); + } + + // ── Lightning invoice fallback ────────────────────────────── + // All configured mints are empty or unreachable. As a last resort, + // create a Lightning invoice to fund the wallet. If NWC is connected, + // auto-pay it; otherwise log prominently so the operator can pay. + logger.warn( + `[wallet] createProviderToken: all ${candidates.length} mint(s) unreachable or out of proofs. ` + + `Attempting Lightning invoice top-up as final fallback...`, + ); + + const topUpAmount = Math.max(1, Math.min(options.amount || 21, 1_000_000)); + + try { + let handled = false; + + // ── SECOND: Routstr-core Lightning invoice ────────────── + // Prefer the actual upstream provider being requested (options.baseUrl) + // over the static config provider URL. This credits the provider account + // directly — no local wallet invoice needed. + const coreProviderUrl = options.baseUrl || upstreamProviderUrl; + if (coreProviderUrl) { + try { + const coreResult = await createInvoiceViaRoutstrCore( + coreProviderUrl, + topUpAmount, + options.token, + ); + if (coreResult) { + logger.log( + `[wallet] Routstr-core invoice: ${coreResult.bolt11} ` + + `(${topUpAmount} sats, id=${coreResult.invoice_id}, provider=${coreProviderUrl})`, + ); + + // Try to pay via NWC + const corePaid = await payInvoiceViaNwc( + walletAdapter, + coreResult.bolt11, + topUpAmount, + logger, + ); + + if (corePaid) { + handled = true; + pollRoutstrCoreInvoice(coreProviderUrl, coreResult.invoice_id, topUpAmount, logger); + const retryResult = await originalCreateProviderToken(options); + if (retryResult.success) { + return retryResult; + } + logger.warn( + `[wallet] createProviderToken still failed after routstr-core NWC payment — may need a moment for balance to settle.`, + ); + } else { + handled = true; // invoice created — surface it, don't fall through + logger.log(`[wallet] Pay the routstr-core invoice above and retry.`); + pollRoutstrCoreInvoice(coreProviderUrl, coreResult.invoice_id, topUpAmount, logger); + } + } + } catch (coreError) { + logger.error( + `[wallet] Routstr-core invoice creation failed: ` + + `${coreError instanceof Error ? coreError.message : String(coreError)}`, + ); + } + } + + // ── LAST: Local wallet Lightning invoice + NWC ────────── + // Routstr-core invoice couldn't be created or paid. Create a local + // wallet invoice and try to fund it via NWC. + if (!handled) { + const allMints = await walletClient.listMints().catch(() => candidates.filter(Boolean) as string[]); + if (allMints.length > 0) { + const { invoice, mintUrl } = await receiveBolt11WithMintFallback( + walletClient, + topUpAmount, + allMints, + "[wallet:create-token-fallback]", + ); + + logger.log( + `[wallet] Local wallet Lightning invoice for ${topUpAmount} sats via ${mintUrl}: ${invoice}`, + ); + + const nwcPaid = await payInvoiceViaNwc(walletAdapter, invoice, topUpAmount, logger); + if (nwcPaid) { + const retryResult = await originalCreateProviderToken(options); + if (retryResult.success) { + return retryResult; + } + logger.warn( + `[wallet] createProviderToken still failed after NWC payment — may need a moment for proofs to settle.`, + ); + } else { + logger.log( + "[wallet] 💡 Manual top-up required: pay this invoice to fund the wallet and retry:", + ); + logger.log(` ${invoice}`); + logger.log(` Amount: ${topUpAmount} sats | Mint: ${mintUrl}`); + } + } + } + } catch (invoiceError) { + logger.error( + `[wallet] Lightning invoice fallback also failed: ` + + `${invoiceError instanceof Error ? invoiceError.message : String(invoiceError)}`, + ); + } + + return lastResult ?? originalCreateProviderToken(options); + }; + + balanceManager[CREATE_TOKEN_PATCH_MARKER] = true; +} + export function installMintFallbackTopUp( client: RoutstrClientLike, walletClient: CocodClient, walletAdapter: WalletAdapterLike, logger: LoggerLike, + upstreamProviderUrl?: string, ): void { installMintUnreachableErrorRetry(client, walletClient, walletAdapter, logger); + installCreateProviderTokenFallback(client, walletClient, walletAdapter, logger, upstreamProviderUrl); const balanceManager = client.getBalanceManager() as BalanceManagerLike & { [PATCH_MARKER]?: boolean; @@ -114,6 +439,110 @@ export function installMintFallbackTopUp( ); } + // ── Lightning invoice fallback ────────────────────────────── + // All configured mints are empty or unreachable. As a last resort, + // create a Lightning invoice to fund the wallet. If NWC is connected, + // auto-pay it; otherwise log prominently so the operator can pay. + logger.warn( + `[wallet] All ${candidates.length} mint(s) unreachable or out of proofs. ` + + `Attempting Lightning invoice top-up as final fallback...`, + ); + + const topUpAmount = Math.max(1, Math.min(options.amount || 21, 1_000_000)); // default to minimum + + try { + let handled = false; + + // ── SECOND: Routstr-core Lightning invoice ────────────── + // Prefer the actual upstream provider being requested (options.baseUrl) + // over the static config provider URL. Credits the provider account directly. + const coreProviderUrl = options.baseUrl || upstreamProviderUrl; + if (coreProviderUrl) { + try { + const coreResult = await createInvoiceViaRoutstrCore( + coreProviderUrl, + topUpAmount, + options.token, + ); + if (coreResult) { + logger.log( + `[wallet] Routstr-core invoice: ${coreResult.bolt11} ` + + `(${topUpAmount} sats, id=${coreResult.invoice_id}, provider=${coreProviderUrl})`, + ); + + const corePaid = await payInvoiceViaNwc( + walletAdapter, + coreResult.bolt11, + topUpAmount, + logger, + ); + + if (corePaid) { + handled = true; + pollRoutstrCoreInvoice(coreProviderUrl, coreResult.invoice_id, topUpAmount, logger); + const retryResult = await originalTopUp(options); + if (!isMintUnreachableTopUpResult(retryResult)) { + return retryResult; + } + logger.warn( + `[wallet] topUp still failed after routstr-core NWC payment — may need a moment for balance to settle.`, + ); + } else { + handled = true; // invoice created — surface it, don't fall through + logger.log(`[wallet] Pay the routstr-core invoice above and retry.`); + pollRoutstrCoreInvoice(coreProviderUrl, coreResult.invoice_id, topUpAmount, logger); + } + } + } catch (coreError) { + logger.error( + `[wallet] Routstr-core invoice creation failed: ` + + `${coreError instanceof Error ? coreError.message : String(coreError)}`, + ); + } + } + + // ── LAST: Local wallet Lightning invoice + NWC ────────── + // Routstr-core invoice couldn't be created or paid. Create a local + // wallet invoice and try to fund it via NWC. + if (!handled) { + const allMints = await walletClient.listMints().catch(() => candidates.filter(Boolean) as string[]); + if (allMints.length > 0) { + const { invoice, mintUrl } = await receiveBolt11WithMintFallback( + walletClient, + topUpAmount, + allMints, + "[wallet:topup-fallback]", + ); + + logger.log( + `[wallet] Local wallet Lightning invoice for ${topUpAmount} sats via ${mintUrl}: ${invoice}`, + ); + + const nwcPaid = await payInvoiceViaNwc(walletAdapter, invoice, topUpAmount, logger); + if (nwcPaid) { + const retryResult = await originalTopUp(options); + if (!isMintUnreachableTopUpResult(retryResult)) { + return retryResult; + } + logger.warn( + `[wallet] Top-up still failed after NWC payment — may need a moment for proofs to settle.`, + ); + } else { + logger.log( + "[wallet] 💡 Manual top-up: pay this invoice to fund the wallet:", + ); + logger.log(` ${invoice}`); + logger.log(` Amount: ${topUpAmount} sats | Mint: ${mintUrl}`); + } + } + } + } catch (invoiceError) { + logger.error( + `[wallet] Lightning invoice fallback also failed: ` + + `${invoiceError instanceof Error ? invoiceError.message : String(invoiceError)}`, + ); + } + return lastMintUnreachableResult ?? originalTopUp(options); }; diff --git a/src/start-daemon.ts b/src/start-daemon.ts index f807886..f6f7b4d 100644 --- a/src/start-daemon.ts +++ b/src/start-daemon.ts @@ -1,10 +1,56 @@ -import { openSync } from "fs"; +import { openSync, statSync, renameSync, existsSync, unlinkSync } from "fs"; import { logger } from "./utils/logger"; import { CONFIG_DIR, LOGS_DIR } from "./utils/config"; import { withCrossProcessLock } from "./utils/process-lock"; const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`; -const DEBUG_LOG_PATH = `${CONFIG_DIR}/debug.log`; +const STDOUT_LOG_PATH = `${CONFIG_DIR}/stdout.log`; +const STDERR_LOG_PATH = `${CONFIG_DIR}/stderr.log`; + +// Rotate the stdout/stderr capture files before attaching so they don't grow +// without bound (the old single debug.log hit 195 MB and contributed to an +// OOM crash). Keep a handful of archives. Set ROUTSTRD_CAPTURE_MAX_BYTES=0 to +// disable. 50 MB default matches the structured log rotation. +const CAPTURE_MAX_BYTES = (() => { + const raw = process.env.ROUTSTRD_CAPTURE_MAX_BYTES; + if (!raw) return 50 * 1024 * 1024; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) ? n : 50 * 1024 * 1024; +})(); +const CAPTURE_MAX_ARCHIVES = 3; + +function rotateCaptureFile(path: string): void { + if (CAPTURE_MAX_BYTES <= 0) return; + let size = 0; + try { + size = statSync(path).size; + } catch { + return; // doesn't exist yet + } + if (size < CAPTURE_MAX_BYTES) return; + + // Drop oldest, shift the rest down: .2 → .3, .1 → .2, current → .1 + for (let i = CAPTURE_MAX_ARCHIVES - 1; i >= 1; i--) { + const older = `${path}.${i}`; + const newer = `${path}.${i + 1}`; + try { + if (existsSync(older)) { + if (i + 1 > CAPTURE_MAX_ARCHIVES) { + unlinkSync(older); + } else { + renameSync(older, newer); + } + } + } catch { + // best-effort + } + } + try { + renameSync(path, `${path}.1`); + } catch { + // best-effort + } +} async function isDaemonHealthy(port: string): Promise { const controller = new AbortController(); @@ -44,11 +90,16 @@ async function startDaemonUnlocked( const daemonScript = new URL("./daemon/index.js", import.meta.url).pathname; const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")}`; - const debugLogFd = openSync(DEBUG_LOG_PATH, "a"); + // Rotate capture files before attaching so they never grow unbounded. + rotateCaptureFile(STDOUT_LOG_PATH); + rotateCaptureFile(STDERR_LOG_PATH); + + const stdoutFd = openSync(STDOUT_LOG_PATH, "a"); + const stderrFd = openSync(STDERR_LOG_PATH, "a"); const proc = Bun.spawn(["sh", "-c", shellCmd], { - stdout: debugLogFd, - stderr: debugLogFd, + stdout: stdoutFd, + stderr: stderrFd, stdin: "ignore", detached: true, }); @@ -66,18 +117,21 @@ async function startDaemonUnlocked( if (exitCode !== null) { throw new Error( - `Daemon process exited early with code ${exitCode}. Check logs in ${LOGS_DIR}`, + `Daemon process exited early with code ${exitCode}. Check stderr log: ${STDERR_LOG_PATH} and structured logs: ${LOGS_DIR}`, ); } if (await isDaemonHealthy(port)) { console.log(`Routstr daemon started (PID: ${proc.pid}).`); + console.log(` stdout → ${STDOUT_LOG_PATH}`); + console.log(` stderr → ${STDERR_LOG_PATH}`); + console.log(` logs → ${LOGS_DIR}`); return; } } throw new Error( - `Daemon failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds. Check logs in ${LOGS_DIR}`, + `Daemon failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds. Check stderr log: ${STDERR_LOG_PATH} and structured logs: ${LOGS_DIR}`, ); } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index cc317e0..56d3f9f 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,4 +1,4 @@ -import { appendFile, mkdir } from "fs/promises"; +import { appendFile, mkdir, stat } from "fs/promises"; import { existsSync } from "fs"; import { join } from "path"; @@ -6,6 +6,22 @@ const HOME = process.env.HOME || process.env.USERPROFILE || ""; const LOG_DIR = process.env.ROUTSTRD_DIR || `${HOME}/.routstrd`; const LOGS_DIR = join(LOG_DIR, "logs"); +// Max log file size before rotation kicks in (50 MB). Prevents the runaway +// log growth that previously filled swap and killed the daemon (a 195 MB +// debug.log full of per-token SSE spam was the root cause of silent OOM +// crashes). Set ROUTSTRD_LOG_MAX_BYTES=0 to disable rotation. +const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; +const MAX_BYTES = + (() => { + const raw = process.env.ROUTSTRD_LOG_MAX_BYTES; + if (!raw) return DEFAULT_MAX_BYTES; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) ? n : DEFAULT_MAX_BYTES; + })(); + +// Keep this many rotated archives (file.log.1, file.log.2, ...). +const MAX_ARCHIVES = 3; + function getLogFileForDate(date: Date = new Date()): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); @@ -19,6 +35,49 @@ async function ensureLogDir() { } } +/** + * Rotate the log file if it has grown past MAX_BYTES. + * Renames the current file to file.log.1 (shifting older archives down), + * then the caller creates a fresh file. Keeps at most MAX_ARCHIVES archives. + */ +async function rotateIfNeeded(logFile: string): Promise { + if (MAX_BYTES <= 0) return; + let size: number; + try { + const info = await stat(logFile); + size = info.size; + } catch { + return; // file doesn't exist yet — nothing to rotate + } + if (size < MAX_BYTES) return; + + // Shift archives: .2 → .3, .1 → .2, then current → .1 + for (let i = MAX_ARCHIVES - 1; i >= 1; i--) { + const older = `${logFile}.${i}`; + const newer = `${logFile}.${i + 1}`; + try { + if (existsSync(older)) { + const { rename } = await import("fs/promises"); + if (i + 1 > MAX_ARCHIVES) { + // Drop the oldest archive + const { unlink } = await import("fs/promises"); + await unlink(older).catch(() => {}); + } else { + await rename(older, newer).catch(() => {}); + } + } + } catch { + // best-effort + } + } + try { + const { rename } = await import("fs/promises"); + await rename(logFile, `${logFile}.1`); + } catch { + // best-effort — if rename fails (e.g. permissions), we keep appending + } +} + async function writeLog(level: string, ...args: unknown[]) { await ensureLogDir(); const timestamp = new Date().toISOString(); @@ -40,6 +99,7 @@ async function writeLog(level: string, ...args: unknown[]) { const line = `[${timestamp}] [${level}] ${message}\n`; const logFile = getLogFileForDate(new Date(timestamp)); try { + await rotateIfNeeded(logFile); await appendFile(logFile, line); } catch (error) { console.error("Failed to write log:", error); diff --git a/tests/sdk-mint-fallback-hardening.test.ts b/tests/sdk-mint-fallback-hardening.test.ts new file mode 100644 index 0000000..2bba59d --- /dev/null +++ b/tests/sdk-mint-fallback-hardening.test.ts @@ -0,0 +1,625 @@ +import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test"; +import type { CocodClient } from "../src/daemon/wallet/cocod-client"; +import { + installMintFallbackTopUp, +} from "../src/daemon/wallet/sdk-mint-fallback"; + +function createCocodClient(mints: string[]): CocodClient { + return { + ping: async () => true, + getStatus: async () => "UNLOCKED", + unlock: async () => "ok", + getBalances: async () => ({}), + receiveCashu: async () => "ok", + receiveBolt11: async () => "invoice", + sendCashu: async () => "token", + sendBolt11: async () => "ok", + listMints: async () => mints, + addMint: async () => "ok", + getMintInfo: async () => ({}), + }; +} + +function createWalletAdapter(overrides: Record = {}) { + return { + getBalances: async () => ({}), + getNwcStatus: async () => ({ connected: false }), + ...overrides, + }; +} + +function createBalanceManager(overrides: Record = {}) { + return { + topUp: async (_options: { mintUrl: string }) => ({ success: false, message: "not patched" }), + createProviderToken: async (_options: { mintUrl: string }) => ({ + success: false, + error: "Send failed: Not enough proofs to send", + }), + ...overrides, + }; +} + +describe("Hardened fallback — input validation", () => { + test("createInvoiceViaRoutstrCore rejects non-HTTPS provider URL", async () => { + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = createWalletAdapter(); + + // Mock fetch — should NOT be called for non-HTTPS + const fetchMock = mock(() => Promise.resolve(new Response("{}"))); + + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "http://insecure.example", // non-HTTPS — should be rejected + ); + + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "http://insecure.example", + amount: 21, + }); + + // Should fail without calling fetch (no invoice created) + expect(result.success).toBe(false); + }); + + test("createInvoiceViaRoutstrCore rejects malformed provider URL", async () => { + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = createWalletAdapter(); + + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "not-a-url", + ); + + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "not-a-url", + amount: 21, + }); + + expect(result.success).toBe(false); + }); + + test("createProviderToken rejects amount <= 0", async () => { + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = createWalletAdapter(); + + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + // Amount = 0 → should default to 21 (not pass 0 to invoice creation) + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 0, + }); + + // Should fail (no proofs) but should NOT have called routstr-core with amount=0 + expect(result.success).toBe(false); + }); + + test("createInvoiceViaRoutstrCore rejects amount > 1_000_000 sats", async () => { + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = createWalletAdapter(); + + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 10_000_000, // 10M sats — way over routstr-core's 1M limit + }); + + expect(result.success).toBe(false); + }); + + test("createInvoiceViaRoutstrCore validates bolt11 starts with 'lnbc'", async () => { + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = createWalletAdapter(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const urlStr = String(url); + if (urlStr.includes("/lightning/invoice") && init?.method === "POST") { + return new Response(JSON.stringify({ + invoice_id: "test-id", + bolt11: "INVALID_INVOICE_NOT_LNBC", + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("Not found", { status: 404 }); + }) as typeof fetch; + + try { + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + // Should fail — invalid bolt11 should be rejected + expect(result.success).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("createInvoiceViaRoutstrCore validates invoice_id is non-empty", async () => { + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = createWalletAdapter(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const urlStr = String(url); + if (urlStr.includes("/lightning/invoice") && init?.method === "POST") { + return new Response(JSON.stringify({ + invoice_id: "", // empty + bolt11: "lnbc1validinvoice", + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("Not found", { status: 404 }); + }) as typeof fetch; + + try { + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + expect(result.success).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +describe("Hardened fallback — error handling", () => { + test("createInvoiceViaRoutstrCore handles fetch timeout gracefully", async () => { + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = createWalletAdapter(); + + const originalFetch = globalThis.fetch; + let fetchCalled = false; + globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => { + fetchCalled = true; + // Simulate the AbortController firing (timeout) + if (init?.signal) { + // Wait for the signal to abort, then reject + return new Promise((_, reject) => { + init.signal!.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted", "AbortError")); + }); + }); + } + return new Response("{}"); + }) as typeof fetch; + + try { + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + // Should handle the abort timeout gracefully and fall through + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + expect(fetchCalled).toBe(true); + // Should fail (no proofs) but not hang + expect(result.success).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("createInvoiceViaRoutstrCore handles 500 from routstr-core", async () => { + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = createWalletAdapter(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const urlStr = String(url); + if (urlStr.includes("/lightning/invoice") && init?.method === "POST") { + return new Response(JSON.stringify({ detail: "Internal server error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("Not found", { status: 404 }); + }) as typeof fetch; + + try { + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + // Should fail gracefully and fall through to local wallet + expect(result.success).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("payInvoiceViaNwc handles NWC wallet disconnect mid-payment", async () => { + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + let payCalled = false; + const walletAdapter = createWalletAdapter({ + getNwcStatus: async () => ({ connected: true }), + payBolt11: async (_bolt11: string) => { + payCalled = true; + return { success: false, error: "Wallet disconnected" }; + }, + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const urlStr = String(url); + if (urlStr.includes("/lightning/invoice") && init?.method === "POST") { + return new Response(JSON.stringify({ + invoice_id: "test-id", + bolt11: "lnbc1validinvoice", + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("Not found", { status: 404 }); + }) as typeof fetch; + + try { + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + expect(payCalled).toBe(true); + // NWC failed → should surface invoice, not crash + expect(result.success).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("payInvoiceViaNwc handles NWC throwing exception", async () => { + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = createWalletAdapter({ + getNwcStatus: async () => ({ connected: true }), + payBolt11: async (_bolt11: string) => { + throw new Error("NWC relay connection lost"); + }, + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const urlStr = String(url); + if (urlStr.includes("/lightning/invoice") && init?.method === "POST") { + return new Response(JSON.stringify({ + invoice_id: "test-id", + bolt11: "lnbc1validinvoice", + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("Not found", { status: 404 }); + }) as typeof fetch; + + try { + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + // Exception caught, not propagated — should fail gracefully + expect(result.success).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +describe("Hardened fallback — concurrency & idempotency", () => { + test("installMintFallbackTopUp is idempotent (double-install safe)", async () => { + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = createWalletAdapter(); + + const cocod = createCocodClient(["https://mint-a.example"]); + + // Install twice — should not double-patch + installMintFallbackTopUp( + client, + cocod, + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + installMintFallbackTopUp( + client, + cocod, + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + // The second install should be a no-op — both patch markers set + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + expect(result.success).toBe(false); + }); + + test("concurrent createProviderToken calls don't interfere", async () => { + let invoiceCreateCount = 0; + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = createWalletAdapter(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const urlStr = String(url); + if (urlStr.includes("/lightning/invoice") && init?.method === "POST") { + invoiceCreateCount++; + return new Response(JSON.stringify({ + invoice_id: `invoice-${invoiceCreateCount}`, + bolt11: "lnbc1validinvoice", + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("Not found", { status: 404 }); + }) as typeof fetch; + + try { + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + // Fire 3 concurrent calls + const results = await Promise.all([ + balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }), + balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }), + balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }), + ]); + + // All should fail (no proofs to send) + results.forEach(r => expect(r.success).toBe(false)); + // Each call creates its own invoice — that's acceptable (no shared state corruption) + expect(invoiceCreateCount).toBe(3); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +describe("Hardened fallback — API key safety", () => { + test("API key is not logged in error messages", async () => { + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = createWalletAdapter(); + + const loggedMessages: string[] = []; + const logger = { + log: (msg: string) => loggedMessages.push(msg), + warn: (msg: string) => loggedMessages.push(msg), + error: (msg: string) => loggedMessages.push(msg), + }; + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const urlStr = String(url); + if (urlStr.includes("/lightning/invoice") && init?.method === "POST") { + return new Response("Internal error", { status: 500 }); + } + return new Response("Not found", { status: 404 }); + }) as typeof fetch; + + try { + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + logger, + "https://provider.example", + ); + + const secretApiKey = "sk-verysecretkey123456"; + await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + token: secretApiKey, + }); + + // No logged message should contain the API key + const leakedKey = loggedMessages.find(m => m.includes(secretApiKey)); + expect(leakedKey).toBeUndefined(); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +describe("Hardened fallback — poller lifecycle", () => { + test("pollRoutstrCoreInvoice stops after max polls", async () => { + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = createWalletAdapter(); + + let statusCallCount = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const urlStr = String(url); + if (urlStr.includes("/lightning/invoice") && init?.method === "POST") { + return new Response(JSON.stringify({ + invoice_id: "test-id", + bolt11: "lnbc1validinvoice", + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (urlStr.includes("/lightning/invoice/") && urlStr.includes("/status")) { + statusCallCount++; + return new Response(JSON.stringify({ status: "pending" }), { + status: 200, headers: { "Content-Type": "application/json" }, + }); + } + return new Response("Not found", { status: 404 }); + }) as typeof fetch; + + try { + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + // Wait a bit for the poller to start (but it polls every 5s, so we + // won't hit maxPolls in this test — just verify it doesn't throw) + await new Promise(resolve => setTimeout(resolve, 100)); + // The poller is non-blocking, so we just verify no crash + expect(statusCallCount).toBeGreaterThanOrEqual(0); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("pollRoutstrCoreInvoice detects expired invoice and stops", async () => { + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = createWalletAdapter(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const urlStr = String(url); + if (urlStr.includes("/lightning/invoice") && init?.method === "POST") { + return new Response(JSON.stringify({ + invoice_id: "test-id", + bolt11: "lnbc1validinvoice", + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (urlStr.includes("/lightning/invoice/") && urlStr.includes("/status")) { + return new Response(JSON.stringify({ status: "expired" }), { + status: 200, headers: { "Content-Type": "application/json" }, + }); + } + return new Response("Not found", { status: 404 }); + }) as typeof fetch; + + try { + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + // Invoice expired — should fail gracefully + expect(result.success).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/tests/sdk-mint-fallback.test.ts b/tests/sdk-mint-fallback.test.ts index 16cfc48..3211ff6 100644 --- a/tests/sdk-mint-fallback.test.ts +++ b/tests/sdk-mint-fallback.test.ts @@ -45,7 +45,7 @@ describe("SDK top-up mint fallback", () => { client, createCocodClient(["https://mint-a.example", "https://mint-b.example"]), walletAdapter, - { log: () => undefined, warn: () => undefined }, + { log: () => undefined, warn: () => undefined, error: () => undefined }, ); const result = await balanceManager.topUp({ @@ -95,7 +95,7 @@ describe("SDK top-up mint fallback", () => { client, createCocodClient(["https://mint-a.example", "https://mint-b.example"]), walletAdapter, - { log: () => undefined, warn: () => undefined }, + { log: () => undefined, warn: () => undefined, error: () => undefined }, ); const result = await client._handleErrorResponse( @@ -146,7 +146,7 @@ describe("SDK top-up mint fallback", () => { client, createCocodClient(["https://mint-a.example", "https://mint-b.example"]), walletAdapter, - { log: () => undefined, warn: () => undefined }, + { log: () => undefined, warn: () => undefined, error: () => undefined }, ); const result = await balanceManager.topUp({ @@ -158,4 +158,318 @@ describe("SDK top-up mint fallback", () => { expect(result).toEqual({ success: false, message: "insufficient balance" }); expect(attempts).toEqual(["https://mint-a.example"]); }); + + test("retries on 'Not enough proofs' as wallet-empty fallback", async () => { + const attempts: string[] = []; + const balanceManager = { + topUp: async (options: { mintUrl: string }) => { + attempts.push(options.mintUrl); + if (options.mintUrl === "https://mint-a.example") { + return { + success: false, + message: "Send failed: Not enough proofs to send", + }; + } + return { success: true, toppedUpAmount: 21 }; + }, + }; + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = { + getBalances: async () => ({ "https://mint-b.example": 100 }), + }; + + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example", "https://mint-b.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + ); + + const result = await balanceManager.topUp({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + expect(result.success).toBe(true); + expect(attempts).toEqual(["https://mint-a.example", "https://mint-b.example"]); + }); + + test("falls back to lightning invoice when all mints are empty", async () => { + const attempts: string[] = []; + const balanceManager = { + topUp: async (options: { mintUrl: string }) => { + attempts.push(options.mintUrl); + return { + success: false, + message: "Send failed: Not enough proofs to send", + }; + }, + }; + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = { + getBalances: async () => ({}), + getNwcStatus: async () => ({ connected: false }), + }; + const cocodClient = createCocodClient([ + "https://mint-a.example", + "https://mint-b.example", + ]); + + installMintFallbackTopUp( + client, + cocodClient, + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + ); + + const result = await balanceManager.topUp({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + // Should have tried both mints, then attempted lightning invoice fallback + expect(attempts).toEqual(["https://mint-a.example", "https://mint-b.example"]); + // Final result is still the last failure (invoice was created but not paid) + expect(result.success).toBe(false); + }); + + test("createProviderToken retries on 'Not enough proofs' across mints", async () => { + const attempts: string[] = []; + const balanceManager = { + topUp: async (_options: { mintUrl: string }) => ({ success: false, message: "not patched" }), + createProviderToken: async (options: { mintUrl: string }) => { + attempts.push(options.mintUrl); + if (options.mintUrl === "https://mint-a.example") { + return { success: false, error: "Send failed: Not enough proofs to send" }; + } + return { success: true, token: "cashu-token", selectedMintUrl: options.mintUrl, amountSpent: 21 }; + }, + }; + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = { + getBalances: async () => ({ "https://mint-b.example": 100 }), + }; + + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example", "https://mint-b.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + ); + + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + expect(result.success).toBe(true); + expect(attempts).toEqual(["https://mint-a.example", "https://mint-b.example"]); + }); + + test("createProviderToken falls back to routstr-core invoice + NWC payment", async () => { + const createAttempts: string[] = []; + const balanceManager = { + topUp: async (_options: { mintUrl: string }) => ({ success: false, message: "not patched" }), + createProviderToken: async (options: { mintUrl: string }) => { + createAttempts.push(options.mintUrl); + return { success: false, error: "Send failed: Not enough proofs to send" }; + }, + }; + const client = { getBalanceManager: () => balanceManager }; + let nwcPaid = false; + const walletAdapter = { + getBalances: async () => ({}), + getNwcStatus: async () => ({ connected: true }), + payBolt11: async (_bolt11: string) => { + nwcPaid = true; + return { success: true, preimage: "abc123" }; + }, + }; + + // Mock fetch for routstr-core invoice creation + const originalFetch = globalThis.fetch; + let invoiceCreated = false; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const urlStr = String(url); + if (urlStr.includes("/lightning/invoice") && init?.method === "POST") { + invoiceCreated = true; + return new Response(JSON.stringify({ + invoice_id: "test-invoice-id", + bolt11: "lnbc1testinvoice", + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (urlStr.includes("/lightning/invoice/") && urlStr.includes("/status")) { + return new Response(JSON.stringify({ status: "paid" }), { + status: 200, headers: { "Content-Type": "application/json" }, + }); + } + return new Response("Not found", { status: 404 }); + }) as typeof fetch; + + try { + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + // Should have tried mint-a, then created a routstr-core invoice + // After NWC payment, it retries createProviderToken (mint-a again) + expect(createAttempts).toEqual(["https://mint-a.example", "https://mint-a.example"]); + expect(invoiceCreated).toBe(true); + // NWC should have been used to pay the routstr-core invoice + expect(nwcPaid).toBe(true); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("falls back to local NWC invoice when routstr-core returns HTTP error", async () => { + const createAttempts: string[] = []; + const balanceManager = { + topUp: async (_o: { mintUrl: string }) => ({ success: false, message: "not patched" }), + createProviderToken: async (options: { mintUrl: string }) => { + createAttempts.push(options.mintUrl); + return { success: false, error: "Send failed: Not enough proofs to send" }; + }, + }; + const client = { getBalanceManager: () => balanceManager }; + let nwcPaidBolt11: string | null = null; + const walletAdapter = { + getBalances: async () => ({}), + getNwcStatus: async () => ({ connected: true }), + payBolt11: async (bolt11: string) => { + nwcPaidBolt11 = bolt11; + return { success: true, preimage: "def456" }; + }, + }; + + // Mock fetch: routstr-core returns 500 + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const urlStr = String(url); + if (urlStr.includes("/lightning/invoice") && init?.method === "POST") { + return new Response("Internal server error", { status: 500 }); + } + return new Response("Not found", { status: 404 }); + }) as typeof fetch; + + try { + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + "https://provider.example", + ); + + const result = await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + // routstr-core failed → fell back to local NWC invoice + expect(nwcPaidBolt11).not.toBeNull(); + // local wallet invoice is a static test string from createCocodClient + expect(nwcPaidBolt11).toBe("invoice"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("shows manual top-up when both routstr-core and NWC are unavailable", async () => { + const logs: string[] = []; + const logger = { + log: (...args: unknown[]) => logs.push(args.join(" ")), + warn: () => {}, + error: () => {}, + }; + const balanceManager = { + topUp: async (_o: { mintUrl: string }) => ({ success: false, message: "not patched" }), + createProviderToken: async (_options: { mintUrl: string }) => ({ + success: false, error: "Send failed: Not enough proofs to send", + }), + }; + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = { + getBalances: async () => ({}), + getNwcStatus: async () => ({ connected: false, error: "not configured" }), + }; + + // Mock fetch: routstr-core unreachable (network error) + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + throw new Error("Network error: connection refused"); + }) as typeof fetch; + + try { + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + logger, + "https://provider.example", + ); + + await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + amount: 21, + }); + + // Should have logged the manual top-up message + const manualLog = logs.find((l) => l.includes("Manual top-up")); + expect(manualLog).toBeDefined(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("skips routstr-core when upstreamProviderUrl is undefined, uses NWC only", async () => { + const createAttempts: string[] = []; + const balanceManager = { + topUp: async (_o: { mintUrl: string }) => ({ success: false, message: "not patched" }), + createProviderToken: async (options: { mintUrl: string }) => { + createAttempts.push(options.mintUrl); + return { success: false, error: "Send failed: Not enough proofs to send" }; + }, + }; + const client = { getBalanceManager: () => balanceManager }; + let nwcCalled = false; + const walletAdapter = { + getBalances: async () => ({}), + getNwcStatus: async () => ({ connected: true }), + payBolt11: async (_bolt11: string) => { + nwcCalled = true; + return { success: true, preimage: "ghi789" }; + }, + }; + + // No upstreamProviderUrl passed → should not call fetch at all + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined, error: () => undefined }, + ); + + await balanceManager.createProviderToken({ + mintUrl: "https://mint-a.example", + amount: 21, + }); + + // NWC should have been used (routstr-core was skipped) + expect(nwcCalled).toBe(true); + }); });