From 0d55c0ccceb378a9c1992f6e7d82a6786cdd19b8 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 13 Jul 2026 20:21:49 -0700 Subject: preimages: enforce a merchant-set price per preimage batch Invoice creation is unauthenticated, so buyers could name their own price for a queued preimage (create the usePreimage invoice with amount: 1). POST /preimages now takes an optional price (sats) that is stored per entry and overrides the requested amount when the preimage is consumed; omitting it keeps the old any-amount behavior. Also gate the stored-preimage reveal in credit() on the invoice being fully paid, since internal sends can settle partial amounts. Bare-hex entries queued before this change still parse (treated as priceless). Co-Authored-By: Claude Fable 5 --- lib/invoices.ts | 33 +++++++++++++++++++++++++++------ lib/payments.ts | 7 +++++-- 2 files changed, 32 insertions(+), 8 deletions(-) (limited to 'lib') diff --git a/lib/invoices.ts b/lib/invoices.ts index 434a2375..c3722ef5 100644 --- a/lib/invoices.ts +++ b/lib/invoices.ts @@ -13,6 +13,16 @@ import { PaymentType } from "$lib/types"; const bc = rpc(config.bitcoin); const lq = rpc(config.liquid); +// Preimage queue entries are {preimage, price} JSON, but entries queued +// before prices existed are bare hex strings — treat those as priceless +export const parseEntry = (e) => { + try { + const parsed = JSON.parse(e); + if (parsed?.preimage) return { price: null, ...parsed }; + } catch {} + return { preimage: e, price: null }; +}; + export const generate = async ({ invoice, user }) => { let { address_type, @@ -89,7 +99,6 @@ export const generate = async ({ invoice, user }) => { expiry ||= 60 * 60 * 24 * 30; const args = { - amount_msat: amount ? `${amount + tip}sat` : "any", label: `${id} ${user.username} ${Date.now()}`, description: memo || "", expiry, @@ -103,10 +112,19 @@ export const generate = async ({ invoice, user }) => { // merchant chose. Never fall back to a random preimage — a buyer would // be paying for a secret that unlocks nothing. const queue = `${user.id}:preimages`; - let preimage; - while ((preimage = await db.lPop(queue))) { + let entry; + while ((entry = await db.lPop(queue))) { + const { preimage, price } = parseEntry(entry); + // The invoice creator is unauthenticated, so a merchant-set price + // must win over the amount in the request — otherwise a buyer + // could name their own price for the secret + if (price) amount = price; try { - r = await ln.invoice({ ...args, preimage }); + r = await ln.invoice({ + ...args, + amount_msat: amount ? `${amount + tip}sat` : "any", + preimage, + }); // Keep the preimage recoverable for internal settlements, where // cln never pays the invoice and so never reveals it — credit() // picks this up as the payment ref @@ -121,13 +139,16 @@ export const generate = async ({ invoice, user }) => { warn("discarding unusable preimage", user.username, e.message); continue; } - await db.lPush(queue, preimage); + await db.lPush(queue, entry); throw e; } } if (!r) fail("no preimages available"); } else { - r = await ln.invoice(args); + r = await ln.invoice({ + ...args, + amount_msat: amount ? `${amount + tip}sat` : "any", + }); } } diff --git a/lib/payments.ts b/lib/payments.ts index 01de2eff..df2c8857 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -293,10 +293,13 @@ export const credit = async ({ // preimage:${paymentHash} (see generate()). Prefer it as the settlement // ref: external settlements already carry the same value, but internal // ones pass ref=sender.id, which would otherwise be surfaced as the - // payment preimage and lose the secret the buyer paid for. + // payment preimage and lose the secret the buyer paid for. Only once the + // invoice is fully paid, though — internal sends can settle partial + // amounts, and an underpayment must not reveal the secret. if ( inv.paymentHash && - ![PaymentType.bitcoin, PaymentType.liquid].includes(type) + ![PaymentType.bitcoin, PaymentType.liquid].includes(type) && + inv.received + amount >= inv.amount ) { const stored = await db.get(`preimage:${inv.paymentHash}`); if (stored) ref = stored; -- cgit v1.2.3