From 42e3657fe1d48fd02ba2ae2279a25650f31e9a1e Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 14:24:13 +0200 Subject: [PATCH 1/2] fix(access): pass plain objects over IPC for session Complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At Complete the store handed the session's withdraw/pay step to window.electronAPI straight out of the loadedBoltCard ref — a Vue reactive proxy — and Electron's structured clone refused it: '[ATM] Bolt Card withdraw failed: Error: An object could not be cloned.' (sintra, 2026-09-21 06:51). The customer had to re-tap, which works because the direct-tap path passes a plain string. Copy the steps field by field into plain objects before they cross the bridge. Co-Authored-By: Claude Fable 5.1 --- apps/machine/src/stores/atm.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index fd00df2..bb5bc8b 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. @@ -673,7 +689,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 +729,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 -- 2.55.0 From 8e11c41f6227d83bee77b61e74f68fb18ab02910 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 14:24:13 +0200 Subject: [PATCH 2/2] perf(access): keep the rate lookup off the unlock path, log entry timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/machine/src/stores/atm.ts | 26 ++++++++++++++++++++++++++ docs/boltcard-session.md | 8 +++++--- 2 files changed, 31 insertions(+), 3 deletions(-) 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 -- 2.55.0