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 => { 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, ds
import { fileTypeFromStream } from "file-type";
import pump from "pump";
import Clone from "readable-stream-clone";
import fs from "fs";
import path from "path";
import sharp from "sharp";
import { pipeline } from "stream";
import { g, s, db } from "$lib/db";

export default async (req, res) => {
  let {
    params: { type },
    user
  } = req;
  console.log("TYPE", type);
  if (!["banner", "profile"].includes(type)) throw new Error("invalid upload");

  let data = await req.file();
  let s1 = new Clone(data.file);
  let s2 = new Clone(data.file);

  let [format, ext] = (await fileTypeFromStream(s1)).mime.split("/");

  if (format !== "image" && !["jpg", "jpeg", "png"].includes(ext))
    throw new Error("unsupported file type");

  let name = `${user.id}-${type}.webp`;
  let path = `/app/data/uploads/${name}`;
  let size = type === "banner" ? 1920 : 240;

  let t = sharp()
    .rotate()
    .resize(size)
    .webp();

  await new Promise(r => pipeline(s2, t, fs.createWriteStream(path), r));

  user[type] = true;
  await s(`user:${user.id}`, user);

  res.send(name);
};