fix(lightning): arm the cash-out settlement watch before the invoice is shown
A one-tap Bolt Card Complete settles in about a second. Subscribing took two sequential nostr round trips first — decode_payment to recover the hash, then subscribe_payments — roughly eight seconds against a remote relay, because the watch was armed when the invoice was DISPLAYED. The settlement push is an ephemeral event with no replay, so it fired before anything was listening: the machine sat on a paid invoice until it timed out and the customer's sats were taken with no cash dispensed. On sintra 2026-09-22 this hit both one-tap sells (26,660 and 26,500 sats). The old two-tap flow only ever worked because fumbling with the card covered the window; at 07:18 the push landed two seconds after the watch went live. Three layered defences, one mechanism: - Arm at creation. generateInvoice does not resolve until the watch is live, so the invoice cannot reach the screen unwatched. - Take the payment hash from the create_invoice response instead of decoding it back off the bolt11 — the value was already in hand and the round trip was half the window (repo guidance says as much). - Latch and poll. A settlement that still beats the consumer is replayed on attach, and get_payment runs alongside the subscription so a push that is lost or never sent cannot strand a payment either. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
da4969d510
commit
ebb07ce22d
2 changed files with 331 additions and 43 deletions
148
apps/machine/src/services/__tests__/settlement-watch.test.ts
Normal file
148
apps/machine/src/services/__tests__/settlement-watch.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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(() => {})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue