fix(cash-out): settlement watch missed one-tap payments #99

Merged
padreug merged 2 commits from fix/cashout-settlement-race into dev 2026-09-22 16:29:36 +00:00
2 changed files with 331 additions and 43 deletions
Showing only changes of commit ebb07ce22d - Show all commits

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>
Padreug 2026-09-22 18:16:06 +02:00

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 }>
payInvoice(
bolt11: string,
amountSats: number,
amountSats: number
): Promise<{ success: boolean; preimage?: string; error?: string }>
}
@ -434,12 +434,12 @@ export async function initializeLightningServices(options?: {
console.log(
'[Lightning] Relay(s):',
relays.join(', '),
envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)',
envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)'
)
console.log(
'[Lightning] LNbits server pubkey:',
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
// (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):',
CONFIG.operatorPubkeys.length
? 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
@ -473,7 +473,7 @@ export async function initializeLightningServices(options?: {
if (!CONFIG.lnbitsServerPubkey) {
throw new Error(
'[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()
if (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) {
// 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,
schemaVersion: mc.fee_config.schema_version,
},
mc.created_at,
mc.created_at
)
console.log(
'[Lightning] Server-delivered fee config:',
applied.applied ? 'applied' : `skipped (${applied.reason})`,
applied.applied ? 'applied' : `skipped (${applied.reason})`
)
}
} catch (e) {
console.warn(
'[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,
lnbitsWalletId,
lnbitsWalletId
)
return {
@ -706,13 +710,142 @@ export async function initializeLightningServices(options?: {
/**
* Create ATMServices implementation using the LNbits nostr-transport.
*/
function createATMServices(
export function createATMServices(
onPaymentSuccess: (preimage: string) => void,
lnbits: LnbitsClient,
lnbitsWalletId: string,
lnbitsWalletId: string
): ATMServices {
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 {
/**
* 3b.4: ndebit cash-in path removed. CashInView.vue ignores this
@ -806,7 +939,7 @@ function createATMServices(
if (onPaymentCallback) {
onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`)
}
},
}
)
// Wire per-session cleanup so abort/expiry tears it down cleanly.
const session = lnurlSessions.get(link.link_id)
@ -849,15 +982,14 @@ function createATMServices(
* matches machine fiat_code
* - `type: "cash_out"` / `source: "bitspire"` — discriminators
*/
// (see armInvoiceWatch below — the watch is armed before this resolves)
generateInvoice: async (context: ATMContext): Promise<string> => {
const amountSats = context.satsAmount
// Cash-out: satsAmount = principal + commission. principal is
// derived from the raw market rate (no commission baked in) so a
// consumer can independently audit the split.
const principalSats =
context.exchangeRate > 0
? Math.floor((context.fiatCents / 100) * context.exchangeRate)
: 0
context.exchangeRate > 0 ? Math.floor((context.fiatCents / 100) * context.exchangeRate) : 0
const feeSats = Math.max(0, amountSats - principalSats)
console.log(
'[ATM Service] Generating invoice — gross',
@ -897,6 +1029,17 @@ function createATMServices(
if (!payment.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
},
@ -1063,15 +1206,30 @@ function createATMServices(
* push, filtered by payment_hash. Returns a cleanup function.
*/
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')) {
console.error('[ATM Service] Invalid invoice format - expected BOLT11')
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 subId: string | null = null
;(async () => {
try {
const decoded = await lnbits.decodePayment(invoice)
@ -1081,36 +1239,18 @@ function createATMServices(
return
}
if (cancelled) return
// 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.
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')
},
)
await armInvoiceWatch(invoice, paymentHash)
const late = invoiceWatches.get(invoice)
if (!late || cancelled) return
late.consumer = callback
if (late.settled) callback(late.settled)
} catch (e) {
console.error('[ATM Service] LNbits watchInvoice failed:', e)
}
})()
return () => {
cancelled = true
if (subId) {
// wallet_id omitted to match the subscribePayments call above.
void lnbits.unsubscribe(undefined, subId).catch(() => {})
}
releaseInvoiceWatch(invoice)
}
},