fix(access): session Complete failed on IPC clone; keep rate lookup off the unlock path #97

Merged
padreug merged 2 commits from fix/boltcard-session-ipc-clone into dev 2026-09-22 14:02:47 +00:00
2 changed files with 31 additions and 3 deletions
Showing only changes of commit 8e11c41f62 - Show all commits

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>
Padreug 2026-09-22 14:24:13 +02:00

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'

View file

@ -63,9 +63,11 @@ one `hit` is recorded.
- `balance_msat` — the card wallet's balance. Display only.
- `currency` / `fiat` — the balance priced the way the LNbits wallet page does
it: the wallet's own currency (per-wallet setting) first, else the instance's
default accounting currency, at the server's rate. `null` when the server has
no currency or the rate lookup failed; the ATM then prices the sats itself in
its own fiat at its display rate. A rate failure never fails the session.
default accounting currency. `fiat` is filled **only from the server's
already-warm rate cache** — this response gates the unlock, and a cold rate
lookup queries external exchanges (~1 s). On a cache miss it is `null` and
the ATM prices the sats itself: in `currency` from its own rate source, else
in its own fiat at its display rate. No rate lookup ever blocks the session.
- `withdraw` — the LUD-03 second step. The ATM calls
`callback?k1=<hit>&pr=<bolt11>` at cash-out Complete. `null` with
`withdraw_blocked_reason` set when `/scan` would have refused (daily limit