diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index fd00df2..c868322 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -306,6 +306,22 @@ export const useAtmStore = defineStore('atm', () => { // screen (raw lnurlw, spent by this call) or the session opened at entry // (hit-keyed steps, no p/c). type BoltCardSource = { lnurlw: string } | { session: CardSession } + // Electron IPC structured-clones its arguments and rejects Vue reactive + // proxies with "An object could not be cloned". `loadedBoltCard` is a ref, + // so anything reached through it is a proxy — copy the steps field by field + // into plain objects before they cross the bridge. + const plainWithdrawStep = (w: NonNullable) => ({ + callback: w.callback, + k1: w.k1, + minWithdrawable: w.minWithdrawable, + maxWithdrawable: w.maxWithdrawable, + }) + const plainPayStep = (p: CardSession['pay']) => ({ + callback: p.callback, + minSendable: p.minSendable, + maxSendable: p.maxSendable, + metadata: p.metadata, + }) // Access-control gate config (ADR-003). Defaults disabled → the machine's // `locked` state bypasses straight to `idle` (behaviour identical to no gate). // Populated from RuntimeConfig.accessControl in initializeForProduction. @@ -327,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) @@ -472,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 } } @@ -540,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 @@ -673,7 +698,7 @@ export const useAtmStore = defineStore('atm', () => { 'session' in source ? source.session.withdraw ? await api.withdrawWithSession({ - withdraw: source.session.withdraw, + withdraw: plainWithdrawStep(source.session.withdraw), bolt11: invoice, amountMsat, }) @@ -713,7 +738,7 @@ export const useAtmStore = defineStore('atm', () => { const api = window.electronAPI! const res = 'session' in source - ? await api.resolveSessionInvoice({ pay: source.session.pay, amountMsat }) + ? await api.resolveSessionInvoice({ pay: plainPayStep(source.session.pay), amountMsat }) : await api.resolveCardInvoice({ lnurlw: source.lnurlw, amountMsat }) if (!res.ok || !res.bolt11) { boltCardProcessing.value = false @@ -763,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) @@ -777,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