aboutsummaryrefslogtreecommitdiffstats
path: root/lib
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 /lib
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]>
Diffstat (limited to 'lib')
-rw-r--r--lib/invoices.ts33
-rw-r--r--lib/payments.ts7
2 files changed, 32 insertions, 8 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;