From 81c3593f1eec7f94dc9cbc22edc3456fa57b95cb Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 14 Jul 2026 22:06:25 -0700 Subject: preimages: support fiat prices, converted at invoice-creation time POST /preimages now accepts fiat + optional currency (defaults to the merchant's) as an alternative to a sat price. The fiat amount is stored per entry and converted to sats at the moment a usePreimage invoice is created, so the price tracks the exchange rate instead of freezing at upload time. The invoice also takes on the entry's currency and rate so receipts display the fiat price exactly as the merchant set it. If no rate is available for the entry's currency, the preimage is pushed back onto the queue and creation fails rather than issuing an any-amount invoice. Co-Authored-By: Claude Fable 5 --- lib/invoices.ts | 30 ++++++++++++++++++++++++------ routes/preimages.ts | 33 +++++++++++++++++++++++++-------- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/lib/invoices.ts b/lib/invoices.ts index c3722ef5..613287a3 100644 --- a/lib/invoices.ts +++ b/lib/invoices.ts @@ -13,14 +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 +// Preimage queue entries are {preimage, price, fiat, currency} 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 }; + if (parsed?.preimage) + return { price: null, fiat: null, currency: null, ...parsed }; } catch {} - return { preimage: e, price: null }; + return { preimage: e, price: null, fiat: null, currency: null }; }; export const generate = async ({ invoice, user }) => { @@ -114,11 +116,27 @@ export const generate = async ({ invoice, user }) => { const queue = `${user.id}:preimages`; let entry; while ((entry = await db.lPop(queue))) { - const { preimage, price } = parseEntry(entry); + const { + preimage, + price, + fiat: entryFiat, + currency: entryCurrency, + } = 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 + // could name their own price for the secret. Fiat prices convert + // at the current rate, and the invoice takes on the merchant's + // currency so receipts show the fiat price as set. if (price) amount = price; + else if (entryFiat) { + if (!rates[entryCurrency]) { + await db.lPush(queue, entry); + fail(`no rate available for ${entryCurrency}`); + } + currency = entryCurrency; + rate = rates[currency]; + amount = Math.round((SATS * entryFiat) / rate); + } try { r = await ln.invoice({ ...args, diff --git a/routes/preimages.ts b/routes/preimages.ts index f6632b38..49673d17 100644 --- a/routes/preimages.ts +++ b/routes/preimages.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { db } from "$lib/db"; +import { db, g } from "$lib/db"; import { parseEntry } from "$lib/invoices"; import { bail, fail } from "$lib/utils"; @@ -7,9 +7,10 @@ import { bail, fail } from "$lib/utils"; // 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. 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. +// with no backend of its own. An optional per-batch price — sats, or a fiat +// amount converted at invoice-creation time — is enforced at invoice creation; +// 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`; @@ -19,7 +20,8 @@ const sha256 = (hex) => export default { async add(req, res) { try { - const { preimages, price = null } = req.body; + const { preimages, price = null, fiat = null } = req.body; + let { currency = null } = req.body; if (!Array.isArray(preimages) || !preimages.length) fail("preimages array required"); if ( @@ -28,8 +30,19 @@ export default { ) ) fail("preimages must be 64-character hex strings"); + if (price !== null && fiat !== null) + fail("specify price (sats) or fiat, not both"); if (price !== null && (!Number.isInteger(price) || price <= 0)) fail("price must be a positive integer amount of sats"); + if (fiat !== null) { + if (typeof fiat !== "number" || !(fiat > 0)) + fail("fiat must be a positive number"); + currency ||= req.user.currency; + const rates = await g("rates"); + if (!rates?.[currency]) fail(`unsupported currency ${currency}`); + } else { + currency = null; + } const k = key(req.user.id); const existing = await db.lRange(k, 0, -1); @@ -47,7 +60,9 @@ export default { if (fresh.length) await db.rPush( k, - fresh.map((preimage) => JSON.stringify({ preimage, price })), + fresh.map((preimage) => + JSON.stringify({ preimage, price, fiat, currency }), + ), ); res.send({ @@ -56,6 +71,8 @@ export default { preimage, hash: sha256(preimage), price, + fiat, + currency, })), }); } catch (e) { @@ -69,8 +86,8 @@ export default { res.send({ queued: entries.length, preimages: entries.map((e) => { - const { preimage, price } = parseEntry(e); - return { preimage, hash: sha256(preimage), price }; + const { preimage, price, fiat, currency } = parseEntry(e); + return { preimage, hash: sha256(preimage), price, fiat, currency }; }), }); } catch (e) { -- cgit v1.2.3