diff options
Diffstat (limited to 'lib/invoices.ts')
| -rw-r--r-- | lib/invoices.ts | 235 |
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; +}; |
