diff options
| -rw-r--r-- | index.ts | 5 | ||||
| -rw-r--r-- | lib/invoices.ts | 39 | ||||
| -rw-r--r-- | lib/payments.ts | 13 | ||||
| -rw-r--r-- | routes/preimages.ts | 76 |
4 files changed, 131 insertions, 2 deletions
@@ -18,6 +18,7 @@ import lnurl from "$routes/lnurl"; import locations from "$routes/locations"; import nostr from "$routes/nostr"; import payments from "$routes/payments"; +import preimages from "$routes/preimages"; import rates from "$routes/rates"; import shopify from "$routes/shopify"; import square from "$routes/square"; @@ -52,6 +53,10 @@ app.post("/invoice", optional, invoices.create); app.post("/invoice/:id", optional, invoices.update); app.post("/sign", auth, invoices.sign); +app.get("/preimages", auth, preimages.list); +app.post("/preimages", auth, preimages.add); +app.post("/preimages/delete", auth, preimages.clear); + app.get("/assetlinks.json", (_, res) => res.sendFile("assetlinks.json")); app.get("/nostr.json", nostr.identities); app.get("/profile/:profile", nostr.profile); 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; diff --git a/routes/preimages.ts b/routes/preimages.ts new file mode 100644 index 00000000..ec58c064 --- /dev/null +++ b/routes/preimages.ts @@ -0,0 +1,76 @@ +import { createHash } from "node:crypto"; +import { db } from "$lib/db"; +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. + +const MAX_QUEUE = 1000; +const key = (uid) => `${uid}:preimages`; +const sha256 = (hex) => + createHash("sha256").update(Buffer.from(hex, "hex")).digest("hex"); + +export default { + async add(req, res) { + try { + const { preimages } = req.body; + if (!Array.isArray(preimages) || !preimages.length) + fail("preimages array required"); + if ( + !preimages.every( + (p) => typeof p === "string" && /^[0-9a-f]{64}$/i.test(p), + ) + ) + fail("preimages must be 64-character hex strings"); + + const k = key(req.user.id); + const existing = await db.lRange(k, 0, -1); + const seen = new Set(existing); + const fresh = []; + for (let p of preimages) { + p = p.toLowerCase(); + if (seen.has(p)) continue; + seen.add(p); + fresh.push(p); + } + + if (existing.length + fresh.length > MAX_QUEUE) + fail(`queue limited to ${MAX_QUEUE} preimages`); + if (fresh.length) await db.rPush(k, fresh); + + res.send({ + queued: existing.length + fresh.length, + added: fresh.map((preimage) => ({ preimage, hash: sha256(preimage) })), + }); + } catch (e) { + bail(res, e.message); + } + }, + + async list(req, res) { + try { + const preimages = await db.lRange(key(req.user.id), 0, -1); + res.send({ + queued: preimages.length, + preimages: preimages.map((preimage) => ({ + preimage, + hash: sha256(preimage), + })), + }); + } catch (e) { + bail(res, e.message); + } + }, + + async clear(req, res) { + try { + await db.del(key(req.user.id)); + res.send({ queued: 0 }); + } catch (e) { + bail(res, e.message); + } + }, +}; |
