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">
|
<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"
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue