feat(machine): real exchange rate with LNbits, ShockWallet, CoinGecko fallback

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 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-25 18:50:30 -05:00
commit 648f089ac2

View file

@ -622,6 +622,120 @@ interface LightningServices {
stopDebitApproval: () => void 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<string, { satsPerUnit: number; timestamp: number }>()
const pendingRequests = new Map<string, Promise<number>>()
async function fetchExchangeRate(currency: string): Promise<number> {
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<number> {
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<number> {
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<number> {
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 */ /** Callback for incoming offer requests */
type OfferRequestCallback = (request: OfferRequest, senderPubkey: string) => void type OfferRequestCallback = (request: OfferRequest, senderPubkey: string) => void
@ -962,13 +1076,19 @@ function createATMServices(
}, },
/** /**
* Get current exchange rate * Get current exchange rate (sats per 1 unit of fiat)
* TODO: Fetch from Lightning.Pub or external price feed *
* 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<number> => { getExchangeRate: async (currency: string): Promise<number> => {
console.log('[ATM Service] Using fixed exchange rate for', currency) const rate = await fetchExchangeRate(currency)
// Fixed rate for development/testing console.log(`[ATM Service] Exchange rate for ${currency}: ${rate} sats/unit`)
return 1000 // sats per USD (~$100k BTC) return rate
}, },
/** /**