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:
parent
4105f750de
commit
648f089ac2
1 changed files with 125 additions and 5 deletions
|
|
@ -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
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue