perf(access): keep the rate lookup off the unlock path, log entry timing

Tap → unlock took ~3 s on sintra. The card server's /session now fills
fiat only from its warm rate cache (aiolabs/boltcards
fix/session-fiat-from-cache); when it returns a currency with fiat null,
the store prices the balance in that currency from the ATM's own rate
source after the unlock, so the chip still shows the wallet's currency.
Log how long the session call took and whether the server priced it, so
the next latency question can be answered from the journal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-22 14:24:13 +02:00
commit 8e11c41f62
2 changed files with 31 additions and 3 deletions

View file

@ -343,6 +343,10 @@ export const useAtmStore = defineStore('atm', () => {
// The card balance is hidden by default on the public screen; the holder
// reveals it with the eye toggle. Resets on re-lock.
const cardBalanceRevealed = ref(false)
// When the card server names a currency but didn't price the balance (its
// rate cache was cold — it never blocks the unlock on a rate lookup), price
// it here from the ATM's own rate source, in that currency.
const cardFiatRate = ref<{ currency: string; btcPrice: number } | null>(null)
const fiatCode = ref('USD')
// Defaults are 0 — the operator's fee config (received via Nostr
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
@ -488,6 +492,10 @@ export const useAtmStore = defineStore('atm', () => {
const card = loadedBoltCard.value
if (!card) return null
if (card.currency && card.fiat !== null) return { amount: card.fiat, currency: card.currency }
const rate = cardFiatRate.value
if (card.currency && rate && rate.currency === card.currency) {
return { amount: (card.balanceSats / 1e8) * rate.btcPrice, currency: card.currency }
}
if (btcPrice.value && btcPrice.value > 0) {
return { amount: (card.balanceSats / 1e8) * btcPrice.value, currency: fiatCode.value }
}
@ -556,6 +564,7 @@ export const useAtmStore = defineStore('atm', () => {
if (state === 'locked' && loadedBoltCard.value) {
loadedBoltCard.value = null
cardBalanceRevealed.value = false
cardFiatRate.value = null
}
// Detect network from first invoice we see
@ -779,7 +788,14 @@ export const useAtmStore = defineStore('atm', () => {
boltCardProcessing.value = true
nfcStatus.value = { state: 'processing', message: 'Verifying card…' }
try {
const t0 = Date.now()
const opened = await window.electronAPI.openCardSession({ lnurlw })
console.info(
`[ATM] Card session ${opened.ok ? 'opened' : 'refused'} in ${Date.now() - t0} ms` +
(opened.ok
? ` (fiat ${opened.session.fiat === null ? 'not ' : ''}priced by card server)`
: '')
)
if (!opened.ok) {
nfcStatus.value = { state: 'declined', message: opened.reason }
denyAccess(opened.reason)
@ -793,8 +809,18 @@ export const useAtmStore = defineStore('atm', () => {
if (outcome.status === 'granted') {
loadedBoltCard.value = opened.session
cardBalanceRevealed.value = false
cardFiatRate.value = null
nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
grantAccess(outcome.role, outcome.credentialIdHash)
// Price the balance in the card's currency off the unlock path.
const { currency, fiat, externalId } = opened.session
if (currency && fiat === null) {
void fetchBtcPrice(currency).then((price) => {
if (price && loadedBoltCard.value?.externalId === externalId) {
cardFiatRate.value = { currency, btcPrice: price }
}
})
}
} else {
// pin-required can't occur for card-only open-enrollment; treat as denied.
const reason = outcome.status === 'denied' ? outcome.reason : 'card not authorized'