diff options
| author | Adam Soltys <[email protected]> | 2026-07-13 20:21:49 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-13 20:22:06 -0700 |
| commit | 0d55c0ccceb378a9c1992f6e7d82a6786cdd19b8 (patch) | |
| tree | e6cce8802e6f6159cbe5647eba47be1306d34e01 | |
| parent | 2e90d709178ac718e4f32229951e6ea38dfcc371 (diff) | |
| download | coinos-server-0d55c0ccceb378a9c1992f6e7d82a6786cdd19b8.tar.gz coinos-server-0d55c0ccceb378a9c1992f6e7d82a6786cdd19b8.zip | |
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 <[email protected]>
| -rw-r--r-- | lib/invoices.ts | 33 | ||||
| -rw-r--r-- | lib/payments.ts | 7 | ||||
| -rw-r--r-- | routes/preimages.ts | 35 |
3 files changed, 56 insertions, 19 deletions
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; diff --git a/routes/preimages.ts b/routes/preimages.ts index ec58c064..f6632b38 100644 --- a/routes/preimages.ts +++ b/routes/preimages.ts @@ -1,12 +1,15 @@ import { createHash } from "node:crypto"; import { db } from "$lib/db"; +import { parseEntry } from "$lib/invoices"; import { bail, fail } from "$lib/utils"; // Merchants pre-load payment preimages here, then have buyers create invoices // with usePreimage: true — each such invoice consumes the next queued preimage // so its payment hash is sha256(preimage). Once paid, the preimage is exposed // on the invoice record, letting a static storefront sell decryption secrets -// with no backend of its own. +// with no backend of its own. An optional per-batch price (sats) is enforced +// at invoice creation — invoice creation is unauthenticated, so without it a +// buyer could name their own price for the secret. const MAX_QUEUE = 1000; const key = (uid) => `${uid}:preimages`; @@ -16,7 +19,7 @@ const sha256 = (hex) => export default { async add(req, res) { try { - const { preimages } = req.body; + const { preimages, price = null } = req.body; if (!Array.isArray(preimages) || !preimages.length) fail("preimages array required"); if ( @@ -25,10 +28,12 @@ export default { ) ) fail("preimages must be 64-character hex strings"); + if (price !== null && (!Number.isInteger(price) || price <= 0)) + fail("price must be a positive integer amount of sats"); const k = key(req.user.id); const existing = await db.lRange(k, 0, -1); - const seen = new Set(existing); + const seen = new Set(existing.map((e) => parseEntry(e).preimage)); const fresh = []; for (let p of preimages) { p = p.toLowerCase(); @@ -39,11 +44,19 @@ export default { if (existing.length + fresh.length > MAX_QUEUE) fail(`queue limited to ${MAX_QUEUE} preimages`); - if (fresh.length) await db.rPush(k, fresh); + if (fresh.length) + await db.rPush( + k, + fresh.map((preimage) => JSON.stringify({ preimage, price })), + ); res.send({ queued: existing.length + fresh.length, - added: fresh.map((preimage) => ({ preimage, hash: sha256(preimage) })), + added: fresh.map((preimage) => ({ + preimage, + hash: sha256(preimage), + price, + })), }); } catch (e) { bail(res, e.message); @@ -52,13 +65,13 @@ export default { async list(req, res) { try { - const preimages = await db.lRange(key(req.user.id), 0, -1); + const entries = await db.lRange(key(req.user.id), 0, -1); res.send({ - queued: preimages.length, - preimages: preimages.map((preimage) => ({ - preimage, - hash: sha256(preimage), - })), + queued: entries.length, + preimages: entries.map((e) => { + const { preimage, price } = parseEntry(e); + return { preimage, hash: sha256(preimage), price }; + }), }); } catch (e) { bail(res, e.message); |
