Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/services/ibex/webhook-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const ibexWebhookPaths = {
onPay: {
invoice: "/pay/invoice",
lnurl: "/pay/lnurl/:username",
verify: "/pay/lnurl/verify/:paymentHash",
onchain: "/pay/onchain",
},
cryptoReceive: {
Expand All @@ -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: {
Expand Down
126 changes: 123 additions & 3 deletions src/services/ibex/webhook-server/routes/on-pay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

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"
Expand All @@ -32,6 +33,15 @@
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,
Expand All @@ -41,6 +51,93 @@

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(
Expand Down Expand Up @@ -101,7 +198,21 @@
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,
Expand All @@ -115,11 +226,12 @@
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")
Expand All @@ -128,6 +240,14 @@
},
)

router.get(
paths.verify,
verifyRateLimit,
cors({ origin: true, methods: ["GET"] }),

Check warning

Code scanning / CodeQL

Permissive CORS configuration Medium

CORS Origin allows broad access due to
permissive or user controlled value
.
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(
Expand Down
26 changes: 26 additions & 0 deletions src/services/mongoose/lnurl-invoice.ts
Original file line number Diff line number Diff line change
@@ -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<LnurlInvoiceDoc>({
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<LnurlInvoiceDoc>(
"LnurlInvoice",
lnurlInvoiceSchema,
)
Loading
Loading