aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes/(app)/send
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)/send
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)/send')
-rw-r--r--src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts45
-rw-r--r--src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte76
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>