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">
import { onMounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, computed } from 'vue'
import { useAtmStore } from '@/stores/atm'
import { useTheme } from '@/composables/useTheme'
import { Badge } from '@/components/ui/badge'
@ -15,14 +15,26 @@ function formatSats(sats: number): string {
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 () => {
if (isElectron) {
// Production: initialize with real hardware + Lightning
await atmStore.initializeForProduction()
} else {
// Browser dev mode: Lightning-only (no hardware)
await atmStore.initializeWithLightning()
}
atmStore.startPricePolling()
})
onUnmounted(() => {
atmStore.stopPricePolling()
})
function toggleLiveServices() {
@ -44,6 +56,12 @@ function toggleLiveServices() {
<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"
>
<Badge
v-if="formattedBtcPrice"
class="bg-primary/10 font-mono text-primary border border-primary/30 text-xs sm:text-sm"
>
{{ formattedBtcPrice }}
</Badge>
<Badge
v-if="atmStore.balanceSats !== null"
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 RATE_CACHE_MS = 5 * 60 * 1000 // 5 minutes
const LNBITS_URL = 'https://lnbits.atitlan.io/api/v1/conversion'
const RATE_CACHE_MS = 10 * 1000 // 10 seconds (polled for display)
const LNBITS_RATE_URL = 'https://lnbits.atitlan.io/api/v1/rate/'
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>>()
interface RateCacheEntry {
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> {
const entry = await fetchRateEntry(currency)
return entry.satsPerUnit
}
async function fetchRateEntry(currency: string): Promise<RateCacheEntry> {
const upper = currency.toUpperCase()
// Check cache
const cached = rateCache.get(upper)
if (cached && Date.now() - cached.timestamp < RATE_CACHE_MS) {
return cached.satsPerUnit
return cached
}
// Deduplicate concurrent requests
@ -672,36 +686,45 @@ async function fetchExchangeRate(currency: string): Promise<number> {
pendingRequests.set(upper, promise)
try {
const rate = await promise
rateCache.set(upper, { satsPerUnit: rate, timestamp: Date.now() })
return rate
const entry = await promise
rateCache.set(upper, entry)
latestBtcPrice = { currency: upper, price: entry.btcPrice }
return entry
} 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 }),
/** Fetch latest BTC price for display (bypasses transaction-rate cache) */
export async function fetchBtcPrice(currency: string): Promise<number | null> {
try {
const entry = await fetchRateEntry(currency)
return entry.btcPrice
} 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),
})
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) {
const satsPerUnit = data.rate
const btcPrice = data.price
if (typeof satsPerUnit !== 'number' || satsPerUnit <= 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
console.log(
`[ExchangeRate] LNbits: ${currency} = ${Math.round(satsPerUnit)} sats/unit (${currency}/BTC=${Math.round(btcPrice)})`
)
return { satsPerUnit: Math.round(satsPerUnit), btcPrice, timestamp: Date.now() }
}
/** 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 res = await fetch(url, { signal: AbortSignal.timeout(5000) })
if (!res.ok) throw new Error(`ShockWallet HTTP ${res.status}`)
@ -714,11 +737,11 @@ async function fetchFromShockwallet(currency: string): Promise<number> {
console.log(
`[ExchangeRate] ShockWallet: ${currency} = ${satsPerUnit} sats/unit (BTC=${btcPrice})`
)
return satsPerUnit
return { satsPerUnit, btcPrice, timestamp: Date.now() }
}
/** 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 res = await fetch(COINGECKO_URL + lower, { signal: AbortSignal.timeout(5000) })
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)
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 ATMMachine,
} from '@lamassu/state-machine'
import { initializeLightningServices } from '@/services/lightning'
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
import type { HalConfig, HalServices } from '@/services/hal'
import type { MachineModel } from '@/config'
import type { LightningPubClient } from '@lamassu/lightning'
@ -147,7 +147,10 @@ export const useAtmStore = defineStore('atm', () => {
const paymentError = ref<string | null>(null)
const lnurlWithdrawUri = ref<string | 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 pricePollingInterval: ReturnType<typeof setInterval> | null = null
// Store reference to ATM services for direct calls
let atmServicesRef: ATMServices | null = null
@ -914,6 +917,30 @@ export const useAtmStore = defineStore('atm', () => {
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 {
// State
actor,
@ -923,6 +950,8 @@ export const useAtmStore = defineStore('atm', () => {
useLiveServices,
connectionStatus,
balanceSats,
btcPrice,
btcPriceUsd,
halServices,
isPayingInvoice,
isRequestingDebit,
@ -962,5 +991,7 @@ export const useAtmStore = defineStore('atm', () => {
confirmAmount,
refreshInventory,
toggleDebug,
startPricePolling,
stopPricePolling,
}
})