From d466aab4451a89b2d69a2618f0a739b2e89903c5 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 10 Jul 2026 21:46:40 -0700 Subject: invoices: sell merchant-supplied preimages via a usePreimage queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merchants POST /preimages (auth) to pre-load 32-byte hex preimages into a FIFO queue; creating an invoice with usePreimage: true consumes the next one so the invoice's payment hash is sha256(preimage). Paying the invoice reveals the merchant-chosen secret to the buyer — via their own wallet on external settlements, and via GET /invoice/:id once settled — enabling backend-less digital download stores (encrypted files on static hosting, preimage + public string = decryption key). - GET /preimages lists the queue with payment hashes so the merchant can map files to future invoices; POST /preimages/delete clears it - empty queue fails invoice creation rather than issuing a random preimage the buyer would pay for but couldn't decrypt with - preimages cln rejects as already used are discarded and the next tried - consumed preimages persist at preimage:${payment_hash} (TTL = invoice expiry) and credit() prefers them as the settlement ref, so internal coinos-to-coinos payments reveal the real preimage instead of the sender's uid Co-Authored-By: Claude Fable 5 --- lib/invoices.ts | 39 +++++++++++++++++++++++++++++++++++++-- lib/payments.ts | 13 +++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) (limited to 'lib') diff --git a/lib/invoices.ts b/lib/invoices.ts index 79bf6f19..9f98319d 100644 --- a/lib/invoices.ts +++ b/lib/invoices.ts @@ -2,6 +2,7 @@ import config from "$config"; import { db, g, s } from "$lib/db"; import { request } from "$lib/ecash"; import ln from "$lib/ln"; +import { warn } from "$lib/logging"; import { emit } from "$lib/sockets"; import { SATS, bip21, fail, getInvoice, getUser } from "$lib/utils"; import rpc from "@coinos/rpc"; @@ -32,6 +33,7 @@ export const generate = async ({ invoice, user }) => { memoPrompt, prompt, type = PaymentType.lightning, + usePreimage, webhook, secret, } = invoice; @@ -74,14 +76,47 @@ export const generate = async ({ invoice, user }) => { } else { expiry ||= 60 * 60 * 24 * 30; - r = await ln.invoice({ + const args = { amount_msat: amount ? `${amount + tip}sat` : "any", label: `${id} ${user.username} ${Date.now()}`, description: memo || "", expiry, deschashonly: true, cltv: 19, - }); + }; + + if (usePreimage) { + // Consume the merchant's pre-loaded preimages (FIFO) so the invoice's + // payment hash is sha256(preimage) and paying it reveals a secret the + // 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))) { + try { + r = await ln.invoice({ ...args, 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 + await db.set(`preimage:${r.payment_hash}`, preimage, { + EX: expiry, + }); + break; + } catch (e) { + // cln rejects a preimage whose payment hash it has already seen — + // that one is permanently unusable, so drop it and try the next + if (/preimage/i.test(e.message)) { + warn("discarding unusable preimage", user.username, e.message); + continue; + } + await db.lPush(queue, preimage); + throw e; + } + } + if (!r) fail("no preimages available"); + } else { + r = await ln.invoice(args); + } } hash = r.bolt11; diff --git a/lib/payments.ts b/lib/payments.ts index c715af47..baaa6aa7 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -288,6 +288,19 @@ export const credit = async ({ return; } + // usePreimage invoices keep their merchant-supplied preimage at + // 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. + if ( + inv.paymentHash && + ![PaymentType.bitcoin, PaymentType.liquid].includes(type) + ) { + const stored = await db.get(`preimage:${inv.paymentHash}`); + if (stored) ref = stored; + } + let { path, tip } = inv; tip = Number.parseInt(tip) || 0; -- cgit v1.2.3