refactor(machine): route cash-out methods through LnbitsClient when wired

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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-13 13:00:48 +02:00
commit 967f24f864

View file

@ -1064,12 +1064,42 @@ export async function initializeLightningServices(options?: {
clink.startListening() clink.startListening()
console.log('[Lightning] CLINK client initialized with offer + management handlers') console.log('[Lightning] CLINK client initialized with offer + management handlers')
// Create ATM services using Lightning.Pub's native LNURL-withdraw // Resolve the ATM's LNbits wallet id, if LNbits is wired. First contact
const atmServices = createATMServices(lightningPub, clink, identity, (preimage) => { // 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) { if (paymentReceivedCallback) {
paymentReceivedCallback(preimage) paymentReceivedCallback(preimage)
} }
}) },
_lnbitsRef,
lnbitsWalletId,
)
return { return {
nostrClient, nostrClient,
@ -1106,10 +1136,18 @@ function createATMServices(
lightningPub: LightningPubClient, lightningPub: LightningPubClient,
clink: CLINKClient, clink: CLINKClient,
_identity: MachineIdentity, _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 { ): ATMServices {
// Store callback for LNURL-withdraw polling to use // Store callback for LNURL-withdraw polling to use
const onPaymentCallback = onPaymentSuccess const onPaymentCallback = onPaymentSuccess
const lnbitsActive = lnbits !== null && lnbitsWalletId !== null
return { return {
/** /**
@ -1265,6 +1303,26 @@ function createATMServices(
const amountSats = Math.floor(amountMsat / 1000) const amountSats = Math.floor(amountMsat / 1000)
console.log('[ATM Service] Amount in sats:', amountSats) 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 { try {
const invoice = await lightningPub.createInvoice({ const invoice = await lightningPub.createInvoice({
amountSats, amountSats,
@ -1306,6 +1364,18 @@ function createATMServices(
* Used to limit cash-in transactions to what the ATM can pay out * Used to limit cash-in transactions to what the ATM can pay out
*/ */
getAvailableBalance: async (): Promise<number> => { getAvailableBalance: async (): Promise<number> => {
// 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') console.log('[ATM Service] Fetching available balance from Lightning.Pub')
try { try {
const { balanceSats } = await lightningPub.getBalance() const { balanceSats } = await lightningPub.getBalance()
@ -1466,6 +1536,43 @@ function createATMServices(
return () => {} 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 // Lightning.Pub's watchInvoice subscribes to LiveUserOperation events
// and returns a cleanup function // and returns a cleanup function
const cleanup = lightningPub.watchInvoice(invoice, (paidInvoice) => { const cleanup = lightningPub.watchInvoice(invoice, (paidInvoice) => {