From 98da785810aebb251be52b158f0dddbeb09b7391 Mon Sep 17 00:00:00 2001 From: Dread Date: Tue, 7 Jul 2026 10:57:26 -0500 Subject: [PATCH 1/3] feat(lnurl): LUD-21 verify on the LNURL-pay proxy callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public GET /pay/lnurl/:username callback returned {pr} with no way for the payer to confirm settlement — integrators (e.g. Hustle Link) had to rely on the vendor noticing a push notification. LUD-21 closes that loop: - Callback response now includes verify: /pay/lnurl/verify/ - New public GET /pay/lnurl/verify/:paymentHash returns {status, settled, preimage, pr} per LUD-21, backed by IBEX invoice-from-hash. settled = state SETTLED or settleDateUtc present; preimage only revealed once settled. - Unknown/malformed hashes (strict 64-hex validation, checked before any IBEX call) and IBEX errors all map to LUD-21's 404 {status:"ERROR", reason:"Not found"}. - Permissive CORS on verify only — the payment hash is an unguessable capability and web wallets poll cross-origin. Same public rate limit as the callback. Benefits every app building on Flash lightning addresses. 5 tests; webhook-server suites green. --- src/scripts/setup-bankowner-usdt.ts | 67 +++++++++++ src/scripts/setup-cutover-treasury.ts | 76 +++++++++++++ src/services/ibex/webhook-config.ts | 2 + .../ibex/webhook-server/routes/on-pay.ts | 47 +++++++- .../ibex/webhook-server/lnurl-verify.spec.ts | 106 ++++++++++++++++++ 5 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 src/scripts/setup-bankowner-usdt.ts create mode 100644 src/scripts/setup-cutover-treasury.ts create mode 100644 test/flash/unit/services/ibex/webhook-server/lnurl-verify.spec.ts diff --git a/src/scripts/setup-bankowner-usdt.ts b/src/scripts/setup-bankowner-usdt.ts new file mode 100644 index 000000000..ba674f9ab --- /dev/null +++ b/src/scripts/setup-bankowner-usdt.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// One-off (TEST, cashout prerequisite): create the bankowner's USDT wallet. +// Cashout routing (cashout-routing.ts) hard-errors when the bankowner account +// has no USDT wallet. Unlike the cutover treasury, the DEFAULT wallet is left +// untouched — routing resolves the USDT wallet by currency. Idempotent; +// dry-run unless --apply. +import { addWalletIfNonexistent } from "@app/accounts" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" +import { AccountsRepository, WalletsRepository } from "@services/mongoose" +import { setupMongoConnection } from "@services/mongodb" + +const apply = process.argv.includes("--apply") + +// USD or USDT (default USDT — original cashout prerequisite) +const CURRENCY = (process.env.WALLET_CURRENCY || "USDT").toUpperCase() + +// role:"bankowner" account on TEST (statusHistory import artifact fixed 2026-07-05) +const BANKOWNER_ACCOUNT_ID = (process.env.BANKOWNER_ACCOUNT_ID || + "66b030b64278550357f1413f") as AccountId + +const run = async () => { + const account = await AccountsRepository().findById(BANKOWNER_ACCOUNT_ID) + if (account instanceof Error) throw account + + const before = await WalletsRepository().listByAccountId(account.id) + if (before instanceof Error) throw before + + const summary = { + apply, + bankownerAccountId: account.id, + defaultWalletId: account.defaultWalletId, + walletsBefore: before.map((w) => ({ id: w.id, currency: w.currency })), + } + + if (!apply) { + console.log(JSON.stringify({ ...summary, note: "dry-run; pass --apply" }, null, 1)) + return + } + + const currency = CURRENCY === "USD" ? WalletCurrency.Usd : WalletCurrency.Usdt + const usdt = await addWalletIfNonexistent({ + accountId: account.id, + type: WalletType.Checking, + currency, + }) + if (usdt instanceof Error) throw usdt + + console.log( + JSON.stringify( + { ...summary, createdOrFoundWalletId: usdt.id, currency: CURRENCY, fundThisIbexAccount: usdt.id }, + null, + 1, + ), + ) +} + +setupMongoConnection() + .then(async (m) => { + await run() + await m?.connection.close() + process.exit(0) + }) + .catch((e) => { + console.error(String(e)) + process.exit(1) + }) diff --git a/src/scripts/setup-cutover-treasury.ts b/src/scripts/setup-cutover-treasury.ts new file mode 100644 index 000000000..19c2ae2f7 --- /dev/null +++ b/src/scripts/setup-cutover-treasury.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// One-off (ENG-482 / ENG-461 rehearsal): give the funder account a USDT wallet +// and make it the default, so getTreasuryWalletId() resolves to a USDT-capable +// treasury for cutover fee reimbursements. Prints the wallet + IBEX account id +// to fund. Idempotent. Dry-run unless --apply is passed. +import { addWalletIfNonexistent, updateDefaultWalletId } from "@app/accounts" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" +import { AccountsRepository, WalletsRepository } from "@services/mongoose" +import { setupMongoConnection } from "@services/mongodb" +import { baseLogger } from "@services/logger" + +const apply = process.argv.includes("--apply") + +// Funder account id (role:"funder") on TEST — matches funderWalletResolver's +// Account.findOne({ role: "funder" }). +const FUNDER_ACCOUNT_ID = (process.env.FUNDER_ACCOUNT_ID || + "66b030b64278550357f1413d") as AccountId + +const run = async () => { + const account = await AccountsRepository().findById(FUNDER_ACCOUNT_ID) + if (account instanceof Error) throw account + + const before = await WalletsRepository().listByAccountId(account.id) + if (before instanceof Error) throw before + + const summary: Record = { + apply, + funderAccountId: account.id, + currentDefaultWalletId: account.defaultWalletId, + walletsBefore: before.map((w) => ({ id: w.id, currency: w.currency })), + } + + if (!apply) { + console.log(JSON.stringify({ ...summary, note: "dry-run; pass --apply to make changes" }, null, 1)) + return + } + + const usdt = await addWalletIfNonexistent({ + accountId: account.id, + type: WalletType.Checking, + currency: WalletCurrency.Usdt, + }) + if (usdt instanceof Error) throw usdt + + const updated = await updateDefaultWalletId({ + accountId: account.id, + walletId: usdt.id, + }) + if (updated instanceof Error) throw updated + + console.log( + JSON.stringify( + { + ...summary, + createdOrFoundUsdtWalletId: usdt.id, + newDefaultWalletId: usdt.id, + fundThisIbexAccount: usdt.id, + }, + null, + 1, + ), + ) +} + +setupMongoConnection() + .then(async (mongoose) => { + await run() + await mongoose?.connection.close() + process.exit(0) + }) + .catch((error) => { + baseLogger.error({ error }, "setup-cutover-treasury failed") + console.error(String(error)) + process.exit(1) + }) 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..f841b82a8 100644 --- a/src/services/ibex/webhook-server/routes/on-pay.ts +++ b/src/services/ibex/webhook-server/routes/on-pay.ts @@ -8,7 +8,7 @@ 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" @@ -41,6 +41,40 @@ const webhookRateLimit = rateLimitMiddleware({ const paths = ibexWebhookPaths.onPay +const PAYMENT_HASH_RE = /^[0-9a-f]{64}$/i + +export const buildVerifyUrl = (paymentHash: string): string => + ibexWebhookEndpoints.onPay.verify.replace(":paymentHash", paymentHash) + +// LUD-21: settlement check for an invoice issued by the LNURL-pay callback +// above. The payment hash acts as an unguessable capability, so the endpoint +// is public (permissive CORS — web wallets poll it cross-origin). +export const lnurlVerifyHandler = async (req: Request, resp: Response) => { + try { + const { paymentHash } = req.params + if (!paymentHash || !PAYMENT_HASH_RE.test(paymentHash)) { + return resp.status(404).json({ status: "ERROR", reason: "Not found" }) + } + + const invoice = await Ibex.invoiceFromHash(paymentHash as PaymentHash) + if (invoice instanceof Error || !invoice.bolt11) { + return resp.status(404).json({ status: "ERROR", reason: "Not found" }) + } + + const settled = invoice.state?.name === "SETTLED" || Boolean(invoice.settleDateUtc) + + return resp.json({ + status: "OK", + settled, + preimage: settled && invoice.preImage ? invoice.preImage : null, + pr: invoice.bolt11, + }) + } catch (err) { + logger.error({ err }, "LNURL-pay verify failed") + return resp.status(404).json({ status: "ERROR", reason: "Not found" }) + } +} + const router = express.Router() router.get( @@ -115,11 +149,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 +163,14 @@ router.get( }, ) +router.get( + paths.verify, + publicLnurlRateLimit, + 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/test/flash/unit/services/ibex/webhook-server/lnurl-verify.spec.ts b/test/flash/unit/services/ibex/webhook-server/lnurl-verify.spec.ts new file mode 100644 index 000000000..c497da395 --- /dev/null +++ b/test/flash/unit/services/ibex/webhook-server/lnurl-verify.spec.ts @@ -0,0 +1,106 @@ +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", () => ({ AccountsRepository: jest.fn() })) +jest.mock("@utils", () => ({ extractPaymentHashFromBolt11: jest.fn() })) +jest.mock("../../../../../../src/services/ibex/webhook-server/middleware", () => ({ + authenticate: jest.fn(), + logRequest: jest.fn(), + validateIbexIp: jest.fn(), +})) + +import { Request, Response } from "express" + +const mockInvoiceFromHash = jest.requireMock("@services/ibex/client").default + .invoiceFromHash as jest.Mock + +import { + lnurlVerifyHandler, + buildVerifyUrl, +} from "@services/ibex/webhook-server/routes/on-pay" + +const HASH = "a".repeat(64) + +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 + +describe("LUD-21 lnurlVerifyHandler", () => { + afterEach(() => jest.clearAllMocks()) + + it("returns settled=true with preimage for a settled invoice", async () => { + mockInvoiceFromHash.mockResolvedValue({ + bolt11: "lnbc1...", + 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: "lnbc1...", + }) + }) + + it("returns settled=false with null preimage for an open invoice", async () => { + mockInvoiceFromHash.mockResolvedValue({ + bolt11: "lnbc1...", + preImage: "", + 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: "lnbc1...", + }) + }) + + it("404s LUD-21-style for an unknown hash (IBEX error)", async () => { + mockInvoiceFromHash.mockResolvedValue(new Error("not found")) + const res = makeRes() + await lnurlVerifyHandler(makeReq(HASH), res) + expect(res.status).toHaveBeenCalledWith(404) + expect(res.json).toHaveBeenCalledWith({ status: "ERROR", reason: "Not found" }) + }) + + it("404s without calling IBEX for a malformed hash", async () => { + const res = makeRes() + await lnurlVerifyHandler(makeReq("nope"), res) + expect(mockInvoiceFromHash).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(404) + }) + + it("builds the verify URL from the webhook base", () => { + expect(buildVerifyUrl(HASH)).toBe( + `https://ibex.test.flashapp.me/pay/lnurl/verify/${HASH}`, + ) + }) +}) From c7acb08cec6f5da83425d999fd2ae4795cb7cbf1 Mon Sep 17 00:00:00 2001 From: Dread Date: Tue, 7 Jul 2026 11:05:58 -0500 Subject: [PATCH 2/3] chore: remove stray operational scripts accidentally swept into the branch --- src/scripts/setup-bankowner-usdt.ts | 67 ----------------------- src/scripts/setup-cutover-treasury.ts | 76 --------------------------- 2 files changed, 143 deletions(-) delete mode 100644 src/scripts/setup-bankowner-usdt.ts delete mode 100644 src/scripts/setup-cutover-treasury.ts diff --git a/src/scripts/setup-bankowner-usdt.ts b/src/scripts/setup-bankowner-usdt.ts deleted file mode 100644 index ba674f9ab..000000000 --- a/src/scripts/setup-bankowner-usdt.ts +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node -// One-off (TEST, cashout prerequisite): create the bankowner's USDT wallet. -// Cashout routing (cashout-routing.ts) hard-errors when the bankowner account -// has no USDT wallet. Unlike the cutover treasury, the DEFAULT wallet is left -// untouched — routing resolves the USDT wallet by currency. Idempotent; -// dry-run unless --apply. -import { addWalletIfNonexistent } from "@app/accounts" -import { WalletCurrency } from "@domain/shared" -import { WalletType } from "@domain/wallets" -import { AccountsRepository, WalletsRepository } from "@services/mongoose" -import { setupMongoConnection } from "@services/mongodb" - -const apply = process.argv.includes("--apply") - -// USD or USDT (default USDT — original cashout prerequisite) -const CURRENCY = (process.env.WALLET_CURRENCY || "USDT").toUpperCase() - -// role:"bankowner" account on TEST (statusHistory import artifact fixed 2026-07-05) -const BANKOWNER_ACCOUNT_ID = (process.env.BANKOWNER_ACCOUNT_ID || - "66b030b64278550357f1413f") as AccountId - -const run = async () => { - const account = await AccountsRepository().findById(BANKOWNER_ACCOUNT_ID) - if (account instanceof Error) throw account - - const before = await WalletsRepository().listByAccountId(account.id) - if (before instanceof Error) throw before - - const summary = { - apply, - bankownerAccountId: account.id, - defaultWalletId: account.defaultWalletId, - walletsBefore: before.map((w) => ({ id: w.id, currency: w.currency })), - } - - if (!apply) { - console.log(JSON.stringify({ ...summary, note: "dry-run; pass --apply" }, null, 1)) - return - } - - const currency = CURRENCY === "USD" ? WalletCurrency.Usd : WalletCurrency.Usdt - const usdt = await addWalletIfNonexistent({ - accountId: account.id, - type: WalletType.Checking, - currency, - }) - if (usdt instanceof Error) throw usdt - - console.log( - JSON.stringify( - { ...summary, createdOrFoundWalletId: usdt.id, currency: CURRENCY, fundThisIbexAccount: usdt.id }, - null, - 1, - ), - ) -} - -setupMongoConnection() - .then(async (m) => { - await run() - await m?.connection.close() - process.exit(0) - }) - .catch((e) => { - console.error(String(e)) - process.exit(1) - }) diff --git a/src/scripts/setup-cutover-treasury.ts b/src/scripts/setup-cutover-treasury.ts deleted file mode 100644 index 19c2ae2f7..000000000 --- a/src/scripts/setup-cutover-treasury.ts +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env node -// One-off (ENG-482 / ENG-461 rehearsal): give the funder account a USDT wallet -// and make it the default, so getTreasuryWalletId() resolves to a USDT-capable -// treasury for cutover fee reimbursements. Prints the wallet + IBEX account id -// to fund. Idempotent. Dry-run unless --apply is passed. -import { addWalletIfNonexistent, updateDefaultWalletId } from "@app/accounts" -import { WalletCurrency } from "@domain/shared" -import { WalletType } from "@domain/wallets" -import { AccountsRepository, WalletsRepository } from "@services/mongoose" -import { setupMongoConnection } from "@services/mongodb" -import { baseLogger } from "@services/logger" - -const apply = process.argv.includes("--apply") - -// Funder account id (role:"funder") on TEST — matches funderWalletResolver's -// Account.findOne({ role: "funder" }). -const FUNDER_ACCOUNT_ID = (process.env.FUNDER_ACCOUNT_ID || - "66b030b64278550357f1413d") as AccountId - -const run = async () => { - const account = await AccountsRepository().findById(FUNDER_ACCOUNT_ID) - if (account instanceof Error) throw account - - const before = await WalletsRepository().listByAccountId(account.id) - if (before instanceof Error) throw before - - const summary: Record = { - apply, - funderAccountId: account.id, - currentDefaultWalletId: account.defaultWalletId, - walletsBefore: before.map((w) => ({ id: w.id, currency: w.currency })), - } - - if (!apply) { - console.log(JSON.stringify({ ...summary, note: "dry-run; pass --apply to make changes" }, null, 1)) - return - } - - const usdt = await addWalletIfNonexistent({ - accountId: account.id, - type: WalletType.Checking, - currency: WalletCurrency.Usdt, - }) - if (usdt instanceof Error) throw usdt - - const updated = await updateDefaultWalletId({ - accountId: account.id, - walletId: usdt.id, - }) - if (updated instanceof Error) throw updated - - console.log( - JSON.stringify( - { - ...summary, - createdOrFoundUsdtWalletId: usdt.id, - newDefaultWalletId: usdt.id, - fundThisIbexAccount: usdt.id, - }, - null, - 1, - ), - ) -} - -setupMongoConnection() - .then(async (mongoose) => { - await run() - await mongoose?.connection.close() - process.exit(0) - }) - .catch((error) => { - baseLogger.error({ error }, "setup-cutover-treasury failed") - console.error(String(error)) - process.exit(1) - }) From 5bc1a34d5bc8aaf7a30e9863d6d109c0ea3d5852 Mon Sep 17 00:00:00 2001 From: Dread Date: Tue, 7 Jul 2026 11:16:07 -0500 Subject: [PATCH 3/3] fix(lnurl): scope LUD-21 verify to proxy-issued invoices + review hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the adversarial review: B2 (security): verify previously answered for ANY Flash/IBEX invoice via the org-scoped invoice-from-hash — disclosing bolt11 (amount, memo), settlement timing, and post-settlement preimages for invoices this proxy never issued, on the false premise that payment hashes are unguessable (routing nodes and anyone shown an invoice see them; zap receipts embed bolt11s publicly). Issued hashes are now recorded in LnurlInvoiceModel (unique index, 30d TTL) at callback time; verify 404s any hash not recorded, before any IBEX call — which also kills the garbage-hash IBEX amplification vector. B3: verify gets its own rate-limit bucket (60/min) — LUD-21 polling can no longer starve the payment callback's shared 120/min budget. N1: settled is now the single strict signal (state.id === 1, matching payment-status-checker); settleDateUtc no longer gates preimage release. N2: settled results (immutable) served from a bounded in-process cache. N3: LNURL convention — HTTP 200 with status:ERROR body, not 404 (some wallet libs treat non-2xx as transport failure). N4: outcome logging with hash prefixes on the verify route. Nits: hash lowercased before lookups; misleading "unguessable capability" comment corrected; spec relocated to routes/ with alias-based mocks. Tests 5 -> 12, including the previously-vacuous preimage-suppression case (now asserts a PRESENT preimage is withheld while OPEN), thrown-rejection path, scope-guard no-IBEX-call, ACCEPTED-state-with-settleDateUtc not settled, cache single-call, and case normalization. 102 webhook-server tests green. --- .../ibex/webhook-server/routes/on-pay.ts | 107 +++++++-- src/services/mongoose/lnurl-invoice.ts | 26 +++ .../ibex/webhook-server/lnurl-verify.spec.ts | 106 --------- .../routes/lnurl-verify.spec.ts | 204 ++++++++++++++++++ 4 files changed, 322 insertions(+), 121 deletions(-) create mode 100644 src/services/mongoose/lnurl-invoice.ts delete mode 100644 test/flash/unit/services/ibex/webhook-server/lnurl-verify.spec.ts create mode 100644 test/flash/unit/services/ibex/webhook-server/routes/lnurl-verify.spec.ts diff --git a/src/services/ibex/webhook-server/routes/on-pay.ts b/src/services/ibex/webhook-server/routes/on-pay.ts index f841b82a8..9f2017b35 100644 --- a/src/services/ibex/webhook-server/routes/on-pay.ts +++ b/src/services/ibex/webhook-server/routes/on-pay.ts @@ -4,6 +4,7 @@ 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" @@ -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, @@ -43,35 +53,88 @@ 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) -// LUD-21: settlement check for an invoice issued by the LNURL-pay callback -// above. The payment hash acts as an unguessable capability, so the endpoint -// is public (permissive CORS — web wallets poll it cross-origin). +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 { paymentHash } = req.params - if (!paymentHash || !PAYMENT_HASH_RE.test(paymentHash)) { - return resp.status(404).json({ status: "ERROR", reason: "Not found" }) + 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) { - return resp.status(404).json({ status: "ERROR", reason: "Not found" }) + logger.warn( + { route: "lnurl-verify", hashPrefix: paymentHash.slice(0, 8) }, + "verify: issued hash not resolvable at IBEX", + ) + return lnurlVerifyNotFound(resp) } - const settled = invoice.state?.name === "SETTLED" || Boolean(invoice.settleDateUtc) - - return resp.json({ - status: "OK", + 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 resp.status(404).json({ status: "ERROR", reason: "Not found" }) + return lnurlVerifyNotFound(resp) } } @@ -135,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, @@ -165,7 +242,7 @@ router.get( router.get( paths.verify, - publicLnurlRateLimit, + verifyRateLimit, cors({ origin: true, methods: ["GET"] }), logRequest, lnurlVerifyHandler, 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/lnurl-verify.spec.ts b/test/flash/unit/services/ibex/webhook-server/lnurl-verify.spec.ts deleted file mode 100644 index c497da395..000000000 --- a/test/flash/unit/services/ibex/webhook-server/lnurl-verify.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -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", () => ({ AccountsRepository: jest.fn() })) -jest.mock("@utils", () => ({ extractPaymentHashFromBolt11: jest.fn() })) -jest.mock("../../../../../../src/services/ibex/webhook-server/middleware", () => ({ - authenticate: jest.fn(), - logRequest: jest.fn(), - validateIbexIp: jest.fn(), -})) - -import { Request, Response } from "express" - -const mockInvoiceFromHash = jest.requireMock("@services/ibex/client").default - .invoiceFromHash as jest.Mock - -import { - lnurlVerifyHandler, - buildVerifyUrl, -} from "@services/ibex/webhook-server/routes/on-pay" - -const HASH = "a".repeat(64) - -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 - -describe("LUD-21 lnurlVerifyHandler", () => { - afterEach(() => jest.clearAllMocks()) - - it("returns settled=true with preimage for a settled invoice", async () => { - mockInvoiceFromHash.mockResolvedValue({ - bolt11: "lnbc1...", - 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: "lnbc1...", - }) - }) - - it("returns settled=false with null preimage for an open invoice", async () => { - mockInvoiceFromHash.mockResolvedValue({ - bolt11: "lnbc1...", - preImage: "", - 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: "lnbc1...", - }) - }) - - it("404s LUD-21-style for an unknown hash (IBEX error)", async () => { - mockInvoiceFromHash.mockResolvedValue(new Error("not found")) - const res = makeRes() - await lnurlVerifyHandler(makeReq(HASH), res) - expect(res.status).toHaveBeenCalledWith(404) - expect(res.json).toHaveBeenCalledWith({ status: "ERROR", reason: "Not found" }) - }) - - it("404s without calling IBEX for a malformed hash", async () => { - const res = makeRes() - await lnurlVerifyHandler(makeReq("nope"), res) - expect(mockInvoiceFromHash).not.toHaveBeenCalled() - expect(res.status).toHaveBeenCalledWith(404) - }) - - it("builds the verify URL from the webhook base", () => { - expect(buildVerifyUrl(HASH)).toBe( - `https://ibex.test.flashapp.me/pay/lnurl/verify/${HASH}`, - ) - }) -}) 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}`, + ) + }) +})