aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--index.ts14
-rw-r--r--lib/lnquote.ts303
-rw-r--r--lib/payments.ts45
-rw-r--r--routes/payments.ts128
4 files changed, 482 insertions, 8 deletions
diff --git a/index.ts b/index.ts
index ef353486..2c386556 100644
--- a/index.ts
+++ b/index.ts
@@ -91,6 +91,20 @@ app.post("/send/:lnaddress/:amount", auth, payments.lnaddress);
app.post("/send", auth, payments.internal);
app.post("/replace", auth, payments.replace);
app.get("/decode/:bolt11", payments.decode);
+app.post(
+ "/lightning/quote",
+ {
+ ...auth,
+ config: {
+ rateLimit: {
+ max: 20,
+ timeWindow: 10000,
+ keyGenerator: (req) => `lnquote:${(req.headers.authorization || "").slice(0, 50) || (req.headers["cf-connecting-ip"] as string) || req.ip}`,
+ },
+ },
+ },
+ payments.quote,
+);
app.get("/offer", auth, payments.offer);
app.post("/fetchinvoice", payments.fetchinvoice);
diff --git a/lib/lnquote.ts b/lib/lnquote.ts
new file mode 100644
index 00000000..de96f69a
--- /dev/null
+++ b/lib/lnquote.ts
@@ -0,0 +1,303 @@
+import config from "$config";
+import { g } from "$lib/db";
+import ln from "$lib/ln";
+import { warn } from "$lib/logging";
+import { PaymentType } from "$lib/types";
+import { fail } from "$lib/utils";
+import { v4 } from "uuid";
+
+// Up-front routing-fee quotes for lightning sends.
+//
+// xpay routes with askrene's `getroutes`, so asking askrene the same question
+// before we debit tells us exactly what the payment will cost — instead of
+// reserving a guessed maxfee and refunding the unused part afterwards. We
+// mirror what xpay does in plugins/xpay/xpay.c (v26.06): a throwaway private
+// layer carrying the invoice's route hints (bolt11) or blinded paths (bolt12,
+// via a fake destination node), then getroutes over the same layer stack in
+// the same order (auto.localchans, auto.sourcefree, xpay, private, user).
+
+// xpay's fake destination for blinded paths (xpay.c: pubkey 02 || 0..01).
+const FAKE_NODE =
+ "020000000000000000000000000000000000000000000000000000000000000001";
+
+const XPAY_MAXDELAY = 2016; // xpay's default maxdelay
+
+// Extra layers sendLightning passes to xpay. xpay forwards user layers to
+// getroutes verbatim, and getroutes rejects an unknown layer outright, so only
+// pass the ones that actually exist on this node (prod has prefer-kappa,
+// regtest doesn't). Cached briefly — layers don't come and go.
+const WANTED_LAYERS = ["prefer-kappa"];
+let layerCache: { at: number; layers: string[] } | undefined;
+export const xpayLayers = async (): Promise<string[]> => {
+ if (layerCache && Date.now() - layerCache.at < 60_000)
+ return layerCache.layers;
+ let layers: string[] = [];
+ try {
+ const r = await ln.call("askrene-listlayers", {});
+ const existing = new Set((r?.layers ?? []).map((l: any) => l.layer));
+ layers = WANTED_LAYERS.filter((l) => existing.has(l));
+ } catch (e: any) {
+ warn("askrene-listlayers failed", e?.message ?? String(e));
+ return [];
+ }
+ layerCache = { at: Date.now(), layers };
+ return layers;
+};
+
+let ourId: string | undefined;
+const nodeId = async () => {
+ if (!ourId) ({ id: ourId } = await ln.getinfo());
+ return ourId;
+};
+
+// Channel direction bit as CLN defines it: 0 when the source node id sorts
+// before the destination's (node_id_idx). Node ids are same-length lowercase
+// hex, so plain string comparison matches the byte comparison.
+const dir = (src: string, dst: string) => (src < dst ? 0 : 1);
+
+const isHex66 = (s: any) =>
+ typeof s === "string" && /^0[23][0-9a-f]{64}$/.test(s);
+
+// The routing-relevant parts of a decoded invoice: where it goes, the final
+// cltv, and the private topology xpay would add. Fails on anything that isn't
+// a bolt11 invoice or a bolt12 invoice (offers must be fetched first).
+const routingTarget = async (decoded: any) => {
+ const me = await nodeId();
+
+ if (decoded.type === "bolt11 invoice") {
+ const dest = decoded.payee;
+ if (!isHex66(dest)) fail("Invoice has no payee");
+ const hints: any[][] = decoded.routes ?? [];
+ return {
+ dest,
+ finalCltv: decoded.min_final_cltv_expiry ?? 18,
+ hints,
+ paths: [],
+ me,
+ };
+ }
+
+ if (decoded.type === "bolt12 invoice") {
+ // decode nests each path's payinfo inside the path (v26); older output
+ // put them in a parallel invoice_blindedpay array. Accept both.
+ const paths = decoded.invoice_paths ?? [];
+ const payinfos = decoded.invoice_blindedpay ?? [];
+ return {
+ dest: decoded.invoice_node_id,
+ finalCltv: 0, // xpay: unknown for blinded paths, payinfo carries the delta
+ hints: [],
+ paths: paths.map((p: any, i: number) => ({
+ path: p,
+ payinfo: p.payinfo ?? payinfos[i],
+ })),
+ me,
+ };
+ }
+
+ fail(`Cannot quote a ${decoded.type}`);
+};
+
+// Ask askrene what delivering `amount` sats along this invoice would cost.
+// Returns the routing fee in sats (rounded up — xpay's maxfee is msat, but
+// everything we store is sats, and a cap that's a few msat short fails hard).
+export const quoteRoutingFee = async ({
+ pr,
+ amount,
+}: {
+ pr: string;
+ amount: number;
+}) => {
+ amount = Number.parseInt(amount as any);
+ if (!(amount > 0)) fail("Invalid amount");
+ const amountMsat = amount * 1000;
+
+ const decoded = await ln.decode(pr.replace(/\s/g, "").toLowerCase());
+ const { dest, finalCltv, hints, paths, me } = await routingTarget(decoded);
+
+ // Self-pay: xpay short-circuits to an inject with no routing at all.
+ if (dest === me) return { fee: 0, feeMsat: 0, parts: 0 };
+
+ // Search cap for getroutes. Generous on purpose: this bounds the search,
+ // and the caller decides what to do with the answer.
+ const cap = Math.max(50_000, Math.ceil(amountMsat * 0.05));
+ // xpay sizes its fake hint channels at 100x (amount + maxfee).
+ const bigCap = (amountMsat + cap) * 100;
+
+ let destination = dest;
+ const fakes: any[] = [];
+ for (const route of hints) {
+ for (let i = 0; i < route.length; i++) {
+ const hop = route[i];
+ const next = i + 1 < route.length ? route[i + 1].pubkey : dest;
+ // xpay skips hints from ourselves — we know our own channels.
+ if (hop.pubkey === me) continue;
+ if (hop.pubkey === next) continue; // askrene rejects self-loops
+ fakes.push({
+ src: hop.pubkey,
+ dst: next,
+ scid: hop.short_channel_id,
+ cap: bigCap,
+ htlcMin: 0,
+ htlcMax: bigCap,
+ base: hop.fee_base_msat ?? 0,
+ ppm: hop.fee_proportional_millionths ?? 0,
+ delta: hop.cltv_expiry_delta ?? 0,
+ });
+ }
+ }
+
+ if (paths.length) {
+ destination = FAKE_NODE;
+ let n = 0;
+ for (const [i, { path, payinfo }] of paths.entries()) {
+ // Only introduction points given as a node id can be resolved here;
+ // xpay resolves scid-form ones through gossmap and discards the rest.
+ const first = path?.first_node_id;
+ if (!isHex66(first) || !payinfo) continue;
+ // Earlier paths get more capacity so askrene prefers them, like xpay.
+ const c = (amountMsat + cap) * (100 + (paths.length - i));
+ fakes.push({
+ src: first,
+ dst: FAKE_NODE,
+ scid: `0x0x${i}`, // block 0 is impossible, so it can't collide
+ cap: c,
+ htlcMin: payinfo.htlc_minimum_msat ?? 0,
+ htlcMax: payinfo.htlc_maximum_msat ?? c,
+ base: payinfo.fee_base_msat ?? 0,
+ ppm: payinfo.fee_proportional_millionths ?? 0,
+ delta: payinfo.cltv_expiry_delta ?? 0,
+ });
+ n++;
+ }
+ if (!n) fail("No usable blinded paths");
+ }
+
+ const layer = `quote-${v4()}`;
+ try {
+ await ln.call("askrene-create-layer", { layer, persistent: false });
+ for (const f of fakes) {
+ await ln.call("askrene-create-channel", {
+ layer,
+ source: f.src,
+ destination: f.dst,
+ short_channel_id: f.scid,
+ capacity_msat: f.cap,
+ });
+ await ln.call("askrene-update-channel", {
+ layer,
+ short_channel_id_dir: `${f.scid}/${dir(f.src, f.dst)}`,
+ enabled: true,
+ htlc_minimum_msat: f.htlcMin,
+ htlc_maximum_msat: f.htlcMax,
+ fee_base_msat: f.base,
+ fee_proportional_millionths: f.ppm,
+ cltv_expiry_delta: f.delta,
+ });
+ }
+
+ const r = await ln.call("getroutes", {
+ source: me,
+ destination,
+ amount_msat: amountMsat,
+ // Same stack and order as xpay: our channels re-added with real fees,
+ // then zeroed as the source, then xpay's learned constraints.
+ layers: [
+ "auto.localchans",
+ "auto.sourcefree",
+ "xpay",
+ layer,
+ ...(await xpayLayers()),
+ ],
+ maxfee_msat: cap,
+ final_cltv: finalCltv,
+ maxdelay: XPAY_MAXDELAY,
+ });
+
+ const routes: any[] = r?.routes ?? [];
+ if (!routes.length) fail("No route found");
+ const sent = routes.reduce(
+ (s, rt) => s + Number(rt.path?.[0]?.amount_msat ?? 0),
+ 0,
+ );
+ const delivered = routes.reduce(
+ (s, rt) => s + Number(rt.amount_msat ?? 0),
+ 0,
+ );
+ if (
+ !Number.isFinite(sent) ||
+ !Number.isFinite(delivered) ||
+ delivered < amountMsat
+ )
+ fail("No route found");
+ const feeMsat = Math.max(0, sent - delivered);
+ return { fee: Math.ceil(feeMsat / 1000), feeMsat, parts: routes.length };
+ } finally {
+ try {
+ await ln.call("askrene-remove-layer", { layer });
+ } catch (e: any) {
+ warn("askrene-remove-layer failed", layer, e?.message ?? String(e));
+ }
+ }
+};
+
+// Platform fee for a lightning send, exactly as debit() will charge it:
+// rate × (amount + routing fee), less any free-tier credit, and nothing at
+// all on sub-accounts.
+export const platformFee = async ({
+ uid,
+ aid,
+ amount,
+ fee,
+}: {
+ uid: string;
+ aid: string;
+ amount: number;
+ fee: number;
+}) => {
+ if (aid !== uid) return 0;
+ let rate = config.fee[PaymentType.lightning];
+ if (await g(`ln:nofree:${uid}`)) rate = config.fee.lightningHigh ?? rate;
+ const credit = Number.parseInt(await g(`credit:lightning:${uid}`)) || 0;
+ const ourfee = Math.round((amount + fee) * rate);
+ return Math.max(0, ourfee - Math.min(credit, ourfee));
+};
+
+// The most the account can deliver along this invoice such that amount +
+// routing fee + platform fee lands exactly on the balance. Both fees grow
+// with the amount, so iterate: quote at the balance, subtract the fees, quote
+// again at the smaller amount. Each step's fees are ≥ the next step's (fees
+// are monotone in amount), so from the second quote on the total never
+// exceeds the balance; the extra rounds only shave the last sat or two.
+export const quoteMax = async ({
+ pr,
+ uid,
+ aid,
+ ceiling,
+}: {
+ pr: string;
+ uid: string;
+ aid: string;
+ ceiling?: number;
+}) => {
+ const balance = Number.parseInt(await g(`balance:${aid}`)) || 0;
+ let amount = balance;
+ if (ceiling > 0) amount = Math.min(amount, Math.floor(ceiling));
+ if (amount <= 0) fail("Insufficient funds");
+
+ let fee = 0;
+ let ourfee = 0;
+ for (let i = 0; i < 5; i++) {
+ ({ fee } = await quoteRoutingFee({ pr, amount }));
+ ourfee = await platformFee({ uid, aid, amount, fee });
+ const next = Math.min(
+ balance - fee - ourfee,
+ ceiling > 0 ? Math.floor(ceiling) : Number.POSITIVE_INFINITY,
+ );
+ if (next <= 0) fail("Insufficient funds");
+ if (next === amount) break;
+ amount = next;
+ }
+
+ if (amount + fee + ourfee > balance) fail("Insufficient funds");
+ return { amount, fee, ourfee, balance, total: amount + fee + ourfee };
+};
diff --git a/lib/payments.ts b/lib/payments.ts
index bd706b71..2c18f399 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -20,6 +20,7 @@ const isWithdrawLocked = (type: string): string | null => {
};
import { generate } from "$lib/invoices";
import ln from "$lib/ln";
+import { quoteRoutingFee, xpayLayers } from "$lib/lnquote";
import { err, l, shortError, warn } from "$lib/logging";
import { handleZap } from "$lib/nostr";
import { notify, nwcNotify } from "$lib/notifications";
@@ -965,12 +966,35 @@ export const sendLightning = async ({
}
const amt = amount_msat ? Math.round(amount_msat / 1000) : amount;
- let minfee = Math.max(5, Math.round(amt * 0.005));
- const { channels } = await ln.listpeerchannels();
- if (channels.some((c) => c.peer_id === payee)) minfee = 0;
- fee = Math.max(Number.parseInt(fee) || minfee, minfee);
- if (fee < 0) fail("Fee cannot be negative");
+ // Ask askrene up front what routing this will cost — the same query xpay
+ // runs — so the maxfee we reserve is the real fee, not a guess. A caller
+ // that names its own cap keeps it (finalize refunds whatever xpay doesn't
+ // spend), but a cap below the quoted fee is rejected here, before any
+ // debit, instead of failing inside xpay after one. If the quote itself
+ // fails (askrene unavailable, no route) fall back to the old heuristic and
+ // let xpay have a go.
+ let quoted: number | undefined;
+ try {
+ ({ fee: quoted } = await quoteRoutingFee({ pr, amount: amt }));
+ } catch (e: any) {
+ warn("route quote failed", pr.substr(-8), shortError(e?.message));
+ }
+
+ const explicit = Number.parseInt(fee);
+ if (Number.isFinite(explicit)) {
+ if (explicit < 0) fail("Fee cannot be negative");
+ if (quoted !== undefined && explicit < quoted)
+ fail(`Routing fee of ⚡️${quoted} required, max fee is ⚡️${explicit}`);
+ fee = explicit;
+ } else if (quoted !== undefined) {
+ fee = quoted;
+ } else {
+ let minfee = Math.max(5, Math.round(amt * 0.005));
+ const { channels } = await ln.listpeerchannels();
+ if (channels.some((c) => c.peer_id === payee)) minfee = 0;
+ fee = minfee;
+ }
const { pays } = await ln.listpays(pr);
if (pays.find((p) => p.status === "complete"))
@@ -1028,7 +1052,7 @@ export const sendLightning = async ({
// still driving and we refund one that settles (the LEAKED DEBIT
// losses). Change both together.
retry_for: 60,
- layers: ["prefer-kappa"],
+ layers: await xpayLayers(),
});
// Only log the xpay response when there's a real concern — no preimage
@@ -1425,7 +1449,12 @@ const finalize = async (r, p) => {
const decoded = await ln.decode(p.hash);
const invMsat = Number(decoded.amount_msat ?? r.amount_msat);
const sentMsat = Number(r.amount_sent_msat);
- const computedFee = Math.round((sentMsat - invMsat) / 1000);
+ // Round UP: the routing fee was quoted and reserved in whole sats (a
+ // 301 msat route is a 1 sat maxfee — xpay's cap is exact and a few msat
+ // short fails the payment), so charge the sat that was quoted rather than
+ // rounding 301 msat to 0 and refunding it. Otherwise a "send everything"
+ // computed from the quote strands a single sat in the account.
+ const computedFee = Math.ceil((sentMsat - invMsat) / 1000);
if (!Number.isFinite(computedFee)) {
warn("finalize: non-finite fee compute, keeping reserved maxfee", p.id,
"sent_msat=", r.amount_sent_msat, "inv_msat=", decoded.amount_msat ?? r.amount_msat);
@@ -1469,7 +1498,7 @@ const finalizeKeysend = async (r, p, preimage) => {
const maxfee = p.fee;
const sentMsat = Number(r.amount_sent_msat);
- const actualFee = Math.round((sentMsat - Math.abs(p.amount) * 1000) / 1000);
+ const actualFee = Math.ceil((sentMsat - Math.abs(p.amount) * 1000) / 1000);
p.fee = Number.isFinite(actualFee) && actualFee >= 0 ? actualFee : maxfee;
p.ref = preimage;
p.confirmed = true;
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) {