Files
timmy-tower/artifacts/api-server/src/lib/lnbits.ts

164 lines
5.5 KiB
TypeScript

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<string>();
export class LNbitsService {
private readonly url: string;
private readonly apiKey: string;
readonly stubMode: boolean;
constructor(config?: Partial<LNbitsConfig>) {
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 });
}
}
// ── Inbound invoices ─────────────────────────────────────────────────────
async createInvoice(amountSats: number, memo: string): Promise<LNbitsInvoice> {
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<boolean> {
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<string> {
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<string, string> {
return {
"Content-Type": "application/json",
"X-Api-Key": this.apiKey,
};
}
}
export const lnbitsService = new LNbitsService();