import { randomBytes } from "crypto"; import { makeLogger } from "./logger.js"; const logger = makeLogger("lnbits"); export interface LNbitsInvoice { paymentHash: string; paymentRequest: string; } export interface LNbitsConfig { url: string; apiKey: string; } const stubPaidInvoices = new Set(); export class LNbitsService { private readonly url: string; private readonly apiKey: string; readonly stubMode: boolean; constructor(config?: Partial) { this.url = config?.url ?? process.env.LNBITS_URL ?? ""; this.apiKey = config?.apiKey ?? process.env.LNBITS_API_KEY ?? ""; this.stubMode = !this.url || !this.apiKey; if (this.stubMode) { logger.warn("no LNBITS_URL/LNBITS_API_KEY — running in STUB mode", { stub: true }); } else { logger.info("LNbits real mode active", { url: this.url, stub: false }); } } // ── Inbound invoices ───────────────────────────────────────────────────── async createInvoice(amountSats: number, memo: string): Promise { if (this.stubMode) { const paymentHash = randomBytes(32).toString("hex"); const paymentRequest = `lnbcrt${amountSats}u1stub_${paymentHash.slice(0, 16)}`; logger.info("stub invoice created", { amountSats, memo, paymentHash }); return { paymentHash, paymentRequest }; } const response = await fetch(`${this.base()}/api/v1/payments`, { method: "POST", headers: this.headers(), body: JSON.stringify({ out: false, amount: amountSats, memo }), }); if (!response.ok) { const body = await response.text(); throw new Error(`LNbits createInvoice failed (${response.status}): ${body}`); } const data = (await response.json()) as { payment_hash: string; payment_request: string; }; return { paymentHash: data.payment_hash, paymentRequest: data.payment_request }; } async checkInvoicePaid(paymentHash: string): Promise { if (this.stubMode) { return stubPaidInvoices.has(paymentHash); } const response = await fetch( `${this.base()}/api/v1/payments/${paymentHash}`, { method: "GET", headers: this.headers() }, ); if (!response.ok) { const body = await response.text(); throw new Error(`LNbits checkInvoice failed (${response.status}): ${body}`); } const data = (await response.json()) as { paid: boolean }; return data.paid; } // ── Outbound payments (refunds) ────────────────────────────────────────── /** * Decode a BOLT11 invoice and return its amount in satoshis. * Stub mode: parses the embedded amount from our known stub format (lnbcrtXu1stub_...), * or returns null for externally-generated invoices. */ async decodeInvoice(bolt11: string): Promise<{ amountSats: number } | null> { if (this.stubMode) { // Our stub format: lnbcrtNNNu1stub_... where NNN is the amount in sats const m = bolt11.match(/^lnbcrt(\d+)u1stub_/i); if (m) return { amountSats: parseInt(m[1], 10) }; // Unknown format in stub mode — accept blindly (simulated environment) return null; } const response = await fetch(`${this.base()}/api/v1/payments/decode`, { method: "POST", headers: this.headers(), body: JSON.stringify({ data: bolt11 }), }); if (!response.ok) { const body = await response.text(); throw new Error(`LNbits decodeInvoice failed (${response.status}): ${body}`); } const data = (await response.json()) as { amount_msat?: number }; if (!data.amount_msat) return null; return { amountSats: Math.floor(data.amount_msat / 1000) }; } /** * Pay an outgoing BOLT11 invoice (e.g. to return a refund to a user). * Returns the payment hash. * Stub mode: simulates a successful payment. */ async payInvoice(bolt11: string): Promise { if (this.stubMode) { const paymentHash = randomBytes(32).toString("hex"); logger.info("stub outgoing payment", { paymentHash, invoiceType: "outbound" }); return paymentHash; } const response = await fetch(`${this.base()}/api/v1/payments`, { method: "POST", headers: this.headers(), body: JSON.stringify({ out: true, bolt11 }), }); if (!response.ok) { const body = await response.text(); throw new Error(`LNbits payInvoice failed (${response.status}): ${body}`); } const data = (await response.json()) as { payment_hash: string }; return data.payment_hash; } // ── Stub helpers ───────────────────────────────────────────────────────── /** Stub-only: mark an inbound invoice as paid for testing/dev flows. */ stubMarkPaid(paymentHash: string): void { if (!this.stubMode) { throw new Error("stubMarkPaid called on a real LNbitsService instance"); } stubPaidInvoices.add(paymentHash); logger.info("stub invoice marked paid", { paymentHash, invoiceType: "inbound" }); } // ── Private helpers ────────────────────────────────────────────────────── private base(): string { return this.url.replace(/\/$/, ""); } private headers(): Record { return { "Content-Type": "application/json", "X-Api-Key": this.apiKey, }; } } export const lnbitsService = new LNbitsService();