diff options
| author | Adam Soltys <[email protected]> | 2026-07-10 21:46:40 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-10 21:46:40 -0700 |
| commit | d466aab4451a89b2d69a2618f0a739b2e89903c5 (patch) | |
| tree | 083502eec7f32d3b142f92ee40d94528a0edaf55 /lib | |
| parent | c5bcf51d6adb1c7718f08ff11a9652762c51669d (diff) | |
| download | coinos-server-d466aab4451a89b2d69a2618f0a739b2e89903c5.tar.gz coinos-server-d466aab4451a89b2d69a2618f0a739b2e89903c5.zip | |
invoices: sell merchant-supplied preimages via a usePreimage queue
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 <[email protected]>
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/invoices.ts | 39 | ||||
| -rw-r--r-- | lib/payments.ts | 13 |
2 files changed, 50 insertions, 2 deletions
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; |
