From 648f089ac2a799bb5cbc2c629de9829069818159 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 25 Feb 2026 18:50:30 -0500 Subject: [PATCH] feat(machine): real exchange rate with LNbits, ShockWallet, CoinGecko fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded mock rate (1000 sats/USD) with live price feeds: 1. LNbits (lnbits.atitlan.io) — primary, supports GTQ/USD/EUR 2. rates.shockwallet.app — ShockWallet ecosystem fallback 3. CoinGecko — last resort Includes 5-minute cache and request deduplication. Co-Authored-By: Claude Opus 4.6 --- apps/machine/src/services/lightning.ts | 130 ++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 5 deletions(-) diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index fddd603..602c62d 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -622,6 +622,120 @@ interface LightningServices { stopDebitApproval: () => void } +// ============================================================================ +// Exchange Rate Service +// ============================================================================ + +const SATS_PER_BTC = 100_000_000 +const RATE_CACHE_MS = 5 * 60 * 1000 // 5 minutes +const LNBITS_URL = 'https://lnbits.atitlan.io/api/v1/conversion' +const SHOCKWALLET_URL = 'https://rates.shockwallet.app/v2/prices/BTC-$$/spot' +const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=' + +const rateCache = new Map() +const pendingRequests = new Map>() + +async function fetchExchangeRate(currency: string): Promise { + const upper = currency.toUpperCase() + + // Check cache + const cached = rateCache.get(upper) + if (cached && Date.now() - cached.timestamp < RATE_CACHE_MS) { + return cached.satsPerUnit + } + + // Deduplicate concurrent requests + const pending = pendingRequests.get(upper) + if (pending) return pending + + const promise = (async () => { + try { + // Source 1: LNbits (own infra, supports GTQ) + return await fetchFromLnbits(upper) + } catch (e1) { + console.warn('[ExchangeRate] LNbits failed:', (e1 as Error).message) + try { + // Source 2: ShockWallet rates service + return await fetchFromShockwallet(upper) + } catch (e2) { + console.warn('[ExchangeRate] ShockWallet failed:', (e2 as Error).message) + try { + // Source 3: CoinGecko (last resort) + return await fetchFromCoingecko(upper) + } catch (e3) { + console.error('[ExchangeRate] All sources failed for', upper) + throw new Error(`Failed to fetch exchange rate for ${upper}`) + } + } + } + })() + + pendingRequests.set(upper, promise) + try { + const rate = await promise + rateCache.set(upper, { satsPerUnit: rate, timestamp: Date.now() }) + return rate + } finally { + pendingRequests.delete(upper) + } +} + +/** LNbits conversion: POST {from: "sat", amount: 1, to: "GTQ"} → {"sats":1,"GTQ":0.00523} */ +async function fetchFromLnbits(currency: string): Promise { + const res = await fetch(LNBITS_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 'sat', amount: 1, to: currency }), + signal: AbortSignal.timeout(5000), + }) + if (!res.ok) throw new Error(`LNbits HTTP ${res.status}`) + const data = await res.json() + const fiatPerSat = data[currency] + if (typeof fiatPerSat !== 'number' || fiatPerSat <= 0) { + throw new Error(`LNbits: invalid rate for ${currency}`) + } + // 1 sat = fiatPerSat units of fiat → 1 fiat unit = 1/fiatPerSat sats + const satsPerUnit = Math.round(1 / fiatPerSat) + console.log(`[ExchangeRate] LNbits: ${currency} = ${satsPerUnit} sats/unit`) + return satsPerUnit +} + +/** ShockWallet: GET BTC-USD/spot → {"data":{"amount":"67708.515"}} */ +async function fetchFromShockwallet(currency: string): Promise { + const url = SHOCKWALLET_URL.replace('$$', currency) + const res = await fetch(url, { signal: AbortSignal.timeout(5000) }) + if (!res.ok) throw new Error(`ShockWallet HTTP ${res.status}`) + const data = await res.json() + const btcPrice = parseFloat(data?.data?.amount) + if (!btcPrice || btcPrice <= 0) { + throw new Error(`ShockWallet: invalid price for ${currency}`) + } + const satsPerUnit = Math.round(SATS_PER_BTC / btcPrice) + console.log( + `[ExchangeRate] ShockWallet: ${currency} = ${satsPerUnit} sats/unit (BTC=${btcPrice})` + ) + return satsPerUnit +} + +/** CoinGecko: GET ?vs_currencies=usd → {"bitcoin":{"usd":67695}} */ +async function fetchFromCoingecko(currency: string): Promise { + const lower = currency.toLowerCase() + const res = await fetch(COINGECKO_URL + lower, { signal: AbortSignal.timeout(5000) }) + if (!res.ok) throw new Error(`CoinGecko HTTP ${res.status}`) + const data = await res.json() + const btcPrice = data?.bitcoin?.[lower] + if (typeof btcPrice !== 'number' || btcPrice <= 0) { + throw new Error(`CoinGecko: invalid price for ${currency}`) + } + const satsPerUnit = Math.round(SATS_PER_BTC / btcPrice) + console.log(`[ExchangeRate] CoinGecko: ${currency} = ${satsPerUnit} sats/unit (BTC=${btcPrice})`) + return satsPerUnit +} + +// ============================================================================ +// Lightning Service Types & Initialization +// ============================================================================ + /** Callback for incoming offer requests */ type OfferRequestCallback = (request: OfferRequest, senderPubkey: string) => void @@ -962,13 +1076,19 @@ function createATMServices( }, /** - * Get current exchange rate - * TODO: Fetch from Lightning.Pub or external price feed + * Get current exchange rate (sats per 1 unit of fiat) + * + * Sources (in priority order): + * 1. LNbits (lnbits.atitlan.io) — own infra, supports GTQ + * 2. rates.shockwallet.app — ShockWallet ecosystem + * 3. CoinGecko — last resort fallback + * + * Results are cached for 5 minutes. */ getExchangeRate: async (currency: string): Promise => { - console.log('[ATM Service] Using fixed exchange rate for', currency) - // Fixed rate for development/testing - return 1000 // sats per USD (~$100k BTC) + const rate = await fetchExchangeRate(currency) + console.log(`[ATM Service] Exchange rate for ${currency}: ${rate} sats/unit`) + return rate }, /**