feat(machine): live BTC price badge with 10s polling from LNbits

- Poll LNbits /api/v1/rate/ every 10s for live price display
- Show local currency rate + USD reference (e.g. GTQ/BTC: Q525,034 ($67,960))
- Reduce rate cache from 5min to 10s for responsive display
- Use simpler /api/v1/rate/ endpoint instead of /conversion POST

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-25 19:12:52 -05:00
commit 625af457d1
3 changed files with 100 additions and 28 deletions

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, onUnmounted, ref, computed } from 'vue'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -15,14 +15,26 @@ function formatSats(sats: number): string {
return sats.toLocaleString() return sats.toLocaleString()
} }
const formattedBtcPrice = computed(() => {
if (atmStore.btcPrice === null) return null
const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}`
if (atmStore.btcPriceUsd !== null && atmStore.fiatCode !== 'USD') {
return `${local} ($${Math.round(atmStore.btcPriceUsd).toLocaleString()})`
}
return local
})
onMounted(async () => { onMounted(async () => {
if (isElectron) { if (isElectron) {
// Production: initialize with real hardware + Lightning
await atmStore.initializeForProduction() await atmStore.initializeForProduction()
} else { } else {
// Browser dev mode: Lightning-only (no hardware)
await atmStore.initializeWithLightning() await atmStore.initializeWithLightning()
} }
atmStore.startPricePolling()
})
onUnmounted(() => {
atmStore.stopPricePolling()
}) })
function toggleLiveServices() { function toggleLiveServices() {
@ -44,6 +56,12 @@ function toggleLiveServices() {
<div <div
class="fixed right-2 top-2 z-50 flex flex-wrap items-center justify-end gap-1 sm:right-4 sm:top-4 sm:gap-2" class="fixed right-2 top-2 z-50 flex flex-wrap items-center justify-end gap-1 sm:right-4 sm:top-4 sm:gap-2"
> >
<Badge
v-if="formattedBtcPrice"
class="bg-primary/10 font-mono text-primary border border-primary/30 text-xs sm:text-sm"
>
{{ formattedBtcPrice }}
</Badge>
<Badge <Badge
v-if="atmStore.balanceSats !== null" v-if="atmStore.balanceSats !== null"
class="bg-bitcoin/20 font-mono text-bitcoin border border-bitcoin/30 text-xs sm:text-sm" class="bg-bitcoin/20 font-mono text-bitcoin border border-bitcoin/30 text-xs sm:text-sm"

View file

@ -627,21 +627,35 @@ interface LightningServices {
// ============================================================================ // ============================================================================
const SATS_PER_BTC = 100_000_000 const SATS_PER_BTC = 100_000_000
const RATE_CACHE_MS = 5 * 60 * 1000 // 5 minutes const RATE_CACHE_MS = 10 * 1000 // 10 seconds (polled for display)
const LNBITS_URL = 'https://lnbits.atitlan.io/api/v1/conversion' const LNBITS_RATE_URL = 'https://lnbits.atitlan.io/api/v1/rate/'
const SHOCKWALLET_URL = 'https://rates.shockwallet.app/v2/prices/BTC-$$/spot' 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 COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies='
const rateCache = new Map<string, { satsPerUnit: number; timestamp: number }>() interface RateCacheEntry {
const pendingRequests = new Map<string, Promise<number>>() satsPerUnit: number
btcPrice: number // fiat per BTC (for display)
timestamp: number
}
const rateCache = new Map<string, RateCacheEntry>()
const pendingRequests = new Map<string, Promise<RateCacheEntry>>()
/** Latest BTC price in fiat (for display badge) */
export let latestBtcPrice: { currency: string; price: number } | null = null
async function fetchExchangeRate(currency: string): Promise<number> { async function fetchExchangeRate(currency: string): Promise<number> {
const entry = await fetchRateEntry(currency)
return entry.satsPerUnit
}
async function fetchRateEntry(currency: string): Promise<RateCacheEntry> {
const upper = currency.toUpperCase() const upper = currency.toUpperCase()
// Check cache // Check cache
const cached = rateCache.get(upper) const cached = rateCache.get(upper)
if (cached && Date.now() - cached.timestamp < RATE_CACHE_MS) { if (cached && Date.now() - cached.timestamp < RATE_CACHE_MS) {
return cached.satsPerUnit return cached
} }
// Deduplicate concurrent requests // Deduplicate concurrent requests
@ -672,36 +686,45 @@ async function fetchExchangeRate(currency: string): Promise<number> {
pendingRequests.set(upper, promise) pendingRequests.set(upper, promise)
try { try {
const rate = await promise const entry = await promise
rateCache.set(upper, { satsPerUnit: rate, timestamp: Date.now() }) rateCache.set(upper, entry)
return rate latestBtcPrice = { currency: upper, price: entry.btcPrice }
return entry
} finally { } finally {
pendingRequests.delete(upper) pendingRequests.delete(upper)
} }
} }
/** LNbits conversion: POST {from: "sat", amount: 1, to: "GTQ"} → {"sats":1,"GTQ":0.00523} */ /** Fetch latest BTC price for display (bypasses transaction-rate cache) */
async function fetchFromLnbits(currency: string): Promise<number> { export async function fetchBtcPrice(currency: string): Promise<number | null> {
const res = await fetch(LNBITS_URL, { try {
method: 'POST', const entry = await fetchRateEntry(currency)
headers: { 'Content-Type': 'application/json' }, return entry.btcPrice
body: JSON.stringify({ from: 'sat', amount: 1, to: currency }), } catch {
return null
}
}
/** LNbits rate: GET /api/v1/rate/GTQ → {"rate":190.46,"price":525033.85} */
async function fetchFromLnbits(currency: string): Promise<RateCacheEntry> {
const res = await fetch(LNBITS_RATE_URL + currency, {
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}) })
if (!res.ok) throw new Error(`LNbits HTTP ${res.status}`) if (!res.ok) throw new Error(`LNbits HTTP ${res.status}`)
const data = await res.json() const data = await res.json()
const fiatPerSat = data[currency] const satsPerUnit = data.rate
if (typeof fiatPerSat !== 'number' || fiatPerSat <= 0) { const btcPrice = data.price
if (typeof satsPerUnit !== 'number' || satsPerUnit <= 0) {
throw new Error(`LNbits: invalid rate for ${currency}`) throw new Error(`LNbits: invalid rate for ${currency}`)
} }
// 1 sat = fiatPerSat units of fiat → 1 fiat unit = 1/fiatPerSat sats console.log(
const satsPerUnit = Math.round(1 / fiatPerSat) `[ExchangeRate] LNbits: ${currency} = ${Math.round(satsPerUnit)} sats/unit (${currency}/BTC=${Math.round(btcPrice)})`
console.log(`[ExchangeRate] LNbits: ${currency} = ${satsPerUnit} sats/unit`) )
return satsPerUnit return { satsPerUnit: Math.round(satsPerUnit), btcPrice, timestamp: Date.now() }
} }
/** ShockWallet: GET BTC-USD/spot → {"data":{"amount":"67708.515"}} */ /** ShockWallet: GET BTC-USD/spot → {"data":{"amount":"67708.515"}} */
async function fetchFromShockwallet(currency: string): Promise<number> { async function fetchFromShockwallet(currency: string): Promise<RateCacheEntry> {
const url = SHOCKWALLET_URL.replace('$$', currency) const url = SHOCKWALLET_URL.replace('$$', currency)
const res = await fetch(url, { signal: AbortSignal.timeout(5000) }) const res = await fetch(url, { signal: AbortSignal.timeout(5000) })
if (!res.ok) throw new Error(`ShockWallet HTTP ${res.status}`) if (!res.ok) throw new Error(`ShockWallet HTTP ${res.status}`)
@ -714,11 +737,11 @@ async function fetchFromShockwallet(currency: string): Promise<number> {
console.log( console.log(
`[ExchangeRate] ShockWallet: ${currency} = ${satsPerUnit} sats/unit (BTC=${btcPrice})` `[ExchangeRate] ShockWallet: ${currency} = ${satsPerUnit} sats/unit (BTC=${btcPrice})`
) )
return satsPerUnit return { satsPerUnit, btcPrice, timestamp: Date.now() }
} }
/** CoinGecko: GET ?vs_currencies=usd → {"bitcoin":{"usd":67695}} */ /** CoinGecko: GET ?vs_currencies=usd → {"bitcoin":{"usd":67695}} */
async function fetchFromCoingecko(currency: string): Promise<number> { async function fetchFromCoingecko(currency: string): Promise<RateCacheEntry> {
const lower = currency.toLowerCase() const lower = currency.toLowerCase()
const res = await fetch(COINGECKO_URL + lower, { signal: AbortSignal.timeout(5000) }) const res = await fetch(COINGECKO_URL + lower, { signal: AbortSignal.timeout(5000) })
if (!res.ok) throw new Error(`CoinGecko HTTP ${res.status}`) if (!res.ok) throw new Error(`CoinGecko HTTP ${res.status}`)
@ -729,7 +752,7 @@ async function fetchFromCoingecko(currency: string): Promise<number> {
} }
const satsPerUnit = Math.round(SATS_PER_BTC / btcPrice) const satsPerUnit = Math.round(SATS_PER_BTC / btcPrice)
console.log(`[ExchangeRate] CoinGecko: ${currency} = ${satsPerUnit} sats/unit (BTC=${btcPrice})`) console.log(`[ExchangeRate] CoinGecko: ${currency} = ${satsPerUnit} sats/unit (BTC=${btcPrice})`)
return satsPerUnit return { satsPerUnit, btcPrice, timestamp: Date.now() }
} }
// ============================================================================ // ============================================================================

View file

@ -9,7 +9,7 @@ import {
type SnapshotFrom, type SnapshotFrom,
type ATMMachine, type ATMMachine,
} from '@lamassu/state-machine' } from '@lamassu/state-machine'
import { initializeLightningServices } from '@/services/lightning' import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
import type { HalConfig, HalServices } from '@/services/hal' import type { HalConfig, HalServices } from '@/services/hal'
import type { MachineModel } from '@/config' import type { MachineModel } from '@/config'
import type { LightningPubClient } from '@lamassu/lightning' import type { LightningPubClient } from '@lamassu/lightning'
@ -147,7 +147,10 @@ export const useAtmStore = defineStore('atm', () => {
const paymentError = ref<string | null>(null) const paymentError = ref<string | null>(null)
const lnurlWithdrawUri = ref<string | null>(null) const lnurlWithdrawUri = ref<string | null>(null)
const balanceSats = ref<number | null>(null) const balanceSats = ref<number | null>(null)
const btcPrice = ref<number | null>(null) // local fiat per BTC (for display)
const btcPriceUsd = ref<number | null>(null) // USD per BTC (always shown)
let stopBalanceWatch: (() => void) | null = null let stopBalanceWatch: (() => void) | null = null
let pricePollingInterval: ReturnType<typeof setInterval> | null = null
// Store reference to ATM services for direct calls // Store reference to ATM services for direct calls
let atmServicesRef: ATMServices | null = null let atmServicesRef: ATMServices | null = null
@ -914,6 +917,30 @@ export const useAtmStore = defineStore('atm', () => {
debugMode.value = !debugMode.value debugMode.value = !debugMode.value
} }
/** Start polling BTC price every 10s for the display badge */
function startPricePolling() {
if (pricePollingInterval) return
const poll = async () => {
const currency = fiatCode.value
const [localPrice, usdPrice] = await Promise.all([
fetchBtcPrice(currency),
currency !== 'USD' ? fetchBtcPrice('USD') : null,
])
if (localPrice !== null) btcPrice.value = localPrice
if (usdPrice !== null) btcPriceUsd.value = usdPrice
else if (currency === 'USD' && localPrice !== null) btcPriceUsd.value = localPrice
}
poll() // immediate first fetch
pricePollingInterval = setInterval(poll, 10_000)
}
function stopPricePolling() {
if (pricePollingInterval) {
clearInterval(pricePollingInterval)
pricePollingInterval = null
}
}
return { return {
// State // State
actor, actor,
@ -923,6 +950,8 @@ export const useAtmStore = defineStore('atm', () => {
useLiveServices, useLiveServices,
connectionStatus, connectionStatus,
balanceSats, balanceSats,
btcPrice,
btcPriceUsd,
halServices, halServices,
isPayingInvoice, isPayingInvoice,
isRequestingDebit, isRequestingDebit,
@ -962,5 +991,7 @@ export const useAtmStore = defineStore('atm', () => {
confirmAmount, confirmAmount,
refreshInventory, refreshInventory,
toggleDebug, toggleDebug,
startPricePolling,
stopPricePolling,
} }
}) })