aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-13 20:21:49 -0700
committerAdam Soltys <[email protected]>2026-07-13 20:22:06 -0700
commit0d55c0ccceb378a9c1992f6e7d82a6786cdd19b8 (patch)
treee6cce8802e6f6159cbe5647eba47be1306d34e01
parent2e90d709178ac718e4f32229951e6ea38dfcc371 (diff)
downloadcoinos-server-0d55c0ccceb378a9c1992f6e7d82a6786cdd19b8.tar.gz
coinos-server-0d55c0ccceb378a9c1992f6e7d82a6786cdd19b8.zip
preimages: enforce a merchant-set price per preimage batch
Invoice creation is unauthenticated, so buyers could name their own price for a queued preimage (create the usePreimage invoice with amount: 1). POST /preimages now takes an optional price (sats) that is stored per entry and overrides the requested amount when the preimage is consumed; omitting it keeps the old any-amount behavior. Also gate the stored-preimage reveal in credit() on the invoice being fully paid, since internal sends can settle partial amounts. Bare-hex entries queued before this change still parse (treated as priceless). Co-Authored-By: Claude Fable 5 <[email protected]>
-rw-r--r--lib/invoices.ts33
-rw-r--r--lib/payments.ts7
-rw-r--r--routes/preimages.ts35
3 files changed, 56 insertions, 19 deletions
diff --git a/lib/invoices.ts b/lib/invoices.ts
index 434a2375..c3722ef5 100644
--- a/lib/invoices.ts
+++ b/lib/invoices.ts
@@ -13,6 +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
+export const parseEntry = (e) => {
+ try {
+ const parsed = JSON.parse(e);
+ if (parsed?.preimage) return { price: null, ...parsed };
+ } catch {}
+ return { preimage: e, price: null };
+};
+
export const generate = async ({ invoice, user }) => {
let {
address_type,
@@ -89,7 +99,6 @@ export const generate = async ({ invoice, user }) => {
expiry ||= 60 * 60 * 24 * 30;
const args = {
- amount_msat: amount ? `${amount + tip}sat` : "any",
label: `${id} ${user.username} ${Date.now()}`,
description: memo || "",
expiry,
@@ -103,10 +112,19 @@ export const generate = async ({ invoice, user }) => {
// 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))) {
+ let entry;
+ while ((entry = await db.lPop(queue))) {
+ const { preimage, price } = 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
+ if (price) amount = price;
try {
- r = await ln.invoice({ ...args, preimage });
+ r = await ln.invoice({
+ ...args,
+ amount_msat: amount ? `${amount + tip}sat` : "any",
+ 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
@@ -121,13 +139,16 @@ export const generate = async ({ invoice, user }) => {
warn("discarding unusable preimage", user.username, e.message);
continue;
}
- await db.lPush(queue, preimage);
+ await db.lPush(queue, entry);
throw e;
}
}
if (!r) fail("no preimages available");
} else {
- r = await ln.invoice(args);
+ r = await ln.invoice({
+ ...args,
+ amount_msat: amount ? `${amount + tip}sat` : "any",
+ });
}
}
diff --git a/lib/payments.ts b/lib/payments.ts
index 01de2eff..df2c8857 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -293,10 +293,13 @@ export const credit = async ({
// 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.
+ // payment preimage and lose the secret the buyer paid for. Only once the
+ // invoice is fully paid, though — internal sends can settle partial
+ // amounts, and an underpayment must not reveal the secret.
if (
inv.paymentHash &&
- ![PaymentType.bitcoin, PaymentType.liquid].includes(type)
+ ![PaymentType.bitcoin, PaymentType.liquid].includes(type) &&
+ inv.received + amount >= inv.amount
) {
const stored = await db.get(`preimage:${inv.paymentHash}`);
if (stored) ref = stored;
diff --git a/routes/preimages.ts b/routes/preimages.ts
index ec58c064..f6632b38 100644
--- a/routes/preimages.ts
+++ b/routes/preimages.ts
@@ -1,12 +1,15 @@
import { createHash } from "node:crypto";
import { db } from "$lib/db";
+import { parseEntry } from "$lib/invoices";
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.
+// 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.
const MAX_QUEUE = 1000;
const key = (uid) => `${uid}:preimages`;
@@ -16,7 +19,7 @@ const sha256 = (hex) =>
export default {
async add(req, res) {
try {
- const { preimages } = req.body;
+ const { preimages, price = null } = req.body;
if (!Array.isArray(preimages) || !preimages.length)
fail("preimages array required");
if (
@@ -25,10 +28,12 @@ export default {
)
)
fail("preimages must be 64-character hex strings");
+ if (price !== null && (!Number.isInteger(price) || price <= 0))
+ fail("price must be a positive integer amount of sats");
const k = key(req.user.id);
const existing = await db.lRange(k, 0, -1);
- const seen = new Set(existing);
+ const seen = new Set(existing.map((e) => parseEntry(e).preimage));
const fresh = [];
for (let p of preimages) {
p = p.toLowerCase();
@@ -39,11 +44,19 @@ export default {
if (existing.length + fresh.length > MAX_QUEUE)
fail(`queue limited to ${MAX_QUEUE} preimages`);
- if (fresh.length) await db.rPush(k, fresh);
+ if (fresh.length)
+ await db.rPush(
+ k,
+ fresh.map((preimage) => JSON.stringify({ preimage, price })),
+ );
res.send({
queued: existing.length + fresh.length,
- added: fresh.map((preimage) => ({ preimage, hash: sha256(preimage) })),
+ added: fresh.map((preimage) => ({
+ preimage,
+ hash: sha256(preimage),
+ price,
+ })),
});
} catch (e) {
bail(res, e.message);
@@ -52,13 +65,13 @@ export default {
async list(req, res) {
try {
- const preimages = await db.lRange(key(req.user.id), 0, -1);
+ const entries = await db.lRange(key(req.user.id), 0, -1);
res.send({
- queued: preimages.length,
- preimages: preimages.map((preimage) => ({
- preimage,
- hash: sha256(preimage),
- })),
+ queued: entries.length,
+ preimages: entries.map((e) => {
+ const { preimage, price } = parseEntry(e);
+ return { preimage, hash: sha256(preimage), price };
+ }),
});
} catch (e) {
bail(res, e.message);