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/(app)/send | |
| 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/(app)/send')
| -rw-r--r-- | src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts | 45 | ||||
| -rw-r--r-- | src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte | 76 |
2 files changed, 112 insertions, 9 deletions
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> |
