From 967f24f8640453af6d51ef94c5acc854accfad29 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 13 May 2026 13:00:48 +0200 Subject: [PATCH] refactor(machine): route cash-out methods through LnbitsClient when wired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3b.2 of the LP→LNbits migration. With the LnbitsClient parallel-wired in 3b.1, this commit routes three of the ATMServices methods through LNbits when CONFIG.lnbitsServerPubkey is set: generateInvoice → lnbits.createInvoice(walletId, {amount, memo, unit}) getAvailableBalance → lnbits.getBalance(walletId) watchInvoice → lnbits.decodePayment(bolt11) + subscribePayments( {payment_hash, max_seconds: 600} ) Each method keeps its LP path as the fallback when LNbits isn't configured (CONFIG.lnbitsServerPubkey empty). So: - VITE_LNBITS_SERVER_PUBKEY unset → behaves exactly like before this PR (LP for everything). - VITE_LNBITS_SERVER_PUBKEY set → cash-out (invoice + payment observation) routes through LNbits. Cash-in (ndebit) still on LP until 3b.3. Init flow change: at startup, after LnbitsClient is instantiated, we call `list_wallets` to discover the account's default wallet id. This is the wallet that auto-account-creation lands the account in (and where LNBITS_DEMO_MODE deposits the auto-credit). It's then passed into createATMServices alongside the LnbitsClient reference. createATMServices signature gained two parameters (`lnbits`, `lnbitsWalletId`). When both are present, `lnbitsActive` flips and the LNbits paths fire. Verified: pnpm typecheck clean (14/14, machine task cache miss → exec OK) Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/machine/src/services/lightning.ts | 119 +++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 6 deletions(-) diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 9e3408d..07a50aa 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -1064,12 +1064,42 @@ export async function initializeLightningServices(options?: { clink.startListening() console.log('[Lightning] CLINK client initialized with offer + management handlers') - // Create ATM services using Lightning.Pub's native LNURL-withdraw - const atmServices = createATMServices(lightningPub, clink, identity, (preimage) => { - if (paymentReceivedCallback) { - paymentReceivedCallback(preimage) + // Resolve the ATM's LNbits wallet id, if LNbits is wired. First contact + // auto-creates the account on the LNbits side (with prvkey=NULL, per + // issue aiolabs/lnbits#9 alignment) and `list_wallets` returns the + // auto-created default wallet — which is where LNBITS_DEMO_MODE deposits + // the auto-credit, and where any subsequent `create_invoice` / + // `pay_invoice` will hit. + let lnbitsWalletId: string | null = null + if (_lnbitsRef) { + try { + const wallets = await _lnbitsRef.listWallets() + lnbitsWalletId = wallets[0]?.id ?? null + if (lnbitsWalletId) { + console.log('[Lightning] LNbits wallet:', lnbitsWalletId) + } else { + console.warn('[Lightning] LNbits list_wallets returned empty — falling back to LP for cash-out') + } + } catch (e) { + console.error('[Lightning] LNbits list_wallets failed; falling back to LP for cash-out:', e) } - }) + } + + // Create ATM services. Cash-out (generateInvoice / getAvailableBalance / + // watchInvoice) routes through LNbits if available; cash-in (ndebit) + // stays on LP until 3b.3 replaces it with lnurlw + subscribe_payments. + const atmServices = createATMServices( + lightningPub, + clink, + identity, + (preimage) => { + if (paymentReceivedCallback) { + paymentReceivedCallback(preimage) + } + }, + _lnbitsRef, + lnbitsWalletId, + ) return { nostrClient, @@ -1106,10 +1136,18 @@ function createATMServices( lightningPub: LightningPubClient, clink: CLINKClient, _identity: MachineIdentity, - onPaymentSuccess: (preimage: string) => void + onPaymentSuccess: (preimage: string) => void, + /** + * Migration: when an LNbits client + wallet id are wired (3b.2+), + * cash-out paths route through them. Cash-in still goes through LP + * (ndebit/CLINK) until 3b.3. + */ + lnbits: LnbitsClient | null, + lnbitsWalletId: string | null, ): ATMServices { // Store callback for LNURL-withdraw polling to use const onPaymentCallback = onPaymentSuccess + const lnbitsActive = lnbits !== null && lnbitsWalletId !== null return { /** @@ -1265,6 +1303,26 @@ function createATMServices( const amountSats = Math.floor(amountMsat / 1000) console.log('[ATM Service] Amount in sats:', amountSats) + // LNbits path (3b.2): cash-out invoice via the nostr-transport. + if (lnbitsActive) { + try { + const payment = await lnbits!.createInvoice(lnbitsWalletId!, { + amount: amountSats, + memo: 'bitSpire - Cash Out', + unit: 'sat', + }) + if (!payment.payment_request) { + throw new Error('LNbits createInvoice returned empty payment_request') + } + console.log('[ATM Service] Generated invoice via LNbits:', payment.payment_request.slice(0, 32) + '...') + return payment.payment_request + } catch (error) { + console.error('[ATM Service] LNbits createInvoice failed:', error) + throw error + } + } + + // LP path (legacy, kept until LNbits is fully wired). try { const invoice = await lightningPub.createInvoice({ amountSats, @@ -1306,6 +1364,18 @@ function createATMServices( * Used to limit cash-in transactions to what the ATM can pay out */ getAvailableBalance: async (): Promise => { + // LNbits path (3b.2): balance straight from get_wallet over transport. + if (lnbitsActive) { + try { + const { balanceSats } = await lnbits!.getBalance(lnbitsWalletId!) + console.log('[ATM Service] Available balance (LNbits):', balanceSats, 'sats') + return balanceSats + } catch (error) { + console.error('[ATM Service] LNbits getBalance failed:', error) + return 0 + } + } + console.log('[ATM Service] Fetching available balance from Lightning.Pub') try { const { balanceSats } = await lightningPub.getBalance() @@ -1466,6 +1536,43 @@ function createATMServices( return () => {} } + // LNbits path (3b.2): subscribe_payments by payment_hash. + if (lnbitsActive) { + let cancelled = false + let subId: string | null = null + ;(async () => { + try { + // Extract payment_hash from the bolt11. LNbits has a `decode_payment` + // RPC for this but a plain bolt11 decode is cheap and avoids a roundtrip. + const decoded = await lnbits!.decodePayment(invoice) + const paymentHash = (decoded as { payment_hash?: string }).payment_hash + if (!paymentHash) { + console.error('[ATM Service] LNbits decode_payment did not return payment_hash') + return + } + if (cancelled) return + subId = await lnbits!.subscribePayments( + lnbitsWalletId!, + { 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) { + console.error('[ATM Service] LNbits watchInvoice failed:', e) + } + })() + return () => { + cancelled = true + if (subId) { + void lnbits!.unsubscribe(lnbitsWalletId!, subId).catch(() => {}) + } + } + } + // Lightning.Pub's watchInvoice subscribes to LiveUserOperation events // and returns a cleanup function const cleanup = lightningPub.watchInvoice(invoice, (paidInvoice) => {