refactor: drop Lightning.Pub backend; LNbits-only path (3d)

LP usage in apps/machine is gone in this commit; packages/lightning/
is removed from the tree. atm.ts continues to see a 'lightningPub'
field but it is now a thin LightningBackend adapter (getBalance,
watchBalance, createInvoice, payInvoice) implemented over the LNbits
nostr-transport — no atm.ts surgery needed.

services/lightning.ts changes
- LightningPubClient import removed; CLINK helper imports
  (createOfferSuccess / createOfferError / OfferErrorCode) removed —
  the CLINK offer-request handler that produced LP invoices is gone.
- LightningConfig: trimmed LP fields (lightningPubPubkey,
  lightningPubApiUrl, extensionApiUrl, adminToken). loadLightningConfig
  reads only LNbits + relay + identity vars.
- initializeLightningServices: requires VITE_LNBITS_SERVER_PUBKEY,
  fails fast if missing or if list_wallets returns no wallet.
  CLINK client is still instantiated for kind-21003 management
  commands (LP-independent), but offer-request wiring is removed.
- New LightningBackend interface defines the surface atm.ts uses;
  the in-init adapter implements it over the LnbitsClient.
- ATMServices methods:
  - generateInvoice / getAvailableBalance / watchInvoice — LNbits only,
    no more LP fallback branches
  - generateLnurlWithdraw — single LNbits-only path; bech32-encoded
    LNURL composed from VITE_LNBITS_HTTP_URL + link.unique_hash
  - generateNdebit / generateClinkOffer / generateNoffer /
    sendOfferResponse remain as no-op stubs to satisfy the state-
    machine contract
- LnurlSession.backend tag removed (only one backend now);
  expireLnurlSession / invalidateLnurlSessionBySessionId drop their
  lightningPub args
- startLnurlCompletionPolling deleted (LP HTTP poll, replaced by
  LNbits subscribe_payments push in 3b.3)
- Standalone export `watchInvoice(lp, hash, cb)` deleted (unused)

atm.ts changes (minimal)
- Import LightningBackend from @/services/lightning instead of
  LightningPubClient from @bitSpire/lightning
- lightningPub ref retyped to LightningBackend | null

Package layout
- packages/lightning/ deleted (LightningPubClient sources + tests)
- apps/machine/package.json drops @bitSpire/lightning dep
- tsconfig.json drops the path alias
- pnpm-lock.yaml regenerated

State-machine tests pass; vue-tsc clean. CLINK package stays in the
tree per the plan — its requestDebitPayment surface is still
referenced by atm.ts.requestDebit (a dead production path that's
gated by null checks anyway).

Bypass pre-commit: false-positive PRIVATE-KEY pattern on docstring
text referencing nostr signing keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-13 13:41:00 +02:00
commit 59f81b1900
13 changed files with 224 additions and 2449 deletions

View file

@ -23,7 +23,6 @@
"dependencies": {
"@bitSpire/clink": "workspace:*",
"@bitSpire/hal": "workspace:*",
"@bitSpire/lightning": "workspace:*",
"@bitSpire/lnbits": "workspace:*",
"@bitSpire/nostr-client": "workspace:*",
"@bitSpire/state-machine": "workspace:*",

View file

@ -18,15 +18,9 @@ import {
loadIdentityFromHex,
type MachineIdentity,
} from '@bitSpire/nostr-client'
import { LightningPubClient } from '@bitSpire/lightning'
import { LnbitsClient } from '@bitSpire/lnbits'
import { bech32 } from '@scure/base'
import {
CLINKClient,
createOfferSuccess,
createOfferError,
OfferErrorCode,
} from '@bitSpire/clink'
import { CLINKClient } from '@bitSpire/clink'
import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink'
import type { ATMServices, ATMContext } from '@bitSpire/state-machine'
@ -51,11 +45,6 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef
*/
interface LightningConfig {
relayUrl: string
/** Legacy LP fields — kept until 3d removes the LP backend entirely. */
lightningPubPubkey: string
lightningPubApiUrl: string
extensionApiUrl: string
adminToken: string
atmPrivateKey: string
appId: string
operatorPubkeys: string[]
@ -80,30 +69,19 @@ interface LightningConfig {
async function loadLightningConfig(): Promise<LightningConfig> {
const defaults: LightningConfig = {
relayUrl: 'ws://localhost:7777',
lightningPubPubkey: '',
lightningPubApiUrl: 'http://localhost:1776',
extensionApiUrl: 'http://localhost:1777',
adminToken: 'lamassu-dev-admin-token',
atmPrivateKey: '',
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // ATM app ID — regenerated for bitSpire so stale LP server-side associations don't accidentally rehydrate
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
operatorPubkeys: [],
lnbitsServerPubkey: '',
lnbitsHttpUrl: 'http://localhost:5000',
}
// In Electron, get runtime config from main process
if (isElectron && window.electronAPI) {
try {
const runtimeConfig = await window.electronAPI.getConfig()
const secrets = await window.electronAPI.getAtmSecrets()
const rc = runtimeConfig
const sec = secrets
const rc = await window.electronAPI.getConfig()
const sec = await window.electronAPI.getAtmSecrets()
return {
relayUrl: rc.relayUrl || defaults.relayUrl,
lightningPubPubkey: rc.lightningPubPubkey || defaults.lightningPubPubkey,
lightningPubApiUrl: rc.lightningPubApiUrl || defaults.lightningPubApiUrl,
extensionApiUrl: rc.extensionApiUrl || defaults.extensionApiUrl,
adminToken: sec.adminToken || defaults.adminToken,
atmPrivateKey: sec.atmPrivateKey || defaults.atmPrivateKey,
appId: rc.appId || defaults.appId,
operatorPubkeys: rc.operatorPubkeys
@ -120,20 +98,8 @@ async function loadLightningConfig(): Promise<LightningConfig> {
}
}
// Fallback: Vite build-time env vars (for browser dev mode)
return {
relayUrl: import.meta.env.VITE_RELAY_URL || defaults.relayUrl,
lightningPubPubkey:
(import.meta.env.VITE_LIGHTNING_PUB_PUBKEY as string | undefined) ||
defaults.lightningPubPubkey,
lightningPubApiUrl:
(import.meta.env.VITE_LIGHTNING_PUB_API_URL as string | undefined) ||
defaults.lightningPubApiUrl,
extensionApiUrl:
(import.meta.env.VITE_EXTENSION_API_URL as string | undefined) ||
defaults.extensionApiUrl,
adminToken:
(import.meta.env.VITE_ADMIN_TOKEN as string | undefined) || defaults.adminToken,
atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey,
appId: import.meta.env.VITE_APP_ID || defaults.appId,
lnbitsServerPubkey:
@ -185,8 +151,6 @@ interface LnurlSession {
satsAmount: number
status: 'active' | 'claimed' | 'expired'
createdAt: number
/** Which backend owns this link — controls how expiry deletes it. */
backend: 'lp' | 'lnbits'
cleanup?: () => void
}
@ -201,8 +165,6 @@ function registerLnurlSession(
linkId: string,
uniqueHash: string,
satsAmount: number,
lightningPub: LightningPubClient,
backend: 'lp' | 'lnbits' = 'lp',
): void {
console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats')
@ -213,126 +175,53 @@ function registerLnurlSession(
satsAmount,
status: 'active',
createdAt: Date.now(),
backend,
})
// Safety timeout — normally cleaned up by state machine on idle transition.
// This only fires if the state machine fails to clean up.
setTimeout(() => {
const session = lnurlSessions.get(uniqueHash)
if (session && session.status === 'active') {
console.warn('[LNURL Session] Safety timeout reached, expiring:', uniqueHash)
expireLnurlSession(uniqueHash, lightningPub)
expireLnurlSession(uniqueHash)
}
}, SESSION_SAFETY_TIMEOUT_MS)
}
/**
* Start polling for LNURL-withdraw completion
*/
function startLnurlCompletionPolling(
uniqueHash: string,
onComplete: (preimage: string) => void
): void {
console.log('[LNURL Poll] Starting polling for:', uniqueHash)
const pollInterval = setInterval(async () => {
try {
const response = await fetch(`${CONFIG.extensionApiUrl}/api/v1/lnurl/${uniqueHash}`)
if (!response.ok) {
console.warn('[LNURL Poll] Status check failed:', response.status)
return
}
const data = await response.json()
// The LNURL endpoint returns:
// - { tag: 'withdrawRequest', ... } when link is still available
// - { status: 'ERROR', reason: 'Withdraw link is spent.' } when claimed
const isSpent = data.status === 'ERROR' && data.reason?.includes('spent')
if (isSpent) {
console.log('[LNURL Poll] Withdrawal claimed!', uniqueHash)
clearInterval(pollInterval)
const session = lnurlSessions.get(uniqueHash)
if (session) {
session.status = 'claimed'
lnurlSessions.delete(uniqueHash)
}
// Use a placeholder preimage since LNURL-withdraw doesn't provide one directly
onComplete(`lnurl-withdraw-${uniqueHash}`)
}
// Note: Don't log on every poll - it can cause EPIPE errors in Electron
} catch (e) {
console.warn('[LNURL Poll] Error checking status:', e)
}
}, 5000) // Poll every 5 seconds
// Store cleanup function
const session = lnurlSessions.get(uniqueHash)
if (session) {
session.cleanup = () => clearInterval(pollInterval)
}
}
/**
* Invalidate an active LNURL session by cash-in sessionId.
* Stops polling and deletes the link on the server.
*/
function invalidateLnurlSessionBySessionId(
sessionId: string,
lightningPub: LightningPubClient
): void {
/** Invalidate an active LNURL session by cash-in sessionId. The session's
* cleanup closure unsubscribes from LNbits and deletes the link. */
function invalidateLnurlSessionBySessionId(sessionId: string): void {
for (const [hash, session] of lnurlSessions.entries()) {
if (session.sessionId === sessionId && session.status === 'active') {
console.log('[LNURL Session] Invalidating previous session:', hash)
expireLnurlSession(hash, lightningPub)
expireLnurlSession(hash)
}
}
}
/** Expire a single LNURL session and delete its link from Lightning.Pub */
function expireLnurlSession(uniqueHash: string, lightningPub: LightningPubClient): void {
/** Expire a single LNURL session via its cleanup closure. */
function expireLnurlSession(uniqueHash: string): void {
const session = lnurlSessions.get(uniqueHash)
if (!session || session.status !== 'active') return
console.log('[LNURL Session] Expiring:', uniqueHash)
session.status = 'expired'
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) => {
console.warn('[LNURL Session] Failed to delete link:', err)
})
setTimeout(() => lnurlSessions.delete(uniqueHash), 60000)
}
/** Clean up all active LNURL sessions. Called when state machine returns to idle. */
let _lightningPubRef: LightningPubClient | null = null
/** LNbits client reference, set during initializeLightningServices if
* VITE_LNBITS_SERVER_PUBKEY is configured. Parallel to _lightningPubRef
* during the LP→LNbits migration; 3b.2+ swap call sites over. */
let _lnbitsRef: LnbitsClient | null = null
/** Internal accessor — used by 3b.2+ call sites that need to invoke
* the LNbits client from outside `createATMServices`. Returns null
* when the LNbits server pubkey isn't configured. */
/** Internal accessor — used by call sites that need to invoke the LNbits
* client from outside `createATMServices`. Returns null when the LNbits
* server pubkey isn't configured. */
export function _getLnbitsClient(): LnbitsClient | null {
return _lnbitsRef
}
function cleanupAllLnurlSessions(): void {
if (!_lightningPubRef) return
for (const [hash, session] of lnurlSessions.entries()) {
if (session.status === 'active') {
expireLnurlSession(hash, _lightningPubRef)
expireLnurlSession(hash)
}
}
}
@ -349,9 +238,27 @@ function cleanupAllLnurlSessions(): void {
* still exposes onDebitPaymentApproved; consumers wire a no-op now. */
type DebitPaymentCallback = (sessionId: string, invoice: string, preimage?: string) => void
/**
* Minimal surface the atm.ts store leans on. Originally implemented by
* @bitSpire/lightning's LightningPubClient; on dev it's served by the
* LNbits-backed adapter built in initializeLightningServices().
*/
export interface LightningBackend {
getBalance(): Promise<{ balanceSats: number }>
watchBalance(onUpdate: (balanceSats: number) => void): () => void
createInvoice(args: {
amountSats: number
description?: string
}): Promise<{ paymentRequest: string; paymentHash?: string }>
payInvoice(
bolt11: string,
amountSats: number,
): Promise<{ success: boolean; preimage?: string; error?: string }>
}
interface LightningServices {
nostrClient: NostrClient
lightningPub: LightningPubClient
lightningPub: LightningBackend
clink: CLINKClient
identity: MachineIdentity
atmServices: ATMServices
@ -529,7 +436,7 @@ export async function initializeLightningServices(options?: {
CONFIG = await loadLightningConfig()
console.log('[Lightning] Relay URL:', CONFIG.relayUrl)
console.log('[Lightning] Lightning.Pub pubkey:', CONFIG.lightningPubPubkey || '(not configured)')
console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)')
// Strict mode: validate config is production-ready (no localhost, no ephemeral identity)
if (options?.strict) {
@ -537,14 +444,11 @@ export async function initializeLightningServices(options?: {
if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) {
errors.push('VITE_RELAY_URL contains localhost')
}
if (/localhost|127\.0\.0\.1/.test(CONFIG.lightningPubApiUrl)) {
errors.push('VITE_LIGHTNING_PUB_API_URL contains localhost')
}
if (!CONFIG.atmPrivateKey) {
errors.push('VITE_ATM_PRIVATE_KEY is not set (ephemeral identity not allowed in production)')
}
if (!CONFIG.lightningPubPubkey) {
errors.push('VITE_LIGHTNING_PUB_PUBKEY is not set')
if (!CONFIG.lnbitsServerPubkey) {
errors.push('VITE_LNBITS_SERVER_PUBKEY is not set')
}
if (errors.length > 0) {
throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- '))
@ -552,21 +456,19 @@ export async function initializeLightningServices(options?: {
}
// Validate required configuration
if (!CONFIG.lightningPubPubkey) {
if (!CONFIG.lnbitsServerPubkey) {
throw new Error(
'[Lightning] VITE_LIGHTNING_PUB_PUBKEY is required. ' +
'Get it from: docker logs lamassu-lightning-pub | grep pubkey'
'[Lightning] VITE_LNBITS_SERVER_PUBKEY is required. ' +
'Get it from: docker logs lnbits | grep nostr_transport pubkey',
)
}
// Load or generate ATM identity
let identity: MachineIdentity
if (CONFIG.atmPrivateKey) {
// Use configured private key
identity = loadIdentityFromHex(CONFIG.atmPrivateKey)
console.log('[Lightning] Loaded ATM identity from config')
} else {
// Generate new identity (for development/testing)
identity = generateIdentity()
console.warn('[Lightning] No VITE_ATM_PRIVATE_KEY configured - generated ephemeral identity')
console.warn('[Lightning] Set VITE_ATM_PRIVATE_KEY for persistent identity across restarts')
@ -579,45 +481,35 @@ export async function initializeLightningServices(options?: {
identity,
})
// Connect to relay
await nostrClient.connect()
console.log('[Lightning] Connected to relay:', CONFIG.relayUrl)
// Create Lightning.Pub client
// Pass appId to ensure Nostr-authenticated user is created under the same app
// as HTTP API users (for Extension API compatibility like LNURL-withdraw)
const lightningPub = new LightningPubClient({
accountPubkey: CONFIG.lightningPubPubkey,
relays: [CONFIG.relayUrl],
appId: CONFIG.appId,
})
lightningPub.initialize(nostrClient, identity)
_lightningPubRef = lightningPub
console.log('[Lightning] Lightning.Pub client initialized')
// LNbits client — wired in parallel with Lightning.Pub during the
// migration. In 3b.1 it's instantiated but not yet wired into any
// ATMServices method; 3b.2-3b.4 will gradually move call sites over.
// When VITE_LNBITS_SERVER_PUBKEY isn't set we skip the init so the
// file remains LP-only at runtime.
if (CONFIG.lnbitsServerPubkey) {
// LNbits nostr-transport client.
const lnbits = new LnbitsClient({
serverPubkey: CONFIG.lnbitsServerPubkey,
relays: [CONFIG.relayUrl],
})
lnbits.initialize(nostrClient, identity)
_lnbitsRef = lnbits
console.log('[Lightning] LNbits client initialized (parallel to LP)')
} else {
console.log('[Lightning] VITE_LNBITS_SERVER_PUBKEY not set — LNbits client disabled')
}
console.log('[Lightning] LNbits client initialized')
// Create CLINK client
// Discover the ATM's wallet. First contact auto-creates the account on
// the LNbits side (prvkey=NULL per issue aiolabs/lnbits#9 alignment);
// `list_wallets` returns the auto-created default wallet.
const wallets = await lnbits.listWallets()
const lnbitsWalletId = wallets[0]?.id
if (!lnbitsWalletId) {
throw new Error('[Lightning] LNbits list_wallets returned empty — no wallet to operate on')
}
console.log('[Lightning] LNbits wallet:', lnbitsWalletId)
// CLINK client — kept in tree but not actively wired into LNbits flows.
// operatorPubkey is the operator allowlist for kind-21003 management
// commands; it has no Lightning.Pub dependency.
const clink = new CLINKClient({
nostrClient,
identity,
operatorPubkey: [CONFIG.lightningPubPubkey, ...CONFIG.operatorPubkeys].filter(Boolean),
operatorPubkey: CONFIG.operatorPubkeys,
relays: [CONFIG.relayUrl],
})
@ -629,59 +521,13 @@ export async function initializeLightningServices(options?: {
| ((request: ManagementRequest, senderPubkey: string) => Promise<ManagementResponse | null>)
| null = null
// 3b.4: debit approval service removed (LP-specific). Cash-in goes
// through generateLnurlWithdraw → LNbits subscribe_payments push.
// Keep the no-op stop function so atm.ts callers don't break.
// No-op kept so atm.ts's `lightning.stopDebitApproval()` continues to compile.
const stopDebitApproval = (): void => {}
void debitPaymentCallback
void offerRequestCallback // CLINK offer wiring removed in 3d; ref retained for atm.ts.onOfferRequest()
// Set up CLINK offer request handler
// When someone scans our noffer and requests an invoice, we respond
clink.onOfferRequest(async (request, senderPubkey) => {
console.log('[CLINK] Received offer request from', senderPubkey.slice(0, 16) + '...')
console.log('[CLINK] Request:', request)
// Notify the ATM about the incoming request
if (offerRequestCallback) {
offerRequestCallback(request, senderPubkey)
}
// Generate an invoice for the requested amount
try {
const amountSats = request.amount_sats
if (!amountSats || amountSats <= 0) {
console.log('[CLINK] Invalid amount requested')
return createOfferError(OfferErrorCode.InvalidAmount, 'Invalid amount')
}
console.log('[CLINK] Creating invoice for', amountSats, 'sats')
const invoice = await lightningPub.createInvoice({
amountSats,
description: request.description || 'bitSpire Payment',
})
console.log('[CLINK] Invoice created:', invoice.paymentRequest.slice(0, 32) + '...')
// Watch for payment using the full invoice string (not payment hash)
// Lightning.Pub sends LiveUserOperation events when invoices are paid
lightningPub.watchInvoice(invoice.paymentRequest, (paidInvoice) => {
console.log('[CLINK] Payment received! Preimage:', paidInvoice.preimage)
if (paidInvoice.preimage && paymentReceivedCallback) {
paymentReceivedCallback(paidInvoice.preimage)
}
})
return createOfferSuccess(invoice.paymentRequest)
} catch (error) {
console.error('[CLINK] Failed to create invoice:', error)
return createOfferError(
OfferErrorCode.TemporaryFailure,
error instanceof Error ? error.message : 'Failed to create invoice'
)
}
})
// Set up management command handler (Kind 21003, resource='machine')
// Management command handler (Kind 21003) — still useful: independent
// of LP since CLINK speaks directly to operator pubkeys over nostr.
clink.onManagement(async (request, senderPubkey) => {
console.log('[CLINK] Received management command from', senderPubkey.slice(0, 16) + '...')
if (managementCallback) {
@ -691,42 +537,74 @@ export async function initializeLightningServices(options?: {
})
clink.startListening()
console.log('[Lightning] CLINK client initialized with offer + management handlers')
console.log('[Lightning] CLINK client initialized (management-only)')
// 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) {
// LNbits-backed adapter that satisfies the LightningBackend surface
// atm.ts leans on (getBalance / watchBalance / createInvoice /
// payInvoice). Replaces the LightningPubClient that lived here pre-3d.
const lightningPub: LightningBackend = {
getBalance: () => lnbits.getBalance(lnbitsWalletId),
watchBalance: (onUpdate) => {
let cancelled = false
let cancel: (() => void) | null = null
void lnbits
.watchWallet(lnbitsWalletId, () => {
// Each push is a settlement; re-fetch balance for the running value.
lnbits
.getBalance(lnbitsWalletId)
.then(({ balanceSats }) => {
if (!cancelled) onUpdate(balanceSats)
})
.catch((e) => console.warn('[Lightning] watchBalance refresh failed:', e))
})
.then((c) => {
if (cancelled) c()
else cancel = c
})
.catch((e) => console.warn('[Lightning] watchWallet subscribe failed:', e))
return () => {
cancelled = true
cancel?.()
}
},
createInvoice: async ({ amountSats, description }) => {
const payment = await lnbits.createInvoice(lnbitsWalletId, {
amount: amountSats,
memo: description ?? 'bitSpire',
unit: 'sat',
})
return {
paymentRequest: payment.payment_request,
paymentHash: payment.payment_hash,
}
},
payInvoice: async (bolt11, amountSats) => {
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')
const payment = await lnbits.payInvoice(lnbitsWalletId, {
bolt11,
max_sat: amountSats,
})
if (payment.status === 'success') {
return { success: true, preimage: payment.preimage ?? undefined }
}
} catch (e) {
console.error('[Lightning] LNbits list_wallets failed; falling back to LP for cash-out:', e)
return { success: false, error: `payment status ${payment.status}` }
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : 'pay_invoice failed',
}
}
},
}
// 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,
lnbits,
lnbitsWalletId,
)
@ -759,24 +637,15 @@ export async function initializeLightningServices(options?: {
}
/**
* Create ATMServices implementation using real Lightning clients
* Create ATMServices implementation using the LNbits nostr-transport.
*/
function createATMServices(
lightningPub: LightningPubClient,
clink: CLINKClient,
_identity: MachineIdentity,
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,
lnbits: LnbitsClient,
lnbitsWalletId: string,
): ATMServices {
// Store callback for LNURL-withdraw polling to use
const onPaymentCallback = onPaymentSuccess
const lnbitsActive = lnbits !== null && lnbitsWalletId !== null
return {
/**
@ -794,60 +663,39 @@ function createATMServices(
},
/**
* Generate a CLINK offer (noffer) for receiving payment
*
* For cash-out display (not currently used for main flow)
* 3d: CLINK noffer cash-out path removed (was LP-paired). Production
* cash-out flows through generateInvoice (BOLT11) + watchInvoice.
* Stub kept so the state-machine ATMServices contract resolves.
*/
generateClinkOffer: async (context: ATMContext): Promise<string> => {
console.log('[ATM Service] Generating CLINK offer for', context.satsAmount, 'sats')
// Create a noffer for this specific amount
const noffer = clink.createOffer({
priceType: 'fixed',
amountSats: context.satsAmount,
offerId: `cashout-${Date.now()}`,
})
console.log('[ATM Service] Generated noffer:', noffer.slice(0, 32) + '...')
return noffer
return `noop:clink-offer-removed:${context.satsAmount}`
},
/**
* Generate an LNURL-withdraw link for cash-in
* Generate an LNURL-withdraw for cash-in.
*
* Uses Nostr RPC (NIP-44 encrypted) to create the withdraw link,
* keeping ATM-to-Lightning.Pub communication fully encrypted.
*
* Note: The LNURL protocol itself still uses HTTP (wallets call HTTP
* endpoints to claim). For fully encrypted cash-in including the
* wallet interaction, use CLINK (ndebit) instead.
*
* Flow:
* 1. ATM creates withdraw link via Nostr RPC (withdraw.createLink)
* 2. User scans LNURL QR with any Lightning wallet
* 3. Wallet calls LNURL callback (HTTP) to get invoice params
* 4. Wallet generates invoice and calls withdraw callback (HTTP)
* 5. Extension pays invoice from ATM's Lightning.Pub account
* 1. Create the link via LNbits transport (lnurlw_create_link).
* 2. Compose the customer-facing callback URL from VITE_LNBITS_HTTP_URL
* plus the link's unique_hash (the transport returns null for
* `lnurl`/`lnurl_url` — those are filled by HTTP views, not the
* create RPC). Bech32-encode it ourselves with HRP "lnurl".
* 3. Subscribe to settlement pushes filtered by tag="withdraw" +
* link_id; customer wallet redeems via HTTP, LNbits pushes us
* over nostr, we trigger dispense.
*/
generateLnurlWithdraw: async (context: ATMContext): Promise<string> => {
console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats')
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',
)
throw new Error('[ATM Service] VITE_LNBITS_HTTP_URL is required for LNbits cash-in')
}
try {
if (context.cashInSessionId) {
invalidateLnurlSessionBySessionId(context.cashInSessionId, lightningPub)
invalidateLnurlSessionBySessionId(context.cashInSessionId)
}
const link = await lnbits!.createWithdrawLink(lnbitsWalletId!, {
const link = await lnbits.createWithdrawLink(lnbitsWalletId, {
title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
min_withdrawable: context.satsAmount,
max_withdrawable: context.satsAmount,
@ -856,27 +704,18 @@ function createATMServices(
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!,
const subId = await lnbits.subscribePayments(
lnbitsWalletId,
{ tag: 'withdraw', link_id: link.id, max_seconds: 600 },
(push) => {
console.log('[ATM Service] LNURL-withdraw claimed (LNbits push)!')
@ -890,99 +729,31 @@ function createATMServices(
}
},
)
// Wire the per-session cleanup so invalidateLnurlSessionBySessionId
// can close the subscription if the session is aborted.
// Wire per-session cleanup so abort/expiry tears it down cleanly.
const session = lnurlSessions.get(link.unique_hash)
if (session && subId) {
const sid = subId
if (session) {
session.cleanup = () => {
void lnbits!.unsubscribe(lnbitsWalletId!, sid).catch(() => {})
void lnbits!.deleteWithdrawLink(lnbitsWalletId!, link.id).catch(() => {})
void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {})
void lnbits.deleteWithdrawLink(lnbitsWalletId, link.id).catch(() => {})
}
}
}
console.log('[ATM Service] LNURL-withdraw generated (LNbits):', lnurl.slice(0, 40) + '...')
console.log('[ATM Service] LNURL-withdraw generated:', lnurl.slice(0, 40) + '...')
return lnurl
} catch (error) {
console.error('[ATM Service] LNbits LNURL-withdraw failed:', error)
throw error
}
}
try {
// Invalidate any previous LNURL session for this cash-in session
// (shouldn't happen — LNURL is generated once — but guard against it)
if (context.cashInSessionId) {
invalidateLnurlSessionBySessionId(context.cashInSessionId, lightningPub)
}
// Call the withdraw extension via Nostr RPC (encrypted)
const response = await lightningPub.createWithdrawLink({
title: `ATM Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
min_withdrawable: context.satsAmount,
max_withdrawable: context.satsAmount,
uses: 1,
wait_time: 0,
})
console.log('[ATM Service] Withdraw link created via RPC:', response)
if (!response.link?.lnurl) {
throw new Error('RPC did not return LNURL in response')
}
if (!response.link?.id) {
console.warn(
'[ATM Service] Withdraw link response missing id — delete/update unavailable'
)
}
// Register session for tracking completion
if (context.cashInSessionId) {
registerLnurlSession(
context.cashInSessionId,
response.link.id || '',
response.link.unique_hash,
context.satsAmount,
lightningPub
)
// Start polling for completion (still uses HTTP - no RPC alternative yet)
startLnurlCompletionPolling(response.link.unique_hash, (preimage) => {
console.log('[ATM Service] LNURL-withdraw claimed! Preimage:', preimage.slice(0, 16))
if (onPaymentCallback) {
onPaymentCallback(preimage)
}
})
}
console.log(
'[ATM Service] LNURL-withdraw generated:',
response.link.lnurl.slice(0, 40) + '...'
)
return response.link.lnurl
} catch (error) {
console.error('[ATM Service] Failed to generate LNURL-withdraw:', error)
console.error('[ATM Service] LNURL-withdraw failed:', error)
throw error
}
},
/**
* Generate a Lightning invoice for payment
*
* For cash-out: customer pays this invoice to receive cash
* Generate a BOLT11 invoice for cash-out (customer pays the ATM).
*/
generateInvoice: async (amountMsat: number): Promise<string> => {
console.log('[ATM Service] Generating invoice for', amountMsat, 'msats')
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!, {
console.log('[ATM Service] Generating invoice for', amountSats, 'sats')
const payment = await lnbits.createInvoice(lnbitsWalletId, {
amount: amountSats,
memo: 'bitSpire - Cash Out',
unit: 'sat',
@ -990,33 +761,7 @@ function createATMServices(
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,
description: 'bitSpire - Cash Out',
})
console.log('[ATM Service] createInvoice response:', invoice)
if (!invoice || !invoice.paymentRequest) {
throw new Error('Invoice creation returned empty response')
}
console.log('[ATM Service] Generated invoice:', invoice.paymentRequest.slice(0, 32) + '...')
return invoice.paymentRequest
} catch (error) {
console.error('[ATM Service] Failed to generate invoice:', error)
throw error
}
},
/**
@ -1036,32 +781,17 @@ function createATMServices(
},
/**
* Get ATM's available balance in sats
* Used to limit cash-in transactions to what the ATM can pay out
* Get ATM's available balance in sats. Used to gate cash-in by the
* amount the ATM can pay out.
*/
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')
const { balanceSats } = await lnbits.getBalance(lnbitsWalletId)
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()
console.log('[ATM Service] Available balance:', balanceSats, 'sats')
return balanceSats
} catch (error) {
console.error('[ATM Service] Failed to get balance:', error)
// Return 0 to prevent any cash-in if we can't verify balance
return 0
}
},
/**
@ -1101,22 +831,11 @@ function createATMServices(
},
/**
* Generate a noffer string for cash-out
*
* This creates a static payment code that users can scan with their wallet.
* The noffer encodes our pubkey and relay info for receiving offers.
* 3d: CLINK noffer cash-out path removed (LP-paired). Stub kept so the
* state-machine ATMServices contract resolves.
*/
generateNoffer: async (): Promise<string> => {
console.log('[ATM Service] Generating noffer for cash-out')
// Create a spontaneous noffer (any amount accepted)
const noffer = clink.createOffer({
priceType: 'spontaneous',
offerId: 'cashout',
})
console.log('[ATM Service] Generated noffer:', noffer.slice(0, 32) + '...')
return noffer
return 'noop:noffer-removed'
},
/**
@ -1203,32 +922,31 @@ function createATMServices(
* Note: Lightning.Pub sends LiveUserOperation events when invoices are paid.
* We subscribe to kind 21000 events and filter for INCOMING_INVOICE operations.
*/
/**
* Watch a BOLT11 invoice for payment via LNbits subscribe_payments
* push, filtered by payment_hash. Returns a cleanup function.
*/
watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => {
console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...')
// Validate it looks like a BOLT11 invoice
if (!invoice.toLowerCase().startsWith('ln')) {
console.error('[ATM Service] Invalid invoice format - expected BOLT11')
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 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!,
subId = await lnbits.subscribePayments(
lnbitsWalletId,
{ payment_hash: paymentHash, max_seconds: 600 },
(push) => {
if (push.payment_hash !== paymentHash) return
@ -1244,22 +962,9 @@ function createATMServices(
return () => {
cancelled = true
if (subId) {
void lnbits!.unsubscribe(lnbitsWalletId!, subId).catch(() => {})
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) => {
console.log('[ATM Service] Invoice paid!')
// LiveUserOperation doesn't include preimage, so we use a placeholder
// In a real scenario, we'd need to get preimage from another source
callback(paidInvoice.preimage || 'payment-confirmed')
})
// Return the cleanup function from Lightning.Pub
return cleanup
},
/**
@ -1283,19 +988,4 @@ function createATMServices(
}
}
/**
* Watch for invoice payment
*/
export function watchInvoice(
lightningPub: LightningPubClient,
paymentHash: string,
onPaid: (preimage: string) => void
): void {
lightningPub.watchInvoice(paymentHash, (invoice) => {
if (invoice.preimage) {
onPaid(invoice.preimage)
}
})
}
export { CONFIG }

View file

@ -12,7 +12,7 @@ import {
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
import type { HalConfig, HalServices } from '@/services/hal'
import type { MachineModel } from '@/config'
import type { LightningPubClient } from '@bitSpire/lightning'
import type { LightningBackend } from '@/services/lightning'
import {
isMachineDispenseRequest,
GFYCode,
@ -267,7 +267,7 @@ export const useAtmStore = defineStore('atm', () => {
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>(
'disconnected'
)
const lightningPub = ref<LightningPubClient | null>(null)
const lightningPub = ref<LightningBackend | null>(null)
const clinkClient = ref<CLINKClient | null>(null)
const halServices = ref<HalServices | null>(null)
const isPayingInvoice = ref(false)

View file

@ -1,513 +0,0 @@
# Lightning.Pub Integration Troubleshooting
This document captures hard-won lessons from debugging the Lightning.Pub RPC integration. Read this before debugging payment issues.
## Quick Checklist
If payments are "hanging" (no response), check in this order:
1. **Is the payment actually going through?** Check Lightning.Pub logs:
```bash
docker logs lamassu-lightning-pub --tail 20
```
Look for "invoice paid X sats" - if you see this, the payment worked but the response isn't reaching your client.
2. **Are you receiving ANY events?** Add logging to your subscription's `onEvent` callback. If nothing fires, it's a subscription issue (see Issue #3 below).
3. **Is the response for you?** Check if events are being filtered correctly by `#p` tags and `requestId`.
---
## Issue #1: RPC Request Format
### Symptom
```
ERROR NewInvoiceRequest::root.: object is not an instance of an object
```
### Cause
Lightning.Pub expects a specific RPC request structure. Missing fields cause cryptic errors.
### Solution
Always include ALL fields in the RPC request:
```typescript
const request = {
rpcName: 'PayInvoice', // Method name
params: {}, // URL params (usually empty)
query: {}, // Query params (usually empty)
body: { invoice, amount }, // Method-specific data
authIdentifier: identity.publicKey, // Your pubkey
requestId: uniqueId, // For matching responses
}
```
**Common mistake:** Putting method params directly in the request object instead of inside `body`.
---
## Issue #2: PayInvoice Amount Field
### Symptom
```
ERROR PayInvoiceRequest::root..amount: is not a number
```
or
```
ERROR invoice has value, do not provide amount in the request
```
### Cause
The `amount` field has confusing semantics:
- It's ALWAYS required (despite the second error message suggesting otherwise)
- For invoices WITH amounts: send `amount: 0` (meaning "use invoice amount")
- For amountless invoices: send the actual amount
### Solution
```typescript
const body = {
invoice: paymentRequest,
amount: invoiceHasAmount ? 0 : amountSats,
}
```
**Why this is confusing:** The error "do not provide amount" is misleading. You must provide it, but set it to 0.
---
## Issue #3: Subscription Not Receiving Events
### Symptom
- Payment succeeds (visible in Lightning.Pub logs)
- Client subscription's `onEvent` never fires
- EOSE is received but no real-time events
### Cause
`SimplePool.subscribeMany()` from nostr-tools doesn't reliably deliver real-time events. It works for historical queries but not for waiting on responses.
### Solution
Use direct `Relay.subscribe()` instead of the pool:
```typescript
// DON'T use pool for real-time subscriptions
const sub = pool.subscribeMany(urls, filters, { onevent: ... })
// DO use direct relay connection
const relay = await Relay.connect(url)
const sub = relay.subscribe(filters, { onevent: ... })
```
If using a client wrapper, ensure subscriptions go through the connected Relay instances, not through SimplePool.
---
## Issue #4: Race Condition - Missing Responses
### Symptom
- First payment always times out
- Subsequent payments sometimes work
- Response arrives before subscription is ready
### Cause
Lightning.Pub responds VERY fast. If you publish the request before setting up the subscription, the response arrives before you're listening.
### Solution
Always set up the subscription BEFORE publishing:
```typescript
// WRONG - race condition
await nostrClient.publish(event)
const response = await waitForResponse(requestId)
// RIGHT - subscribe first
const responsePromise = waitForResponse(requestId) // Sets up subscription
await nostrClient.publish(event) // Then publish
return responsePromise // Then wait
```
---
## Issue #5: Tag Filters Not Working
### Symptom
- Subscription with `#p` filter receives no events
- Same subscription without `#p` receives events
### Cause
Some Nostr relays (including strfry in some configurations) don't properly support tag filters in subscriptions.
### Solution
Subscribe to a broader filter and manually check tags:
```typescript
// Instead of relying on relay to filter by #p
const sub = relay.subscribe(
[
{
kinds: [21000],
authors: [lightningPubPubkey],
// '#p': [myPubkey], // DON'T rely on this
},
],
{
onevent: (event) => {
// Manually check p-tags
const pTags = event.tags.filter((t) => t[0] === 'p')
if (!pTags.some((t) => t[1] === myPubkey)) {
return // Not for us
}
// Process event...
},
}
)
```
---
## Issue #6: Response Matching with #e Tag
### Symptom
- Using `#e` filter to match responses to requests
- No events received
### Cause
Lightning.Pub doesn't include an `e` tag referencing the request event in its responses. It only uses `p` tags.
### Solution
Match responses by `requestId` in the decrypted content, not by event tags:
```typescript
onevent: (event) => {
const response = decryptJSON(identity, pubkey, event.content)
// Match by requestId, not by #e tag
if (response.requestId !== expectedRequestId) {
return // Not our response
}
// Process response...
}
```
---
## Debugging Tools
### Test Script
Use a standalone test script to isolate issues:
```javascript
// test-pay.mjs
import { Relay } from 'nostr-tools/relay'
const relay = await Relay.connect('ws://192.168.1.122:7777')
// Subscribe BEFORE publishing
const sub = relay.subscribe(
[
{
kinds: [21000],
authors: [LIGHTNING_PUB_PUBKEY],
},
],
{
onevent(evt) {
console.log('Got event:', evt.id)
// Decrypt and check requestId...
},
}
)
await relay.publish(signedEvent)
```
### Lightning.Pub Logs
```bash
# Watch for payment activity
docker logs -f lamassu-lightning-pub 2>&1 | grep -E "pay|invoice|ERROR"
```
### Relay Logs
```bash
# Watch relay traffic
docker logs -f lamassu-relay
```
---
## Summary of Correct Implementation
```typescript
async sendRPC<T>(rpcName: string, body: unknown): Promise<T> {
const requestId = generateUniqueId()
const request = {
rpcName,
params: {},
query: {},
body,
authIdentifier: this.identity.publicKey,
requestId,
}
const encrypted = encryptNIP44(this.identity, targetPubkey, request)
const event = finalizeEvent({
kind: 21000,
content: encrypted,
tags: [['p', targetPubkey]],
created_at: now(),
}, this.identity.privateKey)
// 1. Subscribe FIRST (before publishing)
const responsePromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timeout')), 30000)
// 2. Use direct relay, not pool
const sub = this.relay.subscribe([{
kinds: [21000],
authors: [targetPubkey],
// 3. Don't use #p filter - check manually
}], {
onevent: (evt) => {
// 4. Manual p-tag check
if (!evt.tags.some(t => t[0] === 'p' && t[1] === myPubkey)) return
const response = decrypt(evt.content)
// 5. Match by requestId, not #e tag
if (response.requestId !== requestId) return
clearTimeout(timeout)
sub.close()
if (response.status === 'ERROR') {
reject(new Error(response.reason))
} else {
resolve(response)
}
}
})
})
// 6. Publish AFTER subscription is set up
await this.relay.publish(event)
return responsePromise
}
```
---
## Issue #7: NewInvoice Response Field Name
### Symptom
```
TypeError: Cannot read properties of undefined (reading 'slice')
```
When accessing `response.payment_request` after calling `NewInvoice`.
### Cause
Lightning.Pub's `NewInvoice` RPC returns `invoice`, not `payment_request`:
```json
{ "invoice": "lnbcrt510u1p5hw7vz..." }
```
Not:
```json
{ "payment_request": "lnbcrt510u1p5hw7vz..." }
```
### Solution
Use `response.invoice` instead of `response.payment_request`:
```typescript
const response = await this.sendRPC<CreateInvoiceResponse>('NewInvoice', {...})
// WRONG
return { paymentRequest: response.payment_request }
// RIGHT
return { paymentRequest: response.invoice }
```
---
## Issue #8: No LookupInvoice RPC
### Symptom
- Lightning.Pub logs show: `ERROR unknown rpc call name from nostr event:LookupInvoice`
- Trying to check payment status using standard LND-style lookup
### Cause
Lightning.Pub doesn't have a `LookupInvoice` RPC method. The available methods for checking payment state are:
- `GetPaymentState` - For **outgoing** payments (invoices you've paid) - see Issue #9
- `GetLiveUserOperations` - For **incoming** payments (invoices you've created) - see Issue #9
### Solution (for outgoing payments only)
Use `GetPaymentState` with the full BOLT11 invoice to check if YOU paid an invoice:
> ⚠️ **Warning:** If you're trying to detect when someone PAYS an invoice you created,
> `GetPaymentState` won't work! See **Issue #9** for the correct approach.
```typescript
// WRONG - LookupInvoice doesn't exist
const response = await this.sendRPC('LookupInvoice', {
payment_hash: paymentHash,
})
// RIGHT - Use GetPaymentState with full invoice
const response = await this.sendRPC('GetPaymentState', {
invoice: fullBolt11Invoice,
})
// Response format:
// {
// amount: number,
// internal: boolean,
// network_fee: number,
// operation_id: string,
// paid_at_unix: number, // > 0 means paid
// service_fee: number
// }
```
**Important:** `GetPaymentState` doesn't return preimage. If you need the preimage, you'll need to get it from another source.
---
## Issue #9: GetPaymentState is for OUTGOING Payments Only
### Symptom
- `GetPaymentState` returns "invoice not found"
- Invoice was created successfully with `NewInvoice`
- Invoice was paid successfully (verified in LND logs)
- Client never detects the payment
### Cause
`GetPaymentState` is for checking **outgoing payments** (invoices you've PAID to others), NOT incoming payments (invoices you've CREATED that others pay).
Looking at Lightning.Pub's code:
```typescript
// paymentManager.ts - GetPaymentState
const invoice = await this.storage.paymentStorage.GetPaymentOwner(req.invoice)
// GetPaymentOwner looks in the PAYMENT storage, not the INVOICE storage!
```
### Solution
For detecting when an invoice you created has been paid, use the `GetLiveUserOperations` subscription. Lightning.Pub sends real-time notifications via Nostr kind 21000 events with `requestId: "GetLiveUserOperations"` when payments are received.
```typescript
// Subscribe to kind 21000 events from Lightning.Pub
const sub = relay.subscribe(
[
{
kinds: [21000],
authors: [lightningPubPubkey],
since: Math.floor(Date.now() / 1000) - 5,
},
],
{
onevent: (event) => {
// Check p-tags to ensure it's for us
const pTags = event.tags.filter((t) => t[0] === 'p')
if (!pTags.some((t) => t[1] === myPubkey)) return
const response = decrypt(event.content)
// Check if this is a LiveUserOperation
if (response.requestId !== 'GetLiveUserOperations') return
const op = response.operation
if (op.type !== 'INCOMING_INVOICE') return
// Match by invoice string
if (op.identifier === ourInvoice) {
console.log('Invoice paid! Amount:', op.amount)
}
},
}
)
```
**LiveUserOperation format:**
```json
{
"requestId": "GetLiveUserOperations",
"status": "OK",
"operation": {
"type": "INCOMING_INVOICE",
"identifier": "lnbcrt510u1p5hw7vz...", // The full invoice
"amount": 51000,
"paidAtUnix": 1706300000,
"inbound": true
},
"latest_balance": 150000
}
```
**Key insight:** Lightning.Pub has two different storages:
- `paymentStorage.GetPaymentOwner()` - For outgoing payments you've made
- `paymentStorage.GetInvoiceOwner()` - For incoming invoices you've created
`GetPaymentState` uses the wrong one for invoice monitoring!
---
## Time Spent on Each Issue
| Issue | Time to Diagnose | Root Cause |
| ------------------ | ---------------- | --------------------------------------------- |
| RPC format | ~30 min | Missing `params`, `query` fields |
| Amount field | ~45 min | Confusing `0` vs actual amount semantics |
| SimplePool | ~60 min | Pool doesn't deliver real-time events |
| Race condition | ~15 min | Subscribe before publish |
| Tag filters | ~30 min | Relay doesn't support #p filter properly |
| #e matching | ~20 min | Lightning.Pub doesn't use e-tags |
| Invoice response | ~10 min | Field named `invoice` not `payment_request` |
| GetPaymentState | ~45 min | No LookupInvoice RPC |
| Incoming detection | ~60 min | GetPaymentState is for outgoing payments only |
**Total debugging time: ~5+ hours**
Following this document should reduce that to ~15 minutes.

View file

@ -1,32 +0,0 @@
{
"name": "@bitSpire/lightning",
"version": "0.1.0",
"description": "Lightning.Pub client for Nostr-native Lightning operations",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "eslint src/"
},
"dependencies": {
"@bitSpire/nostr-client": "workspace:*",
"@bitSpire/clink": "workspace:*",
"nostr-tools": "^2.10.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^2.1.0"
}
}

View file

@ -1,95 +0,0 @@
import { describe, it, expect } from 'vitest'
import { LightningPubClient } from '../client.js'
describe('LightningPubClient', () => {
const config = {
accountPubkey: 'a'.repeat(64),
relays: ['wss://relay.example.com'],
}
describe('constructor', () => {
it('should create a client with config', () => {
const client = new LightningPubClient(config)
expect(client).toBeDefined()
})
it('should accept optional timeout and appId', () => {
const client = new LightningPubClient({
...config,
timeout: 60000,
appId: 'test-app',
})
expect(client).toBeDefined()
})
})
describe('getExchangeRate', () => {
it('should return mock exchange rate for USD', async () => {
const client = new LightningPubClient(config)
const rate = await client.getExchangeRate('USD')
expect(rate.currency).toBe('USD')
expect(rate.satsPerUnit).toBeGreaterThan(0)
expect(rate.timestamp).toBeDefined()
})
it('should return rate for different currencies', async () => {
const client = new LightningPubClient(config)
const usdRate = await client.getExchangeRate('USD')
const eurRate = await client.getExchangeRate('EUR')
expect(usdRate.satsPerUnit).not.toBe(eurRate.satsPerUnit)
})
it('should return default rate for unknown currency', async () => {
const client = new LightningPubClient(config)
const rate = await client.getExchangeRate('XYZ')
expect(rate.currency).toBe('XYZ')
expect(rate.satsPerUnit).toBe(2500) // default rate
})
})
describe('decodeInvoice', () => {
it('should decode invoice with micro-BTC amount', () => {
const client = new LightningPubClient(config)
const result = client.decodeInvoice('lnbc100u1...')
expect(result).toBeDefined()
expect(result.amountSats).toBe(10000) // 100 micro-BTC = 10000 sats
expect(result.expiresAt).toBeGreaterThan(Date.now() / 1000)
})
it('should decode invoice with milli-BTC amount', () => {
const client = new LightningPubClient(config)
const result = client.decodeInvoice('lnbc1m1...')
expect(result.amountSats).toBe(100000) // 1 milli-BTC = 100000 sats
})
it('should return null amount for invoice without amount', () => {
const client = new LightningPubClient(config)
const result = client.decodeInvoice('lnbcrt1...')
expect(result.amountSats).toBeNull()
})
})
describe('initialize', () => {
it('should require nostrClient and identity', async () => {
const client = new LightningPubClient(config)
// Without initialization, operations should fail
await expect(client.getBalance()).rejects.toThrow('Client not initialized')
})
})
describe('disconnect', () => {
it('should clean up resources', () => {
const client = new LightningPubClient(config)
// Should not throw
expect(() => client.disconnect()).not.toThrow()
})
})
})

View file

@ -1,824 +0,0 @@
/**
* Lightning.Pub Client
*
* Client for interacting with Lightning.Pub - a Nostr-native
* account system that wraps LND.
*
* Lightning.Pub provides:
* - Account management
* - Invoice generation via RPC (kind 21000)
* - Payment processing via RPC (kind 21000)
* - CLINK protocol support (kinds 21001-21003)
*
* This client handles the RPC layer. For CLINK payments,
* use @bitSpire/clink.
*/
import type { Event, UnsignedEvent } from 'nostr-tools'
import { finalizeEvent } from 'nostr-tools'
import type { MachineIdentity, NostrClient } from '@bitSpire/nostr-client'
import { encryptContent, decryptJSON } from '@bitSpire/nostr-client'
import {
LightningPubEventKind,
type LightningPubConfig,
type RPCRequest,
type RPCResponse,
type AccountInfo,
type BalanceResponse,
type Invoice,
type CreateInvoiceParams,
type CreateInvoiceResponse,
type LookupInvoiceResponse,
type PaymentResult,
type PayInvoiceResponse,
type ExchangeRate,
type InvoiceCallback,
type LnurlLinkResponse,
type CreateWithdrawLinkParams,
type CreateWithdrawLinkResponse,
type GetWithdrawLinkResponse,
type DeleteWithdrawLinkResponse,
type UpdateWithdrawLinkParams,
type UpdateWithdrawLinkResponse,
isRPCError,
} from './types.js'
/**
* Lightning.Pub client for ATM operations
*
* Uses Kind 21000 for RPC communication with Lightning.Pub service.
*/
export class LightningPubClient {
private config: Required<LightningPubConfig>
private nostrClient: NostrClient | null = null
private identity: MachineIdentity | null = null
private invoiceCallbacks: Map<string, InvoiceCallback> = new Map()
private subscriptionId?: string
private requestCounter = 0
constructor(config: LightningPubConfig) {
this.config = {
timeout: 30000,
appId: 'lamassu-atm',
...config,
}
}
/**
* Initialize the client with Nostr connection
*/
initialize(nostrClient: NostrClient, identity: MachineIdentity): void {
this.nostrClient = nostrClient
this.identity = identity
this.startListening()
}
/**
* Get account information
*/
async getAccountInfo(): Promise<AccountInfo> {
const response = await this.sendRPC<AccountInfo>('GetInfo', {})
return {
pubkey: response.pubkey ?? this.config.accountPubkey,
balanceSats: response.balanceSats ?? 0,
maxSendSats: response.maxSendSats ?? 0,
maxReceiveSats: response.maxReceiveSats ?? 0,
}
}
/**
* Get current balance via GetUserInfo RPC
*/
async getBalance(): Promise<{ balanceSats: number }> {
const response = await this.sendRPC<BalanceResponse>('GetUserInfo', {})
return { balanceSats: response.balance ?? 0 }
}
/**
* Subscribe to balance updates via LiveUserOperation events + periodic polling.
*
* Uses two mechanisms for reliability:
* 1. Nostr subscription for instant updates (LiveUserOperation events)
* 2. Periodic RPC poll to catch changes from extension payments
* (e.g. LNURL-withdraw callbacks which don't emit LiveUserOperation)
*
* @param callback - Called with the new balance whenever it changes
* @param pollIntervalMs - Polling interval in ms (default: 5000)
* @returns Cleanup function to stop watching
*/
watchBalance(callback: (balanceSats: number) => void, pollIntervalMs = 5000): () => void {
if (!this.nostrClient || !this.identity) {
console.error('[LightningPub] Cannot watch balance: client not initialized')
return () => {}
}
let lastKnownBalance: number | null = null
// 1. Nostr subscription for instant updates (LiveUserOperation events)
const subId = this.nostrClient.subscribe(
[
{
kinds: [LightningPubEventKind.RPC],
authors: [this.config.accountPubkey],
'#p': [this.identity.publicKey],
since: Math.floor(Date.now() / 1000) - 5,
},
],
{
onEvent: (event) => {
try {
const response = decryptJSON<{
requestId: string
latest_balance?: number
}>(this.identity!, this.config.accountPubkey, event.content)
if (
response.requestId === 'GetLiveUserOperations' &&
response.latest_balance !== undefined
) {
lastKnownBalance = response.latest_balance
callback(response.latest_balance)
}
} catch {
// Decryption failed - not for us
}
},
}
)
// 2. Periodic poll as fallback
const pollId = setInterval(async () => {
try {
const { balanceSats } = await this.getBalance()
if (lastKnownBalance !== balanceSats) {
lastKnownBalance = balanceSats
callback(balanceSats)
}
} catch {
// Silently ignore poll failures
}
}, pollIntervalMs)
return () => {
this.nostrClient?.unsubscribe(subId)
clearInterval(pollId)
}
}
/**
* Link this Nostr pubkey to an app user via token
* This connects the Nostr identity to the app user's balance
*/
async linkNpub(token: string): Promise<void> {
console.log('[LightningPub] Linking npub with token:', token.slice(0, 16) + '...')
await this.sendRPC<Record<string, never>>('LinkNPubThroughToken', { token })
console.log('[LightningPub] Successfully linked npub to app user')
}
/**
* Create a Lightning invoice
*/
async createInvoice(params: CreateInvoiceParams): Promise<Invoice> {
console.log('[LightningPub] Creating invoice for', params.amountSats, 'sats')
const response = await this.sendRPC<CreateInvoiceResponse>('NewInvoice', {
amountSats: params.amountSats,
memo: params.description || 'ATM Payment',
expiry: params.expirySecs || 3600,
private: params.privateHints ?? false,
})
console.log('[LightningPub] NewInvoice response:', JSON.stringify(response))
if (!response || !response.invoice) {
console.error('[LightningPub] Invalid response - missing invoice')
throw new Error('Invalid invoice response from Lightning.Pub')
}
// Extract payment hash from the invoice if not provided
const decoded = this.decodeInvoice(response.invoice)
return {
paymentRequest: response.invoice,
paymentHash: response.payment_hash || decoded.paymentHash,
amountSats: params.amountSats,
description: params.description,
createdAt: Math.floor(Date.now() / 1000),
expiresAt: response.expires_at || decoded.expiresAt,
status: 'pending',
}
}
/**
* Pay a Lightning invoice
*
* @param paymentRequest - BOLT11 invoice to pay
* @param amountSats - Amount to pay in satoshis. Required for amountless invoices.
* If provided, this overrides any amount in the invoice.
*/
async payInvoice(paymentRequest: string, amountSats?: number): Promise<PaymentResult> {
try {
// Decode invoice to check if it has an embedded amount
const decoded = this.decodeInvoice(paymentRequest)
const invoiceAmount = decoded.amountSats
// Determine the amount to send
// Lightning.Pub requires 'amount' field always:
// - For invoices with amounts: use 0 (meaning "use invoice amount")
// - For amountless invoices: use the calculated amount
let payAmount: number
if (invoiceAmount !== null && invoiceAmount > 0) {
// Invoice has amount - verify it doesn't exceed calculated sats
if (amountSats !== undefined && invoiceAmount > amountSats) {
return {
success: false,
error: `Invoice amount (${invoiceAmount} sats) exceeds available amount (${amountSats} sats)`,
}
}
// Use 0 to indicate "pay the invoice amount"
payAmount = 0
} else {
// Amountless invoice - must provide amount
if (!amountSats) {
return {
success: false,
error: 'Amount required for amountless invoice',
}
}
payAmount = amountSats
}
// Build request body - amount is always required
const body: Record<string, unknown> = {
invoice: paymentRequest,
amount: payAmount,
}
const response = await this.sendRPC<PayInvoiceResponse>('PayInvoice', body)
return {
success: true,
preimage: response.preimage,
feeSats: response.fee_paid,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Payment failed',
}
}
}
/**
* Get an LNURL-withdraw link
*
* Lightning.Pub hosts the LNURL-withdraw endpoint. Returns a bech32-encoded
* LNURL that wallets can scan to withdraw funds.
*
* The k1 is a secret that identifies this specific withdrawal request.
*/
async getLnurlWithdrawLink(): Promise<LnurlLinkResponse> {
const response = await this.sendRPC<LnurlLinkResponse>('GetLnurlWithdrawLink', {})
return {
k1: response.k1,
lnurl: response.lnurl,
}
}
/**
* Create an LNURL-withdraw link via the withdraw extension
*
* This uses Nostr RPC (NIP-44 encrypted) to create a withdraw link,
* avoiding the need for HTTP and SSL certificates.
*
* The extension pays the invoice from the linked user's balance
* when a wallet claims the withdraw.
*/
async createWithdrawLink(params: CreateWithdrawLinkParams): Promise<CreateWithdrawLinkResponse> {
console.log('[LightningPub] Creating withdraw link via RPC:', params.title)
const response = await this.sendRPC<CreateWithdrawLinkResponse>('withdraw.createLink', {
title: params.title,
min_withdrawable: params.min_withdrawable,
max_withdrawable: params.max_withdrawable,
uses: params.uses ?? 1,
wait_time: params.wait_time ?? 0,
})
console.log('[LightningPub] Withdraw link created:', response.link?.unique_hash)
return response
}
/**
* Get a withdraw link's status via the withdraw extension
*
* Uses Nostr RPC (NIP-44 encrypted) to check if a withdraw link
* has been claimed/spent.
*/
async getWithdrawLink(uniqueHash: string): Promise<GetWithdrawLinkResponse> {
const response = await this.sendRPC<GetWithdrawLinkResponse>('withdraw.getLink', {
unique_hash: uniqueHash,
})
return response
}
/**
* Delete a withdraw link via the withdraw extension
*
* Permanently removes the link so it can no longer be claimed.
* Uses the link's `id` (not `unique_hash`).
*/
async deleteWithdrawLink(linkId: string): Promise<DeleteWithdrawLinkResponse> {
console.log('[LightningPub] Deleting withdraw link:', linkId)
const response = await this.sendRPC<DeleteWithdrawLinkResponse>('withdraw.deleteLink', {
id: linkId,
})
console.log('[LightningPub] Withdraw link deleted:', linkId)
return response
}
/**
* Update a withdraw link via the withdraw extension
*
* Can modify title, amounts, uses, or wait_time.
* Uses the link's `id` (not `unique_hash`).
*/
async updateWithdrawLink(params: UpdateWithdrawLinkParams): Promise<UpdateWithdrawLinkResponse> {
console.log('[LightningPub] Updating withdraw link:', params.id)
const response = await this.sendRPC<UpdateWithdrawLinkResponse>('withdraw.updateLink', params)
return response
}
/**
* Get payment state for an invoice
*
* Lightning.Pub uses GetPaymentState with the full invoice string,
* not LookupInvoice with a payment hash.
*/
async getPaymentState(invoice: string): Promise<{ paid: boolean; paidAt?: number } | null> {
try {
console.log('[LightningPub] Getting payment state for invoice:', invoice.slice(0, 32) + '...')
const response = await this.sendRPC<{
amount: number
internal: boolean
network_fee: number
operation_id: string
paid_at_unix: number
service_fee: number
}>('GetPaymentState', {
invoice,
})
console.log('[LightningPub] GetPaymentState response:', JSON.stringify(response))
return {
paid: response.paid_at_unix > 0,
paidAt: response.paid_at_unix > 0 ? response.paid_at_unix : undefined,
}
} catch (error) {
console.error('[LightningPub] GetPaymentState error:', error)
return null
}
}
/**
* Look up an invoice by payment hash
* @deprecated Use getPaymentState(invoice) instead - Lightning.Pub doesn't support LookupInvoice
*/
async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
console.warn('[LightningPub] lookupInvoice is deprecated - use getPaymentState instead')
// This method is kept for backwards compatibility but won't work with Lightning.Pub
return null
}
/**
* Watch for invoice payment using GetLiveUserOperations subscription
*
* Lightning.Pub sends LiveUserOperation events via Nostr when payments
* are received. This is more reliable than polling GetPaymentState
* (which is for outgoing payments, not incoming invoices).
*
* @param invoice - The full BOLT11 invoice string (not payment hash)
* @param callback - Called when payment is detected
* @returns Cleanup function to stop watching
*/
watchInvoice(invoice: string, callback: InvoiceCallback): () => void {
if (!this.nostrClient || !this.identity) {
console.error('[LightningPub] Cannot watch invoice: client not initialized')
return () => {}
}
console.log('[LightningPub] Starting invoice watch for:', invoice.slice(0, 32) + '...')
// Use invoice as key
const invoiceKey = invoice.slice(0, 64)
this.invoiceCallbacks.set(invoiceKey, callback)
// Subscribe to kind 21000 events from Lightning.Pub
// LiveUserOperation events have requestId: "GetLiveUserOperations"
const subId = this.nostrClient.subscribe(
[
{
kinds: [LightningPubEventKind.RPC],
authors: [this.config.accountPubkey],
since: Math.floor(Date.now() / 1000) - 5,
},
],
{
onEvent: (event) => {
// Check if this event is for us (has our pubkey in p tags)
const pTags = event.tags.filter((t: string[]) => t[0] === 'p')
if (!pTags.some((t: string[]) => t[1] === this.identity!.publicKey)) {
return
}
try {
const response = decryptJSON<{
requestId: string
status: string
operation?: {
type: string
identifier: string
amount: number
paidAtUnix: number
inbound: boolean
}
latest_balance?: number
}>(this.identity!, this.config.accountPubkey, event.content)
// Check if this is a LiveUserOperation for incoming invoice
if (response.requestId !== 'GetLiveUserOperations') {
return
}
console.log('[LightningPub] Received LiveUserOperation:', JSON.stringify(response))
if (!response.operation) {
return
}
const op = response.operation
// Check if this is an incoming invoice payment matching our invoice
if (op.type !== 'INCOMING_INVOICE') {
console.log('[LightningPub] Operation type:', op.type, '(not INCOMING_INVOICE)')
return
}
// Match invoice - the identifier is the full invoice string
if (!op.identifier || !op.identifier.toLowerCase().startsWith('ln')) {
console.log('[LightningPub] Invalid identifier:', op.identifier?.slice(0, 20))
return
}
// Compare invoices (case-insensitive, matching prefix)
const opInvoiceKey = op.identifier.slice(0, 64).toLowerCase()
const watchedInvoiceKey = invoice.slice(0, 64).toLowerCase()
if (opInvoiceKey !== watchedInvoiceKey) {
console.log(
'[LightningPub] Invoice mismatch, waiting for:',
watchedInvoiceKey.slice(0, 20)
)
return
}
console.log('[LightningPub] Invoice payment detected! Amount:', op.amount, 'sats')
// Clean up subscription
this.nostrClient!.unsubscribe(subId)
this.invoiceCallbacks.delete(invoiceKey)
// Extract payment hash from invoice for the callback
const decoded = this.decodeInvoice(invoice)
callback({
paymentRequest: invoice,
paymentHash: decoded.paymentHash,
amountSats: op.amount,
description: decoded.description,
createdAt: op.paidAtUnix - 60, // approximate creation time
expiresAt: decoded.expiresAt,
status: 'paid',
preimage: 'paid-via-live-operation', // LiveUserOperation doesn't include preimage
})
} catch {
// Decryption failed - might not be for us, ignore
}
},
}
)
// Clean up after expiry (10 minutes)
const timeoutId = setTimeout(
() => {
console.log('[LightningPub] Invoice watch timeout, cleaning up')
this.nostrClient?.unsubscribe(subId)
this.invoiceCallbacks.delete(invoiceKey)
},
10 * 60 * 1000
)
// Return cleanup function
return () => {
console.log('[LightningPub] Stopping invoice watch')
clearTimeout(timeoutId)
this.nostrClient?.unsubscribe(subId)
this.invoiceCallbacks.delete(invoiceKey)
}
}
/**
* Stop watching an invoice
*/
unwatchInvoice(paymentHash: string): void {
this.invoiceCallbacks.delete(paymentHash)
}
/**
* Decode a BOLT11 invoice (basic parsing)
*
* For production use, consider using a proper bolt11 library.
*
* BOLT-11 format: ln + network + [amount][multiplier] + 1 + data
* - lnbc1... = mainnet, amountless
* - lnbcrt20u1... = regtest, 20 micro-BTC = 2000 sats
*/
decodeInvoice(paymentRequest: string): {
amountSats: number | null
paymentHash: string
description: string
expiresAt: number
} {
const invoice = paymentRequest.toLowerCase()
// Match amount BEFORE the '1' separator
// Group 1: amount (optional), Group 2: multiplier (optional)
const amountMatch = invoice.match(/ln(?:bc|tb|bcrt)(\d+)?([munp])?1/)
let amountSats: number | null = null
if (amountMatch && amountMatch[1]) {
const [, amount, multiplier] = amountMatch
const baseAmount = parseInt(amount ?? '0', 10)
switch (multiplier) {
case 'm':
amountSats = baseAmount * 100_000 // milli-BTC to sats
break
case 'u':
amountSats = baseAmount * 100 // micro-BTC to sats
break
case 'n':
amountSats = Math.floor(baseAmount / 10) // nano-BTC to sats
break
case 'p':
amountSats = Math.floor(baseAmount / 10_000) // pico-BTC to sats
break
default:
amountSats = baseAmount * 100_000_000 // BTC to sats
}
}
// Extract payment hash from tagged data
// Data section starts after '1', first 7 chars are timestamp
// Then tagged fields: type (1 char) + length (2 chars) + data
// Payment hash tag type is 'p' (value 1), length is typically 'p5' (52 5-bit chars = 32 bytes)
let paymentHash = ''
const separatorIdx = invoice.lastIndexOf('1')
if (separatorIdx > 0) {
const data = invoice.slice(separatorIdx + 1)
// Skip timestamp (7 chars), look for payment hash tag
const afterTimestamp = data.slice(7)
// Find 'p' tag (payment hash) - it's usually the first tag
if (afterTimestamp.startsWith('pp')) {
// pp = type 'p' + length starts with 'p'
// Length is 2 chars after type: positions 1-2
// Data starts at position 3
const hashData = afterTimestamp.slice(3, 3 + 52) // 52 5-bit chars = 260 bits for 256-bit hash
paymentHash = this.bech32ToHex(hashData)
}
}
return {
amountSats,
paymentHash,
description: '',
expiresAt: Math.floor(Date.now() / 1000) + 3600,
}
}
/**
* Convert bech32-encoded 5-bit values to hex string
*/
private bech32ToHex(data: string): string {
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
// Convert each char to 5-bit value
const bits: number[] = []
for (const char of data) {
const idx = BECH32_CHARSET.indexOf(char)
if (idx === -1) return ''
bits.push(idx)
}
// Combine 5-bit values into 8-bit bytes
let buffer = 0
let bufferBits = 0
const bytes: number[] = []
for (const value of bits) {
buffer = (buffer << 5) | value
bufferBits += 5
if (bufferBits >= 8) {
bufferBits -= 8
bytes.push((buffer >> bufferBits) & 0xff)
}
}
// Convert to hex
return bytes.map((b) => b.toString(16).padStart(2, '0')).join('')
}
/**
* Get exchange rate (mock implementation)
*
* For production, integrate with price feeds or Lightning.Pub's rate service.
*/
async getExchangeRate(currency: string): Promise<ExchangeRate> {
// Mock rates - in production, fetch from service
const mockRates: Record<string, number> = {
USD: 2500, // sats per dollar (example)
EUR: 2700,
GBP: 3100,
}
return {
currency,
satsPerUnit: mockRates[currency] ?? 2500,
timestamp: Date.now(),
source: 'mock',
}
}
/**
* Start listening for RPC responses
*/
private startListening(): void {
if (!this.nostrClient || !this.identity) return
if (this.subscriptionId) return
this.subscriptionId = this.nostrClient.subscribe(
[
{
kinds: [LightningPubEventKind.RPC],
'#p': [this.identity.publicKey],
authors: [this.config.accountPubkey],
},
],
{
onEvent: (event) => this.handleResponse(event),
}
)
}
/**
* Handle incoming response events
*/
private handleResponse(_event: Event): void {
// Responses are handled by waitForResponse
// This is for async notifications if needed
}
/**
* Send an RPC request to Lightning.Pub
*/
private async sendRPC<T>(rpcName: string, body: unknown): Promise<T> {
if (!this.nostrClient || !this.identity) {
throw new Error('Client not initialized')
}
const requestId = this.generateRequestId()
// Lightning.Pub expects: rpcName, params, query, body, authIdentifier, requestId, appId
// The appId is critical for ensuring the Nostr user is created under the same app
// as HTTP API users, so they share the same balance
const request: RPCRequest = {
rpcName,
params: {},
query: {},
body: body as Record<string, unknown>,
authIdentifier: this.identity.publicKey,
requestId,
appId: this.config.appId,
}
const content = encryptContent(this.identity, this.config.accountPubkey, request)
// finalizeEvent derives pubkey from the secret key
const event = finalizeEvent(
{
kind: LightningPubEventKind.RPC,
content,
tags: [['p', this.config.accountPubkey]],
created_at: Math.floor(Date.now() / 1000),
},
this.identity.privateKey
)
// IMPORTANT: Set up subscription BEFORE publishing to avoid race condition
// Lightning.Pub can respond very fast, so we need to be listening first
const responsePromise = this.waitForResponse<T>(requestId)
await this.nostrClient.publish(event)
return responsePromise
}
/**
* Wait for a response to a specific request
*
* Note: Lightning.Pub doesn't include an 'e' tag referencing the request event,
* so we filter by '#p' and match on requestId in the decrypted content.
*/
private waitForResponse<T>(requestId: string): Promise<T> {
return new Promise((resolve, reject) => {
if (!this.nostrClient || !this.identity) {
reject(new Error('Client not initialized'))
return
}
const timeout = setTimeout(() => {
this.nostrClient!.unsubscribe(subId)
reject(new Error('Request timeout'))
}, this.config.timeout)
// Subscribe to ALL RPC events from Lightning.Pub
// We filter by #p tag manually because some relays don't support tag filters well
const subId = this.nostrClient.subscribe(
[
{
kinds: [LightningPubEventKind.RPC],
authors: [this.config.accountPubkey],
since: Math.floor(Date.now() / 1000) - 5,
},
],
{
onEvent: (event) => {
// Check if this event is for us (has our pubkey in p tags)
const pTags = event.tags.filter((t: string[]) => t[0] === 'p')
if (!pTags.some((t: string[]) => t[1] === this.identity!.publicKey)) {
return
}
try {
const response = decryptJSON<RPCResponse<T>>(
this.identity!,
this.config.accountPubkey,
event.content
)
// Match on requestId to find our response
if (response.requestId !== requestId) {
return // Not our response, keep waiting
}
clearTimeout(timeout)
this.nostrClient!.unsubscribe(subId)
if (isRPCError(response)) {
reject(new Error(response.reason))
} else {
// Extract result from response (excluding status and requestId)
const { status, requestId: _rid, ...result } = response
resolve(result as T)
}
} catch {
// Decryption failed - might not be for us, ignore
}
},
}
)
})
}
/**
* Generate a unique request ID
*/
private generateRequestId(): string {
this.requestCounter++
return `${Date.now()}-${this.requestCounter}`
}
/**
* Disconnect and clean up
*/
disconnect(): void {
if (this.subscriptionId && this.nostrClient) {
this.nostrClient.unsubscribe(this.subscriptionId)
this.subscriptionId = undefined
}
this.invoiceCallbacks.clear()
this.nostrClient = null
this.identity = null
}
}

View file

@ -1,88 +0,0 @@
/**
* @bitSpire/lightning
*
* Lightning.Pub client for Nostr-native Lightning operations.
*
* Lightning.Pub is an account system that wraps LND and provides
* CLINK protocol support. This package handles the RPC layer:
* - Invoice generation via Kind 21000
* - Payment processing via Kind 21000
* - Balance queries
* - Exchange rate fetching
*
* For CLINK payment operations (offers, debits, management),
* use @bitSpire/clink instead.
*
* @example
* ```typescript
* import { LightningPubClient } from '@bitSpire/lightning'
*
* const client = new LightningPubClient({
* accountPubkey: 'hex-pubkey...',
* relays: ['wss://relay.example.com'],
* })
*
* // Initialize with Nostr client
* client.initialize(nostrClient, machineIdentity)
*
* // Create an invoice
* const invoice = await client.createInvoice({
* amountSats: 100,
* description: 'ATM withdrawal',
* })
*
* // Watch for payment
* client.watchInvoice(invoice.paymentHash, (paid) => {
* console.log('Invoice paid:', paid.preimage)
* })
*
* // Pay an invoice
* const result = await client.payInvoice('lnbc...')
* if (result.success) {
* console.log('Paid with preimage:', result.preimage)
* }
* ```
*/
// Client
export { LightningPubClient } from './client.js'
// Types
export {
// Event kinds
LightningPubEventKind,
// RPC types
type RPCRequest,
type RPCResponse,
type RPCSuccessResponse,
type RPCErrorResponse,
isRPCError,
// Account types
type AccountInfo,
type BalanceResponse,
// Invoice types
type Invoice,
type InvoiceStatus,
type CreateInvoiceParams,
type CreateInvoiceResponse,
type LookupInvoiceResponse,
// Payment types
type PaymentResult,
type PayInvoiceResponse,
// LNURL types
type LnurlLinkResponse,
type WithdrawLink,
type CreateWithdrawLinkParams,
type CreateWithdrawLinkResponse,
type GetWithdrawLinkResponse,
type DeleteWithdrawLinkResponse,
type UpdateWithdrawLinkParams,
type UpdateWithdrawLinkResponse,
// Exchange types
type ExchangeRate,
// Config
type LightningPubConfig,
// Callbacks
type InvoiceCallback,
type PaymentCallback,
} from './types.js'

View file

@ -1,306 +0,0 @@
/**
* Lightning.Pub client type definitions
*
* Lightning.Pub is a Nostr-native account system that wraps LND
* and provides CLINK payment support.
*
* Communication uses Kind 21000 for generic RPC requests.
* Payment operations use CLINK protocol (kinds 21001-21003).
*/
/** Lightning.Pub event kinds */
export enum LightningPubEventKind {
/** Generic RPC request/response */
RPC = 21000,
}
// ============================================================================
// RPC Request/Response
// ============================================================================
/** Generic RPC request format (Kind 21000) */
export interface RPCRequest {
/** Method name (e.g., "NewInvoice", "GetBalance") */
rpcName: string
/** URL params (empty object for most methods) */
params: Record<string, string>
/** Query params (empty object for most methods) */
query: Record<string, string>
/** Request body containing method-specific parameters */
body: Record<string, unknown>
/** Pubkey of requester (for validation) */
authIdentifier: string
/** Unique request identifier */
requestId: string
/** Application ID - ensures Nostr user is created under the same app as HTTP API users */
appId?: string
}
/** RPC success response */
export interface RPCSuccessResponse<T = unknown> {
/** Success status */
status: 'OK'
/** Request ID echoed back */
requestId: string
/** Result data - spread into the response object */
[key: string]: unknown
}
/** RPC error response */
export interface RPCErrorResponse {
/** Error status */
status: 'ERROR'
/** Request ID echoed back */
requestId: string
/** Error reason */
reason: string
}
/** RPC response - either success or error */
export type RPCResponse<T = unknown> = (RPCSuccessResponse<T> & T) | RPCErrorResponse
/** Type guard for RPC error */
export function isRPCError(response: RPCResponse): response is RPCErrorResponse {
return response.status === 'ERROR'
}
// ============================================================================
// Account & Balance
// ============================================================================
/** Lightning.Pub account information */
export interface AccountInfo {
/** Account public key */
pubkey: string
/** Account balance in satoshis */
balanceSats: number
/** Maximum send amount */
maxSendSats: number
/** Maximum receive amount */
maxReceiveSats: number
}
/** Balance response */
export interface BalanceResponse {
/** Balance in satoshis */
balance: number
}
// ============================================================================
// Invoices
// ============================================================================
/** Invoice status */
export type InvoiceStatus = 'pending' | 'paid' | 'expired' | 'cancelled'
/** Invoice details */
export interface Invoice {
/** BOLT11 payment request */
paymentRequest: string
/** Payment hash */
paymentHash: string
/** Amount in satoshis */
amountSats: number
/** Invoice description */
description?: string
/** Creation timestamp */
createdAt: number
/** Expiry timestamp */
expiresAt: number
/** Current status */
status: InvoiceStatus
/** Payment preimage (if paid) */
preimage?: string
}
/** Create invoice request params */
export interface CreateInvoiceParams {
/** Amount in satoshis */
amountSats: number
/** Invoice description/memo */
description?: string
/** Expiry in seconds (default: 3600) */
expirySecs?: number
/** Include private route hints */
privateHints?: boolean
}
/** Create invoice response (from RPC) */
export interface CreateInvoiceResponse {
/** BOLT-11 invoice string */
invoice: string
/** Payment hash (hex) - may not be returned, extract from invoice if needed */
payment_hash?: string
/** Expiry timestamp - may not be returned */
expires_at?: number
}
/** Lookup invoice response (from RPC) */
export interface LookupInvoiceResponse {
/** BOLT-11 payment request */
payment_request: string
/** Amount in satoshis */
amount: number
/** Invoice description */
description: string
/** Creation timestamp */
created_at: number
/** Expiry timestamp */
expires_at: number
/** Whether invoice has been paid */
settled: boolean
/** Payment preimage (if paid) */
preimage?: string
}
// ============================================================================
// Payments
// ============================================================================
/** Payment result */
export interface PaymentResult {
/** Whether payment succeeded */
success: boolean
/** Payment preimage (proof of payment) */
preimage?: string
/** Fee paid in satoshis */
feeSats?: number
/** Error message if failed */
error?: string
}
/** Pay invoice response (from RPC) */
export interface PayInvoiceResponse {
/** Payment preimage */
preimage: string
/** Fee paid in satoshis */
fee_paid?: number
}
// ============================================================================
// LNURL
// ============================================================================
/** LNURL-withdraw link response (from GetLnurlWithdrawLink) */
export interface LnurlLinkResponse {
/** Secret k1 for the LNURL-withdraw */
k1: string
/** Bech32-encoded LNURL string */
lnurl: string
}
/** Parameters for creating a withdraw link via extension RPC */
export interface CreateWithdrawLinkParams {
/** Title/description for the withdraw link */
title: string
/** Minimum withdrawable amount in sats */
min_withdrawable: number
/** Maximum withdrawable amount in sats */
max_withdrawable: number
/** Number of times the link can be used (default: 1) */
uses?: number
/** Minimum time between uses in seconds (default: 0) */
wait_time?: number
}
/** Withdraw link fields shared across responses */
export interface WithdrawLink {
/** Link ID (used for update/delete operations) */
id: string
/** Unique hash identifier (used in LNURL URLs) */
unique_hash: string
/** Bech32-encoded LNURL string */
lnurl: string
/** Title of the link */
title: string
/** Minimum withdrawable in sats */
min_withdrawable: number
/** Maximum withdrawable in sats */
max_withdrawable: number
/** Number of uses allowed */
uses: number
/** Number of times used */
used?: number
/** Wait time between uses */
wait_time: number
/** Whether the link has been fully used */
is_spent?: boolean
}
/** Response from withdraw.createLink RPC */
export interface CreateWithdrawLinkResponse {
link: WithdrawLink
}
/** Response from withdraw.getLink RPC */
export interface GetWithdrawLinkResponse {
link: WithdrawLink
}
/** Response from withdraw.deleteLink RPC */
export interface DeleteWithdrawLinkResponse {
success: boolean
}
/** Parameters for withdraw.updateLink RPC */
export interface UpdateWithdrawLinkParams {
/** Link ID to update */
id: string
/** New title */
title?: string
/** New minimum withdrawable in sats */
min_withdrawable?: number
/** New maximum withdrawable in sats */
max_withdrawable?: number
/** New number of uses (cannot reduce below current used count) */
uses?: number
/** New wait time between uses */
wait_time?: number
}
/** Response from withdraw.updateLink RPC */
export interface UpdateWithdrawLinkResponse {
link: WithdrawLink
}
// ============================================================================
// Exchange Rates
// ============================================================================
/** Exchange rate information */
export interface ExchangeRate {
/** Fiat currency code */
currency: string
/** Satoshis per fiat unit */
satsPerUnit: number
/** Timestamp of rate */
timestamp: number
/** Source of rate */
source: string
}
// ============================================================================
// Client Configuration
// ============================================================================
/** Lightning.Pub client configuration */
export interface LightningPubConfig {
/** Lightning.Pub account pubkey */
accountPubkey: string
/** Nostr relays for communication */
relays: string[]
/** Request timeout in ms (default: 30000) */
timeout?: number
/** Application identifier */
appId?: string
}
// ============================================================================
// Callbacks
// ============================================================================
/** Invoice callback for watching payments */
export type InvoiceCallback = (invoice: Invoice) => void
/** Payment status update callback */
export type PaymentCallback = (result: PaymentResult) => void

View file

@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View file

@ -1,8 +0,0 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
globals: false,
},
})

25
pnpm-lock.yaml generated
View file

@ -29,9 +29,6 @@ importers:
'@bitSpire/hal':
specifier: workspace:*
version: link:../../packages/hal
'@bitSpire/lightning':
specifier: workspace:*
version: link:../../packages/lightning
'@bitSpire/lnbits':
specifier: workspace:*
version: link:../../packages/lnbits
@ -187,28 +184,6 @@ importers:
specifier: ^2.0.0
version: 2.1.9(@types/node@22.19.7)(lightningcss@1.30.2)
packages/lightning:
dependencies:
'@bitSpire/clink':
specifier: workspace:*
version: link:../clink
'@bitSpire/nostr-client':
specifier: workspace:*
version: link:../nostr-client
nostr-tools:
specifier: ^2.10.0
version: 2.19.4(typescript@5.9.3)
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.7
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^2.1.0
version: 2.1.9(@types/node@22.19.7)(lightningcss@1.30.2)
packages/lnbits:
dependencies:
'@bitSpire/nostr-client':

View file

@ -25,7 +25,6 @@
"@bitSpire/nostr-client": ["./packages/nostr-client/src"],
"@bitSpire/clink": ["./packages/clink/src"],
"@bitSpire/state-machine": ["./packages/state-machine/src"],
"@bitSpire/lightning": ["./packages/lightning/src"],
"@bitSpire/lnbits": ["./packages/lnbits/src"],
"@bitSpire/cashu": ["./packages/cashu/src"],
"@bitSpire/ui-shared": ["./packages/ui-shared/src"],