diff options
| author | Adam Soltys <[email protected]> | 2026-09-21 19:21:29 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-21 19:21:43 -0700 |
| commit | 671cdb3af944b5e6d39361f4a208547d0e9f7291 (patch) | |
| tree | f79311f96084d6a28708efd1223e85f18ff56926 /src/routes | |
| parent | dcca73434f93e8cf8a912e55a6e098791277a976 (diff) | |
| download | coinos-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')
4 files changed, 182 insertions, 11 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; diff --git a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts index 2a30defa..2b637e75 100644 --- a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts +++ b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts @@ -1,13 +1,35 @@ import getRates from "$lib/rates"; -import { auth, fd, post } from "$lib/utils"; +import { auth, fd, get, post } from "$lib/utils"; import { fail, redirect } from "@sveltejs/kit"; +// Ask the server what this send will actually cost: the routing fee askrene +// quotes for the invoice (the same answer xpay gets when it pays) plus the +// platform fee. The quoted routing fee becomes the payment's maxfee, so what +// the user sees is what gets debited. A failed quote isn't fatal — the page +// falls back to the old manual max-fee reserve. +const quote = async (body, cookies) => { + try { + const q = await post("/lightning/quote", body, auth(cookies)); + return { ...q, quoteError: undefined }; + } catch (e) { + const { message } = e as Error; + return { fee: undefined, ourfee: undefined, quoteError: message }; + } +}; + export async function load({ cookies, params, parent }) { const { user } = await parent(); let data = { ...params }; const rates = await getRates(); data = { ...data, ...(await post("/parse", params, auth(cookies))) }; + // Lightning sends always draw on the main account + const { balance } = await get(`/account/${user.id}`, auth(cookies)); + data.balance = balance; + + if (data.amount) + data = { ...data, ...(await quote({ payreq: params.payreq, amount: data.amount }, cookies)) }; + data.rate = rates[user.currency]; return data; } @@ -27,9 +49,30 @@ export const actions = { data.payreq = invoice; } + data = { ...data, ...(await quote({ payreq: data.payreq || payreq, amount }, cookies)) }; + return data; }, + // Send everything: the server solves for the largest amount whose amount + + // routing fee + platform fee lands exactly on the balance. For an offer it + // also fetches the invoice for that exact amount. + max: async ({ cookies, params }) => { + const { payreq } = params; + try { + const q = await post("/lightning/quote", { payreq, max: true }, auth(cookies)); + let data = { amount: q.amount, fee: q.fee, ourfee: q.ourfee, total: q.total, max: true }; + if (q.payreq) { + data = { ...data, ...(await post("/parse", { payreq: q.payreq }, auth(cookies))) }; + data.payreq = q.payreq; + } + return data; + } catch (e) { + const { message } = e as Error; + return fail(400, { message }); + } + }, + send: async ({ cookies, request }) => { let p; try { diff --git a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte index 105f3ace..c84e5d53 100644 --- a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte +++ b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte @@ -11,7 +11,8 @@ let { data, form } = $props(); - let { alias, memo, payreq, ourfee, user } = $derived({ ...data, ...form }); + let { alias, memo, payreq, ourfee, fee, quoteError, balance, user } = + $derived({ ...data, ...form }); let a = $state(); let { currency, locked } = $derived(user); let locale = loc(user); @@ -27,18 +28,36 @@ let next = $state(); let toggle = () => (show = !show); let amount = $derived(form?.amount || data.amount); + + // The max routing fee we hand the server. When the route was quoted this is + // the exact fee askrene found, so the debit matches what's shown; without a + // quote fall back to the old 2% reserve (any unused part is credited back). + let quoted = $derived(typeof fee === "number"); + let fallback = $derived(Math.max(5, Math.round(amount * 0.02) || 0)); let maxfee = $state( - Math.max(5, Math.round(untrack(() => amount) * 0.02 || 0)), + untrack(() => (typeof fee === "number" ? fee : Math.max(5, Math.round(amount * 0.02) || 0))), + ); + $effect(() => (maxfee = quoted ? fee : fallback)); + + let total = $derived((amount || 0) + (maxfee || 0) + (ourfee || 0)); + + // Server-side messages worth showing verbatim: a balance shortfall, or the + // pre-flight check that the max fee is below what routing now costs. + let verbatim = $derived( + form?.message && + (form.message.includes($t("payments.insufficientFunds")) || + form.message.includes("Routing fee")), ); - $effect(() => (maxfee = Math.max(5, Math.round(amount * 0.02) || 0))); </script> <div class="container px-4 max-w-xl mx-auto text-center space-y-2"> {#if form?.message} <div class="text-red-600 text-center"> - {#if form.message.includes($t("payments.insufficientFunds"))} + {#if verbatim} <div>{form.message}</div> - <div>{$t("payments.lockedBalance")}: {locked}</div> + {#if form.message.includes($t("payments.insufficientFunds"))} + <div>{$t("payments.lockedBalance")}: {locked}</div> + {/if} {:else} {$t("payments.failedToRoute")} {/if} @@ -58,6 +77,23 @@ <span class="break-words font-semibold">{alias}</span> </div> + {#if quoted} + <div class="text-center"> + <h2 class="text-secondary text-lg">{$t("payments.routingFee")}</h2> + + <div class="flex flex-wrap gap-4 justify-center"> + <div class="my-auto"> + <h2 class="text-xl"> + {f(toFiat(fee, $rate), currency)} + </h2> + <h3 class="text-secondary">⚡️{s(fee)}</h3> + </div> + </div> + </div> + {:else if quoteError} + <div class="text-secondary">{$t("payments.noQuote")}</div> + {/if} + {#if ourfee} <div class="text-center"> <h2 class="text-secondary text-lg">{$t("payments.platformFee")}</h2> @@ -73,6 +109,14 @@ </div> {/if} + {#if quoted && (fee || ourfee)} + <div class="text-center"> + <h2 class="text-secondary text-lg">{$t("payments.total")}</h2> + <h2 class="text-xl">{f(toFiat(total, $rate), currency)}</h2> + <h3 class="text-secondary">⚡️{s(total)}</h3> + </div> + {/if} + <form method="POST" use:enhance @@ -141,9 +185,25 @@ bind:rate={$rate} submit={next} /> - <button type="submit" class="btn" bind:this={next} - >{$t("payments.next")}</button - > + + <div class="flex justify-center gap-2"> + <button + type="submit" + class="btn !w-auto grow" + formaction="?/max" + disabled={loading} + onclick={submit} + > + {#if loading} + <Spinner /> + {:else} + Max ⚡️{s(balance)} + {/if} + </button> + <button type="submit" class="btn btn-accent !w-auto grow" bind:this={next} + >{$t("payments.next")}</button + > + </div> </form> {/if} </div> diff --git a/src/routes/(static)/docs/+page.svelte b/src/routes/(static)/docs/+page.svelte index c323f9bd..7cf7e2cb 100644 --- a/src/routes/(static)/docs/+page.svelte +++ b/src/routes/(static)/docs/+page.svelte @@ -162,8 +162,23 @@ </p> <Code sample="fetchInvoice" /> + <h2 class="text-2xl">POST /lightning/quote</h2> + <p class="text-secondary"> + Find out what a lightning payment will cost before sending it. The routing + fee comes from querying the route ahead of time and is exact; pass it as + <b>fee</b> to POST /payments. Pass <b>amount</b> for an amountless invoice, + or <b>max</b> to solve for the largest amount whose amount + fees uses the + whole balance. + </p> + <Code sample="quote" /> + <p>Sample response</p> + <Code sample="quoteResponse" /> + <h2 class="text-2xl">POST /payments</h2> - <p class="text-secondary">Send a lightning payment</p> + <p class="text-secondary"> + Send a lightning payment. <b>fee</b> caps the routing fee; if omitted, the + route is quoted and the exact fee used. + </p> <Code sample="lightning" /> <p class="text-secondary">Send an internal payment to another user</p> |
