aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/components/Lnurlp.svelte17
-rw-r--r--src/lib/samples.ts14
-rw-r--r--src/locales/en.json4
-rw-r--r--src/routes/(app)/ln/[lnurl]/+page.server.ts55
-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
-rw-r--r--src/routes/(static)/docs/+page.svelte17
7 files changed, 213 insertions, 15 deletions
diff --git a/src/components/Lnurlp.svelte b/src/components/Lnurlp.svelte
index 8f14ddff..2b715667 100644
--- a/src/components/Lnurlp.svelte
+++ b/src/components/Lnurlp.svelte
@@ -6,11 +6,13 @@
import Numpad from "$comp/Numpad.svelte";
import Spinner from "$comp/Spinner.svelte";
import { pin } from "$lib/store";
+ import { s } from "$lib/utils";
let { data, form, send = $bindable(), comment = $bindable() } = $props();
let { currency } = data.user;
- let { minSendable, maxSendable, commentAllowed, callback, metadata, rate } =
+ let { minSendable, maxSendable, commentAllowed, callback, metadata, rate, balance } =
data;
+ let max = $derived(Math.min(balance || 0, Math.floor(maxSendable / 1000)));
let amount = $state(Math.round(minSendable / 1000)),
loading = $state();
@@ -56,8 +58,17 @@
></textarea>
{/if}
- <div class="flex w-full">
- <button bind:this={send} type="submit" class="btn">
+ <div class="flex w-full gap-2">
+ {#if max >= Math.round(minSendable / 1000)}
+ <button
+ type="submit"
+ class="btn !w-auto grow"
+ formaction="?/max"
+ disabled={loading}
+ onclick={submit}>Max ⚡️{s(max)}</button
+ >
+ {/if}
+ <button bind:this={send} type="submit" class="btn btn-accent !w-auto grow">
{#if loading}
<Spinner />
{:else}
diff --git a/src/lib/samples.ts b/src/lib/samples.ts
index ae7ef6d5..cc6b4039 100644
--- a/src/lib/samples.ts
+++ b/src/lib/samples.ts
@@ -29,6 +29,20 @@ export default {
-d '{
"payreq": "lnbc1pj94d8fsp5n77k340ps4m9jn7kp8he8yynpddvurv6mcsrrqpnq5l2jxdxzlwqpp5m96pqhc5nrlk8cqsu9ufdxxa43sarp8vwf9egvm2pg9nl0zu9r8qdq2vdhkjmn0wvxqztgcqpjrzjqwhmav82kntsppmkp8jp4vg4h9nns78tsy8mg7ve4lq5txrkp0h56zlarvqqdtcqqsqqqqqqqqqqp6cq9q9qyysgqzxlypywzphyujm3ga5j5csfcmqvlnae0fgnvymkaaw94eeg7py5rqzysyjkr3ev2snq63qpsc69vf54adkd0szvmvwt5cuadjnuy95sq4xfa40"
}'`,
+ quote: `curl "${api}/lightning/quote"
+ -H "content-type: application/json"
+ -H "Authorization: Bearer $token"
+ -d '{
+ "payreq": "lnbc1pj94d8fsp5n77k340ps4m9jn7kp8he8yynpddvurv6mcsrrqpnq5l2jxdxzlwqpp5m96pqhc5nrlk8cqsu9ufdxxa43sarp8vwf9egvm2pg9nl0zu9r8qdq2vdhkjmn0wvxqztgcqpjrzjqwhmav82kntsppmkp8jp4vg4h9nns78tsy8mg7ve4lq5txrkp0h56zlarvqqdtcqqsqqqqqqqqqqp6cq9q9qyysgqzxlypywzphyujm3ga5j5csfcmqvlnae0fgnvymkaaw94eeg7py5rqzysyjkr3ev2snq63qpsc69vf54adkd0szvmvwt5cuadjnuy95sq4xfa40",
+ "max": true
+}'`,
+ quoteResponse: `{
+ "amount": 99899,
+ "fee": 1,
+ "ourfee": 100,
+ "total": 100000,
+ "balance": 100000
+}`,
bitcoin: `curl "${api}/bitcoin/send"
-H "content-type: application/json"
-H "Authorization: Bearer $token"
diff --git a/src/locales/en.json b/src/locales/en.json
index 5b369ef7..0c3442dc 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -460,7 +460,9 @@
"ecashDesc": "Ecash tokens are funded with bitcoin. They may be redeemed in exchange for a lightning payment equal to their value or loaded back into a coinos account at any time. They can also be transferred to other ecash wallets either wholly or partially. You might use them to give bitcoin to someone who doesn't have a wallet by texting the token to them or sharing a link to it. Learn more at",
"ecashToken": "Ecash token",
"medium": "Medium",
- "maxFeeDesc": "Set an upper limit on the network routing fee. If set too low the payment may fail. You can set it higher and try again. We debit the full amount while attempting the payment but credit back any unused portion.",
+ "maxFeeDesc": "An upper limit on the network routing fee. We look up the route and its exact fee before sending, so normally this is already right. If set too low the payment may fail; you can set it higher and try again. We debit the full amount while attempting the payment but credit back any unused portion.",
+ "routingFee": "Routing fee",
+ "noQuote": "Couldn't look up the routing fee ahead of time. The max routing fee below is a reserve; any unused portion is credited back.",
"slow": "Slow",
"create": "Create",
"fund": "Fund",
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>