aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--index.ts5
-rw-r--r--lib/invoices.ts39
-rw-r--r--lib/payments.ts13
-rw-r--r--routes/preimages.ts76
4 files changed, 131 insertions, 2 deletions
diff --git a/index.ts b/index.ts
index 808252de..cbe08b3a 100644
--- a/index.ts
+++ b/index.ts
@@ -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, config: { rateLimit: { max: 30, timeWindow:
app.post("/invoice/:id", { ...optional, config: { rateLimit: { max: 30, timeWindow: 10000, keyGenerator: (req) => `invoiceupdate:${(req.headers["cf-connecting-ip"] as string) || req.ip}` } } }, 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 fb81914f..434a2375 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;
@@ -86,14 +88,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 c256171c..01de2eff 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -289,6 +289,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 } = inv;
// Use the tip the sender was actually debited (passed in for internal
// transfers) rather than re-reading the invoice. The invoice's tip can be
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);
+ }
+ },
+};