/** * BCH ↔ USD price lookup, with a 60-second in-memory cache. * * We pull from CoinGecko by default — public, no API key, generous for the * once-a-minute cadence we need. The price feeds the invoice quote; a * publisher's quote is locked at issue time and does not re-quote while they * decide whether to pay. * * On fetch failure we return the last good value if it is under 15 minutes * old. Beyond that, we throw — handing out invoices with stale prices is * worse than refusing to quote until the oracle recovers. */ const CACHE_TTL_MS = 60_000; const STALE_FALLBACK_MS = 15 * 60_000; const SOURCE = process.env.PROMETHEUS_PRICE_SOURCE || 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin-cash&vs_currencies=usd'; let cached = null; // { price_usd, at } export async function bchUsd() { const now = Date.now(); if (cached && now - cached.at < CACHE_TTL_MS) return cached.price_usd; try { const resp = await fetch(SOURCE, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(5000), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const body = await resp.json(); const p = Number(body?.['bitcoin-cash']?.usd); if (!Number.isFinite(p) || p <= 0) throw new Error('bad price in response'); cached = { price_usd: p, at: now }; return p; } catch (err) { if (cached && now - cached.at < STALE_FALLBACK_MS) return cached.price_usd; throw new Error(`price oracle: ${err?.message || err}`); } } /** USD → sats, using the current (or last-good) BCH/USD price. */ export async function usdToSats(usd) { const price = await bchUsd(); // USD per BCH const bch = Number(usd) / price; // BCH per `usd` dollars const sats = Math.round(bch * 1e8); // satoshis per BCH = 1e8 return { sats, rate_sat_per_usd: Math.round(1e8 / price), price_usd: price }; }