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:
commit
da4969d510
2 changed files with 49 additions and 5 deletions
|
|
@ -306,6 +306,22 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// screen (raw lnurlw, spent by this call) or the session opened at entry
|
// screen (raw lnurlw, spent by this call) or the session opened at entry
|
||||||
// (hit-keyed steps, no p/c).
|
// (hit-keyed steps, no p/c).
|
||||||
type BoltCardSource = { lnurlw: string } | { session: CardSession }
|
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
|
// Access-control gate config (ADR-003). Defaults disabled → the machine's
|
||||||
// `locked` state bypasses straight to `idle` (behaviour identical to no gate).
|
// `locked` state bypasses straight to `idle` (behaviour identical to no gate).
|
||||||
// Populated from RuntimeConfig.accessControl in initializeForProduction.
|
// 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
|
// The card balance is hidden by default on the public screen; the holder
|
||||||
// reveals it with the eye toggle. Resets on re-lock.
|
// reveals it with the eye toggle. Resets on re-lock.
|
||||||
const cardBalanceRevealed = ref(false)
|
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')
|
const fiatCode = ref('USD')
|
||||||
// Defaults are 0 — the operator's fee config (received via Nostr
|
// Defaults are 0 — the operator's fee config (received via Nostr
|
||||||
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
||||||
|
|
@ -472,6 +492,10 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
const card = loadedBoltCard.value
|
const card = loadedBoltCard.value
|
||||||
if (!card) return null
|
if (!card) return null
|
||||||
if (card.currency && card.fiat !== null) return { amount: card.fiat, currency: card.currency }
|
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) {
|
if (btcPrice.value && btcPrice.value > 0) {
|
||||||
return { amount: (card.balanceSats / 1e8) * btcPrice.value, currency: fiatCode.value }
|
return { amount: (card.balanceSats / 1e8) * btcPrice.value, currency: fiatCode.value }
|
||||||
}
|
}
|
||||||
|
|
@ -540,6 +564,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
if (state === 'locked' && loadedBoltCard.value) {
|
if (state === 'locked' && loadedBoltCard.value) {
|
||||||
loadedBoltCard.value = null
|
loadedBoltCard.value = null
|
||||||
cardBalanceRevealed.value = false
|
cardBalanceRevealed.value = false
|
||||||
|
cardFiatRate.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect network from first invoice we see
|
// Detect network from first invoice we see
|
||||||
|
|
@ -673,7 +698,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
'session' in source
|
'session' in source
|
||||||
? source.session.withdraw
|
? source.session.withdraw
|
||||||
? await api.withdrawWithSession({
|
? await api.withdrawWithSession({
|
||||||
withdraw: source.session.withdraw,
|
withdraw: plainWithdrawStep(source.session.withdraw),
|
||||||
bolt11: invoice,
|
bolt11: invoice,
|
||||||
amountMsat,
|
amountMsat,
|
||||||
})
|
})
|
||||||
|
|
@ -713,7 +738,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
const api = window.electronAPI!
|
const api = window.electronAPI!
|
||||||
const res =
|
const res =
|
||||||
'session' in source
|
'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 })
|
: await api.resolveCardInvoice({ lnurlw: source.lnurlw, amountMsat })
|
||||||
if (!res.ok || !res.bolt11) {
|
if (!res.ok || !res.bolt11) {
|
||||||
boltCardProcessing.value = false
|
boltCardProcessing.value = false
|
||||||
|
|
@ -763,7 +788,14 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
boltCardProcessing.value = true
|
boltCardProcessing.value = true
|
||||||
nfcStatus.value = { state: 'processing', message: 'Verifying card…' }
|
nfcStatus.value = { state: 'processing', message: 'Verifying card…' }
|
||||||
try {
|
try {
|
||||||
|
const t0 = Date.now()
|
||||||
const opened = await window.electronAPI.openCardSession({ lnurlw })
|
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) {
|
if (!opened.ok) {
|
||||||
nfcStatus.value = { state: 'declined', message: opened.reason }
|
nfcStatus.value = { state: 'declined', message: opened.reason }
|
||||||
denyAccess(opened.reason)
|
denyAccess(opened.reason)
|
||||||
|
|
@ -777,8 +809,18 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
if (outcome.status === 'granted') {
|
if (outcome.status === 'granted') {
|
||||||
loadedBoltCard.value = opened.session
|
loadedBoltCard.value = opened.session
|
||||||
cardBalanceRevealed.value = false
|
cardBalanceRevealed.value = false
|
||||||
|
cardFiatRate.value = null
|
||||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
|
nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
|
||||||
grantAccess(outcome.role, outcome.credentialIdHash)
|
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 {
|
} else {
|
||||||
// pin-required can't occur for card-only open-enrollment; treat as denied.
|
// pin-required can't occur for card-only open-enrollment; treat as denied.
|
||||||
const reason = outcome.status === 'denied' ? outcome.reason : 'card not authorized'
|
const reason = outcome.status === 'denied' ? outcome.reason : 'card not authorized'
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,11 @@ one `hit` is recorded.
|
||||||
- `balance_msat` — the card wallet's balance. Display only.
|
- `balance_msat` — the card wallet's balance. Display only.
|
||||||
- `currency` / `fiat` — the balance priced the way the LNbits wallet page does
|
- `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
|
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
|
default accounting currency. `fiat` is filled **only from the server's
|
||||||
no currency or the rate lookup failed; the ATM then prices the sats itself in
|
already-warm rate cache** — this response gates the unlock, and a cold rate
|
||||||
its own fiat at its display rate. A rate failure never fails the session.
|
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
|
- `withdraw` — the LUD-03 second step. The ATM calls
|
||||||
`callback?k1=<hit>&pr=<bolt11>` at cash-out Complete. `null` with
|
`callback?k1=<hit>&pr=<bolt11>` at cash-out Complete. `null` with
|
||||||
`withdraw_blocked_reason` set when `/scan` would have refused (daily limit
|
`withdraw_blocked_reason` set when `/scan` would have refused (daily limit
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue