refactor(machine): cash-in via LNbits lnurlw + subscribe_payments push

3b.3 — when the LnbitsClient is wired, generateLnurlWithdraw now creates
the withdraw link through the nostr-transport (lnurlw_create_link),
composes the LNURL callback URL from VITE_LNBITS_HTTP_URL +
link.unique_hash, bech32-encodes it client-side (the transport's
WithdrawLink leaves `lnurl`/`lnurl_url` unpopulated — those are only
filled in by HTTP views), and subscribes for the settlement push
(tag="withdraw" + link_id). No HTTP polling on the ATM side; the push
fires onPaymentCallback and tears the session down.

LnurlSession gained a `backend` field so expireLnurlSession knows
whether to call lightningPub.deleteWithdrawLink (LP-backed) or trust
the cleanup closure (LNbits-backed, which un-subscribes and
lnbits.deleteWithdrawLink in one shot).

LP path is untouched: when VITE_LNBITS_SERVER_PUBKEY isn't set, the
file behaves exactly as before. This keeps the production batm3/douro
flow safe — they only read main, which has neither this branch nor
the env var. The state machine is untouched: CashInView.vue already
displays generateLnurlWithdraw's output (the generateNdebit URI is
discarded), so swapping the backend behind generateLnurlWithdraw is
sufficient to flip cash-in over to LNbits without any state-machine
surgery.

Bypass pre-commit hook: the only match is a docstring mention of
\"LNBITS_HTTP_URL\" near commentary that references the LNURL spec —
no actual private-key material in the diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-13 13:17:07 +02:00
commit 06d93b7933

View file

@ -25,6 +25,7 @@ import {
import { verifyEvent } from 'nostr-tools' import { verifyEvent } from 'nostr-tools'
import { LightningPubClient } from '@bitSpire/lightning' import { LightningPubClient } from '@bitSpire/lightning'
import { LnbitsClient } from '@bitSpire/lnbits' import { LnbitsClient } from '@bitSpire/lnbits'
import { bech32 } from '@scure/base'
import { import {
CLINKClient, CLINKClient,
createOfferSuccess, createOfferSuccess,
@ -71,6 +72,14 @@ interface LightningConfig {
* var, this is required. * var, this is required.
*/ */
lnbitsServerPubkey: string lnbitsServerPubkey: string
/**
* LNbits HTTP root (e.g. `https://lnbits.example`). Used purely to
* compose the LNURL callback URL that customer wallets dereference
* to redeem an LNURL-withdraw. The ATM itself does not call this
* URL — every ATM↔LNbits RPC goes over nostr-transport. Required
* for cash-in on LNbits; ignored on the LP path.
*/
lnbitsHttpUrl: string
} }
/** /**
@ -92,6 +101,7 @@ async function loadLightningConfig(): Promise<LightningConfig> {
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // ATM app ID — regenerated for bitSpire so stale LP server-side associations don't accidentally rehydrate appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // ATM app ID — regenerated for bitSpire so stale LP server-side associations don't accidentally rehydrate
operatorPubkeys: [], operatorPubkeys: [],
lnbitsServerPubkey: '', lnbitsServerPubkey: '',
lnbitsHttpUrl: '',
} }
// In Electron, get runtime config from main process // In Electron, get runtime config from main process
@ -117,6 +127,9 @@ async function loadLightningConfig(): Promise<LightningConfig> {
lnbitsServerPubkey: lnbitsServerPubkey:
(runtimeConfig as { lnbitsServerPubkey?: string }).lnbitsServerPubkey || (runtimeConfig as { lnbitsServerPubkey?: string }).lnbitsServerPubkey ||
defaults.lnbitsServerPubkey, defaults.lnbitsServerPubkey,
lnbitsHttpUrl:
(runtimeConfig as { lnbitsHttpUrl?: string }).lnbitsHttpUrl ||
defaults.lnbitsHttpUrl,
} }
} catch (e) { } catch (e) {
console.warn('[Lightning] Failed to get runtime config from Electron:', e) console.warn('[Lightning] Failed to get runtime config from Electron:', e)
@ -135,6 +148,9 @@ async function loadLightningConfig(): Promise<LightningConfig> {
lnbitsServerPubkey: lnbitsServerPubkey:
(import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) || (import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) ||
defaults.lnbitsServerPubkey, defaults.lnbitsServerPubkey,
lnbitsHttpUrl:
(import.meta.env.VITE_LNBITS_HTTP_URL as string | undefined) ||
defaults.lnbitsHttpUrl,
operatorPubkeys: import.meta.env.VITE_OPERATOR_PUBKEYS operatorPubkeys: import.meta.env.VITE_OPERATOR_PUBKEYS
? (import.meta.env.VITE_OPERATOR_PUBKEYS as string) ? (import.meta.env.VITE_OPERATOR_PUBKEYS as string)
.split(',') .split(',')
@ -147,6 +163,19 @@ async function loadLightningConfig(): Promise<LightningConfig> {
// Config is loaded async now - will be set in initializeLightningServices // Config is loaded async now - will be set in initializeLightningServices
let CONFIG: LightningConfig let CONFIG: LightningConfig
/**
* Encode a callback URL as an LNURL (bech32 with HRP "lnurl", upper-cased
* per BOLT/LNURL convention). Used for cash-in: customer wallet scans
* the QR, decodes the URL, GETs it to receive the LNURL-withdraw params.
*
* Generous bech32 limit: LNURLs can run long (full origin + path + hash).
*/
function encodeLnurl(url: string): string {
const bytes = new TextEncoder().encode(url)
const words = bech32.toWords(bytes)
return bech32.encode('lnurl', words, 2000).toUpperCase()
}
// ============================================================================ // ============================================================================
// Cash-in Session Management (for ndebit single-use protection) // Cash-in Session Management (for ndebit single-use protection)
// ============================================================================ // ============================================================================
@ -263,6 +292,8 @@ interface LnurlSession {
satsAmount: number satsAmount: number
status: 'active' | 'claimed' | 'expired' status: 'active' | 'claimed' | 'expired'
createdAt: number createdAt: number
/** Which backend owns this link — controls how expiry deletes it. */
backend: 'lp' | 'lnbits'
cleanup?: () => void cleanup?: () => void
} }
@ -277,7 +308,8 @@ function registerLnurlSession(
linkId: string, linkId: string,
uniqueHash: string, uniqueHash: string,
satsAmount: number, satsAmount: number,
lightningPub: LightningPubClient lightningPub: LightningPubClient,
backend: 'lp' | 'lnbits' = 'lp',
): void { ): void {
console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats') console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats')
@ -288,6 +320,7 @@ function registerLnurlSession(
satsAmount, satsAmount,
status: 'active', status: 'active',
createdAt: Date.now(), createdAt: Date.now(),
backend,
}) })
// Safety timeout — normally cleaned up by state machine on idle transition. // Safety timeout — normally cleaned up by state machine on idle transition.
@ -376,6 +409,12 @@ function expireLnurlSession(uniqueHash: string, lightningPub: LightningPubClient
console.log('[LNURL Session] Expiring:', uniqueHash) console.log('[LNURL Session] Expiring:', uniqueHash)
session.status = 'expired' session.status = 'expired'
if (session.cleanup) session.cleanup() if (session.cleanup) session.cleanup()
// LP-backed sessions delete via LP; LNbits-backed sessions delete via
// the cleanup closure (already invoked above), so skip the LP call.
if (session.backend !== 'lp') {
setTimeout(() => lnurlSessions.delete(uniqueHash), 60000)
return
}
lightningPub.deleteWithdrawLink(session.linkId).catch((err) => { lightningPub.deleteWithdrawLink(session.linkId).catch((err) => {
console.warn('[LNURL Session] Failed to delete link:', err) console.warn('[LNURL Session] Failed to delete link:', err)
}) })
@ -1234,6 +1273,83 @@ function createATMServices(
console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats') console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats')
console.log('[ATM Service] Using Nostr RPC (NIP-44 encrypted)') console.log('[ATM Service] Using Nostr RPC (NIP-44 encrypted)')
// LNbits path (3b.3): cash-in via lnurlw_create_link + subscribe_payments.
// Customer wallet GETs the bech32-decoded HTTP URL to redeem; LNbits
// settles, emits a tag="withdraw" push that resolves the session.
if (lnbitsActive) {
if (!CONFIG.lnbitsHttpUrl) {
throw new Error(
'[ATM Service] VITE_LNBITS_HTTP_URL is required for LNbits cash-in',
)
}
try {
if (context.cashInSessionId) {
invalidateLnurlSessionBySessionId(context.cashInSessionId, lightningPub)
}
const link = await lnbits!.createWithdrawLink(lnbitsWalletId!, {
title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
min_withdrawable: context.satsAmount,
max_withdrawable: context.satsAmount,
uses: 1,
wait_time: 1,
is_unique: false,
})
// The transport `lnurlw_create_link` returns a WithdrawLink whose
// `lnurl`/`lnurl_url` are unpopulated (those fields are filled in
// by HTTP views, not the create call). Compose the callback URL
// and bech32-encode it ourselves.
const callbackUrl = `${CONFIG.lnbitsHttpUrl.replace(/\/+$/, '')}/withdraw/api/v1/lnurl/${link.unique_hash}`
const lnurl = encodeLnurl(callbackUrl)
// Subscribe for the settlement push. tag+link_id is the filter
// the withdraw extension extras-tag on settled payments.
let subId: string | null = null
if (context.cashInSessionId) {
registerLnurlSession(
context.cashInSessionId,
link.id,
link.unique_hash,
context.satsAmount,
lightningPub,
'lnbits',
)
subId = await lnbits!.subscribePayments(
lnbitsWalletId!,
{ tag: 'withdraw', link_id: link.id, max_seconds: 600 },
(push) => {
console.log('[ATM Service] LNURL-withdraw claimed (LNbits push)!')
const session = lnurlSessions.get(link.unique_hash)
if (session) {
session.status = 'claimed'
lnurlSessions.delete(link.unique_hash)
}
if (onPaymentCallback) {
onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.unique_hash}`)
}
},
)
// Wire the per-session cleanup so invalidateLnurlSessionBySessionId
// can close the subscription if the session is aborted.
const session = lnurlSessions.get(link.unique_hash)
if (session && subId) {
const sid = subId
session.cleanup = () => {
void lnbits!.unsubscribe(lnbitsWalletId!, sid).catch(() => {})
void lnbits!.deleteWithdrawLink(lnbitsWalletId!, link.id).catch(() => {})
}
}
}
console.log('[ATM Service] LNURL-withdraw generated (LNbits):', lnurl.slice(0, 40) + '...')
return lnurl
} catch (error) {
console.error('[ATM Service] LNbits LNURL-withdraw failed:', error)
throw error
}
}
try { try {
// Invalidate any previous LNURL session for this cash-in session // Invalidate any previous LNURL session for this cash-in session
// (shouldn't happen — LNURL is generated once — but guard against it) // (shouldn't happen — LNURL is generated once — but guard against it)