diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index bb5bc8b..c868322 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -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:` 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' diff --git a/docs/boltcard-session.md b/docs/boltcard-session.md index 2daa775..e93242a 100644 --- a/docs/boltcard-session.md +++ b/docs/boltcard-session.md @@ -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=&pr=` at cash-out Complete. `null` with `withdraw_blocked_reason` set when `/scan` would have refused (daily limit