feat: harden fallback chain — routstr-core Lightning invoice + NWC auto-pay

When the local wallet runs out of proofs, the fallback chain now:

1. Retries across ALL configured mints (createProviderToken patched —
   previously only topUp retried on 'Not enough proofs')
2. Creates a routstr-core Lightning invoice (POST /lightning/invoice)
   → uses 'topup' purpose when an existing API key is available
   → pays via NWC (payBolt11) if connected, then retries
   → otherwise surfaces invoice for manual payment + polls until paid/expired
3. Falls back to local wallet Lightning invoice + NWC funding

Hardening (15 new tests):
- SSRF protection: rejects non-HTTPS provider URLs (except localhost)
- Amount validation: clamps to [1, 1_000_000] sats
- bolt11 validation: must start with 'lnbc'
- invoice_id validation: must be non-empty string
- Fetch timeout: AbortController (10s) on invoice creation
- NWC exception handling: caught, not propagated
- Double-install idempotency: patch markers prevent re-patching
- Concurrent calls: no shared state corruption
- API key safety: never logged in error messages
- Poller lifecycle: stops after 84 attempts (~7min) or on expired status

New wallet adapter method:
- payBolt11(bolt11): pays externally-created invoices via NWC

Verified end-to-end:
- 50-request stress test: 44/50 success, 6 fallback triggers (fugu-ultra)
- Routstr-core topup: 64,781 → 114,340 msats (balance increased)
- Daemon stable post-stress (142MB RSS, immediate recovery)
- 48/48 tests pass (33 existing + 15 hardening)
This commit is contained in:
Paperclip Deployment Engineer
2026-07-19 15:47:27 +00:00
parent 89d27cb62a
commit 4f59168b5f
9 changed files with 1616 additions and 20 deletions
+1 -1
View File
@@ -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,
+94 -6
View File
@@ -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 <pid>, 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<void> {
@@ -144,7 +232,7 @@ async function main(): Promise<void> {
requestResponseLogSink,
},
);
installMintFallbackTopUp(routeClient, walletClient, walletAdapter, daemonSdkLogger);
installMintFallbackTopUp(routeClient, walletClient, walletAdapter, daemonSdkLogger, provider);
const refundClient = new RoutstrClient(
walletAdapter,
+22
View File
@@ -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;
+5 -1
View File
@@ -21,7 +21,11 @@ function uniqueMintUrls(mints: Array<string | undefined | null>): 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("]"))) {
+430 -1
View File
@@ -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<Record<string, number>>;
/** 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<string, string> = { "Content-Type": "application/json" };
const body: Record<string, unknown> = { 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<boolean> {
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 | undefined | null>): string[] {
const seen = new Set<string>();
@@ -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<TopUpResult>;
[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<TopUpResult> => {
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);
};
+61 -7
View File
@@ -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<boolean> {
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}`,
);
}
+61 -1
View File
@@ -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<void> {
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);
+625
View File
@@ -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<string, unknown> = {}) {
return {
getBalances: async () => ({}),
getNwcStatus: async () => ({ connected: false }),
...overrides,
};
}
function createBalanceManager(overrides: Record<string, unknown> = {}) {
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<Response>((_, 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;
}
});
});
+317 -3
View File
@@ -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);
});
});