diff options
| author | Adam Soltys <[email protected]> | 2026-09-21 19:21:29 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-21 19:22:03 -0700 |
| commit | 5929817e0436979821980abf2f5f4b8a187e8654 (patch) | |
| tree | 6411cfff9323f795c50ba335189925727eba1784 /routes | |
| parent | 5f21055178875821007aec25f32ef4168b24cbbc (diff) | |
| download | coinos-server-5929817e0436979821980abf2f5f4b8a187e8654.tar.gz coinos-server-5929817e0436979821980abf2f5f4b8a187e8654.zip | |
lightning: quote the routing fee up front and add a max-send quote endpoint
Users trying to empty their balance over lightning had to guess a maxfee
that covered both an unknown routing fee and the platform fee, and the
0.5% floor we imposed on that guess made an exact "send everything"
impossible. xpay routes with askrene's getroutes, so ask askrene the same
question before we debit and the fee is known exactly.
lib/lnquote.ts mirrors what xpay does in plugins/xpay/xpay.c (v26.06):
a throwaway private layer carrying the invoice's route hints (bolt11) or
blinded paths via xpay's fake destination node (bolt12), then getroutes
over the same layer stack in the same order. The fee is the sum of what
leaves our node minus what the destination receives, rounded up to a
whole sat since that is what xpay's maxfee reserves.
POST /lightning/quote returns the routing fee, the platform fee exactly
as debit() charges it (free-tier credit, ln:nofree rate, none on
sub-accounts) and the total. With `max` it iterates quote / subtract
fees / requote so amount + both fees lands exactly on the balance; an
offer gets a probe invoice then the real one for the solved amount, and
an LNURL caller can pass `ceiling` and a probe invoice the same way.
sendLightning now quotes for every send. With no explicit fee the quote
becomes the maxfee; an explicit fee below the quote is rejected before
any debit instead of failing inside xpay after one; the old 0.5% floor
on explicit fees is gone. If the quote itself fails we fall back to the
heuristic and let xpay try.
finalize rounds the settled fee up to the whole sat that was quoted and
reserved; rounding a 301 msat fee to 0 refunded a sat that the quote had
already accounted for and stranded it in the account.
The prefer-kappa layer is only passed to xpay when it exists on the node;
getroutes rejects unknown layers outright, which broke every regtest send.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'routes')
| -rw-r--r-- | routes/payments.ts | 128 |
1 files changed, 128 insertions, 0 deletions
diff --git a/routes/payments.ts b/routes/payments.ts index cf0248fa..78c0660b 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -5,6 +5,7 @@ import { archive, db, g, gf, s, sa } from "$lib/db"; import { generate, getUserOffer } from "$lib/invoices"; import { replay } from "$lib/lightning"; import ln from "$lib/ln"; +import { platformFee, quoteMax, quoteRoutingFee } from "$lib/lnquote"; import { err, l, shortError, warn } from "$lib/logging"; import mqtt from "$lib/mqtt"; import { @@ -854,6 +855,133 @@ export default { res.send(await ln.decode(bolt11)); }, + // Up-front cost of a lightning send: the routing fee askrene quotes for this + // invoice (the same answer xpay will get) plus the platform fee debit() will + // charge. With `max`, instead solve for the largest amount whose amount + + // fees exactly clears the account balance — the "send everything" case where + // guessing a maxfee always left dust behind or bounced on insufficient funds. + async quote(req, res) { + const { body, user } = req; + try { + let { payreq, amount, max, aid, ceiling } = body; + if (typeof payreq !== "string" || !payreq.trim()) + fail("Invalid payment request"); + payreq = payreq.replace(/\s/g, "").toLowerCase(); + + if (!aid) aid = user.id; + else if (typeof aid !== "string") fail("Invalid account"); + else if (aid !== user.id) { + const pos = await db.lPos(`${user.id}:accounts`, aid); + if (pos == null) fail("account not found"); + } + + const parseSats = (v, name) => { + if (v === undefined || v === null || v === "") return undefined; + const n = Number.parseInt(v); + if (Number.isNaN(n) || n < 0 || n > SATS) fail(`Invalid ${name}`); + return n; + }; + amount = parseSats(amount, "amount"); + ceiling = parseSats(ceiling, "ceiling"); + const balance = Number.parseInt(await g(`balance:${aid}`)) || 0; + + // Paying another coinos user never leaves the ledger: no routing, no + // platform fee, the whole balance is sendable. + const invoice = await getInvoice(payreq); + if (invoice) { + const recipient = await getUser(invoice.uid); + if (recipient?.username !== "mint") { + const a = max ? balance : (amount ?? invoice.amount); + return res.send({ + amount: a, + fee: 0, + ourfee: 0, + total: a, + balance, + internal: true, + }); + } + } + + let decoded = await ln.decode(payreq); + let pr = payreq; + let fetched: string | undefined; + + // An offer isn't routable by itself — the blinded paths live in the + // invoice fetched from it. Fetch one to learn them; in max mode that's a + // placeholder amount, and the final invoice is fetched once we know the + // real one so the caller can pay exactly what we quoted. + const fetchFromOffer = async (a: number) => { + const { invoice } = await ln.fetchinvoice({ + offer: payreq, + amount_msat: decoded.offer_amount_msat ? undefined : a * 1000, + timeout: 60, + }); + return invoice.replace(/\s/g, "").toLowerCase(); + }; + + if (decoded.type === "bolt12 offer") { + if (decoded.offer_currency) + fail("Currency-denominated offers are not supported"); + const offerAmount = decoded.offer_amount_msat + ? Math.round(decoded.offer_amount_msat / 1000) + : undefined; + if (offerAmount) max = false; + const a = offerAmount ?? amount ?? (max ? balance : undefined); + if (!a) fail("Amount required"); + pr = fetched = await fetchFromOffer(a); + decoded = await ln.decode(pr); + } + + let invAmount: number | undefined; + if (decoded.type === "bolt12 invoice") + invAmount = decoded.invoice_amount_msat + ? Math.round(decoded.invoice_amount_msat / 1000) + : undefined; + else + invAmount = decoded.amount_msat + ? Math.round(decoded.amount_msat / 1000) + : undefined; + + if (max) { + // The invoice's own amount is ignored here: an LNURL caller has to + // name an amount to get any invoice at all, so it probes with one at + // the ceiling, takes the amount we solve for, and fetches the real + // invoice for that. Anything that pays a fixed-amount invoice with + // a different amount fails at send time, not here. + const q = await quoteMax({ pr, uid: user.id, aid, ceiling }); + // Offer: the placeholder invoice carried the balance, not the answer. + if (fetched && q.amount !== invAmount) { + fetched = await fetchFromOffer(q.amount); + // Re-quote against the real invoice: same paths, so same fee, but + // never hand back a number we didn't verify against what gets paid. + const { fee } = await quoteRoutingFee({ pr: fetched, amount: q.amount }); + if (fee !== q.fee) fail("Routing fee changed, please try again"); + } + return res.send({ ...q, ...(fetched ? { payreq: fetched } : {}) }); + } + + const a = invAmount ?? amount; + if (!a) fail("Amount required"); + if (invAmount && amount && amount !== invAmount) + fail("Amount does not match invoice"); + const { fee, parts } = await quoteRoutingFee({ pr, amount: a }); + const ourfee = await platformFee({ uid: user.id, aid, amount: a, fee }); + res.send({ + amount: a, + fee, + ourfee, + parts, + total: a + fee + ourfee, + balance, + ...(fetched ? { payreq: fetched } : {}), + }); + } catch (e) { + warn("problem quoting", user.username, shortError(e.message)); + bail(res, e.message); + } + }, + // The user's standing bolt12 offer (lno1...) — reusable receive code they // can publish (e.g. in a nostr kind 10058 list for bolt12 zaps) async offer(req, res) { |
