aboutsummaryrefslogtreecommitdiffstats
path: root/lib/invoices.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/invoices.ts')
-rw-r--r--lib/invoices.ts235
1 files changed, 201 insertions, 34 deletions
diff --git a/lib/invoices.ts b/lib/invoices.ts
index d624ac6b..835b1247 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";
@@ -12,14 +13,28 @@ import { PaymentType } from "$lib/types";
const bc = rpc(config.bitcoin);
const lq = rpc(config.liquid);
+// Preimage queue entries are {preimage, price, fiat, currency} 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, fiat: null, currency: null, ...parsed };
+ } catch {}
+ return { preimage: e, price: null, fiat: null, currency: null };
+};
+
export const generate = async ({ invoice, user }) => {
let {
address_type,
bolt11,
+ bolt12,
aid,
currency,
expiry,
fiat,
+ id,
tip,
amount,
items = [],
@@ -29,19 +44,26 @@ export const generate = async ({ invoice, user }) => {
memo,
memoPrompt,
prompt,
+ single_use,
type = PaymentType.lightning,
+ usePreimage,
webhook,
secret,
} = invoice;
- amount = parseInt(amount || 0);
- tip = parseInt(tip) || null;
+ amount = Number.parseInt(amount || 0);
+ tip = tip == null ? null : Number.parseInt(tip);
if (user) user = await getUser(user.username);
if (!user) fail("user not provided");
if (typeof prompt === "undefined") prompt = user.prompt;
- let account = await g(`account:${aid}`);
+ // The invoice shows `user`'s name but credits `aid`, so a supplied aid must
+ // be one of that user's own accounts — otherwise anyone could put a
+ // merchant's name on an invoice that pays their own account.
+ if (aid && aid !== user.id && (await db.lPos(`${user.id}:accounts`, aid)) === null)
+ aid = undefined;
+ let account = aid ? await g(`account:${aid}`) : null;
if (!account) account = await g(`account:${user.id}`);
if (!account) fail("account not found");
aid = account.id;
@@ -51,63 +73,173 @@ export const generate = async ({ invoice, user }) => {
if (!rate) rate = rates[currency];
if (fiat) amount = Math.round((SATS * fiat) / rate);
if (amount < 0) fail("invalid amount");
+ // On-chain deposits below 300 sats are silently dropped by the dust filter in
+ // the /confirm credit path (routes/payments.ts), so reject creating a bitcoin/
+ // liquid invoice for a specific sub-300-sat amount rather than letting a
+ // deposit vanish. amount = 0 (any-amount address) is unaffected.
+ if (
+ (type === PaymentType.bitcoin || type === PaymentType.liquid) &&
+ amount > 0 &&
+ amount < 300
+ )
+ fail(
+ "On-chain deposits must be at least 300 sats. Use a Lightning invoice for smaller amounts.",
+ );
if (tip < 0) fail("invalid tip");
if (rate < 0) fail("invalid rate");
if (memo && memo.length > 5000) fail("memo too long");
- const id = v4();
+ if (!id) id = v4();
+ else {
+ // An internal invoice's id becomes its hash, and credit() writes
+ // `payment:<hash>`. A free-form id like "<txid>:<vout>" therefore lets a
+ // payer overwrite an on-chain deposit's dedup pointer, so ids must be the
+ // uuids we hand out.
+ if (
+ typeof id !== "string" ||
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
+ )
+ fail("invoice not found");
+ // A caller-supplied id re-uses an existing record (tip/webhook update,
+ // repeated bolt12 offer create). It must be the caller's own: the write
+ // below replaces `invoice:<id>` wholesale with uid = this user, and the
+ // old bolt11 pointer keeps resolving to the same id, so overwriting
+ // another user's invoice re-routes whatever their payer sends into this
+ // account. Same error as a missing invoice so ids can't be probed for
+ // existence.
+ const existing = await getInvoice(id);
+ if (existing && existing.uid !== user.id) fail("invoice not found");
+ }
let hash;
let text;
- let path;
-
- if (account.seed) {
- type = "bitcoin";
- const node = rpc({ ...config[type], wallet: aid });
- hash = await node.getNewAddress({ address_type });
- text = bip21(hash, invoice);
+ let paymentHash;
- ({ hdkeypath: path } = await node.getAddressInfo(hash));
- } else if (type === PaymentType.lightning) {
+ if (type === PaymentType.lightning) {
+ // SECURITY: never accept a caller-supplied bolt11. It let anyone register any
+ // node-payable invoice (incl. a nutshell mint quote) as their own coinos
+ // deposit — which both double-credited (coinos balance + mint ecash) and let
+ // an attacker hijack another user's incoming payment (there was no ownership
+ // guard, so it overwrote invoice:<bolt11>). Always mint the invoice here.
+ if (bolt11) fail("Cannot provide your own Lightning invoice");
let r;
- if (bolt11) {
- const { id: nodeid } = await ln.getinfo();
- r = await ln.decode(bolt11);
- if (r.payee !== nodeid) fail("invalid invoice");
- amount = Math.round(r.amount_msat / 1000);
- r.bolt11 = bolt11;
- } else {
+ {
expiry ||= 60 * 60 * 24 * 30;
- r = await ln.invoice({
- amount_msat: amount ? `${amount + tip}sat` : "any",
- label: id,
+
+ const args = {
+ 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 entry;
+ while ((entry = await db.lPop(queue))) {
+ const {
+ preimage,
+ price,
+ fiat: entryFiat,
+ currency: entryCurrency,
+ } = 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. Fiat prices convert
+ // at the current rate, and the invoice takes on the merchant's
+ // currency so receipts show the fiat price as set.
+ if (price) amount = price;
+ else if (entryFiat) {
+ if (!rates[entryCurrency]) {
+ await db.lPush(queue, entry);
+ fail(`no rate available for ${entryCurrency}`);
+ }
+ currency = entryCurrency;
+ rate = rates[currency];
+ amount = Math.round((SATS * entryFiat) / rate);
+ }
+ try {
+ 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
+ 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, entry);
+ throw e;
+ }
+ }
+ if (!r) fail("no preimages available");
+ } else {
+ r = await ln.invoice({
+ ...args,
+ amount_msat: amount ? `${amount + tip}sat` : "any",
+ });
+ }
}
hash = r.bolt11;
text = r.bolt11;
+ paymentHash = r.payment_hash;
} else if (type === PaymentType.bolt12) {
- const r = await ln.offer({
- amount: amount ? `${amount + tip}sat` : "any",
- label: id,
- description: memo || id,
- });
+ // SECURITY: same as bolt11 — never accept a caller-supplied bolt12. Always
+ // mint the offer server-side.
+ if (bolt12) fail("Cannot provide your own bolt12 invoice");
+ let r;
+ {
+ // The offer itself has to carry the expiry: CLN keeps answering
+ // invoice requests for an offer until its absolute_expiry, so an
+ // expiry only stored on our invoice record was never enforced.
+ expiry = Number.parseInt(expiry) || undefined;
+ r = await ln.offer({
+ amount: amount ? `${amount + tip}sat` : "any",
+ label: `${id} ${user.username} ${new Date()}`,
+ description: memo || id,
+ absolute_expiry: expiry
+ ? Math.floor(Date.now() / 1000) + expiry
+ : undefined,
+ single_use: !!single_use || undefined,
+ });
- if (await getInvoice(r.offer_id)) fail("Duplicate offer exists");
+ // CLN returns the same offer_id for identical offer contents, so a
+ // re-create of an offer we already track must not register a second
+ // invoice for it (duplicate :invoices entries — see kaliyi's OCEAN
+ // offer). If it's the same user's offer, hand back the existing record.
+ const existing = await getInvoice(r.offer_id);
+ if (existing) {
+ if (existing.uid === user.id) return existing;
+ fail("Duplicate offer exists");
+ }
+ await s(`invoice:${r.offer_id}`, id);
+ }
hash = r.bolt12;
text = r.bolt12;
-
- await s(`invoice:${r.offer_id}`, id);
} else if (type === PaymentType.bitcoin) {
address_type ||= "bech32";
hash = await bc.getNewAddress({ address_type });
text = bip21(hash, invoice);
} else if (type === PaymentType.liquid) {
+ if (await g("liquid:deposits:disabled"))
+ fail("Liquid deposits are temporarily disabled");
address_type ||= "blech32";
hash = await lq.getNewAddress({ address_type });
text = bip21(hash, invoice);
@@ -132,12 +264,12 @@ export const generate = async ({ invoice, user }) => {
items,
memo,
rate,
+ paymentHash,
pending: 0,
received: 0,
request_id,
memoPrompt,
own,
- path,
prompt,
secret,
text,
@@ -154,7 +286,13 @@ export const generate = async ({ invoice, user }) => {
await s(`invoice:${hash}`, id);
await s(`invoice:${id}`, invoice);
- await db.lPush(`${aid}:invoices`, id);
+ // generate() is also called on UPDATE (re-using an existing invoice id, e.g.
+ // a tip/webhook change or a repeated bolt12 offer create), which must NOT
+ // re-append the id to the list. Only add it the first time — otherwise the
+ // user's :invoices accumulates duplicate entries and the same offer/invoice
+ // renders multiple times (see kaliyi's OCEAN offer, listed 2-6x).
+ if ((await db.lPos(`${aid}:invoices`, id)) === null)
+ await db.lPush(`${aid}:invoices`, id);
if (request_id) {
const request = await g(`request:${request_id}`);
@@ -169,3 +307,32 @@ export const generate = async ({ invoice, user }) => {
return invoice;
};
+
+// Each user gets one standing amountless bolt12 offer (lno1...) they can hand
+// out as a reusable payment code — e.g. published in a kind 10058 list for
+// NIP-177 bolt12 zaps. Created lazily on first request, then reused; incoming
+// payments credit through the regular bolt12 receive path via local_offer_id.
+export const getUserOffer = async (user) => {
+ const key = `${user.id}:offer`;
+ const existingId = await g(key);
+ if (existingId) {
+ const existing = await g(`invoice:${existingId}`);
+ if (existing?.hash) return existing;
+ }
+
+ let domain;
+ try {
+ domain = new globalThis.URL(process.env.URL).host;
+ } catch (e) {}
+
+ const invoice = await generate({
+ invoice: {
+ type: PaymentType.bolt12,
+ memo: domain ? `${user.username}@${domain}` : user.username,
+ },
+ user,
+ });
+
+ await s(key, invoice.id);
+ return invoice;
+};