fix(machine): subscribe to single-invoice settlement by payment_hash only

Under path B (NOSTR_TRANSPORT_ROSTER_REQUIRED=true), lnbits's
roster-lookup override routes create_invoice to the operator's
wallet, but the subsequent subscribe_payments was scoping its
filter to the ATM's pre-override wallet_id. The dispatcher
AND-filters payment_hash + wallet_id, so the settlement on the
operator wallet was invisible to the subscription — bitspire
stayed in "Watching invoice" forever, dispense never fired.

Omit wallet_id on the single-invoice watcher: lnbits already
resolves the wallet from get_standalone_payment(payment_hash)
and ownership-checks against the auth'd account. Works pre/post-
override; payment_hash is the natural primary key for "wait for
THIS invoice" anyway.

Cash-out subscription site at services/lightning.ts:1008-1010
(production caller) + watchInvoice convenience helper at
packages/lnbits/src/client.ts:286-313 both flipped.

LNURL-withdraw subscription at services/lightning.ts:720
(filter: tag+link_id) is the symmetric case but pending lnbits
confirmation that the tag+link_id branch of _resolve_owner_wallet_id
exists alongside the payment_hash branch.

Coordination: ~/dev/coordination/log.md 2026-05-31T18:35Z (joint
smoke surfaced the bug), 18:40Z (bitspire diagnosis), 18:50Z
(lnbits narrowed the fix shape + confirmed path-2 works against
deployed lnbits today).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-31 21:05:00 +02:00
commit cb8ad3d813
2 changed files with 25 additions and 5 deletions

View file

@ -1005,8 +1005,18 @@ function createATMServices(
return return
} }
if (cancelled) 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( subId = await lnbits.subscribePayments(
lnbitsWalletId, undefined,
{ payment_hash: paymentHash, max_seconds: 600 }, { payment_hash: paymentHash, max_seconds: 600 },
(push) => { (push) => {
if (push.payment_hash !== paymentHash) return if (push.payment_hash !== paymentHash) return
@ -1022,7 +1032,8 @@ function createATMServices(
return () => { return () => {
cancelled = true cancelled = true
if (subId) { if (subId) {
void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {}) // wallet_id omitted to match the subscribePayments call above.
void lnbits.unsubscribe(undefined, subId).catch(() => {})
} }
} }
}, },

View file

@ -284,21 +284,30 @@ export class LnbitsClient {
* shape so services/lightning.ts can swap mechanically. * shape so services/lightning.ts can swap mechanically.
*/ */
async watchInvoice( async watchInvoice(
walletId: string, _walletId: string | undefined,
paymentHash: string, paymentHash: string,
timeoutMs = 5 * 60_000, timeoutMs = 5 * 60_000,
): Promise<LnbitsPayment> { ): Promise<LnbitsPayment> {
// wallet_id intentionally NOT propagated to subscribe_payments —
// payment_hash is the natural primary key for "wait for THIS invoice."
// Under path B (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes
// settlements to the operator's wallet, so a subscription pinned to
// the caller'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 pre/post-override.
// Coordination log 2026-05-31T18:50Z (lnbits) for confirmation.
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let resolved = false let resolved = false
const subId = this.subscribePayments( const subId = this.subscribePayments(
walletId, undefined,
{ payment_hash: paymentHash, max_seconds: Math.ceil(timeoutMs / 1000) }, { payment_hash: paymentHash, max_seconds: Math.ceil(timeoutMs / 1000) },
(payment) => { (payment) => {
if (payment.payment_hash !== paymentHash) return if (payment.payment_hash !== paymentHash) return
if (payment.status !== 'success') return if (payment.status !== 'success') return
if (resolved) return if (resolved) return
resolved = true resolved = true
void this.unsubscribe(walletId, subIdRef).catch(() => {}) void this.unsubscribe(undefined, subIdRef).catch(() => {})
resolve(payment) resolve(payment)
}, },
(reason) => { (reason) => {