bug(cash-out): unwatched-payment windows — no invoice expiry, silent watchInvoice setup failure #78

Open
opened 2026-07-25 23:27:55 +00:00 by padreug · 0 comments
Owner

From the 2026-07 dev-branch money-path review (sibling of #58/PR #77). Two related gaps let a customer pay a cash-out invoice that no one is watching — funds land in the operator wallet with no dispense and no machine-side transaction record. Only spirekeeper-side reconciliation would ever notice (the extra.txid exists but matches no machine row).

Gap 1 — no expiry on the cash-out invoice

generateInvoice (apps/machine/src/services/lightning.ts) calls lnbits.createInvoice without expiry, even though CreateInvoiceRequest supports it (packages/lnbits/src/types.ts). The machine abandons displayingInvoice at INVOICE_TIMEOUT (5 min), but the invoice stays payable for the backend default (~1h). A slow wallet or routing retry paying at minute 7 is money received, no cash dispensed, no record.

Fix: set expiry ≈ INVOICE_TIMEOUT (plus small grace) so the invoice dies when the machine gives up. Single source for the timeout value so they can't drift.

Gap 2 — watcher setup failure is silent

watchInvoice (lightning.ts): if decodePayment or subscribePayments throws (relay hiccup at exactly QR time), it console.errors and returns — no PAYMENT_FAILED is sent to the machine. The QR stays displayed, fully payable, with no active settlement subscription, until the 5-min timeout bounces to selectingAmount.

Fix: on watcher-setup failure, sendBack({type: 'PAYMENT_FAILED', ...}) so the machine leaves displayingInvoice immediately instead of showing an unwatched QR. (The fromCallback actor already routes PAYMENT_FAILED → selectingAmount with error display.)

subscribePayments uses max_seconds: 600; combined with Gap 1's expiry fix the sub always outlives the payable window, closing the pay-after-timeout race entirely.

From the 2026-07 dev-branch money-path review (sibling of #58/PR #77). Two related gaps let a customer pay a cash-out invoice that no one is watching — funds land in the operator wallet with no dispense and no machine-side transaction record. Only spirekeeper-side reconciliation would ever notice (the `extra.txid` exists but matches no machine row). ## Gap 1 — no expiry on the cash-out invoice `generateInvoice` (`apps/machine/src/services/lightning.ts`) calls `lnbits.createInvoice` without `expiry`, even though `CreateInvoiceRequest` supports it (`packages/lnbits/src/types.ts`). The machine abandons `displayingInvoice` at `INVOICE_TIMEOUT` (5 min), but the invoice stays payable for the backend default (~1h). A slow wallet or routing retry paying at minute 7 is money received, no cash dispensed, no record. **Fix:** set `expiry` ≈ `INVOICE_TIMEOUT` (plus small grace) so the invoice dies when the machine gives up. Single source for the timeout value so they can't drift. ## Gap 2 — watcher setup failure is silent `watchInvoice` (`lightning.ts`): if `decodePayment` or `subscribePayments` throws (relay hiccup at exactly QR time), it `console.error`s and returns — no `PAYMENT_FAILED` is sent to the machine. The QR stays displayed, fully payable, with no active settlement subscription, until the 5-min timeout bounces to `selectingAmount`. **Fix:** on watcher-setup failure, `sendBack({type: 'PAYMENT_FAILED', ...})` so the machine leaves `displayingInvoice` immediately instead of showing an unwatched QR. (The `fromCallback` actor already routes `PAYMENT_FAILED` → `selectingAmount` with error display.) ## Related edge (lower priority) `subscribePayments` uses `max_seconds: 600`; combined with Gap 1's expiry fix the sub always outlives the payable window, closing the pay-after-timeout race entirely.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/bitspire#78
No description provided.