aboutsummaryrefslogtreecommitdiffstats
path: root/lib/lnquote.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/lnquote.ts')
-rw-r--r--lib/lnquote.ts303
1 files changed, 303 insertions, 0 deletions
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 };
+};