Merge pull request 'fix(cash-out): settlement watch missed one-tap payments' (#99) from fix/cashout-settlement-race into dev

Reviewed-on: #99
This commit is contained in:
padreug 2026-09-22 16:29:35 +00:00
commit 67763d6e8d
4 changed files with 378 additions and 43 deletions

View file

@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { initialContext, type ATMContext } from '@bitSpire/state-machine'
import type { LnbitsClient, LnbitsPayment } from '@bitSpire/lnbits'
import { createATMServices } from '../lightning'
/**
* The cash-out settlement watch (2026-09-22 regression).
*
* A one-tap Bolt Card Complete settles in about a second; subscribing over
* nostr takes several. When the watch was armed at display time the push —
* an ephemeral event with no replay — fired before anything listened, and the
* machine sat on a paid invoice until it timed out, taking the sats without
* dispensing. These pin the three defences: arm before the invoice is handed
* out, latch a settlement that still beats the consumer, and poll so a push
* that never arrives cannot strand a payment.
*/
const BOLT11 = 'lnbc265u1p4t9gthpp5td44vd9a0s5er'
const HASH = 'aa'.repeat(32)
const paid = (preimage = 'PREIMAGE'): LnbitsPayment =>
({ payment_hash: HASH, status: 'success', preimage }) as LnbitsPayment
function makeLnbits(over: Partial<Record<string, unknown>> = {}) {
let pushTo: ((p: LnbitsPayment) => void) | null = null
const api = {
createInvoice: vi.fn(async () => ({ payment_request: BOLT11, payment_hash: HASH })),
subscribePayments: vi.fn(
async (_w: unknown, _f: unknown, onPush: (p: LnbitsPayment) => void) => {
pushTo = onPush
return 'sub-1'
}
),
getPayment: vi.fn(async (): Promise<LnbitsPayment | null> => null),
unsubscribe: vi.fn(async () => true),
decodePayment: vi.fn(async () => ({ payment_hash: HASH })),
...over,
}
return { api, push: (p: LnbitsPayment) => pushTo?.(p) }
}
const ctx = (): ATMContext => ({ ...initialContext, satsAmount: 26_500, exchangeRate: 1325 })
const services = (l: { api: Record<string, unknown> }) =>
createATMServices(vi.fn(), l.api as unknown as LnbitsClient, 'wallet-1')
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
describe('cash-out settlement watch', () => {
it('is armed before the invoice is handed out, without decoding it back', async () => {
const l = makeLnbits()
const invoice = await services(l).generateInvoice(ctx())
expect(invoice).toBe(BOLT11)
// Armed during generateInvoice, not later at display time.
expect(l.api.subscribePayments).toHaveBeenCalledTimes(1)
expect(l.api.subscribePayments.mock.calls[0]![1]).toMatchObject({ payment_hash: HASH })
// The hash came from the creation response, so no round trip to recover it.
expect(l.api.decodePayment).not.toHaveBeenCalled()
})
it('replays a settlement that beat the consumer (the race that lost payments)', async () => {
const l = makeLnbits()
const svc = services(l)
const invoice = await svc.generateInvoice(ctx())
// Card pays before the machine reaches displayingInvoice.
l.push(paid())
const onPaid = vi.fn()
svc.watchInvoice(invoice, onPaid)
await vi.advanceTimersByTimeAsync(0)
expect(onPaid).toHaveBeenCalledWith('PREIMAGE')
})
it('delivers a push that arrives while the consumer is attached', async () => {
const l = makeLnbits()
const svc = services(l)
const invoice = await svc.generateInvoice(ctx())
const onPaid = vi.fn()
svc.watchInvoice(invoice, onPaid)
l.push(paid('LATER'))
expect(onPaid).toHaveBeenCalledWith('LATER')
})
it('settles from the poll when no push ever arrives', async () => {
const l = makeLnbits()
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
const svc = services(l)
const invoice = await svc.generateInvoice(ctx())
const onPaid = vi.fn()
svc.watchInvoice(invoice, onPaid)
expect(onPaid).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(7_000)
expect(onPaid).toHaveBeenCalledWith('VIA-POLL')
})
it('polls even when arming the subscription fails', async () => {
const l = makeLnbits()
l.api.subscribePayments = vi.fn(async () => {
throw new Error('relay down')
})
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
const svc = services(l)
const invoice = await svc.generateInvoice(ctx())
const onPaid = vi.fn()
svc.watchInvoice(invoice, onPaid)
await vi.advanceTimersByTimeAsync(7_000)
expect(onPaid).toHaveBeenCalledWith('VIA-POLL')
})
it('reports each settlement once, whichever path saw it first', async () => {
const l = makeLnbits()
l.api.getPayment = vi.fn(async () => paid('VIA-POLL'))
const svc = services(l)
const invoice = await svc.generateInvoice(ctx())
const onPaid = vi.fn()
svc.watchInvoice(invoice, onPaid)
l.push(paid('VIA-PUSH'))
await vi.advanceTimersByTimeAsync(20_000)
expect(onPaid).toHaveBeenCalledTimes(1)
expect(onPaid).toHaveBeenCalledWith('VIA-PUSH')
})
it('stops polling and unsubscribes when the transaction ends', async () => {
const l = makeLnbits()
const svc = services(l)
const invoice = await svc.generateInvoice(ctx())
const stop = svc.watchInvoice(invoice, vi.fn())
stop()
expect(l.api.unsubscribe).toHaveBeenCalledWith(undefined, 'sub-1')
const pollsAfterStop = (l.api.getPayment as ReturnType<typeof vi.fn>).mock.calls.length
await vi.advanceTimersByTimeAsync(30_000)
expect((l.api.getPayment as ReturnType<typeof vi.fn>).mock.calls.length).toBe(pollsAfterStop)
})
})

View file

@ -217,7 +217,7 @@ export interface LightningBackend {
}): Promise<{ paymentRequest: string; paymentHash?: string }> }): Promise<{ paymentRequest: string; paymentHash?: string }>
payInvoice( payInvoice(
bolt11: string, bolt11: string,
amountSats: number, amountSats: number
): Promise<{ success: boolean; preimage?: string; error?: string }> ): Promise<{ success: boolean; preimage?: string; error?: string }>
} }
@ -434,12 +434,12 @@ export async function initializeLightningServices(options?: {
console.log( console.log(
'[Lightning] Relay(s):', '[Lightning] Relay(s):',
relays.join(', '), relays.join(', '),
envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)', envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)'
) )
console.log( console.log(
'[Lightning] LNbits server pubkey:', '[Lightning] LNbits server pubkey:',
CONFIG.lnbitsServerPubkey || '(not configured)', CONFIG.lnbitsServerPubkey || '(not configured)',
envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : '', envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : ''
) )
// Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS // Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS
// (env). An empty set disables the fees/operator-config services → the machine // (env). An empty set disables the fees/operator-config services → the machine
@ -449,7 +449,7 @@ export async function initializeLightningServices(options?: {
'[Lightning] Operator pubkey(s):', '[Lightning] Operator pubkey(s):',
CONFIG.operatorPubkeys.length CONFIG.operatorPubkeys.length
? CONFIG.operatorPubkeys.join(', ') + ' (env)' ? CONFIG.operatorPubkeys.join(', ') + ' (env)'
: '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)', : '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)'
) )
// Strict mode: validate the RESOLVED config is production-ready (no // Strict mode: validate the RESOLVED config is production-ready (no
@ -473,7 +473,7 @@ export async function initializeLightningServices(options?: {
if (!CONFIG.lnbitsServerPubkey) { if (!CONFIG.lnbitsServerPubkey) {
throw new Error( throw new Error(
'[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' + '[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' +
'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).', 'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).'
) )
} }
@ -542,7 +542,11 @@ export async function initializeLightningServices(options?: {
const mc = await lnbits.getMachineConfig() const mc = await lnbits.getMachineConfig()
if (mc.operator_pubkey) { if (mc.operator_pubkey) {
CONFIG.operatorPubkeys = [mc.operator_pubkey] CONFIG.operatorPubkeys = [mc.operator_pubkey]
console.log('[Lightning] Operator pubkey(s):', mc.operator_pubkey, '(server-delivered, #70 P1)') console.log(
'[Lightning] Operator pubkey(s):',
mc.operator_pubkey,
'(server-delivered, #70 P1)'
)
} }
if (mc.fee_config && isElectron && window.electronAPI) { if (mc.fee_config && isElectron && window.electronAPI) {
// Persist the server-delivered fee config so atm.ts's awaiting-fees gate // Persist the server-delivered fee config so atm.ts's awaiting-fees gate
@ -555,17 +559,17 @@ export async function initializeLightningServices(options?: {
cashOutFeeFraction: mc.fee_config.cash_out_fee_fraction, cashOutFeeFraction: mc.fee_config.cash_out_fee_fraction,
schemaVersion: mc.fee_config.schema_version, schemaVersion: mc.fee_config.schema_version,
}, },
mc.created_at, mc.created_at
) )
console.log( console.log(
'[Lightning] Server-delivered fee config:', '[Lightning] Server-delivered fee config:',
applied.applied ? 'applied' : `skipped (${applied.reason})`, applied.applied ? 'applied' : `skipped (${applied.reason})`
) )
} }
} catch (e) { } catch (e) {
console.warn( console.warn(
'[Lightning] get_machine_config unavailable; falling back to env/kind-30078 for operator config:', '[Lightning] get_machine_config unavailable; falling back to env/kind-30078 for operator config:',
(e as Error).message, (e as Error).message
) )
} }
} }
@ -671,7 +675,7 @@ export async function initializeLightningServices(options?: {
} }
}, },
lnbits, lnbits,
lnbitsWalletId, lnbitsWalletId
) )
return { return {
@ -706,13 +710,142 @@ export async function initializeLightningServices(options?: {
/** /**
* Create ATMServices implementation using the LNbits nostr-transport. * Create ATMServices implementation using the LNbits nostr-transport.
*/ */
function createATMServices( export function createATMServices(
onPaymentSuccess: (preimage: string) => void, onPaymentSuccess: (preimage: string) => void,
lnbits: LnbitsClient, lnbits: LnbitsClient,
lnbitsWalletId: string, lnbitsWalletId: string
): ATMServices { ): ATMServices {
const onPaymentCallback = onPaymentSuccess const onPaymentCallback = onPaymentSuccess
// ── Cash-out settlement watch ───────────────────────────────────────────
/**
* A watch on one cash-out invoice, armed the moment the invoice exists and
* consumed later by the state machine's `displayingInvoice` actor.
*
* Arming at creation rather than at display closes a race that swallowed
* real payments on 2026-09-22 (sintra). Subscribing costs a nostr round
* trip — about four seconds against a remote relay — while a one-tap Bolt
* Card Complete settles in roughly one. The settlement push is an ephemeral
* event with no replay, so it fired before anything was listening and the
* machine sat on a paid invoice until it timed out, taking the sats without
* dispensing. The old two-tap flow only worked because fumbling with the
* card covered the gap.
*
* Three defences, in order: the invoice is not returned until its watch is
* armed; a settlement that still beats the UI is latched and replayed when
* the consumer attaches; and a poll runs alongside the subscription so a
* lost or unsent push cannot strand a payment either way.
*/
interface InvoiceWatch {
paymentHash: string
subId: string | null
/** Preimage seen before a consumer attached; replayed on attach. */
settled: string | null
consumer: ((preimage: string) => void) | null
poll: ReturnType<typeof setInterval> | null
released: boolean
}
const invoiceWatches = new Map<string, InvoiceWatch>()
// Backstop cadence. One cheap RPC; on the money path a little extra relay
// traffic is worth far more than a payment that lands with no cash.
const SETTLEMENT_POLL_MS = 6_000
function stopInvoiceWatchPoll(watch: InvoiceWatch): void {
if (watch.poll) {
clearInterval(watch.poll)
watch.poll = null
}
}
/** Deliver a settlement exactly once, to the consumer or into the latch. */
function settleInvoiceWatch(watch: InvoiceWatch, preimage: string, via: string): void {
if (watch.released || watch.settled) return
watch.settled = preimage
stopInvoiceWatchPoll(watch)
console.log(`[ATM Service] Invoice paid (${via})!`)
watch.consumer?.(preimage)
}
function startInvoiceWatchPoll(watch: InvoiceWatch): void {
let inFlight = false
watch.poll = setInterval(() => {
if (inFlight || watch.settled || watch.released) return
inFlight = true
void lnbits
.getPayment(watch.paymentHash)
.then((payment) => {
if (payment?.status === 'success') {
settleInvoiceWatch(watch, payment.preimage ?? 'payment-confirmed', 'poll')
}
})
.catch(() => {
/* transport blip — the next tick retries */
})
.finally(() => {
inFlight = false
})
}, SETTLEMENT_POLL_MS)
}
/** Arm the watch for a freshly created invoice. Resolves once it is live. */
async function armInvoiceWatch(bolt11: string, paymentHash: string): Promise<void> {
const startedAt = Date.now()
const watch: InvoiceWatch = {
paymentHash,
subId: null,
settled: null,
consumer: null,
poll: null,
released: false,
}
invoiceWatches.set(bolt11, watch)
try {
// walletId omitted: payment_hash is the natural primary key for "wait
// for THIS invoice to settle." Under path B
// (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment to
// the operator's wallet, so a subscription scoped to the ATM's
// pre-override wallet_id would AND-filter the settlement out and never
// fire. With wallet_id omitted, lnbits resolves the wallet from
// get_standalone_payment(payment_hash) and ownership-checks against the
// auth'd account — works on both pre/post-override wallets.
// Coordination log 2026-05-31T18:50Z (lnbits) for the confirmation,
// §18:35Z for the joint smoke that surfaced the bug.
watch.subId = await lnbits.subscribePayments(
undefined,
{ payment_hash: paymentHash, max_seconds: 600 },
(push) => {
if (push.payment_hash !== paymentHash || push.status !== 'success') return
settleInvoiceWatch(watch, push.preimage ?? 'payment-confirmed', 'LNbits push')
},
(reason) => console.log(`[ATM Service] Settlement subscription closed (${reason})`)
)
console.log(
`[ATM Service] Settlement watch armed in ${Date.now() - startedAt} ms ` +
`(hash ${paymentHash.slice(0, 12)}…)`
)
} catch (e) {
// The poll below then carries settlement on its own, which is exactly
// why it runs whether or not the subscription came up.
console.error('[ATM Service] Settlement subscribe failed — polling only:', e)
}
startInvoiceWatchPoll(watch)
}
/** Tear a watch down: the transaction ended, one way or another. */
function releaseInvoiceWatch(bolt11: string): void {
const watch = invoiceWatches.get(bolt11)
if (!watch) return
watch.released = true
watch.consumer = null
stopInvoiceWatchPoll(watch)
invoiceWatches.delete(bolt11)
if (watch.subId) {
// wallet_id omitted to match the subscribePayments call above.
void lnbits.unsubscribe(undefined, watch.subId).catch(() => {})
}
}
return { return {
/** /**
* 3b.4: ndebit cash-in path removed. CashInView.vue ignores this * 3b.4: ndebit cash-in path removed. CashInView.vue ignores this
@ -806,7 +939,7 @@ function createATMServices(
if (onPaymentCallback) { if (onPaymentCallback) {
onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`) onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`)
} }
}, }
) )
// Wire per-session cleanup so abort/expiry tears it down cleanly. // Wire per-session cleanup so abort/expiry tears it down cleanly.
const session = lnurlSessions.get(link.link_id) const session = lnurlSessions.get(link.link_id)
@ -849,15 +982,14 @@ function createATMServices(
* matches machine fiat_code * matches machine fiat_code
* - `type: "cash_out"` / `source: "bitspire"` — discriminators * - `type: "cash_out"` / `source: "bitspire"` — discriminators
*/ */
// (see armInvoiceWatch below — the watch is armed before this resolves)
generateInvoice: async (context: ATMContext): Promise<string> => { generateInvoice: async (context: ATMContext): Promise<string> => {
const amountSats = context.satsAmount const amountSats = context.satsAmount
// Cash-out: satsAmount = principal + commission. principal is // Cash-out: satsAmount = principal + commission. principal is
// derived from the raw market rate (no commission baked in) so a // derived from the raw market rate (no commission baked in) so a
// consumer can independently audit the split. // consumer can independently audit the split.
const principalSats = const principalSats =
context.exchangeRate > 0 context.exchangeRate > 0 ? Math.floor((context.fiatCents / 100) * context.exchangeRate) : 0
? Math.floor((context.fiatCents / 100) * context.exchangeRate)
: 0
const feeSats = Math.max(0, amountSats - principalSats) const feeSats = Math.max(0, amountSats - principalSats)
console.log( console.log(
'[ATM Service] Generating invoice — gross', '[ATM Service] Generating invoice — gross',
@ -897,6 +1029,17 @@ function createATMServices(
if (!payment.payment_request) { if (!payment.payment_request) {
throw new Error('LNbits createInvoice returned empty payment_request') throw new Error('LNbits createInvoice returned empty payment_request')
} }
// Arm the settlement watch BEFORE the invoice reaches the screen, and
// take the payment hash from the response we already have rather than
// spending a round trip decoding it back off the bolt11. See
// armInvoiceWatch for why the timing matters.
if (payment.payment_hash) {
await armInvoiceWatch(payment.payment_request, payment.payment_hash)
} else {
console.error(
'[ATM Service] createInvoice returned no payment_hash — settlement watch will arm late'
)
}
return payment.payment_request return payment.payment_request
}, },
@ -1063,15 +1206,30 @@ function createATMServices(
* push, filtered by payment_hash. Returns a cleanup function. * push, filtered by payment_hash. Returns a cleanup function.
*/ */
watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => { watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => {
console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...')
if (!invoice.toLowerCase().startsWith('ln')) { if (!invoice.toLowerCase().startsWith('ln')) {
console.error('[ATM Service] Invalid invoice format - expected BOLT11') console.error('[ATM Service] Invalid invoice format - expected BOLT11')
return () => {} return () => {}
} }
console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...')
const armed = invoiceWatches.get(invoice)
if (armed) {
armed.consumer = callback
// Settled between arming and display (a one-tap card pull can beat the
// state transition): replay the latched settlement instead of waiting
// on a push that has already come and gone.
if (armed.settled) {
const preimage = armed.settled
queueMicrotask(() => callback(preimage))
}
return () => releaseInvoiceWatch(invoice)
}
// No armed watch: an invoice this service didn't create. Recover the
// hash over the wire and arm now. This is the pre-2026-09-22 behaviour
// and carries the race that arming-at-creation fixes, so say so.
console.warn('[ATM Service] No armed settlement watch for this invoice — arming late')
let cancelled = false let cancelled = false
let subId: string | null = null
;(async () => { ;(async () => {
try { try {
const decoded = await lnbits.decodePayment(invoice) const decoded = await lnbits.decodePayment(invoice)
@ -1081,36 +1239,18 @@ function createATMServices(
return return
} }
if (cancelled) return if (cancelled) return
// walletId omitted: payment_hash is the natural primary key for await armInvoiceWatch(invoice, paymentHash)
// "wait for THIS invoice to settle." Under path B const late = invoiceWatches.get(invoice)
// (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment if (!late || cancelled) return
// to the operator's wallet, so a subscription scoped to the ATM's late.consumer = callback
// pre-override wallet_id would AND-filter the settlement out and if (late.settled) callback(late.settled)
// never fire. With wallet_id omitted, lnbits resolves the wallet
// from get_standalone_payment(payment_hash) and ownership-checks
// against the auth'd account — works on both pre/post-override
// wallets. Coordination log 2026-05-31T18:50Z (lnbits) for the
// confirmation, §18:35Z for the joint smoke that surfaced the bug.
subId = await lnbits.subscribePayments(
undefined,
{ payment_hash: paymentHash, max_seconds: 600 },
(push) => {
if (push.payment_hash !== paymentHash) return
if (push.status !== 'success') return
console.log('[ATM Service] Invoice paid (LNbits push)!')
callback(push.preimage ?? 'payment-confirmed')
},
)
} catch (e) { } catch (e) {
console.error('[ATM Service] LNbits watchInvoice failed:', e) console.error('[ATM Service] LNbits watchInvoice failed:', e)
} }
})() })()
return () => { return () => {
cancelled = true cancelled = true
if (subId) { releaseInvoiceWatch(invoice)
// wallet_id omitted to match the subscribePayments call above.
void lnbits.unsubscribe(undefined, subId).catch(() => {})
}
} }
}, },

View file

@ -343,6 +343,12 @@ 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)
// Set when a card accepted a cash-out pull and the machine then left the
// invoice screen without ever seeing the payment settle. The customer's
// wallet paid and no cash came out, so this must be visible and carry the
// reference an operator can reconcile against — never a silent return to
// the amount screen. Cleared when the next transaction starts.
const settlementError = ref<{ txid: string | null; message: string } | null>(null)
// When the card server names a currency but didn't price the balance (its // 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 // 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. // it here from the ATM's own rate source, in that currency.
@ -663,6 +669,26 @@ export const useAtmStore = defineStore('atm', () => {
(prevNestedState === 'displayingInvoice' && currentNested !== 'displayingInvoice') || (prevNestedState === 'displayingInvoice' && currentNested !== 'displayingInvoice') ||
(prevNestedState === 'displayingQR' && currentNested !== 'displayingQR') (prevNestedState === 'displayingQR' && currentNested !== 'displayingQR')
if (leftBoltCardScreen) { if (leftBoltCardScreen) {
// The card accepted the pull (that's what leaves processing latched
// with an 'accepted' status) but we left the invoice screen for
// somewhere other than the dispenser: the payment was taken and no
// cash followed. Surface it with the txid instead of dropping the
// customer back on the amount screen as if nothing had happened.
if (
prevNestedState === 'displayingInvoice' &&
currentNested !== 'dispensingCash' &&
boltCardProcessing.value &&
nfcStatus.value?.state === 'accepted'
) {
const txid = context.value?.txid ?? null
console.error(
`[ATM] Settlement never confirmed after the card accepted the pull — txid=${txid}`
)
settlementError.value = {
txid,
message: 'Your payment was accepted but the cash was not dispensed.',
}
}
boltCardProcessing.value = false boltCardProcessing.value = false
nfcStatus.value = null nfcStatus.value = null
} }
@ -1786,10 +1812,12 @@ export const useAtmStore = defineStore('atm', () => {
// Convenience methods for common events // Convenience methods for common events
function selectCashIn() { function selectCashIn() {
settlementError.value = null
send({ type: 'SELECT_CASH_IN' }) send({ type: 'SELECT_CASH_IN' })
} }
function selectCashOut() { function selectCashOut() {
settlementError.value = null
send({ type: 'SELECT_CASH_OUT' }) send({ type: 'SELECT_CASH_OUT' })
} }
@ -1942,6 +1970,7 @@ export const useAtmStore = defineStore('atm', () => {
simulateBoltCardEntry, simulateBoltCardEntry,
// Access control (ADR-003) // Access control (ADR-003)
settlementError,
accessControl, accessControl,
isLocked, isLocked,
grantAccess, grantAccess,

View file

@ -178,6 +178,24 @@ function formatFiat(cents: number): string {
key="selectingAmount" key="selectingAmount"
class="flex flex-1 flex-col justify-center px-4 lg:px-[8vw] py-4 lg:py-6 gap-4 lg:gap-6" class="flex flex-1 flex-col justify-center px-4 lg:px-[8vw] py-4 lg:py-6 gap-4 lg:gap-6"
> >
<!-- A payment was taken and no cash came out. Say so plainly, with
the reference an operator needs; do not let the customer wander
back into the amount grid believing nothing happened. -->
<div
v-if="atmStore.settlementError"
class="rounded-lg border-2 border-destructive bg-destructive/10 px-4 py-3 text-center lg:px-6 lg:py-4"
>
<p class="text-base font-bold text-destructive lg:text-2xl">
{{ atmStore.settlementError.message }}
</p>
<p class="mt-1 text-xs text-muted-foreground lg:text-lg">
Please contact the operator<template v-if="atmStore.settlementError.txid">
and quote
<span class="font-mono-code">{{ atmStore.settlementError.txid }}</span> </template
>.
</p>
</div>
<!-- Denomination grid --> <!-- Denomination grid -->
<div class="grid grid-cols-2 gap-3 lg:gap-6"> <div class="grid grid-cols-2 gap-3 lg:gap-6">
<div <div