diff --git a/src/services/ibex/webhook-config.ts b/src/services/ibex/webhook-config.ts index 17327b7df..38289a507 100644 --- a/src/services/ibex/webhook-config.ts +++ b/src/services/ibex/webhook-config.ts @@ -11,6 +11,7 @@ export const ibexWebhookPaths = { onPay: { invoice: "/pay/invoice", lnurl: "/pay/lnurl/:username", + verify: "/pay/lnurl/verify/:paymentHash", onchain: "/pay/onchain", }, cryptoReceive: { @@ -31,6 +32,7 @@ export const ibexWebhookEndpoints = { onPay: { invoice: endpoint(ibexWebhookPaths.onPay.invoice), lnurl: endpoint(ibexWebhookPaths.onPay.lnurl), + verify: endpoint(ibexWebhookPaths.onPay.verify), onchain: endpoint(ibexWebhookPaths.onPay.onchain), }, cryptoReceive: { diff --git a/src/services/ibex/webhook-server/routes/on-pay.ts b/src/services/ibex/webhook-server/routes/on-pay.ts index 771c2cda3..9f2017b35 100644 --- a/src/services/ibex/webhook-server/routes/on-pay.ts +++ b/src/services/ibex/webhook-server/routes/on-pay.ts @@ -4,11 +4,12 @@ import rateLimitMiddleware from "express-rate-limit" import { WalletsRepository } from "@services/mongoose/wallets" import { ZapRequestModel } from "@services/mongoose/zap-request" +import { LnurlInvoiceModel } from "@services/mongoose/lnurl-invoice" import axios from "axios" import { baseLogger as logger } from "@services/logger" import { AccountsRepository } from "@services/mongoose" import Ibex from "@services/ibex/client" -import { ibexWebhookPaths } from "@services/ibex/webhook-config" +import { ibexWebhookPaths, ibexWebhookEndpoints } from "@services/ibex/webhook-config" import { extractPaymentHashFromBolt11 } from "@utils" import { authenticate, logRequest, validateIbexIp } from "../middleware" @@ -32,6 +33,15 @@ const publicLnurlRateLimit = rateLimitMiddleware({ legacyHeaders: false, }) +// LUD-21 wallets poll verify every 1-2s; a dedicated bucket keeps that +// polling from ever consuming the payment callback's budget (B3). +const verifyRateLimit = rateLimitMiddleware({ + windowMs: 60_000, + limit: 60, + standardHeaders: true, + legacyHeaders: false, +}) + const webhookRateLimit = rateLimitMiddleware({ windowMs: 60_000, limit: 120, @@ -41,6 +51,93 @@ const webhookRateLimit = rateLimitMiddleware({ const paths = ibexWebhookPaths.onPay +const PAYMENT_HASH_RE = /^[0-9a-f]{64}$/i + +// IBEX invoice states: 0 OPEN / 1 SETTLED / 2 CANCEL / 3 ACCEPTED. The +// preimage is proof of payment, so its release is gated on the single strict +// signal (state.id === 1), same as payment-status-checker. +const IBEX_INVOICE_STATE_SETTLED = 1 + +// Settled results are immutable — cache them so wallet polling and +// attacker-supplied hashes don't amplify into repeated IBEX calls. Bounded +// FIFO: at 10k entries the oldest are evicted. +const settledVerifyCache = new Map< + string, + { settled: true; preimage: string | null; pr: string } +>() +const SETTLED_CACHE_MAX = 10_000 + +export const buildVerifyUrl = (paymentHash: string): string => + ibexWebhookEndpoints.onPay.verify.replace(":paymentHash", paymentHash) + +const lnurlVerifyNotFound = (resp: Response) => + // LNURL convention (LUD-06 lineage) is HTTP 200 with a status:ERROR body — + // some wallet libs treat non-2xx as transport failure and never parse it. + resp.json({ status: "ERROR", reason: "Not found" }) + +// LUD-21: settlement check, scoped to invoices ISSUED BY the LNURL-pay +// callback above (recorded in LnurlInvoiceModel). Payment hashes are not +// secrets — routing nodes and anyone shown the invoice see them — so verify +// must not answer for arbitrary Flash/IBEX invoices. Public endpoint, +// permissive CORS: web wallets poll it cross-origin. +export const lnurlVerifyHandler = async (req: Request, resp: Response) => { + try { + const rawHash = req.params.paymentHash + if (!rawHash || !PAYMENT_HASH_RE.test(rawHash)) { + return lnurlVerifyNotFound(resp) + } + const paymentHash = rawHash.toLowerCase() + + const cached = settledVerifyCache.get(paymentHash) + if (cached) { + return resp.json({ status: "OK", ...cached }) + } + + // Scope check first: unknown hashes never reach IBEX + const issued = await LnurlInvoiceModel.exists({ invoiceHash: paymentHash }) + if (!issued) { + logger.info( + { route: "lnurl-verify", hashPrefix: paymentHash.slice(0, 8) }, + "verify: hash not issued by this proxy", + ) + return lnurlVerifyNotFound(resp) + } + + const invoice = await Ibex.invoiceFromHash(paymentHash as PaymentHash) + if (invoice instanceof Error || !invoice.bolt11) { + logger.warn( + { route: "lnurl-verify", hashPrefix: paymentHash.slice(0, 8) }, + "verify: issued hash not resolvable at IBEX", + ) + return lnurlVerifyNotFound(resp) + } + + const settled = invoice.state?.id === IBEX_INVOICE_STATE_SETTLED + const result = { + settled, + preimage: settled && invoice.preImage ? invoice.preImage : null, + pr: invoice.bolt11, + } + + if (settled) { + if (settledVerifyCache.size >= SETTLED_CACHE_MAX) { + const oldest = settledVerifyCache.keys().next().value + if (oldest) settledVerifyCache.delete(oldest) + } + settledVerifyCache.set(paymentHash, { ...result, settled: true }) + } + + logger.info( + { route: "lnurl-verify", hashPrefix: paymentHash.slice(0, 8), settled }, + "verify: answered", + ) + return resp.json({ status: "OK", ...result }) + } catch (err) { + logger.error({ err }, "LNURL-pay verify failed") + return lnurlVerifyNotFound(resp) + } +} + const router = express.Router() router.get( @@ -101,7 +198,21 @@ router.get( if (!invoiceHash) return resp.status(500).json({ error: "Failed to extract payment hash" }) - // 4. Save zap request in Mongo + // 4a. Record the issued hash so LUD-21 verify answers for it (scope + // guard — see lnurlVerifyHandler). Never blocks invoice delivery. + try { + await LnurlInvoiceModel.create({ + invoiceHash: invoiceHash.toLowerCase(), + accountUsername: username, + }) + } catch (err) { + logger.warn( + { err, hashPrefix: invoiceHash.slice(0, 8) }, + "Failed to record LNURL invoice for verify — verify will 404 for it", + ) + } + + // 4b. Save zap request in Mongo if (requestEvent) { const zapRecord = new ZapRequestModel({ bolt11, @@ -115,11 +226,12 @@ router.get( await zapRecord.save() } - // 5. Return LNURL-pay JSON + // 5. Return LNURL-pay JSON (verify per LUD-21) return resp.json({ pr: bolt11, successAction: successAction || null, routes: [], + verify: buildVerifyUrl(invoiceHash), }) } catch (err) { logger.error({ err }, "LNURL-pay proxy callback failed") @@ -128,6 +240,14 @@ router.get( }, ) +router.get( + paths.verify, + verifyRateLimit, + cors({ origin: true, methods: ["GET"] }), + logRequest, + lnurlVerifyHandler, +) + // Keep other routes as stubs. These are Ibex webhooks (authenticated), so they // are IP-restricted; the public GET /pay/lnurl/:username above is not. router.post( diff --git a/src/services/mongoose/lnurl-invoice.ts b/src/services/mongoose/lnurl-invoice.ts new file mode 100644 index 000000000..847a916dc --- /dev/null +++ b/src/services/mongoose/lnurl-invoice.ts @@ -0,0 +1,26 @@ +// services/mongoose/lnurl-invoice.ts +import { Schema, model, Types } from "mongoose" + +// Invoices issued by the public LNURL-pay proxy callback. LUD-21 verify only +// answers for hashes recorded here — payment hashes are NOT secrets (routing +// nodes and anyone shown the invoice see them), so verify must not disclose +// settlement state or preimages for arbitrary Flash/IBEX invoices. +export interface LnurlInvoiceDoc { + _id: Types.ObjectId + invoiceHash: string + accountUsername: string + createdAt: Date +} + +const lnurlInvoiceSchema = new Schema({ + invoiceHash: { type: String, required: true, unique: true }, + accountUsername: { type: String, required: true }, + // TTL: verify polling happens within minutes; proof-of-payment lookups may + // trail by days. 30 days comfortably covers both without unbounded growth. + createdAt: { type: Date, default: () => new Date(), expires: "30d" }, +}) + +export const LnurlInvoiceModel = model( + "LnurlInvoice", + lnurlInvoiceSchema, +) diff --git a/test/flash/unit/services/ibex/webhook-server/routes/lnurl-verify.spec.ts b/test/flash/unit/services/ibex/webhook-server/routes/lnurl-verify.spec.ts new file mode 100644 index 000000000..633f2cdbd --- /dev/null +++ b/test/flash/unit/services/ibex/webhook-server/routes/lnurl-verify.spec.ts @@ -0,0 +1,204 @@ +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { invoiceFromHash: jest.fn() }, +})) +jest.mock("@config", () => ({ + IbexConfig: { webhook: { uri: "https://ibex.test.flashapp.me", secret: "s" } }, +})) +jest.mock("@services/logger", () => { + const logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn(() => logger), + } + return { baseLogger: logger } +}) +jest.mock("@services/mongoose/wallets", () => ({ WalletsRepository: jest.fn() })) +jest.mock("@services/mongoose/zap-request", () => ({ ZapRequestModel: jest.fn() })) +jest.mock("@services/mongoose/lnurl-invoice", () => ({ + LnurlInvoiceModel: { exists: jest.fn(), create: jest.fn() }, +})) +jest.mock("@services/mongoose", () => ({ AccountsRepository: jest.fn() })) +jest.mock("@utils", () => ({ extractPaymentHashFromBolt11: jest.fn() })) +jest.mock("@services/ibex/webhook-server/middleware", () => ({ + authenticate: jest.fn(), + logRequest: jest.fn(), + validateIbexIp: jest.fn(), +})) + +import { Request, Response } from "express" + +import { + lnurlVerifyHandler, + buildVerifyUrl, +} from "@services/ibex/webhook-server/routes/on-pay" + +const mockInvoiceFromHash = jest.requireMock("@services/ibex/client").default + .invoiceFromHash as jest.Mock +const mockExists = jest.requireMock("@services/mongoose/lnurl-invoice").LnurlInvoiceModel + .exists as jest.Mock + +// unique hash per test — the handler's settled-cache is module-level state +let hashCounter = 0 +const freshHash = () => (++hashCounter).toString(16).padStart(4, "0").repeat(16) + +const makeRes = () => { + const res = { status: jest.fn(), json: jest.fn() } as unknown as Response + ;(res.status as jest.Mock).mockReturnValue(res) + ;(res.json as jest.Mock).mockReturnValue(res) + return res +} +const makeReq = (paymentHash?: string) => + ({ params: { paymentHash } }) as unknown as Request + +const NOT_FOUND = { status: "ERROR", reason: "Not found" } + +describe("LUD-21 lnurlVerifyHandler", () => { + beforeEach(() => { + jest.clearAllMocks() + mockExists.mockResolvedValue({ _id: "x" }) + }) + + it("returns settled=true with preimage for a settled invoice (state.id 1)", async () => { + const HASH = freshHash() + mockInvoiceFromHash.mockResolvedValue({ + bolt11: "lnbc1settled", + preImage: "deadbeef", + settleDateUtc: "2026-07-07T12:00:00Z", + state: { id: 1, name: "SETTLED" }, + }) + const res = makeRes() + await lnurlVerifyHandler(makeReq(HASH), res) + expect(res.json).toHaveBeenCalledWith({ + status: "OK", + settled: true, + preimage: "deadbeef", + pr: "lnbc1settled", + }) + }) + + it("suppresses a PRESENT preimage while the invoice is still open", async () => { + const HASH = freshHash() + // non-empty preImage on an OPEN invoice — the settled gate alone must + // withhold it (IBEX generates invoices, so the preimage can pre-exist) + mockInvoiceFromHash.mockResolvedValue({ + bolt11: "lnbc1open", + preImage: "deadbeef", + settleDateUtc: "", + state: { id: 0, name: "OPEN" }, + }) + const res = makeRes() + await lnurlVerifyHandler(makeReq(HASH), res) + expect(res.json).toHaveBeenCalledWith({ + status: "OK", + settled: false, + preimage: null, + pr: "lnbc1open", + }) + }) + + it("does NOT treat settleDateUtc alone as settled (strict state.id gate)", async () => { + const HASH = freshHash() + mockInvoiceFromHash.mockResolvedValue({ + bolt11: "lnbc1weird", + preImage: "deadbeef", + settleDateUtc: "2026-07-07T12:00:00Z", + state: { id: 3, name: "ACCEPTED" }, + }) + const res = makeRes() + await lnurlVerifyHandler(makeReq(HASH), res) + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ settled: false, preimage: null }), + ) + }) + + it("refuses hashes not issued by this proxy, without calling IBEX", async () => { + const HASH = freshHash() + mockExists.mockResolvedValue(null) + const res = makeRes() + await lnurlVerifyHandler(makeReq(HASH), res) + expect(mockInvoiceFromHash).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith(NOT_FOUND) + }) + + it("returns ERROR body for an IBEX-returned error", async () => { + const HASH = freshHash() + mockInvoiceFromHash.mockResolvedValue(new Error("not found")) + const res = makeRes() + await lnurlVerifyHandler(makeReq(HASH), res) + expect(res.json).toHaveBeenCalledWith(NOT_FOUND) + }) + + it("returns ERROR body when the IBEX call REJECTS (network failure)", async () => { + const HASH = freshHash() + mockInvoiceFromHash.mockRejectedValue(new Error("ETIMEDOUT")) + const res = makeRes() + await lnurlVerifyHandler(makeReq(HASH), res) + expect(res.json).toHaveBeenCalledWith(NOT_FOUND) + }) + + it("returns ERROR body when IBEX responds without a bolt11", async () => { + const HASH = freshHash() + mockInvoiceFromHash.mockResolvedValue({ state: { id: 1, name: "SETTLED" } }) + const res = makeRes() + await lnurlVerifyHandler(makeReq(HASH), res) + expect(res.json).toHaveBeenCalledWith(NOT_FOUND) + }) + + it("rejects malformed hashes before any lookup", async () => { + const res = makeRes() + await lnurlVerifyHandler(makeReq("nope"), res) + expect(mockExists).not.toHaveBeenCalled() + expect(mockInvoiceFromHash).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith(NOT_FOUND) + }) + + it("normalizes uppercase hashes to lowercase for lookups", async () => { + const HASH = "ef".repeat(32) // letters, so toUpperCase() actually differs + mockInvoiceFromHash.mockResolvedValue({ + bolt11: "lnbc1x", + state: { id: 0, name: "OPEN" }, + }) + const res = makeRes() + await lnurlVerifyHandler(makeReq(HASH.toUpperCase()), res) + expect(mockExists).toHaveBeenCalledWith({ invoiceHash: HASH }) + expect(mockInvoiceFromHash).toHaveBeenCalledWith(HASH) + }) + + it("serves settled results from cache on repeat polls (single IBEX call)", async () => { + const hash = freshHash() + mockInvoiceFromHash.mockResolvedValue({ + bolt11: "lnbc1cached", + preImage: "feedface", + state: { id: 1, name: "SETTLED" }, + }) + const res1 = makeRes() + await lnurlVerifyHandler(makeReq(hash), res1) + const res2 = makeRes() + await lnurlVerifyHandler(makeReq(hash), res2) + expect(mockInvoiceFromHash).toHaveBeenCalledTimes(1) + expect(res2.json).toHaveBeenCalledWith({ + status: "OK", + settled: true, + preimage: "feedface", + pr: "lnbc1cached", + }) + }) + + it("never returns HTTP error statuses (LNURL 200+ERROR-body convention)", async () => { + const HASH = freshHash() + mockExists.mockResolvedValue(null) + const res = makeRes() + await lnurlVerifyHandler(makeReq(HASH), res) + expect(res.status).not.toHaveBeenCalled() + }) + + it("builds the verify URL from the webhook base", () => { + const HASH = freshHash() + expect(buildVerifyUrl(HASH)).toBe( + `https://ibex.test.flashapp.me/pay/lnurl/verify/${HASH}`, + ) + }) +})