aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes/(app)/ln
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-21 19:21:29 -0700
committerAdam Soltys <[email protected]>2026-09-21 19:21:43 -0700
commit671cdb3af944b5e6d39361f4a208547d0e9f7291 (patch)
treef79311f96084d6a28708efd1223e85f18ff56926 /src/routes/(app)/ln
parentdcca73434f93e8cf8a912e55a6e098791277a976 (diff)
downloadcoinos-ui-671cdb3af944b5e6d39361f4a208547d0e9f7291.tar.gz
coinos-ui-671cdb3af944b5e6d39361f4a208547d0e9f7291.zip
send: quote the lightning routing fee up front and add Max to lightning sends
The lightning send page now asks the server what the payment will cost before showing the Send button: the exact routing fee from the route query, the platform fee, and the total. The quoted fee is submitted as the maxfee, so what the user sees is what gets debited; advanced settings still let them override it, and if the quote fails the page falls back to the old 2% reserve. Max on the amount step asks the server for the largest amount whose amount + routing fee + platform fee lands exactly on the balance, and for an offer picks up the invoice fetched for that exact amount. LNURL-pay gets a Max button too. The invoice only exists once we name an amount, so the action fetches a probe invoice at the ceiling, lets the server solve for the amount, then fetches the real invoice for it. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src/routes/(app)/ln')
-rw-r--r--src/routes/(app)/ln/[lnurl]/+page.server.ts55
1 files changed, 54 insertions, 1 deletions
diff --git a/src/routes/(app)/ln/[lnurl]/+page.server.ts b/src/routes/(app)/ln/[lnurl]/+page.server.ts
index f2628ce1..04cff338 100644
--- a/src/routes/(app)/ln/[lnurl]/+page.server.ts
+++ b/src/routes/(app)/ln/[lnurl]/+page.server.ts
@@ -8,7 +8,7 @@ const { decode, fromWords } = bech32;
const lnurlFetch = async (url: string) =>
get(`/lnurl/proxy?url=${encodeURIComponent(url)}`);
-export async function load({ params, parent }) {
+export async function load({ cookies, params, parent }) {
const { user } = await parent();
const rates = await getRates();
@@ -58,6 +58,12 @@ export async function load({ params, parent }) {
error(500, "We only support LNURLp and LNURLw at this time");
data.rate = rates[user?.currency || "USD"];
+ // Lightning sends draw on the main account; the Max button needs its balance
+ if (user && data.tag === "payRequest") {
+ try {
+ ({ balance: data.balance } = await get(`/account/${user.id}`, auth(cookies)));
+ } catch (e) {}
+ }
return data;
}
@@ -88,6 +94,53 @@ export const actions = {
redirect(307, path);
},
+ // Send everything to an LNURL-pay endpoint. The routing cost depends on the
+ // amount and the amount on the routing cost, and the invoice only exists
+ // once we name an amount — so fetch a probe invoice at the most we could
+ // possibly send (its destination and route hints are what the quote needs),
+ // let the server solve for the amount that lands the balance on zero, then
+ // fetch the real invoice for exactly that amount.
+ max: async ({ cookies, request }) => {
+ let { callback, minSendable, maxSendable, comment } = await fd(request);
+ minSendable = Math.round(minSendable / 1000);
+ maxSendable = Math.round(maxSendable / 1000);
+
+ const user = await get("/me", auth(cookies));
+ const { balance } = await get(`/account/${user.id}`, auth(cookies));
+ const ceiling = Math.min(balance, maxSendable);
+ if (ceiling < minSendable)
+ return fail(400, { error: `Amount must be at least ${minSendable} sats` });
+
+ const invoiceFor = async (amount: number) => {
+ const urlObj = new URL(callback);
+ urlObj.searchParams.set("amount", (amount * 1000).toString());
+ if (comment) urlObj.searchParams.set("comment", comment);
+ const { pr, reason } = await lnurlFetch(urlObj.toString());
+ if (!pr) throw new Error(reason || "Could not fetch invoice");
+ return pr;
+ };
+
+ let pr;
+ try {
+ const probe = await invoiceFor(ceiling);
+ const { amount } = await post(
+ "/lightning/quote",
+ { payreq: probe, max: true, ceiling },
+ auth(cookies),
+ );
+ if (amount < minSendable)
+ return fail(400, { error: `Amount must be at least ${minSendable} sats` });
+ pr = await invoiceFor(amount);
+ } catch (e) {
+ const { error } = e as Error & { error?: string };
+ return fail(400, { error: (e as Error).message || error });
+ }
+
+ let path = `/send/lightning/${pr}`;
+ if (comment) path += `/${encodeURIComponent(comment)}`;
+ redirect(307, path);
+ },
+
withdraw: async ({ cookies, fetch, request }) => {
let error;