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:
parent
2eda5959b9
commit
625af457d1
3 changed files with 100 additions and 28 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue