diff options
Diffstat (limited to 'src/routes/(app)')
3 files changed, 166 insertions, 10 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> |
