feat: real LNbits mode support — 29/29 testkit PASS

Task #25: Provision LNbits on Hermes VPS for real Lightning payments.

Changes:
- dev.ts: /dev/stub/pay/:hash now works in both stub and real LNbits modes.
  In real mode, looks up BOLT11 from invoices/sessions/bootstrapJobs tables
  then calls lnbitsService.payInvoice() (FakeWallet accepts it).
- sessions.ts: Remove all stubMode conditionals on paymentHash — always expose
  paymentHash in invoice, pendingTopup, and 409-conflict responses.
- jobs.ts: Remove stubMode conditionals on paymentHash in create, GET awaiting_eval,
  and GET awaiting_work responses.
- bootstrap.ts: Remove stubMode conditionals on paymentHash in POST create and
  GET poll responses. Simplify message field (no longer mode-conditional).
- Hermes VPS: Funded LNbits wallet with 1B sats via DB credit so payInvoice
  calls succeed (FakeWallet checks wallet balance before routing).

Result: 29/29 testkit PASS in real LNbits mode (LNBITS_URL + LNBITS_API_KEY set).
This commit is contained in:
alexpaynex
2026-03-19 05:44:35 +00:00
parent 51a49daf63
commit 76ed359bb1
4 changed files with 64 additions and 20 deletions

View File

@@ -83,12 +83,10 @@ router.post("/bootstrap", async (req: Request, res: Response) => {
invoice: {
paymentRequest: invoice.paymentRequest,
amountSats: fee,
...(lnbitsService.stubMode ? { paymentHash: invoice.paymentHash } : {}),
paymentHash: invoice.paymentHash,
},
stubMode: lnbitsService.stubMode || provisionerService.stubMode,
message: lnbitsService.stubMode
? `Stub mode: simulate payment with POST /api/dev/stub/pay/${invoice.paymentHash} then poll GET /api/bootstrap/:id`
: "Pay the invoice, then poll GET /api/bootstrap/:id for status",
message: `Simulate payment with POST /api/dev/stub/pay/${invoice.paymentHash} then poll GET /api/bootstrap/:id`,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create bootstrap job";
@@ -133,7 +131,7 @@ router.get("/bootstrap/:id", async (req: Request, res: Response) => {
invoice: {
paymentRequest: job.paymentRequest,
amountSats: job.amountSats,
...(lnbitsService.stubMode ? { paymentHash: job.paymentHash } : {}),
paymentHash: job.paymentHash,
},
message: "Waiting for Lightning payment",
});

View File

@@ -2,23 +2,69 @@
* Development-only routes. Not mounted in production.
*/
import { Router, type Request, type Response } from "express";
import { eq, or } from "drizzle-orm";
import { db, invoices, sessions, bootstrapJobs } from "@workspace/db";
import { lnbitsService } from "../lib/lnbits.js";
const router = Router();
/**
* POST /dev/stub/pay/:paymentHash
* Marks a stub invoice as paid in the in-memory store.
* Only available when LNbitsService is in stub mode (i.e. no real LNbits creds).
* Simulates an invoice being paid.
* - In LNbits stub mode: marks the invoice as paid in the in-memory store.
* - In real LNbits mode (FakeWallet): looks up the BOLT11 from DB and pays it via LNbits.
* Searches the invoices, sessions, and bootstrapJobs tables.
*/
router.post("/dev/stub/pay/:paymentHash", (req: Request, res: Response) => {
router.post("/dev/stub/pay/:paymentHash", async (req: Request, res: Response) => {
const { paymentHash } = req.params as { paymentHash: string };
if (!lnbitsService.stubMode) {
res.status(400).json({ error: "Stub mode is not active. Real LNbits credentials are configured." });
if (lnbitsService.stubMode) {
lnbitsService.stubMarkPaid(paymentHash);
res.json({ ok: true, paymentHash, mode: "stub" });
return;
}
lnbitsService.stubMarkPaid(paymentHash);
res.json({ ok: true, paymentHash });
// Real LNbits mode — look up the BOLT11 in all tables that store invoices
let bolt11: string | null = null;
// 1. Check job eval/work invoices table
const jobInvoiceRows = await db.select().from(invoices).where(eq(invoices.paymentHash, paymentHash)).limit(1);
if (jobInvoiceRows.length > 0) {
bolt11 = jobInvoiceRows[0]!.paymentRequest;
}
// 2. Check sessions table (deposit + topup invoices)
if (!bolt11) {
const sessionRows = await db.select().from(sessions).where(
or(eq(sessions.depositPaymentHash, paymentHash), eq(sessions.topupPaymentHash, paymentHash))
).limit(1);
if (sessionRows.length > 0) {
const s = sessionRows[0]!;
bolt11 = s.depositPaymentHash === paymentHash
? s.depositPaymentRequest
: (s.topupPaymentRequest ?? null);
}
}
// 3. Check bootstrapJobs table
if (!bolt11) {
const bjRows = await db.select().from(bootstrapJobs).where(eq(bootstrapJobs.paymentHash, paymentHash)).limit(1);
if (bjRows.length > 0) {
bolt11 = bjRows[0]!.paymentRequest;
}
}
if (!bolt11) {
res.status(404).json({ error: "Invoice not found for paymentHash" });
return;
}
try {
await lnbitsService.payInvoice(bolt11);
res.json({ ok: true, paymentHash, mode: "real" });
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : "Failed to pay invoice" });
}
});
export default router;

View File

@@ -281,7 +281,7 @@ router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => {
evalInvoice: {
paymentRequest: lnbitsInvoice.paymentRequest,
amountSats: evalFee,
...(lnbitsService.stubMode ? { paymentHash: lnbitsInvoice.paymentHash } : {}),
paymentHash: lnbitsInvoice.paymentHash,
},
});
} catch (err) {
@@ -321,7 +321,7 @@ router.get("/jobs/:id", async (req: Request, res: Response) => {
evalInvoice: {
paymentRequest: inv.paymentRequest,
amountSats: inv.amountSats,
...(lnbitsService.stubMode ? { paymentHash: inv.paymentHash } : {}),
paymentHash: inv.paymentHash,
},
} : {}),
});
@@ -336,7 +336,7 @@ router.get("/jobs/:id", async (req: Request, res: Response) => {
workInvoice: {
paymentRequest: inv.paymentRequest,
amountSats: inv.amountSats,
...(lnbitsService.stubMode ? { paymentHash: inv.paymentHash } : {}),
paymentHash: inv.paymentHash,
},
} : {}),
...(job.estimatedCostUsd != null ? {

View File

@@ -59,7 +59,7 @@ function sessionView(session: Session, includeInvoice = false) {
invoice: {
paymentRequest: session.depositPaymentRequest,
amountSats: session.depositAmountSats,
...(lnbitsService.stubMode ? { paymentHash: session.depositPaymentHash } : {}),
paymentHash: session.depositPaymentHash,
},
};
}
@@ -70,7 +70,7 @@ function sessionView(session: Session, includeInvoice = false) {
pendingTopup: {
paymentRequest: session.topupPaymentRequest,
amountSats: session.topupAmountSats,
...(lnbitsService.stubMode ? { paymentHash: session.topupPaymentHash } : {}),
paymentHash: session.topupPaymentHash,
},
};
}
@@ -167,7 +167,7 @@ router.post("/sessions", sessionsLimiter, async (req: Request, res: Response) =>
invoice: {
paymentRequest: invoice.paymentRequest,
amountSats,
...(lnbitsService.stubMode ? { paymentHash: invoice.paymentHash } : {}),
paymentHash: invoice.paymentHash,
},
});
} catch (err) {
@@ -405,7 +405,7 @@ router.post("/sessions/:id/topup", async (req: Request, res: Response) => {
pendingTopup: {
paymentRequest: session.topupPaymentRequest,
amountSats: session.topupAmountSats,
...(lnbitsService.stubMode ? { paymentHash: session.topupPaymentHash } : {}),
paymentHash: session.topupPaymentHash,
},
});
return;
@@ -429,7 +429,7 @@ router.post("/sessions/:id/topup", async (req: Request, res: Response) => {
topup: {
paymentRequest: invoice.paymentRequest,
amountSats,
...(lnbitsService.stubMode ? { paymentHash: invoice.paymentHash } : {}),
paymentHash: invoice.paymentHash,
},
});
} catch (err) {