mirror of
https://github.com/Routstr/routstrd.git
synced 2026-08-09 03:44:38 +00:00
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("]"))) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user