Merge pull request 'fix(access): session Complete failed on IPC clone; keep rate lookup off the unlock path' (#97) from fix/boltcard-session-ipc-clone into dev

Reviewed-on: #97
This commit is contained in:
padreug 2026-09-22 14:02:47 +00:00
commit da4969d510
2 changed files with 49 additions and 5 deletions

View file

@ -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<CardSession['withdraw']>) => ({
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:<atm_pubkey>` 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'

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