aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes')
-rw-r--r--src/routes/(app)/+layout.server.ts19
-rw-r--r--src/routes/(app)/+layout.svelte45
-rw-r--r--src/routes/(app)/[username]/(profile)/+layout.svelte87
-rw-r--r--src/routes/(app)/[username]/(profile)/+page.svelte33
-rw-r--r--src/routes/(app)/[username]/accepted/[text]/+page.server.ts6
-rw-r--r--src/routes/(app)/[username]/accepted/[text]/+page.svelte57
-rw-r--r--src/routes/(app)/[username]/accepted/[text]/raw/+server.ts16
-rw-r--r--src/routes/(app)/[username]/accepted/create/+page.svelte64
-rw-r--r--src/routes/(app)/[username]/receive/+page.svelte25
-rw-r--r--src/routes/(app)/account/[id]/+page.svelte2
-rw-r--r--src/routes/(app)/account/import/+page.svelte2
-rw-r--r--src/routes/(app)/account/pass/+page.svelte2
-rw-r--r--src/routes/(app)/account/seed/+page.svelte2
-rw-r--r--src/routes/(app)/admin/+layout.server.ts10
-rw-r--r--src/routes/(app)/ecash/[id]/[...version]/+layout.server.ts13
-rw-r--r--src/routes/(app)/flash/+page.server.ts58
-rw-r--r--src/routes/(app)/flash/+page.svelte360
-rw-r--r--src/routes/(app)/fund/[id]/+layout.server.ts12
-rw-r--r--src/routes/(app)/fund/[id]/+page.server.ts13
-rw-r--r--src/routes/(app)/fund/[id]/+page.svelte26
-rw-r--r--src/routes/(app)/fund/[id]/access/+page.server.ts3
-rw-r--r--src/routes/(app)/fund/[id]/access/+page.svelte13
-rw-r--r--src/routes/(app)/fund/[id]/authorize/[...amount]/+page.server.ts36
-rw-r--r--src/routes/(app)/fund/[id]/authorize/[...amount]/+page.svelte27
-rw-r--r--src/routes/(app)/fund/[id]/gift/+page.server.ts6
-rw-r--r--src/routes/(app)/fund/[id]/gift/+page.svelte40
-rw-r--r--src/routes/(app)/fund/[id]/sweep/+page.server.ts13
-rw-r--r--src/routes/(app)/fund/[id]/sweep/+page.svelte41
-rw-r--r--src/routes/(app)/fund/[id]/sweep/[...amount]/+page.server.ts56
-rw-r--r--src/routes/(app)/fund/[id]/sweep/[...amount]/+page.svelte36
-rw-r--r--src/routes/(app)/fund/[id]/withdraw/+page.svelte8
-rw-r--r--src/routes/(app)/funder/+page.svelte55
-rw-r--r--src/routes/(app)/invoice/[id]/+layout.server.ts56
-rw-r--r--src/routes/(app)/invoice/[id]/+page.svelte43
-rw-r--r--src/routes/(app)/invoice/[id]/paid/+page.svelte16
-rw-r--r--src/routes/(app)/invoice/[id]/tip/+page.server.ts48
-rw-r--r--src/routes/(app)/invoice/[id]/tip/+page.svelte24
-rw-r--r--src/routes/(app)/invoices/+page.svelte129
-rw-r--r--src/routes/(app)/ln/[lnurl]/+page.server.ts124
-rw-r--r--src/routes/(app)/ln/[lnurl]/+page.svelte8
-rw-r--r--src/routes/(app)/messages/[recipient]/+page.svelte2
-rw-r--r--src/routes/(app)/migrate/+page.server.ts106
-rw-r--r--src/routes/(app)/migrate/+page.svelte82
-rw-r--r--src/routes/(app)/nsec/+server.ts (renamed from src/routes/(app)/ecash/mint/+server.ts)4
-rw-r--r--src/routes/(app)/pay/[username]/[...amount]/+page.server.ts8
-rw-r--r--src/routes/(app)/pay/[username]/[...amount]/+page.svelte21
-rw-r--r--src/routes/(app)/payment/[id]/+page.svelte23
-rw-r--r--src/routes/(app)/pos/+page.ts6
-rw-r--r--src/routes/(app)/qr/[text]/[icon]/+server.ts33
-rw-r--r--src/routes/(app)/qr/[text]/raw/+server.ts1
-rw-r--r--src/routes/(app)/receive/[...token]/+page.server.ts10
-rw-r--r--src/routes/(app)/reset/[code]/+page.server.ts3
-rw-r--r--src/routes/(app)/reset/[code]/+page.svelte60
-rw-r--r--src/routes/(app)/send/[...text]/+page.server.ts6
-rw-r--r--src/routes/(app)/send/[...text]/+page.svelte75
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/+page.svelte6
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.server.ts2
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte25
-rw-r--r--src/routes/(app)/send/ecash/+page.server.ts26
-rw-r--r--src/routes/(app)/send/ecash/+page.svelte63
-rw-r--r--src/routes/(app)/send/ecash/[request]/+page.server.ts26
-rw-r--r--src/routes/(app)/send/ecash/[request]/+page.svelte128
-rw-r--r--src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte34
-rw-r--r--src/routes/(app)/send/invoice/[id]/+page.server.ts36
-rw-r--r--src/routes/(app)/send/invoice/[id]/+page.svelte23
-rw-r--r--src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts49
-rw-r--r--src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte104
-rw-r--r--src/routes/(app)/settings/+layout.svelte14
-rw-r--r--src/routes/(app)/settings/account/+page.server.ts6
-rw-r--r--src/routes/(app)/settings/account/+page.svelte40
-rw-r--r--src/routes/(app)/settings/nostr/+page.server.ts8
-rw-r--r--src/routes/(app)/settings/nostr/+page.svelte119
-rw-r--r--src/routes/(app)/settings/profile/+page.svelte20
-rw-r--r--src/routes/(app)/settings/security/+page.svelte12
-rw-r--r--src/routes/(app)/settings/shopify/+page.svelte2
-rw-r--r--src/routes/(app)/settings/square/+page.server.ts18
-rw-r--r--src/routes/(app)/settings/square/+page.svelte0
-rw-r--r--src/routes/(static)/+layout.server.ts9
-rw-r--r--src/routes/(static)/bounties/+page.server.ts44
-rw-r--r--src/routes/(static)/bounties/+page.svelte231
-rw-r--r--src/routes/(static)/docs/+page.server.ts5
-rw-r--r--src/routes/(static)/docs/+page.svelte64
-rw-r--r--src/routes/(static)/forgot/+page.svelte53
-rw-r--r--src/routes/(static)/login/+page.server.ts26
-rw-r--r--src/routes/(static)/login/+page.svelte85
-rw-r--r--src/routes/(static)/register/+page.server.ts40
-rw-r--r--src/routes/(static)/register/+page.svelte61
-rw-r--r--src/routes/(static)/support/+page.svelte31
-rw-r--r--src/routes/+layout.server.ts21
-rw-r--r--src/routes/+layout.svelte67
-rw-r--r--src/routes/+page.server.ts2
-rw-r--r--src/routes/phtest/+page.svelte1
92 files changed, 2661 insertions, 914 deletions
diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts
index e28d8b56..8b694241 100644
--- a/src/routes/(app)/+layout.server.ts
+++ b/src/routes/(app)/+layout.server.ts
@@ -1,4 +1,4 @@
-import { auth, get, sleep } from "$lib/utils";
+import { auth, get, isInvalidTokenError, sleep } from "$lib/utils";
import { error, redirect } from "@sveltejs/kit";
export async function load({ cookies, request, url, params }) {
@@ -12,8 +12,21 @@ export async function load({ cookies, request, url, params }) {
user = await get("/me", auth(cookies));
} catch (e) {
const { message } = e as Error;
- if (message.startsWith("Rate")) await sleep(3000);
- user = await get("/me", auth(cookies));
+ if (message.startsWith("Rate")) {
+ await sleep(3000);
+ try {
+ user = await get("/me", auth(cookies));
+ } catch (retryError) {
+ if (isInvalidTokenError(retryError) && pathname !== "/logout") {
+ redirect(307, "/logout");
+ }
+ throw retryError;
+ }
+ } else if (isInvalidTokenError(e) && pathname !== "/logout") {
+ redirect(307, "/logout");
+ } else {
+ throw e;
+ }
}
}
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index af4dbf2c..830e5291 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -46,22 +46,35 @@
checkSocket();
$pin = getCookie("pin");
- if (window.NDEFReader && user.nfc) {
- try {
- $ndef = new NDEFReader();
- await $ndef.scan();
-
- $ndef.addEventListener("readingerror", (e) => {
- console.log("nfc error", e);
- });
-
- $ndef.addEventListener("reading", ({ message, serialNumber }) => {
- goto(`/send/${message}`);
- });
- } catch (error) {
- console.log("Argh! " + error);
- }
- }
+ // if (window.NDEFReader) {
+ // try {
+ // $ndef = new NDEFReader();
+ // await $ndef.scan();
+ //
+ // $ndef.addEventListener("readingerror", (e) => {
+ // console.log("nfc error", e);
+ // });
+ //
+ // $ndef.addEventListener("reading", ({ message, serialNumber }) => {
+ // console.log("MESSAGE", message);
+ //
+ // const record = message.records[0];
+ //
+ // if (record.recordType === "url") {
+ // const textDecoder = new TextDecoder();
+ // const url = textDecoder.decode(record.data);
+ // console.log("URL:", url);
+ //
+ // // Navigate to the URL or use it however you want
+ // goto(`/send/${encodeURIComponent(url)}`);
+ // } else {
+ // console.error("Unsupported record type:", record.recordType);
+ // }
+ // });
+ // } catch (error) {
+ // console.log("Argh! " + error);
+ // }
+ // }
}
});
diff --git a/src/routes/(app)/[username]/(profile)/+layout.svelte b/src/routes/(app)/[username]/(profile)/+layout.svelte
index afd94ab0..b384344e 100644
--- a/src/routes/(app)/[username]/(profile)/+layout.svelte
+++ b/src/routes/(app)/[username]/(profile)/+layout.svelte
@@ -16,12 +16,13 @@
} from "$lib/utils";
import { t } from "$lib/translations";
import { sign, send } from "$lib/nostr";
- import { bech32m } from "@scure/base";
+ import { bech32 } from "@scure/base";
import { page } from "$app/stores";
+ import { PUBLIC_DOMAIN } from "$env/static/public";
let { data, children } = $props();
- let { encode, toWords } = bech32m;
+ let { encode, toWords } = bech32;
let {
events,
@@ -107,6 +108,17 @@
: `${stripped}@${$page.url.host}`,
);
let profile = $derived(`${$page.url.host}/${subject.anon ? npub : stripped}`);
+ let lnurl = $derived(
+ encode(
+ "lnurl",
+ toWords(
+ new TextEncoder().encode(
+ `https://${PUBLIC_DOMAIN}/p/${subject.anon ? npub : stripped}`,
+ ),
+ ),
+ 20000,
+ ),
+ );
$effect(() => followList.then((l) => (list = l)));
let following = $derived(list.some((t) => t.includes(subject.pubkey)));
</script>
@@ -116,19 +128,23 @@
<div
class="w-full lg:w-[280px] xl:w-[360px] lg:absolute space-y-2 left-20 mx-auto"
>
- <button
- type="button"
- class="flex text-3xl font-bold text-center mx-auto justify-center gap-2"
+ <div
+ class="flex text-3xl font-bold text-center mx-auto justify-center gap-1 items-center"
>
- <div class="flex gap-1" onclick={toggleDetails}>
- <div class="my-auto break-words">{display || username}</div>
- <iconify-icon noobserver icon="ph:qr-code-bold" width="32"
- ></iconify-icon>
- </div>
+ <button class="flex gap-1 items-center" onclick={toggleDetails}>
+ <div class="break-words">{display || username}</div>
+ <iconify-icon noobserver icon="ph:list-bold" width="32"></iconify-icon>
+ </button>
+ {#if subject.id === user?.id}
+ <a href="/settings/profile" class="btn contents">
+ <iconify-icon noobserver icon="ph:pencil-bold" width="32"
+ ></iconify-icon>
+ </a>
+ {/if}
<!-- <a href={`/${subject.pubkey}/notes`}> -->
<!-- <iconify-icon noobserver icon="ph:note-bold" width="32"></iconify-icon> -->
<!-- </a> -->
- </button>
+ </div>
{#if subject.about}
<div
@@ -169,7 +185,7 @@
<!-- </div> -->
{#if showDetails}
- <div class="space-y-5 pb-20">
+ <div class="space-y-5">
{#if lnaddr}
<div>
<div class="text-secondary">{$t("user.lightningAddress")}</div>
@@ -214,18 +230,18 @@
</div>
</div>
<div>
- <div class="text-secondary">{$t("user.nostrPubkey")}</div>
+ <div class="text-secondary">{$t("user.lnurl")}</div>
<div class="flex gap-4">
<div class="break-all grow text-xl">
- {subject.pubkey}
+ {lnurl}
</div>
- <div class="flex my-auto gap-1">
- <button class="my-auto" onclick={() => copy(subject.pubkey)}
+ <div class="flex mb-auto gap-1">
+ <button class="my-auto" onclick={() => copy(`lightning:${lnurl}`)}
><iconify-icon noobserver icon="ph:copy-bold" width="32"
></iconify-icon></button
>
<a
- href={`/qr/${encodeURIComponent(subject.pubkey)}`}
+ href={`/${n}/accepted/${encodeURIComponent(`lightning:${lnurl}`)}`}
class="my-auto"
>
<iconify-icon noobserver icon="ph:qr-code-bold" width="32"
@@ -234,24 +250,24 @@
</div>
</div>
</div>
- <div>
- <div class="text-secondary">{$t("user.nostrPubkey")}</div>
- <div class="flex gap-4">
- <div class="break-all grow text-xl">
- {npub}
- </div>
- <div class="flex my-auto gap-1">
- <button class="my-auto" onclick={() => copy(npub)}
- ><iconify-icon noobserver icon="ph:copy-bold" width="32"
- ></iconify-icon></button
- >
- <a href={`/qr/${encodeURIComponent(npub)}`} class="my-auto">
- <iconify-icon noobserver icon="ph:qr-code-bold" width="32"
- ></iconify-icon>
- </a>
- </div>
- </div>
- </div>
+ <!-- <div> -->
+ <!-- <div class="text-secondary">{$t("user.nostrPubkey")}</div> -->
+ <!-- <div class="flex gap-4"> -->
+ <!-- <div class="break-all grow text-xl"> -->
+ <!-- {npub} -->
+ <!-- </div> -->
+ <!-- <div class="flex my-auto gap-1"> -->
+ <!-- <button class="my-auto" onclick={() => copy(npub)} -->
+ <!-- ><iconify-icon noobserver icon="ph:copy-bold" width="32" -->
+ <!-- ></iconify-icon></button -->
+ <!-- > -->
+ <!-- <a href={`/qr/${encodeURIComponent(npub)}`} class="my-auto"> -->
+ <!-- <iconify-icon noobserver icon="ph:qr-code-bold" width="32" -->
+ <!-- ></iconify-icon> -->
+ <!-- </a> -->
+ <!-- </div> -->
+ <!-- </div> -->
+ <!-- </div> -->
</div>
{/if}
@@ -314,6 +330,7 @@
{#if currency}
<div
class="flex fixed w-full px-4 bg-base-100 py-2 pb-3 bottom-0 bg-opacity-90 tabular-nums"
+ style="bottom: calc(var(--safe-area-inset-bottom) + 8px);"
>
<div class="text-secondary flex mr-auto">
<div class="flex mr-1">
diff --git a/src/routes/(app)/[username]/(profile)/+page.svelte b/src/routes/(app)/[username]/(profile)/+page.svelte
index d064c3a9..7d342994 100644
--- a/src/routes/(app)/[username]/(profile)/+page.svelte
+++ b/src/routes/(app)/[username]/(profile)/+page.svelte
@@ -6,7 +6,7 @@
import Account from "$comp/Account.svelte";
import Balance from "$comp/Balance.svelte";
import { t } from "$lib/translations";
- import { installPrompt } from "$lib/store";
+ import { installPrompt, password } from "$lib/store";
import { afterNavigate, preloadData } from "$app/navigation";
import { page } from "$app/stores";
@@ -32,6 +32,35 @@
</script>
<div class="space-y-2">
+ {#if user?.destination && !user?.autowithdraw}
+ <div class="rounded-2xl space-y-2 p-4 shadow-lg">
+ <h1 class="text-3xl font-bold">
+ {$t("user.settings.confirmAutoWithdrawal")}
+ </h1>
+ <div>
+ {$t("user.settings.confirmAutoWithdrawalDesc")}
+ </div>
+
+ <a href="/settings/account" class="btn">
+ <iconify-icon icon="ph:gear-bold" width="32"></iconify-icon>
+ Settings</a
+ >
+ </div>
+ {/if}
+ {#if user?.fresh}
+ <div>
+ {$t("user.settings.yourUsername")} <b>{user?.username}</b>
+ {#if $password}
+ <br />
+ {$t("user.settings.yourPassword")}
+ <b>{$password}</b>{/if}
+ </div>
+
+ <a href="/settings/profile" class="btn">
+ <iconify-icon icon="ph:gear-bold" width="32"></iconify-icon>
+ {$t("user.settings.continueSettingUp")}</a
+ >
+ {/if}
{#if user?.id && user.id === subject.id}
<div class="space-y-5" data-sveltekit-preload-data="false">
{#each accounts as account, i}
@@ -55,6 +84,8 @@
{/if}
</div>
+<!-- <a href="/funder" class="btn">Funder</a> -->
+
<div class="fixed inset-x-0 mx-auto flex bottom-16 px-4">
{#if user?.username !== subject.username && (!subject.anon || subject.lud16)}
<a
diff --git a/src/routes/(app)/[username]/accepted/[text]/+page.server.ts b/src/routes/(app)/[username]/accepted/[text]/+page.server.ts
new file mode 100644
index 00000000..aa25ce7f
--- /dev/null
+++ b/src/routes/(app)/[username]/accepted/[text]/+page.server.ts
@@ -0,0 +1,6 @@
+export async function load({ params, url }) {
+ const { text } = params;
+ const query = new URLSearchParams(url.search);
+ const nfc = !!query.get("nfc");
+ return { nfc, text };
+}
diff --git a/src/routes/(app)/[username]/accepted/[text]/+page.svelte b/src/routes/(app)/[username]/accepted/[text]/+page.svelte
new file mode 100644
index 00000000..1483a294
--- /dev/null
+++ b/src/routes/(app)/[username]/accepted/[text]/+page.svelte
@@ -0,0 +1,57 @@
+<script>
+ import Icon from "$comp/Icon.svelte";
+ import Qr from "$comp/Qr.svelte";
+ import { back, copy } from "$lib/utils";
+ import { bech32 } from "@scure/base";
+ import { page } from "$app/stores";
+ import { PUBLIC_DOMAIN } from "$env/static/public";
+
+ let { encode, toWords } = bech32;
+ let { data } = $props();
+ let { nfc, text } = $derived(data);
+ let { username } = $derived(data.subject);
+ let url = `https://coinos.io/p/${username.toLowerCase()}`;
+
+ let lnurl = $derived(
+ `https://${PUBLIC_DOMAIN}/ln/${encode(
+ "lnurl",
+ toWords(
+ new TextEncoder().encode(`https://${PUBLIC_DOMAIN}/p/${username}`),
+ ),
+ 20000,
+ )}`,
+ );
+</script>
+
+<div class="px-2">
+ <div
+ class="border-8 border-black p-8 max-w-[580px] mx-auto mt-20 space-y-2 rounded-3xl relative"
+ >
+ <div class="flex gap-2 mx-auto justify-center">
+ <!-- <img src="/images/bitcoin.png" class="w-16 h-16 my-auto" /> -->
+ <img src="/images/bitcoin-logo.png" class="my-auto w-[400px]" />
+ <!-- <h3 class="text-5xl font-bold text-center my-auto"> -->
+ <!-- bitcoin -->
+ <!-- </h3> -->
+ </div>
+ {#if nfc}
+ <img
+ src="/images/tap.png"
+ class="w-24 absolute top-52 left-4 rotate-[-10deg]"
+ />
+ <img
+ src="/images/arrow.png"
+ class="w-24 scale-x-[-1] rotate-[120deg] absolute top-52 left-32 w-20 h-52"
+ />
+ {/if}
+ <Qr {text} icon="icon.png" />
+ <div class="text-center text-4xl font-bold break-all">
+ coinos.io/{username.toLowerCase()}
+ </div>
+ </div>
+
+ <button onclick={() => copy(lnurl)} class="flex gap-1 m-auto my-8">
+ <iconify-icon icon="ph:copy-bold" width={32}></iconify-icon>
+ LNURL</button
+ >
+</div>
diff --git a/src/routes/(app)/[username]/accepted/[text]/raw/+server.ts b/src/routes/(app)/[username]/accepted/[text]/raw/+server.ts
new file mode 100644
index 00000000..a24d3a3c
--- /dev/null
+++ b/src/routes/(app)/[username]/accepted/[text]/raw/+server.ts
@@ -0,0 +1,16 @@
+import Qr from "qrcode-base64";
+
+export async function GET({ params }) {
+ const { text } = params;
+
+ const dataUri = Qr.drawImg(text, { size: 600 });
+ const base64Data = dataUri.split(",")[1];
+ const binaryData = Buffer.from(base64Data, "base64");
+
+ return new Response(binaryData, {
+ headers: {
+ "Content-Type": "image/gif",
+ "Cache-Control": "public, max-age=31536000, immutable",
+ },
+ });
+}
diff --git a/src/routes/(app)/[username]/accepted/create/+page.svelte b/src/routes/(app)/[username]/accepted/create/+page.svelte
new file mode 100644
index 00000000..74316ba9
--- /dev/null
+++ b/src/routes/(app)/[username]/accepted/create/+page.svelte
@@ -0,0 +1,64 @@
+<script>
+ import { run, preventDefault } from 'svelte/legacy';
+
+ import { goto } from "$app/navigation";
+ import { browser } from "$app/environment";
+ import { t } from "$lib/translations";
+ import Icon from "$comp/Icon.svelte";
+ let el = $state(), text = $state(), pasted = $state();
+
+ let keypress = (e) =>
+ e.key === "Enter" ? e.preventDefault() || el.click() : (pasted = false);
+
+ let paste = async () => {
+ text = await navigator.clipboard.readText();
+ pasted = true;
+ };
+
+ let submit = () => goto(`/qr/${encodeURIComponent(text)}`);
+
+ run(() => {
+ if (browser && pasted && text) el.click() && (pasted = false);
+ });
+</script>
+
+<div class="container px-4 max-w-lg mx-auto space-y-5 mt-20">
+ <h1 class="px-3 md:px-0 text-center text-3xl md:text-4xl font-semibold">
+ Create QR
+ </h1>
+
+ <form onsubmit={preventDefault(submit)} class="space-y-2 text-xl">
+ <div class="mb-2">
+ <textarea
+ use:focus
+ name="text"
+ placeholder="Enter some text or a URL to put in a QR"
+ onkeypress={keypress}
+ class="w-full p-4 border rounded-xl h-32 text-xl"
+ bind:value={text}
+ onpaste={() => (pasted = true)}
+ autocapitalize="none"
+></textarea>
+ </div>
+
+ <div class="flex gap-2">
+ <button
+ type="button"
+ class="flex border rounded-2xl px-6 py-5 font-bold hover:opacity-80 w-full bg-primary justify-center gap-2"
+ onclick={paste}
+ >
+ <Icon icon="paste" style="w-8 my-auto" />
+ <div class="my-auto">{$t("user.send.paste")}</div>
+ </button>
+
+ <button
+ bind:this={el}
+ type="submit"
+ class="flex bg-black text-white border rounded-2xl px-6 py-5 w-full font-bold gap-2 justify-center"
+ >
+ <Icon icon="send" style="w-8 my-auto invert" />
+ <div class="my-auto">{$t("user.dashboard.go")}</div>
+ </button>
+ </div>
+ </form>
+</div>
diff --git a/src/routes/(app)/[username]/receive/+page.svelte b/src/routes/(app)/[username]/receive/+page.svelte
index b5b3153b..4bc37a7f 100644
--- a/src/routes/(app)/[username]/receive/+page.svelte
+++ b/src/routes/(app)/[username]/receive/+page.svelte
@@ -53,10 +53,10 @@
amountFiat,
tip;
- let update = async () => {
+ let update = async (body) => {
try {
({ id } = await post(`/invoice`, {
- invoice,
+ invoice: body,
user: { username, currency },
}));
@@ -77,10 +77,12 @@
if (type === types.lightning && !amount && typeof newAmount === "undefined")
goto(`/${username}/receive`, { invalidateAll: true, noScroll: true });
else {
- if (typeof newAmount !== "undefined") invoice.amount = newAmount;
- invoice.address_type = address_type;
- invoice.type = type;
- await update();
+ // Build the POST body from a snapshot + explicit changes. Mutating the
+ // $derived `invoice` proxy and POSTing it serializes unreliably, leaking
+ // stale type/amount from a prior interaction (see funk POS bug).
+ const body = { ...$state.snapshot(invoice), type, address_type };
+ if (typeof newAmount !== "undefined") body.amount = newAmount;
+ await update(body);
}
settingType = false;
};
@@ -93,13 +95,12 @@
if (typeof $amountPrompt === "undefined") $amountPrompt = true;
amount = newAmount;
- invoice.amount = newAmount;
- invoice.tip = 0;
+ const body = { ...$state.snapshot(invoice), amount: newAmount, tip: null };
- await update();
+ await update(body);
};
- let newAmount = $state();
+ let newAmount = $state(0);
let settingAmount = $state();
let fiat = $state(true);
let toggleAmount = () => (settingAmount = !settingAmount);
@@ -109,8 +110,8 @@
e.stopPropagation();
settingMemo = false;
- invoice.memo = memo;
- await update();
+ const body = { ...$state.snapshot(invoice), memo };
+ await update(body);
};
let memo = $state();
diff --git a/src/routes/(app)/account/[id]/+page.svelte b/src/routes/(app)/account/[id]/+page.svelte
index a8dcd70b..258912d6 100644
--- a/src/routes/(app)/account/[id]/+page.svelte
+++ b/src/routes/(app)/account/[id]/+page.svelte
@@ -8,7 +8,7 @@
import { t } from "$lib/translations";
import { decrypt } from "nostr-tools/nip49";
import { entropyToMnemonic, mnemonicToSeed } from "@scure/bip39";
- import { wordlist } from "@scure/bip39/wordlists/english";
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
import {
PUBLIC_COINOS_PUBKEY as pk,
PUBLIC_COINOS_RELAY as relay,
diff --git a/src/routes/(app)/account/import/+page.svelte b/src/routes/(app)/account/import/+page.svelte
index b94cc839..10440357 100644
--- a/src/routes/(app)/account/import/+page.svelte
+++ b/src/routes/(app)/account/import/+page.svelte
@@ -8,7 +8,7 @@
import { browser } from "$app/environment";
import Icon from "$comp/Icon.svelte";
import { validateMnemonic } from "@scure/bip39";
- import { wordlist } from "@scure/bip39/wordlists/english";
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
let el = $state(), pasted = $state(), text = $state();
diff --git a/src/routes/(app)/account/pass/+page.svelte b/src/routes/(app)/account/pass/+page.svelte
index b75ac50f..3be7628d 100644
--- a/src/routes/(app)/account/pass/+page.svelte
+++ b/src/routes/(app)/account/pass/+page.svelte
@@ -11,7 +11,7 @@
mnemonicToEntropy,
entropyToMnemonic,
} from "@scure/bip39";
- import { wordlist } from "@scure/bip39/wordlists/english";
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
import { focus, versions, fail, post } from "$lib/utils";
import { goto } from "$app/navigation";
import { HDKey } from "@scure/bip32";
diff --git a/src/routes/(app)/account/seed/+page.svelte b/src/routes/(app)/account/seed/+page.svelte
index 91dfd213..9df891d6 100644
--- a/src/routes/(app)/account/seed/+page.svelte
+++ b/src/routes/(app)/account/seed/+page.svelte
@@ -6,7 +6,7 @@
import { t } from "$lib/translations";
import { mnemonic } from "$lib/store";
import { generateMnemonic } from "@scure/bip39";
- import { wordlist } from "@scure/bip39/wordlists/english";
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
let { data } = $props();
let { user } = data;
diff --git a/src/routes/(app)/admin/+layout.server.ts b/src/routes/(app)/admin/+layout.server.ts
index d4aa181d..a2da1a90 100644
--- a/src/routes/(app)/admin/+layout.server.ts
+++ b/src/routes/(app)/admin/+layout.server.ts
@@ -1,10 +1,16 @@
-import { auth, get } from "$lib/utils";
+import { auth, get, isInvalidTokenError } from "$lib/utils";
import { redirect } from "@sveltejs/kit";
export async function load({ cookies }) {
const token = cookies.get("token");
if (!token) redirect(307, "/login");
- const user = await get("/me", auth(cookies));
+ let user;
+ try {
+ user = await get("/me", auth(cookies));
+ } catch (e) {
+ if (isInvalidTokenError(e)) redirect(307, "/logout");
+ throw e;
+ }
if (!user) redirect(307, "/login");
return { user };
diff --git a/src/routes/(app)/ecash/[id]/[...version]/+layout.server.ts b/src/routes/(app)/ecash/[id]/[...version]/+layout.server.ts
index 50be6986..e241930e 100644
--- a/src/routes/(app)/ecash/[id]/[...version]/+layout.server.ts
+++ b/src/routes/(app)/ecash/[id]/[...version]/+layout.server.ts
@@ -1,5 +1,5 @@
import getRates from "$lib/rates";
-import { get } from "$lib/utils";
+import { get, post } from "$lib/utils";
export async function load({ params, parent }) {
const { user } = await parent();
@@ -8,7 +8,16 @@ export async function load({ params, parent }) {
version ||= 4;
version = parseInt(version);
- const { token, status } = await get(`/cash/${id}/${version}`);
+ // A pasted token arrives in the URL itself and is checked statelessly;
+ // anything else is an id from the share links the server used to hand out.
+ let token: string;
+ let status: any;
+ if (id.startsWith("cashu")) {
+ token = id;
+ status = await post("/ecash/status", { token });
+ } else {
+ ({ token, status } = await get(`/cash/${id}/${version}`));
+ }
const { spent, total, mint, external } = status;
return {
id,
diff --git a/src/routes/(app)/flash/+page.server.ts b/src/routes/(app)/flash/+page.server.ts
new file mode 100644
index 00000000..1e3e1e99
--- /dev/null
+++ b/src/routes/(app)/flash/+page.server.ts
@@ -0,0 +1,58 @@
+import { PUBLIC_COINOS_URL } from "$env/static/public";
+import { auth, fd, get } from "$lib/utils";
+import { hex } from "@scure/base";
+import { fail } from "@sveltejs/kit";
+
+const LITTLEFS_SIZE = 0x20000;
+const LITTLEFS_MAGIC = "littlefs";
+
+// A valid image is exactly one partition long and carries the LittleFS
+// superblock magic at byte 8. Anything else (e.g. a JSON error body from
+// the server) must never reach the flasher.
+const isLittleFS = (buf: Uint8Array) =>
+ buf.length === LITTLEFS_SIZE &&
+ new TextDecoder().decode(buf.subarray(8, 8 + LITTLEFS_MAGIC.length)) ===
+ LITTLEFS_MAGIC;
+
+export async function load({ cookies }) {
+ let token;
+
+ try {
+ token = await get("/ro", auth(cookies));
+ } catch (e) {}
+
+ return { token };
+}
+
+export const actions = {
+ default: async ({ fetch, request }) => {
+ const body = await fd(request);
+ const res = await fetch(`${PUBLIC_COINOS_URL}/flash`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ headers: {
+ "content-type": "application/json",
+ accept: "application/octet-stream",
+ },
+ });
+
+ const buf = new Uint8Array(await res.arrayBuffer());
+
+ if (!res.ok) {
+ let message = `Config server returned ${res.status}`;
+ try {
+ message = JSON.parse(new TextDecoder().decode(buf)).message || message;
+ } catch (e) {}
+ return fail(res.status >= 400 && res.status < 600 ? res.status : 500, {
+ error: message,
+ });
+ }
+
+ if (!isLittleFS(buf))
+ return fail(500, {
+ error: `Config server returned an invalid image (${buf.length} bytes)`,
+ });
+
+ return { bytes: hex.encode(buf) };
+ },
+};
diff --git a/src/routes/(app)/flash/+page.svelte b/src/routes/(app)/flash/+page.svelte
new file mode 100644
index 00000000..5a11d148
--- /dev/null
+++ b/src/routes/(app)/flash/+page.svelte
@@ -0,0 +1,360 @@
+<script>
+ import { enhance } from "$app/forms";
+ import { onDestroy } from "svelte";
+ import { ESPLoader, Transport } from "esptool-js";
+ import { hex } from "@scure/base";
+
+ // Convert Uint8Array → JS "binary string" in safe chunks
+ function bs(uint8Array) {
+ let binaryString = "";
+ const chunkSize = 0x8000;
+ for (let i = 0; i < uint8Array.length; i += chunkSize) {
+ const chunk = uint8Array.subarray(i, i + chunkSize);
+ binaryString += String.fromCharCode(...chunk);
+ }
+ return binaryString;
+ }
+
+ let { data, form } = $props();
+ let { token } = $state(data);
+
+ // --- Defaults ---
+ const LITTLEFS_ADDRESS_DEFAULT = 0x3d0000; // your existing LittleFS offset
+ const FW_ADDRESS_DEFAULT = 0x10000; // typical app offset on ESP32-C3
+ const CONNECT_BAUD = 115200; // initial
+ const WORK_BAUD = 921600; // fast flashing
+
+ // --- Shared state ---
+ let esploader = $state();
+ let chip, transport;
+ let connected = $state(false);
+ let portInfo = $state("");
+
+ // --- Existing config path (generated server-side to hex in form.bytes) ---
+ let bytes = $derived(form?.bytes ? hex.decode(form.bytes) : undefined);
+ let configError = $state("");
+ $effect(() => {
+ configError = form?.error || "";
+ });
+ let littlefsAddress = $state(LITTLEFS_ADDRESS_DEFAULT);
+ let configProgress = $state(0);
+ let configDone = $state(false);
+
+ // --- New firmware flashing path ---
+ let fwFile = $state(null);
+ let fwBytes = $state(); // Uint8Array
+ let fwAddress = $state(FW_ADDRESS_DEFAULT);
+ let fwEraseAll = $state(false);
+ let fwProgress = $state(0);
+ let fwDone = $state(false);
+ let fwError = $state("");
+
+ // --- Latest published release (see coinos-pos/publish.sh) ---
+ /** @type {any} */
+ let release = $state(null); // manifest.json contents
+ let releaseError = $state("");
+ $effect(() => {
+ fetch("/firmware/manifest.json", { cache: "no-store" })
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
+ .then((m) => (release = m))
+ .catch((e) => (releaseError = `No published release: ${e.message}`));
+ });
+
+ // Simple tab toggle
+ let tab = $state("config"); // "config" | "firmware"
+
+ let connect = async () => {
+ try {
+ const device = await navigator.serial.requestPort({});
+ transport = new Transport(device, true);
+ esploader = new ESPLoader({
+ transport,
+ baudrate: CONNECT_BAUD,
+ });
+
+ chip = await esploader.main();
+
+ connected = true;
+ portInfo = (await device.getInfo?.()) ? JSON.stringify(await device.getInfo()) : "Connected";
+ } catch (e) {
+ console.log(e);
+ // A failed connect must not leave the port claimed: the browser keeps
+ // it open until someone calls close(), and the chip may already be
+ // sitting in the ROM download mode.
+ await resetDevice();
+ }
+ };
+
+ // Close the serial port and forget it. The browser holds a port open until
+ // it is explicitly closed — navigating away does not release it.
+ let releasePort = async () => {
+ try {
+ await transport?.disconnect();
+ } catch (e) {}
+ transport = undefined;
+ esploader = undefined;
+ connected = false;
+ };
+
+ // Pulse RTS to hard-reset the chip out of the ROM bootloader so it boots
+ // the app with the new flash contents. Same sequence esptool.py uses
+ // ("Hard resetting via RTS pin"); on the C3's USB-JTAG-serial port RTS
+ // drives EN, so the device re-enumerates and must be reconnected.
+ let resetDevice = async () => {
+ if (!transport) return;
+ try {
+ await transport.setDTR(false);
+ await transport.setRTS(true);
+ await new Promise((r) => setTimeout(r, 100));
+ await transport.setRTS(false);
+ } catch (e) {
+ console.warn("reset failed", e);
+ }
+ await releasePort();
+ portInfo = "Device reset. Reconnect to flash again.";
+ };
+
+ // Connecting parks the chip in the ROM download mode (esptool stub loaded,
+ // app not running). Until it is hard-reset the OLED keeps whatever the app
+ // last drew and the keypad is dead, so it looks frozen rather than
+ // "in flashing mode". Leaving the page — SPA navigation, reload, or tab
+ // close — must therefore reset the device and release the port; neither
+ // happens on its own.
+ onDestroy(() => {
+ resetDevice();
+ });
+ $effect(() => {
+ const onHide = () => resetDevice();
+ window.addEventListener("pagehide", onHide);
+ return () => window.removeEventListener("pagehide", onHide);
+ });
+
+ let flashConfig = async () => {
+ if (!esploader || !bytes) return;
+ if (bytes.length !== 0x20000) {
+ configError = `Refusing to flash: image is ${bytes.length} bytes, expected ${0x20000}`;
+ return;
+ }
+ configDone = false;
+ configProgress = 0;
+
+ const fileArray = [{ data: bs(bytes), address: Number(littlefsAddress) }];
+
+ await esploader.writeFlash({
+ fileArray,
+ flashSize: "keep",
+ eraseAll: false,
+ compress: true,
+ reportProgress: (_fileIndex, written, total) => {
+ configProgress = Math.floor((written / total) * 100);
+ },
+ });
+
+ configDone = true;
+ await resetDevice();
+ };
+
+ function onPickFw(e) {
+ fwError = "";
+ fwDone = false;
+ const f = e.target.files?.[0];
+ fwFile = f || null;
+ if (!fwFile) {
+ fwBytes = undefined;
+ return;
+ }
+ const reader = new FileReader();
+ reader.onload = () => {
+ const buf = new Uint8Array(reader.result);
+ fwBytes = buf;
+ };
+ reader.onerror = () => {
+ fwError = "Failed to read file.";
+ fwBytes = undefined;
+ };
+ reader.readAsArrayBuffer(fwFile);
+ }
+
+ let flashFirmware = async () => {
+ if (!esploader) {
+ fwError = "Not connected.";
+ return;
+ }
+ if (!fwBytes?.length) {
+ fwError = "No firmware selected.";
+ return;
+ }
+ fwError = "";
+ fwDone = false;
+ fwProgress = 0;
+
+ try {
+ const fileArray = [{ data: bs(fwBytes), address: Number(fwAddress) }];
+
+ await esploader.writeFlash({
+ fileArray,
+ flashSize: "keep",
+ eraseAll: Boolean(fwEraseAll),
+ compress: true,
+ reportProgress: (_fileIndex, written, total) => {
+ fwProgress = Math.floor((written / total) * 100);
+ },
+ });
+
+ fwDone = true;
+ await resetDevice();
+ } catch (e) {
+ console.error(e);
+ fwError = e?.message || "Flash failed.";
+ }
+ };
+
+ // Flash every part of the published release at its offset (bootloader,
+ // partition table, OTA data, app). The LittleFS config partition is not
+ // part of the manifest, so device credentials survive.
+ let flashRelease = async () => {
+ if (!esploader) {
+ fwError = "Not connected.";
+ return;
+ }
+ if (!release?.parts?.length) {
+ fwError = "No release available.";
+ return;
+ }
+ fwError = "";
+ fwDone = false;
+ fwProgress = 0;
+
+ try {
+ const fileArray = [];
+ for (const part of release.parts) {
+ const r = await fetch(`${part.path}?v=${encodeURIComponent(release.version)}`, { cache: "no-store" });
+ if (!r.ok) throw new Error(`Download failed: ${part.path} (HTTP ${r.status})`);
+ const buf = new Uint8Array(await r.arrayBuffer());
+ if (part.size && buf.length !== part.size)
+ throw new Error(`Size mismatch for ${part.path}: got ${buf.length}, expected ${part.size}`);
+ fileArray.push({ data: bs(buf), address: Number(part.address) });
+ }
+
+ const totals = fileArray.map((f) => f.data.length);
+ const grand = totals.reduce((a, b) => a + b, 0);
+ await esploader.writeFlash({
+ fileArray,
+ flashSize: "keep",
+ eraseAll: false,
+ compress: true,
+ reportProgress: (fileIndex, written, total) => {
+ const before = totals.slice(0, fileIndex).reduce((a, b) => a + b, 0);
+ fwProgress = Math.floor(((before + (written / total) * totals[fileIndex]) / grand) * 100);
+ },
+ });
+
+ fwDone = true;
+ await resetDevice();
+ } catch (e) {
+ console.error(e);
+ fwError = e?.message || "Flash failed.";
+ }
+ };
+</script>
+
+<div class="container px-4 max-w-lg mx-auto space-y-5 mt-20">
+ <h1 class="px-3 md:px-0 text-center text-3xl md:text-4xl font-semibold">
+ ESP32-C3 Flasher
+ </h1>
+
+ {#if !connected}
+ <div class="text-center">
+ {#if configDone}
+ <div class="text-2xl mb-3">Config written ✔ Device restarted</div>
+ {:else if fwDone}
+ <div class="text-2xl mb-3">Firmware written ✔ Device restarted</div>
+ {/if}
+ <button class="btn" onclick={connect}>Connect</button>
+ <div class="text-sm mt-2 opacity-70">{portInfo}</div>
+ </div>
+{/if}
+
+ {#if connected}
+ <!-- Tabs -->
+ <div class="flex gap-2 justify-center">
+ <button class="btn" class:btn-neutral={tab === "config"} onclick={() => (tab = "config")}>Flash config</button>
+ <button class="btn" class:btn-neutral={tab === "firmware"} onclick={() => (tab = "firmware")}>Flash firmware</button>
+ <button class="btn" onclick={resetDevice} title="Reset the device into its app and release the serial port">Disconnect</button>
+ </div>
+
+ {#if tab === "config"}
+ <!-- Existing CONFIG FLOW -->
+ {#if bytes}
+ {#if configDone}
+ <div class="text-center text-2xl">Config written ✔</div>
+ {:else}
+ <div>Generated config file (<b>{bytes.length} bytes</b>)</div>
+ <label class="label mt-2">LittleFS address (hex)</label>
+ <input class="input" bind:value={littlefsAddress} onchange={(e) => (littlefsAddress = Number(e.target.value))} />
+ <div class="mt-3">
+ <button class="btn" onclick={flashConfig}>Flash config</button>
+ </div>
+ {#if configProgress > 0}<div class="mt-2">Progress: {configProgress}%</div>{/if}
+ {/if}
+ {:else}
+ {#if configError}
+ <div class="text-red-600 mb-2">{configError}</div>
+ {/if}
+ <form method="POST" use:enhance class="space-y-2">
+ <input name="ssid" placeholder="Wifi SSID" class="input" />
+ <input name="key" placeholder="Wifi password" class="input" />
+ <input name="token" bind:value={token} placeholder="Coinos API token" class="input" />
+ <button type="submit" class="btn">Generate Config</button>
+ </form>
+ {/if}
+ {:else}
+ <!-- NEW FIRMWARE FLOW -->
+ <div class="space-y-3">
+ {#if release}
+ <div class="p-3 rounded-lg border border-current/20 space-y-2">
+ <div class="font-semibold">Latest release: {release.version}</div>
+ <div class="text-sm opacity-70">Built {release.built} · flashes bootloader, partitions and app; keeps your wifi/token config</div>
+ <button class="btn" onclick={flashRelease}>Flash latest release</button>
+ </div>
+ {:else if releaseError}
+ <div class="text-sm opacity-70">{releaseError}</div>
+ {/if}
+
+ <label class="label">Or select a .ino.bin (firmware)</label>
+ <input type="file" accept=".bin,application/octet-stream" class="input" onchange={onPickFw} />
+
+ <label class="label">Firmware address (hex, default 0x10000)</label>
+ <input class="input" bind:value={fwAddress} onchange={(e) => (fwAddress = Number(e.target.value))} />
+
+ <label class="flex items-center gap-2 mt-2">
+ <input type="checkbox" bind:checked={fwEraseAll} />
+ <span>Erase all flash (usually NOT needed)</span>
+ </label>
+
+ <div class="mt-3">
+ <button class="btn" disabled={!fwBytes} onclick={flashFirmware}>Flash firmware</button>
+ </div>
+
+ {#if fwProgress > 0 && !fwDone}
+ <div>Progress: {fwProgress}%</div>
+ {/if}
+ {#if fwDone}
+ <div class="text-center text-2xl">Firmware written ✔</div>
+ {/if}
+ {#if fwError}
+ <div class="text-red-600">{fwError}</div>
+ {/if}
+ </div>
+ {/if}
+ {/if}
+</div>
+
+<style>
+ @reference "../../../app.css";
+
+ .btn { @apply px-4 py-2 rounded-2xl shadow; }
+ .btn-neutral { @apply bg-gray-200; }
+ .input { @apply w-full px-3 py-2 border rounded-xl; }
+ .label { @apply text-sm opacity-70; }
+</style>
diff --git a/src/routes/(app)/fund/[id]/+layout.server.ts b/src/routes/(app)/fund/[id]/+layout.server.ts
new file mode 100644
index 00000000..3669eb7d
--- /dev/null
+++ b/src/routes/(app)/fund/[id]/+layout.server.ts
@@ -0,0 +1,12 @@
+import getRates from "$lib/rates";
+import { get } from "$lib/utils";
+
+export const load = async ({ depends, params: { id }, parent }) => {
+ depends("app:payments");
+ const data = await get(`/fund/${id}`);
+ data.managers = await get(`/fund/${id}/managers`);
+ const rates = await getRates();
+ const { user } = await parent();
+ data.rate = rates[user?.currency || "USD"];
+ return data;
+};
diff --git a/src/routes/(app)/fund/[id]/+page.server.ts b/src/routes/(app)/fund/[id]/+page.server.ts
index a4f402af..cd2bd979 100644
--- a/src/routes/(app)/fund/[id]/+page.server.ts
+++ b/src/routes/(app)/fund/[id]/+page.server.ts
@@ -1,17 +1,6 @@
-import getRates from "$lib/rates";
-import { auth, get, post } from "$lib/utils";
+import { auth, post } from "$lib/utils";
import { redirect } from "@sveltejs/kit";
-export const load = async ({ depends, params: { id }, parent }) => {
- depends("app:payments");
- const data = await get(`/fund/${id}`);
- data.managers = await get(`/fund/${id}/managers`);
- const rates = await getRates();
- const { user } = await parent();
- data.rate = rates[user?.currency || "USD"];
- return data;
-};
-
export const actions = {
default: async ({ cookies, locals, params }) => {
const { user } = locals;
diff --git a/src/routes/(app)/fund/[id]/+page.svelte b/src/routes/(app)/fund/[id]/+page.svelte
index f1a1838e..a22e4b93 100644
--- a/src/routes/(app)/fund/[id]/+page.svelte
+++ b/src/routes/(app)/fund/[id]/+page.svelte
@@ -79,32 +79,6 @@
</a>
{/if}
</div>
- <div class="flex flex-wrap gap-2" data-sveltekit-prefetch="off">
- <div class="grow">
- <a href={`/send/fund/${id}`}>
- <button class="btn">
- <iconify-icon noobserver icon="ph:plus-bold" width="32"
- ></iconify-icon>
- {$t("payments.addFunds")}
- </button>
- </a>
- </div>
- {#if !managers.length || managers.some((m) => m.id === user?.id)}
- <div class="grow">
- <a href={`/fund/${id}/withdraw`}>
- <button class="btn">
- <iconify-icon
- noobserver
- icon="ph:hand-coins-bold"
- width="32"
- flip="horizontal"
- ></iconify-icon>
- {$t("payments.takeFunds")}
- </button>
- </a>
- </div>
- {/if}
- </div>
<div class="flex gap-2" data-sveltekit-prefetch="off">
<a href={`/qr/${href}`} class="btn !w-auto grow">
<iconify-icon noobserver icon="ph:link-bold" width="32"
diff --git a/src/routes/(app)/fund/[id]/access/+page.server.ts b/src/routes/(app)/fund/[id]/access/+page.server.ts
index c38cb29b..36191643 100644
--- a/src/routes/(app)/fund/[id]/access/+page.server.ts
+++ b/src/routes/(app)/fund/[id]/access/+page.server.ts
@@ -11,11 +11,8 @@ export const actions = {
const form = await fd(request);
try {
- console.log("SUBMITTING", form);
const managers = await post("/fund/managers", form, auth(cookies));
- console.log(managers);
-
return { managers };
} catch (e) {
const { message } = e as Error;
diff --git a/src/routes/(app)/fund/[id]/access/+page.svelte b/src/routes/(app)/fund/[id]/access/+page.svelte
index a2ea9dff..cc2921c5 100644
--- a/src/routes/(app)/fund/[id]/access/+page.svelte
+++ b/src/routes/(app)/fund/[id]/access/+page.svelte
@@ -6,10 +6,15 @@
let { data } = $props();
let { id, managers } = $derived(data);
+ let m = $state(data.managers);
+ $effect(() => (m = data.managers));
let username = $state();
- let del = async ({ id }) => {
+ let del = async (e, user) => {
+ e.preventDefault();
try {
- await post("/post/fund/access/delete", { id });
+ m = await post(`/post/fund/${id}/managers/delete`, {
+ id: user.id,
+ });
} catch (e) {
fail(e.message);
}
@@ -25,14 +30,14 @@
Fund managers
</h1>
- {#each managers as c}
+ {#each m as c}
<a href={`/${c.username}`} class="contents">
<div class="flex hover:bg-base-200 p-2 items-center">
<Avatar user={c} size={20} disabled={true} />
<div class="my-auto text-left">
<p class="ml-1 text-lg break-words">{c.username}</p>
</div>
- <button onclick={() => del(c)} class="ml-auto">
+ <button onclick={(e) => del(e, c)} class="ml-auto">
<iconify-icon icon="ph:trash-bold" width="32"></iconify-icon>
</button>
</div>
diff --git a/src/routes/(app)/fund/[id]/authorize/[...amount]/+page.server.ts b/src/routes/(app)/fund/[id]/authorize/[...amount]/+page.server.ts
new file mode 100644
index 00000000..e5683ae6
--- /dev/null
+++ b/src/routes/(app)/fund/[id]/authorize/[...amount]/+page.server.ts
@@ -0,0 +1,36 @@
+import getRates from "$lib/rates";
+import { auth, get, post, sats } from "$lib/utils";
+import { error, redirect } from "@sveltejs/kit";
+
+export async function load({ cookies, params, parent }) {
+ const { user } = await parent();
+ const { id } = params;
+ let [amount, currency] = params.amount.split("/");
+ let fiat;
+
+ if (!currency) currency = user?.currency;
+ if (currency) currency = currency.toUpperCase();
+
+ const rates = await getRates();
+ const rate = rates[currency || "USD"];
+ if (currency && !rate) error(500, "Invalid currency symbol");
+
+ if (!amount) {
+ const balance = await get(`/fund/${id}`);
+ amount = balance.amount;
+ } else if (currency) {
+ fiat = amount;
+ amount = Math.round((fiat * sats) / rate);
+ }
+
+ if (!amount) redirect(307, `/fund/${id}`);
+
+ try {
+ await post("/authorize", { amount, currency, fiat, id }, auth(cookies));
+ } catch (e) {
+ console.log("caught", e);
+ redirect(307, `/fund/${id}`);
+ }
+
+ return { amount, currency, id, rate, rates };
+}
diff --git a/src/routes/(app)/fund/[id]/authorize/[...amount]/+page.svelte b/src/routes/(app)/fund/[id]/authorize/[...amount]/+page.svelte
new file mode 100644
index 00000000..b94c1ce9
--- /dev/null
+++ b/src/routes/(app)/fund/[id]/authorize/[...amount]/+page.svelte
@@ -0,0 +1,27 @@
+<script>
+ import { onMount } from "svelte";
+ import Amount from "$comp/Amount.svelte";
+ import Success from "$comp/Success.svelte";
+ import { scale } from "svelte/transition";
+ import { toFiat, f, s } from "$lib/utils";
+ import Icon from "$comp/Icon.svelte";
+ import { t } from "$lib/translations";
+ import { loc } from "$lib/utils";
+
+ let { data } = $props();
+
+ let { amount, currency, id, user, rate, rates } = data;
+ let locale = $derived(loc(user));
+</script>
+
+<div class="container px-4 text-center mx-auto">
+ <Success {amount} {rate} {currency} {locale} title="Authorized" />
+</div>
+
+<a href={`/fund/${id}`} aria-label="Continue">
+ <div class="opacity-0 w-screen h-screen fixed top-24 left-0 z-50"></div>
+</a>
+
+<div class="flex justify-center mt-4">
+ {$t("payments.tapAnywhere")}
+</div>
diff --git a/src/routes/(app)/fund/[id]/gift/+page.server.ts b/src/routes/(app)/fund/[id]/gift/+page.server.ts
new file mode 100644
index 00000000..bd39d82b
--- /dev/null
+++ b/src/routes/(app)/fund/[id]/gift/+page.server.ts
@@ -0,0 +1,6 @@
+export const load = async ({ params, parent }) => {
+ const p = await parent();
+ const { amount, rate } = p;
+ const { id } = params;
+ return { amount, id, rate };
+};
diff --git a/src/routes/(app)/fund/[id]/gift/+page.svelte b/src/routes/(app)/fund/[id]/gift/+page.svelte
new file mode 100644
index 00000000..7c65d314
--- /dev/null
+++ b/src/routes/(app)/fund/[id]/gift/+page.svelte
@@ -0,0 +1,40 @@
+<script>
+ import Amount from "$comp/Amount.svelte";
+ import { t } from "$lib/translations";
+ import { page } from "$app/stores";
+ import Icon from "$comp/Icon.svelte";
+ import Qr from "$comp/Qr.svelte";
+ import { loc, copy } from "$lib/utils";
+
+ let { data } = $props();
+ let { id, user, amount, rate } = $derived(data);
+ let text = $page.url.href.replace("gift", "sweep");
+ let currency = user ? user.currency : "CAD";
+ let locale = loc(user);
+</script>
+
+<div class="container px-4 max-w-4xl mx-auto space-y-5 pb-10">
+ <h1 class="text-center text-2xl font-semibold">{$t("payments.gift")}</h1>
+
+ <Amount {amount} {currency} {rate} {locale} />
+
+ <div class="text-secondary shadow-lg p-8 mx-auto text-center space-y-2">
+ <p>{$t("payments.giftDesc1")}</p>
+ <p>{$t("payments.giftDesc2")}</p>
+ </div>
+
+ <div class="flex gap-1 min-w-0 items-center">
+ <button
+ type="button"
+ class="btn break-all !h-auto flex-nowrap p-4 font-normal shrink !w-auto max-w-full"
+ onclick={() => copy(text)}
+ >
+ <iconify-icon noobserver icon="ph:copy-bold" width="32"></iconify-icon>
+ <div class="my-auto break-all min-w-0 !w-auto max-w-full">{text}</div>
+ </button>
+
+ <a href={`/qr/${encodeURIComponent(text)}`} class="btn !w-auto">
+ <iconify-icon icon="ph:qr-code-bold" width={32}></iconify-icon>
+ </a>
+ </div>
+</div>
diff --git a/src/routes/(app)/fund/[id]/sweep/+page.server.ts b/src/routes/(app)/fund/[id]/sweep/+page.server.ts
deleted file mode 100644
index 5bb42fa4..00000000
--- a/src/routes/(app)/fund/[id]/sweep/+page.server.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { get, auth, post } from "$lib/utils";
-import { redirect } from "@sveltejs/kit";
-
-export async function load({ cookies, params, parent }) {
- const { user } = await parent();
- const { id } = params;
- const { amount } = await get(`/fund/${id}`);
-
- if (!amount) redirect(307, `/fund/${id}`);
- if (!user) redirect(307, `/register?redirect=/fund/${id}/sweep`);
- await post("/take", { amount, id }, auth(cookies));
- return { amount, id };
-}
diff --git a/src/routes/(app)/fund/[id]/sweep/+page.svelte b/src/routes/(app)/fund/[id]/sweep/+page.svelte
deleted file mode 100644
index cd89f6ae..00000000
--- a/src/routes/(app)/fund/[id]/sweep/+page.svelte
+++ /dev/null
@@ -1,41 +0,0 @@
-<script>
- import { scale } from "svelte/transition";
- import { toFiat, f, s } from "$lib/utils";
- import Icon from "$comp/Icon.svelte";
- import { t } from "$lib/translations";
-
- let { data } = $props();
-
- let { amount, id, user, rates } = data;
- let { currency } = user;
- let rate = $derived(rates[currency]);
-</script>
-
-<div class="container px-4 max-w-xl mx-auto mt-10 space-y-5 text-center">
- <div class="w-full flex">
- <a href="/" class="mx-auto">
- <Icon icon="logo" />
- </a>
- </div>
-
- <div class="flex w-full py-5 max-w-[200px] mx-auto" in:scale={{ start: 0.5 }}>
- <Icon icon="check" style="mx-auto" />
- </div>
- <h1 class="text-3xl md:text-4xl font-bold mb-6">
- {$t("payments.fundsClaimed")}
- </h1>
- <h2 class="text-2xl md:text-3xl font-semibold">
- {f(toFiat(amount, rate), currency)}
- </h2>
- <h3 class="text-secondary md:text-lg mb-6 mt-1">
- ⚡️{s(amount)}
- </h3>
-</div>
-
-<a href={`/payments`}>
- <div class="opacity-0 w-screen h-screen fixed top-0 left-0 z-50"></div>
-</a>
-
-<div class="flex justify-center">
- {$t("payments.tapAnywhere")}
-</div>
diff --git a/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.server.ts b/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.server.ts
new file mode 100644
index 00000000..8cc463c2
--- /dev/null
+++ b/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.server.ts
@@ -0,0 +1,56 @@
+import { randomName, randomPassword } from "$lib/random";
+import getRates from "$lib/rates";
+import { auth, get, post, register, sats } from "$lib/utils";
+import { error, redirect } from "@sveltejs/kit";
+import { env } from "$env/dynamic/private";
+
+export async function load({ cookies, request, params, parent }) {
+ let { user } = await parent();
+ const { id } = params;
+ let [amount, currency] = params.amount.split("/");
+
+ if (!currency) currency = user?.currency;
+ if (currency) currency = currency.toUpperCase();
+ const rates = await getRates();
+ const rate = rates[currency || "USD"];
+ if (currency && !rate) error(500, "Invalid currency symbol");
+
+ if (!amount) {
+ const balance = await get(`/fund/${id}`);
+ amount = balance.authorization || balance.amount;
+ } else if (currency) {
+ amount = Math.round((amount * sats) / rate);
+ }
+
+ const username = randomName() + Math.floor(Math.random() * 99) + 1;
+ const password = randomPassword();
+
+ if (!amount) redirect(307, `/fund/${id}`);
+ if (!user) {
+ const ip = request.headers.get("cf-connecting-ip");
+
+ user = {
+ username,
+ password,
+ fresh: true,
+ };
+
+ let redirectUrl = `/fund/${id}/sweep`;
+ if (params.amount && !params.currency) redirectUrl += `/${params.amount}`;
+ if (params.currency) redirectUrl += `/${params.amount}/params.currency}`;
+
+ const extraHeaders: Record<string, string> = {};
+ if (env.INTERNAL_API_KEY)
+ extraHeaders["x-api-key"] = env.INTERNAL_API_KEY;
+
+ await register(user, ip, cookies, redirectUrl, undefined, extraHeaders);
+ }
+
+ try {
+ ({ amount } = await post("/take", { amount, id }, auth(cookies)));
+ } catch (e) {
+ redirect(307, `/fund/${id}`);
+ }
+
+ return { amount, currency, id, rate, rates, password };
+}
diff --git a/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.svelte b/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.svelte
new file mode 100644
index 00000000..7a7d5402
--- /dev/null
+++ b/src/routes/(app)/fund/[id]/sweep/[...amount]/+page.svelte
@@ -0,0 +1,36 @@
+<script>
+ import { onMount } from "svelte";
+ import { password } from "$lib/store";
+ import Amount from "$comp/Amount.svelte";
+ import Success from "$comp/Success.svelte";
+ import { scale } from "svelte/transition";
+ import { toFiat, f, s } from "$lib/utils";
+ import Icon from "$comp/Icon.svelte";
+ import { t } from "$lib/translations";
+ import { loc } from "$lib/utils";
+
+ let { data } = $props();
+
+ let { amount, currency, id, user, rate, rates, password: pw } = data;
+ let locale = $derived(loc(user));
+
+ onMount(() => ($password = pw));
+</script>
+
+<div class="container px-4 text-center mx-auto">
+ <Success
+ {amount}
+ {rate}
+ {currency}
+ {locale}
+ title={$t("payments.received")}
+ />
+</div>
+
+<a href={`/${user.username}`} aria-label="Continue">
+ <div class="opacity-0 w-screen h-screen fixed top-24 left-0 z-50"></div>
+</a>
+
+<div class="flex justify-center mt-4">
+ {$t("payments.tapAnywhere")}
+</div>
diff --git a/src/routes/(app)/fund/[id]/withdraw/+page.svelte b/src/routes/(app)/fund/[id]/withdraw/+page.svelte
index 78f9b16c..69a74a3f 100644
--- a/src/routes/(app)/fund/[id]/withdraw/+page.svelte
+++ b/src/routes/(app)/fund/[id]/withdraw/+page.svelte
@@ -6,15 +6,17 @@
import Numpad from "$comp/Numpad.svelte";
import Spinner from "$comp/Spinner.svelte";
import { page } from "$app/stores";
- import { s } from "$lib/utils";
+ import { loc, s } from "$lib/utils";
import { fiat, pin } from "$lib/store";
let { data, form } = $props();
- let { currency } = $derived(data.user);
+ let { user } = $derived(data);
+ let { currency } = $derived(user);
let loading = $state();
let { id, balance, rate } = $derived(data);
let { amount } = $state(data);
+ let locale = loc(user);
let submit = $state();
let submitting = $state();
@@ -39,7 +41,7 @@
{/if}
<div class="container px-4 mt-20 max-w-xl mx-auto">
- <Numpad bind:amount {currency} {fiat} {rate} {submit} />
+ <Numpad bind:amount {currency} {fiat} {rate} {submit} {locale} />
<form use:enhance={handler(toggle)} method="POST" class="space-y-5">
<input name="id" value={id} type="hidden" />
diff --git a/src/routes/(app)/funder/+page.svelte b/src/routes/(app)/funder/+page.svelte
new file mode 100644
index 00000000..e6549360
--- /dev/null
+++ b/src/routes/(app)/funder/+page.svelte
@@ -0,0 +1,55 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+let here = false;
+ let ready = false;
+ let success = false;
+ let controller: AbortController | null = null;
+ let NfcWriter: any;
+
+ async function write() {
+ NfcWriter = window.Capacitor?.Plugins?.NfcWriter;
+ ready = true;
+ console.log(window.Capacitor);
+ if (!NfcWriter) return;
+ here = true;
+
+ try {
+ await NfcWriter.enableExclusiveNfcMode();
+
+ if (controller) controller.abort();
+ controller = new AbortController();
+
+ ready = true;
+
+ const ndef = new NDEFReader();
+ await ndef.write(
+ {
+ records: [
+ {
+ recordType: 'url',
+ data: 'https://coinos.io',
+ },
+ ],
+ },
+ { signal: controller.signal }
+ );
+
+ success = true;
+ } catch (err) {
+ if (err.name === 'AbortError') {
+ console.warn('Previous write aborted');
+ } else {
+ console.error('Write failed', err);
+ alert('❌ Write failed: ' + err.message);
+ }
+ } finally {
+ await NfcWriter.disableExclusiveNfcMode();
+ }
+ }
+</script>
+
+HI
+<button class="p-4 btn" on:click={write}>write</button>
+{#if here}HERE{/if}
+{#if ready}go{/if}
+{#if success}done{/if}
diff --git a/src/routes/(app)/invoice/[id]/+layout.server.ts b/src/routes/(app)/invoice/[id]/+layout.server.ts
index 590e5d4d..faabb753 100644
--- a/src/routes/(app)/invoice/[id]/+layout.server.ts
+++ b/src/routes/(app)/invoice/[id]/+layout.server.ts
@@ -8,32 +8,60 @@ export async function load({ cookies, depends, params, url, parent }) {
let { subject, user } = await parent();
const { id } = params;
let invoice;
+ // Amount of a payment that arrived while the page was open, set by the
+ // socket handler. Only meaningful for reusable (bolt12) offers, whose
+ // `received` is a lifetime total rather than this payment.
+ let justPaid: string | null = null;
if (id) {
invoice = await get(`/invoice/${id}`);
const options = !!url.searchParams.get("options");
+ let { amount, pending, received } = invoice;
+ amount = parseInt(amount);
+
+ // A bolt12 offer is a standing, REUSABLE payment code: CLN derives the same
+ // offer_id from the same (amount, description), so re-creating one hands back
+ // the original record with `received` accumulated over its entire lifetime.
+ // Treating that as "paid" bounces the user straight to the success screen so
+ // they never get to see the QR or copy the lno — and a long-lived offer that
+ // takes regular payouts is effectively unusable, since its last payment may
+ // be hours or months old. A standing offer has no terminal paid state, so
+ // only a payment arriving live counts — the socket handler signals that
+ // with `?paid=<amount>`.
+ const reusable = invoice.type === "bolt12";
+ justPaid = url.searchParams.get("paid");
+
+ const paid = reusable
+ ? !!justPaid
+ : (!amount && (pending || received)) ||
+ (amount > 0 && (pending >= amount || received >= amount));
+
+ if (paid && !url.pathname.endsWith("paid")) {
+ const q = new URLSearchParams();
+ if (options) q.set("options", "true");
+ // Carry the amount of THIS payment through; the success screen would
+ // otherwise render lifetime `received` for a reusable offer.
+ if (justPaid) q.set("paid", justPaid);
+ const qs = q.toString();
+ redirect(307, `/invoice/${id}/paid${qs ? `?${qs}` : ""}`);
+ }
+
+ subject = invoice.user;
if (
user &&
- invoice.uid !== user.id &&
+ invoice.uid !== user?.id &&
!(url.pathname.includes("tip") || url.pathname.includes("memo")) &&
!options
) {
- redirect(307, `/send/invoice/${id}`);
- }
-
- let { amount, pending, received } = invoice;
- amount = parseInt(amount);
- subject = invoice.user;
-
- const paid =
- (!amount && (pending || received)) ||
- (amount > 0 && (pending >= amount || received >= amount));
- if (paid && !url.pathname.endsWith("paid")) {
- redirect(307, `/invoice/${id}/paid` + (options ? "?options=true" : ""));
+ if (invoice.prompt && invoice.tip === null)
+ redirect(307, `/invoice/${id}/tip`);
+ else {
+ redirect(307, `/send/invoice/${id}`);
+ }
}
}
const theme = cookies.get("theme") || "light";
- return { id, invoice, subject, user, token, theme };
+ return { id, invoice, subject, user, token, theme, justPaid };
}
diff --git a/src/routes/(app)/invoice/[id]/+page.svelte b/src/routes/(app)/invoice/[id]/+page.svelte
index 0cb5aeac..a2a299bd 100644
--- a/src/routes/(app)/invoice/[id]/+page.svelte
+++ b/src/routes/(app)/invoice/[id]/+page.svelte
@@ -56,22 +56,24 @@
let subbed = false;
- onMount(async () => {
- if (browser) {
- last.subscribe((v) => {
- if (!v || subbed) return;
- subbed = true;
- send("subscribe", invoice);
- });
+ $effect(() => {
+ if (!browser) return;
+ last.subscribe((v) => {
+ if (!v || subbed) return;
+ subbed = true;
+ send("subscribe", invoice);
+ });
+ });
- if ($amountPrompt && !amount) toggleAmount();
- }
+ onMount(async () => {
+ if (!browser) return;
+ if ($amountPrompt && !amount) toggleAmount();
});
- let update = async () => {
+ let update = async (body) => {
try {
({ id } = await post(`/invoice`, {
- invoice,
+ invoice: body,
user: { username, currency },
}));
@@ -92,10 +94,12 @@
if (type === types.lightning && !amount && typeof newAmount === "undefined")
goto(`/${username}/receive`, { invalidateAll: true, noScroll: true });
else {
- if (typeof newAmount !== "undefined") invoice.amount = newAmount;
- invoice.address_type = address_type;
- invoice.type = type;
- await update();
+ // Build the POST body from a snapshot + explicit changes. Mutating the
+ // $derived `invoice` proxy and POSTing it serializes unreliably, leaking
+ // stale type/amount/hash from a prior interaction (see funk POS bug).
+ const body = { ...$state.snapshot(invoice), type, address_type };
+ if (typeof newAmount !== "undefined") body.amount = newAmount;
+ await update(body);
}
settingType = false;
};
@@ -111,10 +115,9 @@
settingAmount = false;
if (typeof $amountPrompt === "undefined") $amountPrompt = true;
- invoice.amount = newAmount;
- invoice.tip = 0;
+ const body = { ...$state.snapshot(invoice), amount: newAmount, tip: 0 };
- await update();
+ await update(body);
};
let setMemo = async (e) => {
@@ -122,8 +125,8 @@
e.stopPropagation();
settingMemo = false;
- invoice.memo = memo;
- await update();
+ const body = { ...$state.snapshot(invoice), memo };
+ await update(body);
};
let settingMemo = $state();
diff --git a/src/routes/(app)/invoice/[id]/paid/+page.svelte b/src/routes/(app)/invoice/[id]/paid/+page.svelte
index bc61d7fe..8e5bda27 100644
--- a/src/routes/(app)/invoice/[id]/paid/+page.svelte
+++ b/src/routes/(app)/invoice/[id]/paid/+page.svelte
@@ -10,6 +10,20 @@
);
let locale = $derived(loc(user));
+ // `received` is the invoice's running total. For a reusable bolt12 offer that
+ // is the LIFETIME figure across every payment the offer has ever taken, so
+ // showing it here would announce the accumulated total rather than the payment
+ // that just arrived. For those, show the amount the socket reported — it is
+ // already net of tip, the same basis as `received - tip`. Single-use invoices
+ // keep using `received` so a top-up onto a partly-paid invoice still totals
+ // correctly.
+ let reusable = $derived(data.invoice?.type === "bolt12");
+ let settled = $derived(
+ reusable && data.justPaid
+ ? Number.parseInt(data.justPaid) + (tip ?? 0)
+ : received,
+ );
+
// toast.pop(0);
</script>
@@ -27,7 +41,7 @@
<Amount amount={pending - tip} {tip} {rate} {currency} {locale} />
{:else}
<Success
- amount={received - tip}
+ amount={settled - tip}
{rate}
{tip}
{currency}
diff --git a/src/routes/(app)/invoice/[id]/tip/+page.server.ts b/src/routes/(app)/invoice/[id]/tip/+page.server.ts
index c3be68f0..851d3f1b 100644
--- a/src/routes/(app)/invoice/[id]/tip/+page.server.ts
+++ b/src/routes/(app)/invoice/[id]/tip/+page.server.ts
@@ -1,11 +1,36 @@
-import invoice from "$lib/invoice";
import getRates from "$lib/rates";
-import { redirect } from "@sveltejs/kit";
+import { auth, fd, get, post } from "$lib/utils";
+import { fail, redirect } from "@sveltejs/kit";
-export async function load({ parent }) {
+export async function load({ cookies, params, parent }) {
+ const { id } = params;
const rates = await getRates();
- const { invoice, subject } = await parent();
+ let { invoice, subject, user } = await parent();
if (!invoice.amount) redirect(307, `/invoice/${invoice.id}`);
+ const pin = cookies.get("pin");
+
+ if (user) {
+ const trust = await get("/trust", auth(cookies));
+ const trusted = trust.includes(invoice.uid);
+ if (trusted && (pin || !user.haspin)) {
+ let p;
+ try {
+ if (invoice.tip === null && invoice.user.prompt) {
+ if (user.tip > 0) {
+ invoice.tip = Math.round(invoice.amount * (user.tip / 100));
+ invoice = await post(`/invoice/${id}`, { invoice }, auth(cookies));
+ p = await post("/payments", { ...invoice, pin }, auth(cookies));
+ }
+ } else {
+ p = await post("/payments", { ...invoice, pin }, auth(cookies));
+ }
+ } catch (e) {
+ const { message } = e as Error;
+ fail(400, { message });
+ }
+ if (p) redirect(307, `/sent/${p.id}`);
+ }
+ }
const rate = rates[subject?.currency];
const invoiceRate = rates[invoice.currency];
@@ -13,5 +38,18 @@ export async function load({ parent }) {
}
export const actions = {
- default: invoice,
+ default: async ({ cookies, request }) => {
+ const form = await fd(request);
+ const invoice = {
+ tip: form.tip ?? null,
+ };
+
+ const { id } = await post(
+ `/invoice/${form.id}`,
+ { invoice },
+ auth(cookies),
+ );
+
+ redirect(307, `/invoice/${id}`);
+ },
};
diff --git a/src/routes/(app)/invoice/[id]/tip/+page.svelte b/src/routes/(app)/invoice/[id]/tip/+page.svelte
index d609d86a..a1ea3586 100644
--- a/src/routes/(app)/invoice/[id]/tip/+page.svelte
+++ b/src/routes/(app)/invoice/[id]/tip/+page.svelte
@@ -97,19 +97,8 @@
<div class="container px-4 max-w-lg text-center mx-auto">
<form method="POST" use:enhance class="space-y-5">
- <input type="hidden" name="aid" value={aid} />
- <input type="hidden" name="amount" value={amount} />
+ <input type="hidden" name="id" value={id} />
<input type="hidden" name="tip" value={tip} />
- <input type="hidden" name="username" value={username} />
- <input type="hidden" name="rate" value={invoice.rate} />
- <input type="hidden" name="prompt" value="false" />
- <input type="hidden" name="type" value={type} />
- <input type="hidden" name="hash" value={hash} />
- <input type="hidden" name="items" value={JSON.stringify(items)} />
-
- {#if invoice.memoPrompt}
- <input type="hidden" name="memoPrompt" value={invoice.memoPrompt} />
- {/if}
<h1 class="text-4xl font-semibold my-8">{$t("invoice.addTipq")}</h1>
{#if !showCustomAmount}
@@ -127,7 +116,10 @@
<div class="ml-auto text-lg flex gap-2">
<div class="flex items-center">
- <iconify-icon noobserver icon="ph:lightning-fill" class="text-yellow-300"
+ <iconify-icon
+ noobserver
+ icon="ph:lightning-fill"
+ class="text-yellow-300"
></iconify-icon>
<div>{s(amount, locale)}</div>
</div>
@@ -170,12 +162,14 @@
{/if}
</div>
- <div>
+ <div class="space-y-2">
<button
type="button"
class="btn"
- onclick={() => (showCustomAmount = true)}>Custom</button
+ onclick={() => (showCustomAmount = true)}
+ >{$t("payments.custom")}</button
>
+ <a href={`/invoice/${id}`} class="btn">{$t("payments.cancel")}</a>
</div>
{/if}
diff --git a/src/routes/(app)/invoices/+page.svelte b/src/routes/(app)/invoices/+page.svelte
index fbacb2df..40cf337a 100644
--- a/src/routes/(app)/invoices/+page.svelte
+++ b/src/routes/(app)/invoices/+page.svelte
@@ -1,35 +1,118 @@
<script>
+ import { copy, loc, s, types } from "$lib/utils";
+ import { t } from "$lib/translations";
+
let { data } = $props();
- let { invoices } = $derived(data);
- let show = $state({});
- let display = (id) => (show[id] = true);
+ let { invoices, user } = $derived(data);
+ let locale = $derived(loc(user));
+
+ let open = $state({});
+ let toggle = (idx) => (open[idx] = !open[idx]);
+
+ let memoText = (memo) => {
+ if (!memo?.startsWith("[")) return memo;
+ try {
+ return JSON.parse(memo)[0][1];
+ } catch {
+ return memo;
+ }
+ };
</script>
-<div class="container w-full mx-auto text-lg px-2 space-y-2">
- <div class="mx-auto flex justify-center w-full gap-1">
+<div class="container w-full mx-auto px-2 py-4 max-w-3xl">
+ <h1 class="text-2xl font-bold mb-4 px-2">{$t("invoice.invoice")}</h1>
+
+ <div
+ class="flex items-center gap-3 px-2 py-2 text-secondary text-sm font-medium border-b border-base-200"
+ >
+ <div class="w-24 sm:w-28 shrink-0">{$t("payments.type")}</div>
+ <div class="flex-1 min-w-0">{$t("invoice.invoice")}</div>
+ <div class="shrink-0 text-right">
+ {$t("payments.received")} / {$t("payments.amount")}
+ </div>
+ <div class="w-[18px] shrink-0"></div>
+ </div>
+
+ {#each invoices as i, idx}
+ {@const memo = memoText(i.memo)}
<div
- class="grid md:grid-cols-12 w-full text-center text-lg break-all gap-x-2"
+ class="flex gap-3 border-b border-base-200 hover:bg-base-200 px-2 py-3 cursor-pointer select-none"
+ class:items-center={!open[idx]}
+ class:items-start={open[idx]}
+ onclick={() => toggle(idx)}
>
- {#each invoices as i}
- <div class="md:col-span-2 whitespace-nowrap mt-8 md:mt-0">{i.type}</div>
- {#if show[i.id]}
- <div class="md:col-span-8">{i.hash}</div>
- {:else}
+ <div class="w-24 sm:w-28 shrink-0 flex items-center gap-2 min-w-0">
+ {#if i.type === types.lightning || i.type === types.bolt12}
+ <iconify-icon
+ noobserver
+ icon="ph:lightning-fill"
+ width="22"
+ class="text-yellow-300 shrink-0"
+ ></iconify-icon>
+ {:else if i.type === types.bitcoin}
+ <iconify-icon
+ noobserver
+ icon="logos:bitcoin"
+ width="22"
+ class="shrink-0"
+ ></iconify-icon>
+ {:else if i.type === types.liquid}
+ <img src="/images/liquid.svg" class="w-[22px] shrink-0" alt="Liquid" />
+ {:else if i.type === types.ecash}
+ <img src="/images/cash.png" class="w-[22px] shrink-0" alt="Ecash" />
+ {/if}
+ <span class="truncate text-sm">{i.type}</span>
+ </div>
+
+ <div class="flex-1 min-w-0">
+ <div
+ class="font-mono text-sm"
+ class:truncate={!open[idx]}
+ class:break-all={open[idx]}
+ >
+ {i.hash}
+ </div>
+ {#if memo}
<div
- class="cursor-pointer grow md:col-span-6"
- onclick={() => display(i.id)}
+ class="text-secondary text-xs mt-0.5"
+ class:truncate={!open[idx]}
+ class:break-all={open[idx]}
>
- {i.hash.substr(0, 12)}
- {#if !show[i.id]}...{/if}
+ {memo}
</div>
{/if}
- <div class="md:col-span-2 whitespace-nowrap">
- {i.received} / {i.amount}
- </div>
- <div class="whitespace-nowrap">
- {i.memo?.startsWith("[") ? JSON.parse(i.memo)[0][1] : i.memo}
- </div>
- {/each}
+ {#if open[idx]}
+ <button
+ type="button"
+ class="mt-2 inline-flex items-center gap-1 text-primary text-xs font-medium hover:underline"
+ onclick={(e) => {
+ e.stopPropagation();
+ copy(i.hash);
+ }}
+ >
+ <iconify-icon noobserver icon="ph:copy" width="16"></iconify-icon>
+ {$t("payments.copy")}
+ </button>
+ {/if}
+ </div>
+
+ <div class="shrink-0 text-right whitespace-nowrap tabular-nums">
+ <span class="text-secondary">{s(i.received, locale)}</span>
+ <span class="text-secondary"> / </span>
+ {s(i.amount, locale)}
+ </div>
+
+ <iconify-icon
+ noobserver
+ icon="ph:caret-down"
+ width="18"
+ class="shrink-0 text-secondary transition-transform"
+ class:rotate-180={open[idx]}
+ ></iconify-icon>
</div>
- </div>
+ {:else}
+ <p class="text-secondary text-lg text-center py-12">
+ {$t("payments.empty")}
+ </p>
+ {/each}
</div>
diff --git a/src/routes/(app)/ln/[lnurl]/+page.server.ts b/src/routes/(app)/ln/[lnurl]/+page.server.ts
index 405dc4b3..04cff338 100644
--- a/src/routes/(app)/ln/[lnurl]/+page.server.ts
+++ b/src/routes/(app)/ln/[lnurl]/+page.server.ts
@@ -1,39 +1,69 @@
+import { PUBLIC_DOMAIN } from "$env/static/public";
import getRates from "$lib/rates";
import { auth, fd, get, post } from "$lib/utils";
+import { bech32 } from "@scure/base";
import { error, fail, redirect } from "@sveltejs/kit";
+const { decode, fromWords } = bech32;
-export async function load({ params, parent }) {
+const lnurlFetch = async (url: string) =>
+ get(`/lnurl/proxy?url=${encodeURIComponent(url)}`);
+
+export async function load({ cookies, params, parent }) {
const { user } = await parent();
- if (!user) redirect(307, "/register");
const rates = await getRates();
let data;
- try {
- const { lnurl } = params;
- data = await get(`/decode?text=${lnurl}`);
- let { callback, minSendable, maxSendable, comment, tag } = data;
- if (tag === "payRequest" && minSendable === maxSendable) {
- minSendable = Math.round(minSendable / 1000);
- maxSendable = Math.round(maxSendable / 1000);
- const amount = minSendable;
+ const { lnurl } = params;
- let url = `${callback}?amount=${amount * 1000}`;
- if (comment) url += `&comment=${comment}`;
+ const url = Buffer.from(fromWords(decode(lnurl, 20000).words)).toString();
- const { pr } = await fetch(url).then((r) => r.json());
- let path = `/send/lightning/${pr}`;
- if (comment) path += `/${encodeURIComponent(comment)}`;
- redirect(307, path);
- }
+ try {
+ data = await get(`/decode?text=${lnurl}`);
} catch (e) {
const { message } = e as Error;
error(500, message);
}
+ let { callback, minSendable, maxSendable, comment, tag } = data;
+ if (tag === "payRequest" && minSendable === maxSendable) {
+ minSendable = Math.round(minSendable / 1000);
+ maxSendable = Math.round(maxSendable / 1000);
+ const amount = minSendable;
+
+ const urlObj = new URL(callback);
+ urlObj.searchParams.set("amount", (amount * 1000).toString());
+ if (comment) {
+ urlObj.searchParams.set("comment", comment);
+ }
+ const url = urlObj.toString();
+
+ const { pr } = await lnurlFetch(url);
+
+ let invoice;
+ try {
+ invoice = await get(`/invoice/${pr}`);
+ } catch (e) {}
+
+ if (invoice) redirect(307, `/invoice/${invoice.id}`);
+
+ let path = `/send/lightning/${pr}`;
+ if (comment) path += `/${encodeURIComponent(comment)}`;
+ redirect(307, path);
+ } else if (url.startsWith(`https://${PUBLIC_DOMAIN}/`)) {
+ const username = url.split(`https://${PUBLIC_DOMAIN}/p/`)[1];
+ if (username) redirect(307, `/pay/${username}`);
+ }
+
if (!["payRequest", "withdrawRequest"].includes(data.tag))
error(500, "We only support LNURLp and LNURLw at this time");
- data.rate = rates[user.currency];
+ 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;
}
@@ -52,10 +82,60 @@ export const actions = {
error = `Amount must be at most ${maxSendable} sats`;
if (error) return fail(400, { error });
- let url = `${callback}?amount=${amount * 1000}`;
- if (comment) url += `&comment=${comment}`;
+ const urlObj = new URL(callback);
+ urlObj.searchParams.set("amount", (amount * 1000).toString());
+ if (comment) urlObj.searchParams.set("comment", comment);
+ const url = urlObj.toString();
+
+ const { pr } = await lnurlFetch(url);
+
+ let path = `/send/lightning/${pr}`;
+ if (comment) path += `/${encodeURIComponent(comment)}`;
+ 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 });
+ }
- const { pr } = await fetch(url).then((r) => r.json());
let path = `/send/lightning/${pr}`;
if (comment) path += `/${encodeURIComponent(comment)}`;
redirect(307, path);
@@ -96,7 +176,7 @@ export const actions = {
url.searchParams.append("k1", k1);
url.searchParams.append("pr", pr);
- await fetch(url.toString());
+ await lnurlFetch(url.toString());
redirect(307, "/payments");
},
diff --git a/src/routes/(app)/ln/[lnurl]/+page.svelte b/src/routes/(app)/ln/[lnurl]/+page.svelte
index 386df9d2..0f1b030f 100644
--- a/src/routes/(app)/ln/[lnurl]/+page.svelte
+++ b/src/routes/(app)/ln/[lnurl]/+page.svelte
@@ -1,12 +1,14 @@
<script>
import Lnurlp from "$comp/Lnurlp.svelte";
import Lnurlw from "$comp/Lnurlw.svelte";
- let { data } = $props();
+ let { data, form } = $props();
let { tag } = data;
</script>
+<!-- form carries the action result; without it a failed pay/max/withdraw
+ showed nothing and left the button spinning forever -->
{#if tag === "payRequest"}
- <Lnurlp {data} />
+ <Lnurlp {data} {form} />
{:else if tag === "withdrawRequest"}
- <Lnurlw {data} />
+ <Lnurlw {data} {form} />
{/if}
diff --git a/src/routes/(app)/messages/[recipient]/+page.svelte b/src/routes/(app)/messages/[recipient]/+page.svelte
index d1cf6f94..d383d576 100644
--- a/src/routes/(app)/messages/[recipient]/+page.svelte
+++ b/src/routes/(app)/messages/[recipient]/+page.svelte
@@ -185,6 +185,8 @@
</div>
<style>
+ @reference "../../../../app.css";
+
.ours {
@apply bg-gradient-to-r from-[#F2F6FC] to-[#E1E3FF] text-black rounded-br-none ml-auto;
}
diff --git a/src/routes/(app)/migrate/+page.server.ts b/src/routes/(app)/migrate/+page.server.ts
new file mode 100644
index 00000000..6559485f
--- /dev/null
+++ b/src/routes/(app)/migrate/+page.server.ts
@@ -0,0 +1,106 @@
+import { auth, post } from "$lib/utils";
+import { redirect } from "@sveltejs/kit";
+import { env } from "$env/dynamic/private";
+
+const REGISTRAR = "https://names.coinos.io";
+
+// Tell the v3 registrar who may claim the released name. This session — the
+// old account's authenticated login — is the proof of ownership; the
+// registrar's own claim guard otherwise only accepts a claim signed by the
+// old account's nostr key, which a v3 wallet never has.
+async function grantMigration(username: string, to: string) {
+ if (!env.MIGRATE_TOKEN) throw new Error("migration hand-off isn't configured");
+ let lastErr;
+ for (let i = 0; i < 3; i++) {
+ try {
+ const r = await fetch(`${REGISTRAR}/migrate-grant`, {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: `Bearer ${env.MIGRATE_TOKEN}` },
+ body: JSON.stringify({ name: username, to }),
+ });
+ if (r.ok) return;
+ lastErr = new Error((await r.json().catch(() => null))?.error || `registrar said ${r.status}`);
+ } catch (e) {
+ lastErr = e;
+ }
+ await new Promise((res) => setTimeout(res, 1500));
+ }
+ throw lastErr;
+}
+
+// An invoice from the v3 registrar for this address and amount (LNURL-pay).
+async function invoiceFor(address: string, sats: number) {
+ const [name, domain] = String(address).split("@");
+ if (!name || !domain) throw new Error("bad destination address");
+ const p = await fetch(
+ `${REGISTRAR}/.well-known/lnurlp/${name}?domain=${encodeURIComponent(domain)}`,
+ ).then((r) => r.json());
+ if (!p?.callback) throw new Error(p?.reason || "that address isn't registered on coinos v3 yet");
+ const msat = sats * 1000;
+ if (msat < p.minSendable) throw new Error("balance is below the destination's minimum");
+ if (msat > p.maxSendable) throw new Error("balance is above the destination's maximum");
+ const sep = p.callback.includes("?") ? "&" : "?";
+ const inv = await fetch(`${p.callback}${sep}amount=${msat}`).then((r) => r.json());
+ if (!inv?.pr) throw new Error(inv?.reason || "could not get an invoice");
+ return inv.pr as string;
+}
+
+export async function load({ parent, url }) {
+ const { user } = await parent();
+ if (!user) redirect(307, `/login?redirect=${encodeURIComponent(url.pathname + url.search)}`);
+ return {
+ to: url.searchParams.get("to") || "",
+ back: url.searchParams.get("back") || "",
+ };
+}
+
+export const actions = {
+ default: async ({ cookies, request, url }) => {
+ const form = await request.formData();
+ const to = String(form.get("to") || url.searchParams.get("to") || "");
+ const pin = String(form.get("pin") || "");
+ const balance = Number(form.get("balance") || 0);
+ const username = String(form.get("username") || "");
+ if (!to) return { error: "no destination" };
+
+ let sent = 0;
+ try {
+ if (balance > 0) {
+ // coinos and the ASP peer directly, so a sweep normally costs
+ // nothing. Try the whole balance first and only hold a little
+ // back if the payment actually comes up short.
+ const attempts = [balance, balance - Math.max(1, Math.ceil(balance * 0.005))];
+ let lastErr;
+ for (const amount of attempts) {
+ if (amount <= 0) continue;
+ try {
+ const payreq = await invoiceFor(to, amount);
+ await post("/payments", { payreq, ...(pin ? { pin } : {}) }, auth(cookies));
+ sent = amount;
+ break;
+ } catch (e) {
+ lastErr = e;
+ const msg = String((e as any)?.message || e);
+ // only a shortfall is worth retrying smaller. The server's debit
+ // error is in the user's language ("Unzureichende Mittel ⚡️20582 /
+ // 20600"), so match its numeric tail rather than just the words.
+ if (!/insufficient|balance|not enough|fee|⚡\uFE0F?\s*\d+\s*(\/|of)\s*⚡?\uFE0F?\s*\d+/i.test(msg))
+ throw e;
+ }
+ }
+ if (!sent) throw lastErr;
+ }
+ // Hand the name over for receiving. The account itself stays exactly
+ // as it is — same username, same history, still usable here; the
+ // registrar just becomes the answer for incoming payments.
+ await post("/user", { migrated: true, ...(pin ? { pin } : {}) }, auth(cookies));
+ // …and tell the registrar which v3 identity the name now belongs
+ // to, or their claim over there can never pass the squat guard.
+ await grantMigration(username, to);
+ return { ok: true, sent, released: username };
+ } catch (e) {
+ const msg = e && typeof e === "object" && "message" in e ? String(e.message) : String(e);
+ return { error: msg, sent };
+ }
+ },
+};
diff --git a/src/routes/(app)/migrate/+page.svelte b/src/routes/(app)/migrate/+page.svelte
new file mode 100644
index 00000000..e246b1e8
--- /dev/null
+++ b/src/routes/(app)/migrate/+page.svelte
@@ -0,0 +1,82 @@
+<script>
+ // Migrate a legacy coinos account to coinos v3.
+ //
+ // v3 sends the user here with ?to=<their new payment address>&back=<url>.
+ // They're already signed in here, so no password or key has to travel:
+ // the form action (server side, with their session cookie) sweeps the
+ // balance over Lightning and then releases the username, which is what
+ // lets v3's registrar hand it to them.
+ import { enhance } from "$app/forms";
+
+ let { data, form } = $props();
+ let { user, to, back } = $derived(data);
+
+ let working = $state(false);
+ let balance = $derived(user?.balance || 0);
+ let backUrl = $derived.by(() => {
+ try {
+ const u = new URL(back || "https://v3.coinos.io/");
+ u.searchParams.set("migrated", user.username);
+ return u.toString();
+ } catch {
+ return "https://v3.coinos.io/";
+ }
+ });
+</script>
+
+<div class="max-w-md mx-auto p-4 space-y-5">
+ <h1 class="text-2xl font-bold">Move your account to coinos v3</h1>
+
+ {#if !to}
+ <p class="text-red-600">This page needs a destination — start the move from coinos v3.</p>
+ {:else if form?.ok}
+ <div class="space-y-3">
+ <p class="text-xl">✓ All done!</p>
+ {#if form.sent}
+ <p>{form.sent.toLocaleString()} sats are on their way to your new wallet.</p>
+ {/if}
+ <p>
+ <strong>{form.released}@coinos.io</strong> now receives into your coinos v3 wallet.
+ </p>
+ <a class="btn btn-primary w-full" href={backUrl}>Back to coinos v3</a>
+ </div>
+ {:else}
+ <p>
+ This sends your balance to your new wallet and points
+ <strong>{user.username}@coinos.io</strong> at it, so payments to your address arrive
+ there from now on. This account stays exactly as it is — same name, same history —
+ you just receive on coinos v3.
+ </p>
+
+ <div class="rounded-xl border p-3">
+ <div class="flex justify-between"><span>Balance</span><strong>{balance.toLocaleString()} sats</strong></div>
+ </div>
+
+ {#if form?.error}<p class="text-red-600">{form.error}</p>{/if}
+
+ <form
+ method="POST"
+ use:enhance={() => {
+ working = true;
+ return async ({ update }) => {
+ await update({ reset: false });
+ working = false;
+ };
+ }}
+ class="space-y-3"
+ >
+ <input type="hidden" name="to" value={to} />
+ <input type="hidden" name="balance" value={balance} />
+ <input type="hidden" name="username" value={user.username} />
+ {#if user?.haspin}
+ <input class="input input-bordered w-full" type="password" inputmode="numeric"
+ name="pin" placeholder="Your PIN" />
+ {/if}
+ <button class="btn btn-primary w-full" disabled={working}>
+ {#if working}Migrating…{:else}Migrate{/if}
+ </button>
+ </form>
+
+ <p class="text-sm opacity-60">You can also do nothing — your old account keeps working as it is.</p>
+ {/if}
+</div>
diff --git a/src/routes/(app)/ecash/mint/+server.ts b/src/routes/(app)/nsec/+server.ts
index 02170650..ceead0d5 100644
--- a/src/routes/(app)/ecash/mint/+server.ts
+++ b/src/routes/(app)/nsec/+server.ts
@@ -1,7 +1,7 @@
-import { auth, post } from "$lib/utils";
import { json } from "@sveltejs/kit";
+import { auth, post } from "$lib/utils";
export async function POST({ cookies, request }) {
- const result = await post("/mint", await request.json(), auth(cookies));
+ const result = await post("/nsec", await request.json(), auth(cookies));
return json(result);
}
diff --git a/src/routes/(app)/pay/[username]/[...amount]/+page.server.ts b/src/routes/(app)/pay/[username]/[...amount]/+page.server.ts
index b0f824ed..9c2b1e3b 100644
--- a/src/routes/(app)/pay/[username]/[...amount]/+page.server.ts
+++ b/src/routes/(app)/pay/[username]/[...amount]/+page.server.ts
@@ -13,7 +13,7 @@ export async function load({ cookies, params, parent }) {
let [amount, currency] = params.amount.split("/");
const rate = rates[currency ? currency.toUpperCase() : subject.currency];
- if (!rate) error(500, "Invalid currency symbol");
+ if (currency && !rate) error(500, "Invalid currency symbol");
if (amount) {
if (currency) amount = (amount * sats) / rate;
@@ -33,8 +33,6 @@ export async function load({ cookies, params, parent }) {
auth(cookies),
);
- const { balance } = await get(`/account/${user.id}`, auth(cookies));
-
redirect(307, `/invoice/${id}`);
}
@@ -66,8 +64,8 @@ export const actions = {
return fail(400, { message });
}
- if (parseInt(amount) > 0 && prompt) redirect(307, `/invoice/${id}/tip`);
- else if (parseInt(amount) <= 0 ) redirect(307, `/${username}/receive`);
+ if (parseInt(amount) > 0 && prompt) redirect(307, `/invoice/${id}/tip`);
+ else if (parseInt(amount) <= 0) redirect(307, `/${username}/receive`);
redirect(307, `/invoice/${id}`);
},
};
diff --git a/src/routes/(app)/pay/[username]/[...amount]/+page.svelte b/src/routes/(app)/pay/[username]/[...amount]/+page.svelte
index 618504ca..99502f95 100644
--- a/src/routes/(app)/pay/[username]/[...amount]/+page.svelte
+++ b/src/routes/(app)/pay/[username]/[...amount]/+page.svelte
@@ -20,20 +20,13 @@
let fiat = $state(!initialAmount);
let formElement = $state();
- let setMax = async (e) => {
+ let setMax = (e) => {
e.preventDefault();
- let body = new FormData(formElement);
- body.set("fiat", false);
- body.set("amount", user.balance);
-
- const response = await fetch(formElement.action, {
- method: "POST",
- body,
- });
-
- const result = deserialize(await response.text());
- if (result.type === "success") await invalidateAll();
- applyAction(result);
+ fiat = false;
+ const bal = user.balance;
+ const maxfee = Math.max(5, Math.round(bal * 0.02));
+ const platformFee = Math.round(bal * 0.001);
+ amount = Math.max(0, bal - maxfee - platformFee);
};
</script>
@@ -62,7 +55,7 @@
type="button"
class="btn !w-auto grow"
onclick={setMax}
- onkeydown={setMax}>Max ⚡️{s(user.balance)}</button
+ onkeydown={setMax}>Max</button
>
{/if}
diff --git a/src/routes/(app)/payment/[id]/+page.svelte b/src/routes/(app)/payment/[id]/+page.svelte
index fcde487b..a4c693a0 100644
--- a/src/routes/(app)/payment/[id]/+page.svelte
+++ b/src/routes/(app)/payment/[id]/+page.svelte
@@ -67,7 +67,9 @@
let direction = $state("");
onMount(() => {
- direction = amount > 0 ? $t("payments.from") : $t("payments.to");
+ let a = amount;
+ if (type === types.fund) a = 0 - a;
+ direction = a > 0 ? $t("payments.from") : $t("payments.to");
if (direction)
direction =
@@ -95,7 +97,11 @@
<div class="container mx-auto max-w-lg px-4 space-y-8 break-all text-2xl">
<h1 class="px-3 md:px-0 text-center text-3xl md:text-4xl font-semibold mb-10">
- {$t(amount < 0 ? "payments.sent" : "payments.received")}
+ {#if amount < 0 && !confirmed && (type === types.lightning || type === types.bolt12)}
+ {$t("payments.pending")}
+ {:else}
+ {$t(amount < 0 ? "payments.sent" : "payments.received")}
+ {/if}
</h1>
{#if p.with}
@@ -187,8 +193,19 @@
{#if type === types.lightning || type === types.bolt12}
<div>
+ <span class="text-lg text-secondary">{$t("payments.invoice")}</span>
+ <div>{hash}</div>
+ </div>
+
+ <div>
<span class="text-lg text-secondary">{$t("payments.preimage")}</span>
- <div>{ref}</div>
+ <div>
+ {#if ref}
+ {ref}
+ {:else}
+ <span class="text-secondary">{$t("payments.pending")}</span>
+ {/if}
+ </div>
</div>
{/if}
diff --git a/src/routes/(app)/pos/+page.ts b/src/routes/(app)/pos/+page.ts
new file mode 100644
index 00000000..393950d8
--- /dev/null
+++ b/src/routes/(app)/pos/+page.ts
@@ -0,0 +1,6 @@
+import { redirect } from "@sveltejs/kit";
+
+// The web flasher moved to /flash: /pos is the coinos v3 till.
+export const load = () => {
+ redirect(301, "/flash");
+};
diff --git a/src/routes/(app)/qr/[text]/[icon]/+server.ts b/src/routes/(app)/qr/[text]/[icon]/+server.ts
new file mode 100644
index 00000000..c2825877
--- /dev/null
+++ b/src/routes/(app)/qr/[text]/[icon]/+server.ts
@@ -0,0 +1,33 @@
+import { createCanvas, loadImage } from "canvas";
+import QRCode from "qrcode";
+
+export async function GET({ params }) {
+ const { icon, text } = params;
+ const size = 600;
+
+ const canvas = createCanvas(size, size);
+ await QRCode.toCanvas(canvas, text, {
+ errorCorrectionLevel: "H",
+ width: size,
+ });
+
+ const ctx = canvas.getContext("2d");
+
+ const logo = await loadImage(`static/images/${icon}`);
+ const logoSize = size * 0.3;
+ ctx.drawImage(
+ logo,
+ (size - logoSize) / 2,
+ (size - logoSize) / 2,
+ logoSize,
+ logoSize,
+ );
+
+ const buffer = canvas.toBuffer("image/png");
+ return new Response(buffer, {
+ headers: {
+ "Content-Type": "image/png",
+ "Cache-Control": "public, max-age=31536000, immutable",
+ },
+ });
+}
diff --git a/src/routes/(app)/qr/[text]/raw/+server.ts b/src/routes/(app)/qr/[text]/raw/+server.ts
index a24d3a3c..a2626c4d 100644
--- a/src/routes/(app)/qr/[text]/raw/+server.ts
+++ b/src/routes/(app)/qr/[text]/raw/+server.ts
@@ -14,3 +14,4 @@ export async function GET({ params }) {
},
});
}
+
diff --git a/src/routes/(app)/receive/[...token]/+page.server.ts b/src/routes/(app)/receive/[...token]/+page.server.ts
index 05b2dad8..052d507f 100644
--- a/src/routes/(app)/receive/[...token]/+page.server.ts
+++ b/src/routes/(app)/receive/[...token]/+page.server.ts
@@ -1,17 +1,17 @@
+import parse from "$lib/parse";
import { fd } from "$lib/utils";
import { fail, redirect } from "@sveltejs/kit";
-import parse from "$lib/parse";
-export async function load({ params, parent, url }) {
+export async function load({ cookies, params, parent, url }) {
const { user } = await parent();
if (!user) redirect(307, `/register?redirect=${url.pathname}`);
- await parse(params.token, url.host);
+ await parse(params.token, url.host, cookies);
}
export const actions = {
- default: async ({ request, url }) => {
+ default: async ({ cookies, request, url }) => {
const { text } = await fd(request);
- await parse(text, url.host);
+ await parse(text, url.host, cookies);
return fail(400, { error: "default" });
},
};
diff --git a/src/routes/(app)/reset/[code]/+page.server.ts b/src/routes/(app)/reset/[code]/+page.server.ts
index 533e21f5..35d49be9 100644
--- a/src/routes/(app)/reset/[code]/+page.server.ts
+++ b/src/routes/(app)/reset/[code]/+page.server.ts
@@ -7,11 +7,12 @@ export const actions = {
let error;
const { code } = params;
- const { password } = await fd(request);
+ const { password, recaptcha } = await fd(request);
try {
const user = await post("/reset", { code, password });
user.password = password;
+ user.recaptcha = recaptcha;
try {
await login(user, cookies, ip);
diff --git a/src/routes/(app)/reset/[code]/+page.svelte b/src/routes/(app)/reset/[code]/+page.svelte
index df9d1144..ce43a79d 100644
--- a/src/routes/(app)/reset/[code]/+page.svelte
+++ b/src/routes/(app)/reset/[code]/+page.svelte
@@ -1,14 +1,71 @@
<script>
+ import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public";
+ import { browser } from "$app/environment";
+ import { onDestroy, onMount, tick } from "svelte";
import { t } from "$lib/translations";
import PasswordInput from "$comp/PasswordInput.svelte";
import Icon from "$comp/Icon.svelte";
import Spinner from "$comp/Spinner.svelte";
import { fly } from "svelte/transition";
import { password } from "$lib/store";
+ import { fail } from "$lib/utils";
let { form } = $props();
let email;
let loading;
+ let recaptchaToken = "";
+ let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY;
+ let isTor = browser && location.hostname.endsWith(".onion");
+
+ onMount(() => {
+ if (!isTor && recaptchaSiteKey) {
+ let s = document.createElement("script");
+ s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey;
+ document.head.appendChild(s);
+ }
+ });
+
+ const getRecaptchaToken = () =>
+ new Promise((resolve, reject) => {
+ if (isTor) return resolve("");
+ if (!browser || !grecaptcha) return reject(new Error("captcha unavailable"));
+ grecaptcha.ready(() => {
+ grecaptcha
+ .execute(recaptchaSiteKey, { action: "reset" })
+ .then(resolve)
+ .catch(reject);
+ });
+ });
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ loading = true;
+ try {
+ recaptchaToken = await getRecaptchaToken();
+ await tick();
+ e.currentTarget.submit();
+ } catch (err) {
+ fail(err.message || "captcha failed");
+ loading = false;
+ }
+ };
+
+ onDestroy(() => {
+ if (!browser) return;
+ const nodeBadge = document.querySelector(".grecaptcha-badge");
+ if (nodeBadge) {
+ document.body.removeChild(nodeBadge.parentNode);
+ }
+
+ const scriptSelector =
+ "script[src='https://www.google.com/recaptcha/api.js?render=" +
+ recaptchaSiteKey +
+ "']";
+ const script = document.querySelector(scriptSelector);
+ if (script) {
+ script.remove();
+ }
+ });
</script>
<div class="pt-10">
@@ -30,7 +87,8 @@
</div>
{/if}
- <form class="space-y-5" method="POST">
+ <form class="space-y-5" method="POST" onsubmit={handleSubmit}>
+ <input type="hidden" name="recaptcha" value={recaptchaToken} />
<PasswordInput
bind:value={$password}
placeholder={$t("login.password")}
diff --git a/src/routes/(app)/send/[...text]/+page.server.ts b/src/routes/(app)/send/[...text]/+page.server.ts
index 9f0891d9..59d53dbe 100644
--- a/src/routes/(app)/send/[...text]/+page.server.ts
+++ b/src/routes/(app)/send/[...text]/+page.server.ts
@@ -9,16 +9,16 @@ export async function load({ cookies, depends, params, parent, url }) {
if (!user) redirect(307, "/login");
const { text } = params;
- await parse(text, url.host);
+ await parse(text, url.host, cookies);
const contacts = await get(`/contacts/${limit}`, auth(cookies));
return { contacts };
}
export const actions = {
- default: async ({ request, url }) => {
+ default: async ({ cookies, request, url }) => {
const { text } = await fd(request);
- await parse(text, url.host);
+ await parse(text, url.host, cookies);
return fail(400, { error: "default" });
},
};
diff --git a/src/routes/(app)/send/[...text]/+page.svelte b/src/routes/(app)/send/[...text]/+page.svelte
index 183b4b51..b2b4d6bf 100644
--- a/src/routes/(app)/send/[...text]/+page.svelte
+++ b/src/routes/(app)/send/[...text]/+page.svelte
@@ -51,6 +51,20 @@
invalidate("app:contacts");
};
+ let trust = async (e, { id, trusted }) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (trusted) {
+ await post("/post/trust/delete", { id });
+ } else {
+ await post("/post/trust", { id });
+ }
+
+ if (all) all = await get("/contacts");
+ invalidate("app:contacts");
+ };
+
$effect(() => {
if (browser && pasted && text) el.click() && (pasted = false);
});
@@ -74,17 +88,6 @@
</div>
{/if}
- <textarea
- use:focus
- name="text"
- placeholder={$t("user.send.placeholder")}
- onkeypress={keypress}
- class="w-full p-4 border rounded-xl h-32 text-xl"
- bind:value={text}
- onpaste={() => (pasted = true)}
- autocapitalize="none"
- ></textarea>
-
<div class="flex gap-2">
<a href="/scan" class="contents">
<button type="button" class="btn !w-auto flex-grow">
@@ -101,29 +104,23 @@
</button>
</div>
+ <textarea
+ use:focus
+ name="text"
+ placeholder={$t("user.send.placeholder")}
+ onkeypress={keypress}
+ class="w-full p-4 border rounded-xl h-32 text-xl"
+ bind:value={text}
+ onpaste={() => (pasted = true)}
+ autocapitalize="none"
+ ></textarea>
+
<button bind:this={el} type="submit" class="btn btn-accent">
<iconify-icon noobserver icon="ph:paper-plane-right-bold" width="32"
></iconify-icon>
<div class="my-auto">{$t("user.send.next")}</div>
</button>
- <a href="/send/ecash" class="block">
- <button type="button" class="btn">
- <img src="/images/cash.png" class="w-8" />
- <div class="my-auto">{$t("payments.createEcash")}</div>
- </button>
- </a>
-
- <a href="/send/fund" class="block">
- <button type="button" class="btn">
- <iconify-icon
- noobserver
- icon="ph:lightning-fill"
- class="text-yellow-300 text-2xl"
- ></iconify-icon>
- <div class="my-auto">{$t("payments.createFund")}</div>
- </button>
- </a>
</form>
{#if contacts.length}
@@ -139,12 +136,20 @@
<div class="my-auto text-left">
<p class="ml-1 text-lg break-words">{c.username}</p>
</div>
- <button class="ml-auto" onclick={(e) => pin(e, c)}>
- <iconify-icon
- icon={c.pinned ? "ph:push-pin-fill" : "ph:push-pin-bold"}
- width={32}
- ></iconify-icon>
- </button>
+ <div class="flex ml-auto gap-1">
+ <button onclick={(e) => pin(e, c)}>
+ <iconify-icon
+ icon={c.pinned ? "ph:push-pin-fill" : "ph:push-pin-bold"}
+ width={32}
+ ></iconify-icon>
+ </button>
+ <button class="ml-auto" onclick={(e) => trust(e, c)}>
+ <iconify-icon
+ icon={c.trusted ? "ph:star-fill" : "ph:star-bold"}
+ width={32}
+ ></iconify-icon>
+ </button>
+ </div>
</div>
</a>
{/each}
@@ -152,7 +157,7 @@
</div>
{/if}
- {#if !loaded}
+ {#if !loaded && contacts.length > 0}
<button onclick={loadMore} type="button" class="btn"
>{$t("user.loadMore")}</button
>
diff --git a/src/routes/(app)/send/bitcoin/[address]/+page.svelte b/src/routes/(app)/send/bitcoin/[address]/+page.svelte
index 154534a7..529e2691 100644
--- a/src/routes/(app)/send/bitcoin/[address]/+page.svelte
+++ b/src/routes/(app)/send/bitcoin/[address]/+page.svelte
@@ -5,13 +5,14 @@
import Numpad from "$comp/Numpad.svelte";
import { page } from "$app/stores";
import { rate } from "$lib/store";
- import { fail, s } from "$lib/utils";
+ import { loc, fail, s } from "$lib/utils";
let { data } = $props();
let { balance, user } = data;
let { address } = $page.params;
let { currency, username } = user;
+ let locale = loc(user);
let amount = $state(0);
let a = $state(0);
@@ -34,8 +35,7 @@
<div class="text-xl text-secondary break-all">{address}</div>
- <div>{amount}</div>
- <Numpad bind:amount={a} bind:fiat {currency} {submit} bind:rate={$rate} />
+ <Numpad bind:amount={a} bind:fiat {currency} {submit} bind:rate={$rate} {locale} />
<div class="flex justify-center gap-2">
<button
diff --git a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.server.ts b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.server.ts
index ac883afd..ddee9c1c 100644
--- a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.server.ts
+++ b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.server.ts
@@ -11,8 +11,6 @@ export async function load({
const { user } = await parent();
const aid = cookies.get("aid") || user.id;
- console.log("YO", amount, address, feeRate);
-
try {
const { fee, fees, inputs, ourfee, hex, subtract } = await post(
"/bitcoin/fee",
diff --git a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
index b86082f3..ac904304 100644
--- a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
+++ b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
@@ -1,6 +1,4 @@
<script>
- import { run } from "svelte/legacy";
-
import { tick } from "svelte";
import { t } from "$lib/translations";
import { enhance } from "$app/forms";
@@ -15,11 +13,10 @@
import { applyAction } from "$app/forms";
import { hex as hexUtil } from "@scure/base";
import * as btc from "@scure/btc-signer";
- import { hash160 } from "@scure/btc-signer/utils";
import { decrypt } from "nostr-tools/nip49";
import { HDKey } from "@scure/bip32";
import { entropyToMnemonic, mnemonicToSeed } from "@scure/bip39";
- import { wordlist } from "@scure/bip39/wordlists/english";
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
import Amount from "$comp/Amount.svelte";
@@ -71,8 +68,18 @@
let toggle = () => (submitting = !submitting);
- let { account, amount, address, message, fee, fees, subtract, ourfee, hex, inputs } =
- $derived(data);
+ let {
+ account,
+ amount,
+ address,
+ message,
+ fee,
+ fees,
+ subtract,
+ ourfee,
+ hex,
+ inputs,
+ } = $derived(data);
let { feeRate } = $state(data);
@@ -116,7 +123,11 @@
{:else}
<div class="text-xl text-secondary break-all">{address}</div>
- <Amount amount={subtract ? amount - fee - ourfee : amount} rate={$rate} {currency} />
+ <Amount
+ amount={subtract ? amount - fee - ourfee : amount}
+ rate={$rate}
+ {currency}
+ />
<div class="text-center">
<h2 class="text-secondary text-lg">{$t("payments.networkFee")}</h2>
diff --git a/src/routes/(app)/send/ecash/+page.server.ts b/src/routes/(app)/send/ecash/+page.server.ts
deleted file mode 100644
index b290fb95..00000000
--- a/src/routes/(app)/send/ecash/+page.server.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import getRates from "$lib/rates";
-import { auth, fd, post } from "$lib/utils";
-import { error, redirect } from "@sveltejs/kit";
-
-export async function load({ parent }) {
- const rates = await getRates();
- const { user } = await parent();
- return { rate: rates[user.currency] };
-}
-
-export const actions = {
- default: async ({ cookies, request }) => {
- let id;
- const body = await fd(request);
-
- try {
- ({ id } = await post("/mint", body, auth(cookies)));
- } catch (e) {
- const { message } = e as Error;
- console.log("problem creating ecash", e);
- error(400, { message });
- }
-
- redirect(307, `/ecash/${id}`);
- },
-};
diff --git a/src/routes/(app)/send/ecash/+page.svelte b/src/routes/(app)/send/ecash/+page.svelte
deleted file mode 100644
index b9c93661..00000000
--- a/src/routes/(app)/send/ecash/+page.svelte
+++ /dev/null
@@ -1,63 +0,0 @@
-<script>
- import { enhance, applyAction } from "$app/forms";
- import { t } from "$lib/translations";
- import Icon from "$comp/Icon.svelte";
- import Numpad from "$comp/Numpad.svelte";
- import Spinner from "$comp/Spinner.svelte";
- import { page } from "$app/stores";
- import { rate } from "$lib/store";
- import { fail, s } from "$lib/utils";
- import handler from "$lib/handler";
-
- let { data } = $props();
-
- let { user } = $derived(data);
- let { balance, currency, username } = $derived(user);
-
- let amount = $state(0);
- let submit = $state(),
- fiat = $state();
-
- let setMax = (e) => {
- e.preventDefault();
- fiat = false;
- amount = balance;
- };
-
- let submitting = $state();
- let toggle = () => (submitting = !submitting);
- $effect(() => ({ rate: $rate } = data));
-</script>
-
-<div class="container px-4 max-w-xl mx-auto space-y-5 text-center">
- <form use:enhance={handler(toggle)} method="POST" class="space-y-5">
- <h1 class="px-3 md:px-0 text-center text-3xl md:text-4xl font-semibold">
- {$t("payments.fundingAmount")}
- </h1>
- <Numpad bind:amount bind:fiat {currency} {submit} bind:rate={$rate} />
- <input type="hidden" name="amount" bind:value={amount} />
-
- <div class="flex gap-2">
- <button
- type="button"
- class="btn !w-auto grow"
- onclick={setMax}
- disabled={submitting}
- onkeydown={setMax}>Max ⚡️{s(balance)}</button
- >
-
- <button
- use:focus
- bind:this={submit}
- type="submit"
- class="btn btn-accent !w-auto grow"
- >
- {#if submitting}
- <Spinner />
- {:else}
- <div class="my-auto">{$t("payments.next")}</div>
- {/if}
- </button>
- </div>
- </form>
-</div>
diff --git a/src/routes/(app)/send/ecash/[request]/+page.server.ts b/src/routes/(app)/send/ecash/[request]/+page.server.ts
deleted file mode 100644
index 6cc593cc..00000000
--- a/src/routes/(app)/send/ecash/[request]/+page.server.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { fd, get } from "$lib/utils";
-import { decodePaymentRequest } from "@cashu/cashu-ts";
-import { redirect } from "@sveltejs/kit";
-
-export async function load({ cookies, params, url }) {
- if (!cookies.get("token"))
- redirect(307, `/login?redirect=${encodeURIComponent(url.pathname)}`);
-
- const req = { ...decodePaymentRequest(params.request) };
- const [{ target, type }] = req.transport;
-
- let recipient = { name: "", pubkey: null };
- if (type === "nostr") {
- recipient = await get(`/profile/${target}`);
- } else if (type === "post") {
- recipient.name = new URL(target).hostname;
- }
-
- return { req, recipient, type, target };
-}
-
-export const actions = {
- setAmount: async ({ request }) => {
- return await fd(request);
- },
-};
diff --git a/src/routes/(app)/send/ecash/[request]/+page.svelte b/src/routes/(app)/send/ecash/[request]/+page.svelte
deleted file mode 100644
index 42bc8e9d..00000000
--- a/src/routes/(app)/send/ecash/[request]/+page.svelte
+++ /dev/null
@@ -1,128 +0,0 @@
-<script lang="ts">
- import { goto } from "$app/navigation";
- import { focus, fail, post } from "$lib/utils";
- import { t } from "$lib/translations";
- import { enhance } from "$app/forms";
- import Amount from "$comp/Amount.svelte";
- import Numpad from "$comp/Numpad.svelte";
- import Spinner from "$comp/Spinner.svelte";
- import { rate, pin } from "$lib/store";
- import { wrapEvent } from "nostr-tools/nip17";
- import { getPrivateKey, sign, send } from "$lib/nostr";
- import { getDecodedToken } from "@cashu/cashu-ts";
- import type { PaymentRequestPayload } from "@cashu/cashu-ts";
- import { AbstractSimplePool } from "nostr-tools/abstract-pool";
-
- const alwaysTrue: any = (t: Event) => {
- t[Symbol("verified")] = true;
- return true;
- };
-
- export const pool = new AbstractSimplePool({ verifyEvent: alwaysTrue });
-
- const { data, form } = $props();
-
- let formElement = $state();
- let submitting = $state();
- let submit = async (e) => {
- e.preventDefault();
- submitting = true;
-
- try {
- let { pid, token } = await post("/ecash/mint", { amount });
- let { proofs } = getDecodedToken(token);
- let { id, mints, unit = "" } = req;
- let mint = mints[0];
-
- const payload = {
- id,
- mint,
- unit,
- proofs,
- };
-
- if (type === "nostr") {
- let event = wrapEvent(
- await getPrivateKey(user),
- { publicKey: recipient.pubkey },
- JSON.stringify(payload),
- );
-
- await Promise.any(pool.publish(recipient.relays, event));
- } else if (type === "post") {
- await post("/ecash/post", { target, payload });
- }
-
- goto(`/sent/${pid}`);
- } catch (e) {
- console.log(e);
- fail(e.message);
- }
-
- submitting = false;
- };
-
- let { recipient, req, user, type, target } = $derived({
- ...data,
- ...form,
- });
- let { currency } = $derived(user);
- let a = $state();
- let next = $state();
- let toggle = () => (show = !show);
- let amount = $derived(form?.amount || data.req.amount);
-
- $effect(() => ($rate ||= data.rate));
- $effect(() => form && (submitting = false));
-</script>
-
-<div class="container px-4 max-w-xl mx-auto text-center space-y-2">
- {#if amount}
- {#if form?.message}
- <div class="text-red-600 text-center">
- {$t(form.message)}
- </div>
- {/if}
-
- <div>
- <h1 class="text-lg text-secondary">
- {$t("payments.send")}
- </h1>
-
- <Amount {amount} rate={$rate} {currency} />
- </div>
-
- <div class="text-xl">
- <span class="text-secondary">{$t("payments.to")}</span>
- <span class="break-words font-semibold">{recipient.name}</span>
- </div>
-
- <form onsubmit={submit} class="space-y-2" bind:this={formElement}>
- <input name="amount" value={amount} type="hidden" />
- <input name="pin" value={$pin} type="hidden" />
-
- <button
- type="submit"
- class="btn btn-primary"
- disabled={submitting}
- use:focus
- >
- {#if submitting}
- <Spinner />
- {:else}
- {$t("payments.send")}
- {/if}
- </button>
- </form>
- {:else}
- <form method="POST" action="?/setAmount" class="space-y-2" use:enhance>
- <input type="hidden" value={a} name="amount" />
- <input name="rate" value={$rate} type="hidden" />
-
- <Numpad bind:amount={a} {currency} bind:rate={$rate} submit={next} />
- <button type="submit" class="btn" bind:this={next}
- >{$t("payments.next")}</button
- >
- </form>
- {/if}
-</div>
diff --git a/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte b/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte
index 314be3ef..d34ed073 100644
--- a/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte
+++ b/src/routes/(app)/send/fund/[...name]/[...amount]/+page.svelte
@@ -6,14 +6,16 @@
import { page } from "$app/stores";
import { pin } from "$lib/store";
import handler from "$lib/handler";
- import { s } from "$lib/utils";
+ import { loc, s } from "$lib/utils";
+ import { applyAction, deserialize } from "$app/forms";
let { data, form } = $props();
- let { amount } = $state(data);
- let { balance, currency } = data.user;
+ let { amount, user } = $state(data);
+ let { balance, currency } = $derived(user);
let { name, rate } = $derived(data);
let loading = $state();
let fiat = $state();
+ let locale = loc(user);
let submit = $state();
let submitting = $state();
@@ -22,11 +24,22 @@
if (form?.message?.includes("pin")) $pin = undefined;
loading = false;
});
+ let formElement = $state();
- let setMax = (e) => {
+ let setMax = async (e) => {
e.preventDefault();
- fiat = false;
- amount = balance;
+ let body = new FormData(formElement);
+ body.set("fiat", false);
+ body.set("amount", user.balance);
+
+ const response = await fetch(formElement.action, {
+ method: "POST",
+ body,
+ });
+
+ const result = deserialize(await response.text());
+ if (result.type === "success") await invalidateAll();
+ applyAction(result);
};
</script>
@@ -37,9 +50,14 @@
{/if}
<div class="container px-4 mt-20 max-w-xl mx-auto">
- <Numpad bind:amount {currency} {rate} {fiat} {submit} />
+ <Numpad bind:amount {currency} {rate} {fiat} {submit} {locale} />
- <form use:enhance={handler(toggle)} method="POST" class="space-y-5">
+ <form
+ use:enhance={handler(toggle)}
+ method="POST"
+ class="space-y-5"
+ bind:this={formElement}
+ >
<input name="fund" value={name} type="hidden" />
<input name="amount" value={amount} type="hidden" />
<input name="pin" value={$pin} type="hidden" />
diff --git a/src/routes/(app)/send/invoice/[id]/+page.server.ts b/src/routes/(app)/send/invoice/[id]/+page.server.ts
index 3ef933d4..70b007a8 100644
--- a/src/routes/(app)/send/invoice/[id]/+page.server.ts
+++ b/src/routes/(app)/send/invoice/[id]/+page.server.ts
@@ -1,12 +1,36 @@
import getRates from "$lib/rates";
import { auth, fd, get, post, types } from "$lib/utils";
-import { error, redirect } from "@sveltejs/kit";
+import { error, fail, redirect } from "@sveltejs/kit";
-export async function load({ cookies, params: { id }, parent }) {
- const { user } = await parent();
+export async function load({ cookies, depends, params: { id }, parent }) {
+ depends("app:trust");
+ const { subject, user } = await parent();
const aid = cookies.get("aid") || user.id;
+ const pin = cookies.get("pin");
- const invoice = await get(`/invoice/${id}`);
+ let invoice = await get(`/invoice/${id}`);
+
+ const trust = await get("/trust", auth(cookies));
+ const trusted = trust.includes(invoice.uid);
+ if (trusted && (pin || !user.haspin)) {
+ let p;
+ try {
+ if (!invoice.tip && invoice.user.prompt) {
+ if (user.tip > 0) {
+ invoice.tip = Math.round(invoice.amount * (user.tip / 100));
+ invoice = await post(`/invoice/${id}`, { invoice }, auth(cookies));
+ } else {
+ throw new Error("tip");
+ }
+ }
+ p = await post("/payments", { ...invoice, pin }, auth(cookies));
+ } catch (e) {
+ const { message } = e as Error;
+ if (message === "tip") redirect(307, `/invoice/${id}/tip`);
+ fail(400, { message });
+ }
+ if (p) redirect(307, `/sent/${p.id}`);
+ }
if (invoice.amount && invoice.prompt && invoice.tip === null)
redirect(307, `/invoice/${id}/tip`);
@@ -31,7 +55,7 @@ export async function load({ cookies, params: { id }, parent }) {
const invoiceRate = rates[invoice.currency];
const { balance } = await get(`/account/${aid}`, auth(cookies));
- return { balance, invoice, user, rate, invoiceRate };
+ return { balance, invoice, user, rate, invoiceRate, trusted };
}
export const actions = {
@@ -45,7 +69,7 @@ export const actions = {
} catch (e) {
const { message } = e as Error;
console.log("payment failed", id, e);
- error(500, message);
+ error(500, { message });
}
redirect(307, `/sent/${p.id}`);
diff --git a/src/routes/(app)/send/invoice/[id]/+page.svelte b/src/routes/(app)/send/invoice/[id]/+page.svelte
index 7d167f41..d5fd00a8 100644
--- a/src/routes/(app)/send/invoice/[id]/+page.svelte
+++ b/src/routes/(app)/send/invoice/[id]/+page.svelte
@@ -1,4 +1,5 @@
<script>
+ import { invalidate } from "$app/navigation";
import { untrack } from "svelte";
import handler from "$lib/handler";
import { onDestroy, onMount } from "svelte";
@@ -26,7 +27,7 @@
} from "$lib/utils";
let { data, form } = $props();
- let { balance, invoice, user } = $state(data);
+ let { trusted, balance, invoice, user } = $state(data);
let { address, hash, payreq, user: recipient, tip } = $state(invoice);
let { currency } = $derived(user);
let locale = $derived(loc(user));
@@ -64,6 +65,19 @@
});
};
+ let toggleTrust = async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (trusted) {
+ await post("/post/trust/delete", { id: recipient.id });
+ } else {
+ await post("/post/trust", { id: recipient.id });
+ }
+
+ invalidate("app:trust");
+ };
+
$effect(() => {
if (form?.message?.includes("pin")) $pin = undefined;
submitting = false;
@@ -193,5 +207,12 @@
<button type="button" class="btn" onclick={external}
>{$t("payments.moreOptions")}</button
>
+
+ {#if !trusted}
+ <button type="button" class="btn" onclick={toggleTrust}>
+ <iconify-icon icon="ph:star-fill" width={32}></iconify-icon>
+ {$t("payments.trustUser")}</button
+ >
+ {/if}
</form>
</div>
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 eb324775..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,14 +1,34 @@
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();
- if (!params.payreq.startsWith("lno")) {
- data = { ...data, ...(await post("/parse", params, auth(cookies))) };
- }
+ 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;
@@ -29,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 a5cc168c..7b42a42d 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,53 @@
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.005 || 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")),
+ );
+
+ // A resubmitted invoice (back button, double tap) isn't a routing failure —
+ // the first attempt went through or is still in flight. Say so instead of
+ // telling the user the recipient is unreachable.
+ let duplicate = $derived(
+ form?.message &&
+ (/already (been )?paid/i.test(form.message)
+ ? "payments.alreadyPaid"
+ : form.message.includes("already underway")
+ ? "payments.alreadyUnderway"
+ : form.message.includes("still settling")
+ ? "payments.stillSettling"
+ : null),
);
- $effect(() => (maxfee = Math.max(5, Math.round(amount * 0.005) || 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 if duplicate}
+ <div>{$t(duplicate)}</div>
+ <a href="/payments" class="underline">{$t("payments.header")}</a>
{:else}
{$t("payments.failedToRoute")}
{/if}
@@ -58,6 +94,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 +126,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
@@ -126,11 +187,17 @@
<button type="button" class="btn" onclick={() => (showMax = !showMax)}
>{$t("payments.advancedSettings")}</button
>
- <input name="maxfee" type="hidden" bind:value={maxfee} />
+ <input name="fee" type="hidden" bind:value={maxfee} />
{/if}
</form>
{:else}
- <form method="POST" action="?/setAmount" class="space-y-2" use:enhance>
+ <form
+ method="POST"
+ action="?/setAmount"
+ class="space-y-2"
+ use:enhance
+ onsubmit={submit}
+ >
<input type="hidden" value={a} name="amount" />
<input name="rate" value={$rate} type="hidden" />
@@ -141,9 +208,26 @@
bind:rate={$rate}
submit={next}
/>
- <button type="submit" class="btn" bind:this={next}
- >{$t("payments.next")}</button
- >
+
+ <div class="flex justify-center gap-2">
+ <!-- no onclick: see Lnurlp.svelte — disabling from the click
+ handler cancels the submit; the form's onsubmit sets loading -->
+ <button
+ type="submit"
+ class="btn !w-auto grow"
+ formaction="?/max"
+ disabled={loading}
+ >
+ {#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/(app)/settings/+layout.svelte b/src/routes/(app)/settings/+layout.svelte
index 33398ed5..88708843 100644
--- a/src/routes/(app)/settings/+layout.svelte
+++ b/src/routes/(app)/settings/+layout.svelte
@@ -15,7 +15,7 @@
import { sign, send, getPrivateKey } from "$lib/nostr";
import { invalidateAll } from "$app/navigation";
import { getPublicKey } from "nostr-tools";
- import { bytesToHex } from "@noble/hashes/utils";
+ import { bytesToHex } from "@noble/hashes/utils.js";
let { children, data, form } = $props();
@@ -133,8 +133,7 @@
event = await sign(event, user);
send(event);
} catch (e) {
- console.log(e);
- warning("Nostr profile could not be updated");
+ warning("Nostr profile not updated");
}
}
@@ -209,9 +208,7 @@
{$t("user.settings.header")}
</h1>
- <div
- class="flex flex-wrap justify-around items-center border-b pb-3"
- >
+ <div class="flex flex-wrap justify-around items-center border-b pb-3">
{#each tabs as { name, key }}
<a
href={`/settings/${name}`}
@@ -228,17 +225,18 @@
</div>
<div
- class="z-10 fixed bottom-0 bg-base-100 shadow border-t border-t-8 border-accent w-full flex justify-center items-center py-3"
+ class="z-10 fixed bottom-0 bg-base-100 shadow border-accent w-full flex justify-center items-center py-3"
>
<button
bind:this={$save}
type="submit"
- class="btn !w-auto"
+ class="btn btn-accent !w-auto"
class:bg-base-200={submitting}
>
{#if submitting}
<Spinner />
{:else}
+ <iconify-icon icon="ph:floppy-disk-bold" width={32}></iconify-icon>
<div class="my-auto">{$t("user.settings.saveSettings")}</div>
{/if}
</button>
diff --git a/src/routes/(app)/settings/account/+page.server.ts b/src/routes/(app)/settings/account/+page.server.ts
index f0cc700e..ebb90f05 100644
--- a/src/routes/(app)/settings/account/+page.server.ts
+++ b/src/routes/(app)/settings/account/+page.server.ts
@@ -1,9 +1,11 @@
import getRates from "$lib/rates";
import updateUser from "$lib/settings";
+import { auth, get } from "$lib/utils";
-export async function load() {
+export async function load({ cookies }) {
const rates = await getRates();
- return { rates };
+ const connect = await get("/square/connect", auth(cookies));
+ return { connect, rates };
}
export const actions = {
diff --git a/src/routes/(app)/settings/account/+page.svelte b/src/routes/(app)/settings/account/+page.svelte
index c5c75d88..8b38ffe8 100644
--- a/src/routes/(app)/settings/account/+page.svelte
+++ b/src/routes/(app)/settings/account/+page.svelte
@@ -1,5 +1,6 @@
<script>
import { run } from "svelte/legacy";
+ import { theme } from "$lib/store";
import { onMount, tick } from "svelte";
import { browser } from "$app/environment";
@@ -13,8 +14,8 @@
let { data } = $props();
let { user } = $state(data);
- let { rates, subscriptions } = data;
- let { currency, email, verified } = $state(user);
+ let { connect, rates, subscriptions } = data;
+ let { currency, email, tip, verified } = $state(user);
let rate = rates[currency];
let fiats = Object.keys(rates).sort((a, b) => a.localeCompare(b));
@@ -97,6 +98,8 @@
$effect(() => {
if (email !== user.email) verified = false;
});
+
+ let revoke = () => {};
</script>
<div>
@@ -181,6 +184,19 @@
</p>
</div>
+<div class="space-y-1 relative">
+ <label for="tip" class="font-bold block">{$t("user.settings.tip")}</label>
+ <p class="text-secondary">
+ {$t("user.settings.tipDesc")}
+ </p>
+
+ <label
+ class="input input-bordered border-primary input-lg rounded-2xl flex items-center gap-2"
+ >
+ <input type="text" name="tip" class="clean" bind:value={tip} />
+ </label>
+</div>
+
<div>
<div class="flex justify-between items-center">
<span class="font-bold">{$t("user.settings.autoWithdraw")}</span>
@@ -191,7 +207,7 @@
</p>
</div>
-{#if user.autowithdraw}
+<div class:hidden={!user.autowithdraw}>
<div class="mb-2">
<label for="display" class="font-bold mb-1 block"
>{$t("user.settings.destination")}</label
@@ -252,7 +268,7 @@
{$t("user.settings.reserveDesc")}
</p>
</div>
-{/if}
+</div>
{#if editingThreshold}
<div
@@ -306,3 +322,19 @@
</div>
</div>
{/if}
+
+{#if connect !== "connected"}
+ <a href={connect} class="btn flex">
+ <img src="/images/square.svg" class="w-12" />
+ <div>{$t("user.settings.connectSquare")}</div></a
+ >
+{:else}
+ <button type="button" onclick={revoke} class="btn">
+ <img
+ src="/images/square.svg"
+ class="w-12"
+ class:invert={$theme === "dark"}
+ />
+ <div>{$t("user.settings.revokeSquare")}</div>
+ </button>
+{/if}
diff --git a/src/routes/(app)/settings/nostr/+page.server.ts b/src/routes/(app)/settings/nostr/+page.server.ts
index 1767ebaf..84b3fa27 100644
--- a/src/routes/(app)/settings/nostr/+page.server.ts
+++ b/src/routes/(app)/settings/nostr/+page.server.ts
@@ -4,7 +4,13 @@ import { auth, get } from "$lib/utils";
export const load = async ({ cookies }) => {
const { challenge } = await get("/challenge");
const apps = await get("/apps", auth(cookies));
- return { apps, challenge };
+
+ let offer;
+ try {
+ ({ hash: offer } = await get("/offer", auth(cookies)));
+ } catch (e) {}
+
+ return { apps, challenge, offer };
};
export const actions = {
diff --git a/src/routes/(app)/settings/nostr/+page.svelte b/src/routes/(app)/settings/nostr/+page.svelte
index 421b590a..3fe6e445 100644
--- a/src/routes/(app)/settings/nostr/+page.svelte
+++ b/src/routes/(app)/settings/nostr/+page.svelte
@@ -1,7 +1,7 @@
<script>
import { untrack } from "svelte";
import { browser } from "$app/environment";
- import { getNsec } from "$lib/nostr";
+ import { getNsec, send, sign } from "$lib/nostr";
import { page } from "$app/stores";
import { tick } from "svelte";
import { t } from "$lib/translations";
@@ -13,7 +13,7 @@
import { signer, save } from "$lib/store";
let { data } = $props();
- let { apps, challenge, user } = $derived(data);
+ let { apps, challenge, offer, user } = $derived(data);
let npub = $state(untrack(() => user.npub));
let extensionAvailable = $derived(browser && window.nostr);
let { locale } = $derived(user);
@@ -41,6 +41,33 @@
await tick();
$save.click();
};
+
+ let publishing = $state(),
+ published = $state();
+
+ // Kind 10058 is the NIP-177 BOLT12 offer list — publishing it lets nostr
+ // clients (Amethyst v1.13+) zap this user over BOLT12. Replaceable event,
+ // so republishing just updates it.
+ let publishOffer = async () => {
+ try {
+ publishing = true;
+ let event = {
+ kind: 10058,
+ content: "",
+ created_at: Math.round(Date.now() / 1000),
+ tags: [["offer", offer]],
+ };
+
+ event = await sign(event, user);
+ await send(event);
+ published = true;
+ } catch (e) {
+ console.log(e);
+ fail($t("user.settings.offerPublishFailed"));
+ } finally {
+ publishing = false;
+ }
+ };
</script>
<input type="hidden" name="challenge" value={challenge} />
@@ -145,6 +172,53 @@
</div>
</div>
+{#if offer}
+ <div class="space-y-2">
+ <h2 class="text-2xl font-bold mb-2">
+ {$t("user.settings.bolt12Offer")}
+ </h2>
+ <p class="text-secondary mb-4">
+ {$t("user.settings.bolt12OfferDescription")}
+ </p>
+
+ <div class="break-all text-secondary">{offer}</div>
+
+ <div class="flex my-auto gap-1">
+ <button
+ type="button"
+ class="my-auto btn btn-circle !w-auto grow"
+ onclick={() => copy(offer)}
+ aria-label="Copy"
+ ><iconify-icon noobserver icon="ph:copy-bold" width="32"
+ ></iconify-icon> {$t("accounts.copy")}</button
+ >
+
+ <a
+ href={`/qr/${encodeURIComponent(offer)}`}
+ class="my-auto btn btn-circle !w-auto grow"
+ aria-label="QR"
+ >
+ <iconify-icon noobserver icon="ph:qr-code-bold" width="32"
+ ></iconify-icon>
+ {$t("accounts.qr")}
+ </a>
+
+ <button
+ type="button"
+ class="my-auto btn btn-circle !w-auto grow"
+ onclick={publishOffer}
+ disabled={publishing}
+ aria-label="Publish"
+ ><iconify-icon noobserver icon="ph:megaphone-bold" width="32"
+ ></iconify-icon>
+ {published
+ ? $t("user.settings.offerPublished")
+ : $t("user.settings.publishOffer")}</button
+ >
+ </div>
+ </div>
+{/if}
+
{#if revealNwc}
<div class="break-all grow">
{nwc}
@@ -163,25 +237,26 @@
<div class="space-y-2">
<div class="font-bold">{$t("user.nostrPubkey")}</div>
- <div class="flex gap-4">
- <textarea name="pubkey" bind:value={npub} rows={3}> </textarea>
- <div class="flex my-auto gap-1">
- <button
- type="button"
- class="my-auto btn btn-circle"
- onclick={() => copy(npub)}
- aria-label="Copy"
- ><iconify-icon noobserver icon="ph:copy-bold" width="32"
- ></iconify-icon></button
- >
- <a
- href={`/qr/${encodeURIComponent(npub)}`}
- class="my-auto btn btn-circle"
- aria-label="QR"
- >
- <iconify-icon noobserver icon="ph:qr-code-bold" width="32"
- ></iconify-icon>
- </a>
+ <textarea name="pubkey" bind:value={npub} rows={3}></textarea>
+ <div>
+ <div class="flex my-auto gap-1">
+ <button
+ type="button"
+ class="my-auto btn btn-circle !w-auto grow"
+ onclick={() => copy(npub)}
+ aria-label="Copy"
+ ><iconify-icon noobserver icon="ph:copy-bold" width="32"
+ ></iconify-icon> {$t("accounts.copy")}</button
+ >
+
+ <a
+ href={`/qr/${encodeURIComponent(npub)}`}
+ class="my-auto btn btn-circle !w-auto grow"
+ aria-label="QR"
+ >
+ <iconify-icon noobserver icon="ph:qr-code-bold" width="32"></iconify-icon>
+{$t("accounts.qr")}
+ </a>
</div>
</div>
@@ -193,7 +268,7 @@
{/if}
</div>
-{#if user.nsec}
+{#if user.hasnsec || user.nsec}
<div class="space-y-2">
<label for="seedphrase" class="font-bold"
>{$t("user.settings.nostrKeys")}</label
diff --git a/src/routes/(app)/settings/profile/+page.svelte b/src/routes/(app)/settings/profile/+page.svelte
index bee50d39..3b7de9d3 100644
--- a/src/routes/(app)/settings/profile/+page.svelte
+++ b/src/routes/(app)/settings/profile/+page.svelte
@@ -3,16 +3,18 @@
import { avatar, banner as bannerStore } from "$lib/store";
import { t } from "$lib/translations";
import { page } from "$app/stores";
+ import PasswordInput from "$comp/PasswordInput.svelte";
let { data } = $props();
let { user } = $derived(data);
let { id } = user;
- let { about, banner, picture, display, username } =
- $state(user);
+ let { about, banner, picture, display, username } = $state(user);
let avatarFile,
avatarInput = $state(),
bannerFile,
+ password = $state(),
+ revealPassword = $state(),
bannerInput = $state();
let selectAvatar = () => avatarInput.click();
@@ -64,6 +66,16 @@
</div>
</div>
+<input type="hidden" name="confirm" bind:value={password} />
+
+<div>
+ <label for="password" class="block font-bold block mb-1"
+ >{$t("user.settings.newPassword")}</label
+ >
+
+ <PasswordInput bind:value={password} placeholder="(Leave blank to keep unchanged)" />
+</div>
+
<div>
<label for="display" class="font-bold mb-1 block"
>{$t("user.settings.displayName")}</label
@@ -104,7 +116,7 @@
>
<input
type="file"
- class="hidden"
+ class="hidden!"
bind:this={avatarInput}
onchange={(e) => handleFile(e, "picture")}
/>
@@ -146,7 +158,7 @@
>
<input
type="file"
- class="hidden"
+ class="hidden!"
bind:this={bannerInput}
onchange={(e) => handleFile(e, "banner")}
/>
diff --git a/src/routes/(app)/settings/security/+page.svelte b/src/routes/(app)/settings/security/+page.svelte
index ea1bf1f9..8c38bca1 100644
--- a/src/routes/(app)/settings/security/+page.svelte
+++ b/src/routes/(app)/settings/security/+page.svelte
@@ -4,7 +4,6 @@
import { t } from "$lib/translations";
import Pin from "$comp/Pin.svelte";
import Qr from "$comp/Qr.svelte";
- import PasswordInput from "$comp/PasswordInput.svelte";
import { copy, post, success, fail } from "$lib/utils";
import { save, pin as current } from "$lib/store";
import { invalidate } from "$app/navigation";
@@ -21,9 +20,7 @@
newNsec = $state(),
nsec = $state(),
otp = $state(),
- password = $state(),
pin = $state(""),
- revealPassword = $state(),
revealSeed,
revealNsec = $state(),
token = $state(""),
@@ -157,15 +154,6 @@
</script>
<input type="hidden" name="newpin" value={disablingPin ? "delete" : pin} />
-<input type="hidden" name="confirm" bind:value={password} />
-
-<div>
- <label for="password" class="block font-bold block mb-1"
- >{$t("user.settings.newPassword")}</label
- >
-
- <PasswordInput bind:value={password} />
-</div>
<div>
<span class="font-bold mb-1"
diff --git a/src/routes/(app)/settings/shopify/+page.svelte b/src/routes/(app)/settings/shopify/+page.svelte
index 5b0c7ceb..04f82009 100644
--- a/src/routes/(app)/settings/shopify/+page.svelte
+++ b/src/routes/(app)/settings/shopify/+page.svelte
@@ -91,6 +91,8 @@
</div>
<style>
+ @reference "../../../../app.css";
+
.active {
@apply text-blue-500;
}
diff --git a/src/routes/(app)/settings/square/+page.server.ts b/src/routes/(app)/settings/square/+page.server.ts
new file mode 100644
index 00000000..e00bdf1b
--- /dev/null
+++ b/src/routes/(app)/settings/square/+page.server.ts
@@ -0,0 +1,18 @@
+import { auth, get } from "$lib/utils";
+import { fail, redirect } from "@sveltejs/kit";
+
+export async function load({ cookies, url }) {
+ let connected;
+
+ const query = url.searchParams.toString();
+
+ try {
+ await get(`/square/auth?${query}`, auth(cookies));
+ connected = true;
+ } catch (e) {
+ const { message } = e as Error;
+ fail(400, { message });
+ }
+
+ if (connected) redirect(307, "/settings/account");
+}
diff --git a/src/routes/(app)/settings/square/+page.svelte b/src/routes/(app)/settings/square/+page.svelte
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/routes/(app)/settings/square/+page.svelte
diff --git a/src/routes/(static)/+layout.server.ts b/src/routes/(static)/+layout.server.ts
index c780b5e0..5de752fa 100644
--- a/src/routes/(static)/+layout.server.ts
+++ b/src/routes/(static)/+layout.server.ts
@@ -1,4 +1,5 @@
-import { get, auth } from "$lib/utils";
+import { auth, get, isInvalidTokenError } from "$lib/utils";
+import { redirect } from "@sveltejs/kit";
export async function load({ cookies }) {
const token = cookies.get("token");
@@ -7,7 +8,11 @@ export async function load({ cookies }) {
if (token) {
try {
user = await get("/me", auth(cookies));
- } catch (e) {}
+ } catch (e) {
+ if (isInvalidTokenError(e)) {
+ redirect(307, "/logout");
+ }
+ }
}
return { user };
diff --git a/src/routes/(static)/bounties/+page.server.ts b/src/routes/(static)/bounties/+page.server.ts
new file mode 100644
index 00000000..8378e2f2
--- /dev/null
+++ b/src/routes/(static)/bounties/+page.server.ts
@@ -0,0 +1,44 @@
+import { get } from "$lib/utils";
+export const load = async () => {
+ const users = [
+ {
+ username: "fustler",
+ merchant: "rrdrive",
+ type: "Bakery",
+ amount: 1000000,
+ },
+ {
+ username: "laluna",
+ merchant: "butchersguild",
+ type: "Butcher Shop",
+ amount: 1000000,
+ },
+ {
+ username: "ica547",
+ merchant: "vanlove",
+ type: "Sushi Restaurant",
+ amount: 1000000,
+ },
+ {
+ username: "reverendhodl",
+ merchant: "laughingbean",
+ type: "Cafe",
+ amount: 1000000,
+ },
+ {
+ username: "kimsrepair",
+ merchant: "kimsrepair",
+ type: "Kims Repair",
+ amount: 100000,
+ },
+ ];
+
+ for (const user of users) {
+ const { username, merchant } = user;
+ console.log(username, merchant);
+ user.user = await get(`/users/${username}`);
+ user.merchant = await get(`/users/${merchant}`);
+ }
+
+ return { users };
+};
diff --git a/src/routes/(static)/bounties/+page.svelte b/src/routes/(static)/bounties/+page.svelte
index b53dc85d..695eae3e 100644
--- a/src/routes/(static)/bounties/+page.svelte
+++ b/src/routes/(static)/bounties/+page.svelte
@@ -1,96 +1,172 @@
-<div class="space-y-5">
- <h1 class="text-3xl mb-6 pt-28 md:pt-12">Bounties</h1>
+<script>
+ import Avatar from "$comp/Avatar.svelte";
+ import { s } from "$lib/utils";
+ import Amount from "$comp/Amount.svelte";
- <p>
- We're offering ⚡️1,000,000 to the first 10 people that help us onboard
- these businesses in Vancouver to take bitcoin payments.
- </p>
+ let { data } = $props();
+ let { users } = data;
- <ul class="text-center text-lg space-y-3">
- <li>Grocery / Produce Store</li>
- <li class="leading-3">
- <span class="line-through">Cafe</span><br />
- <span class="text-xs"
- ><a href="/reverendhodl">ReverendHodl</a> for
- <a href="/laughingbean">Laughing Bean Coffee Co.</a></span
- >
- </li>
- <li>Bakery</li>
- <li class="leading-3">
- <span class="line-through">Butcher Shop</span><br />
- <span class="text-xs"
- ><a href="/danielle">danielle</a> for
- <a href="/butchersguild">All Natural &amp; Exotic Meats</a></span
- >
- </li>
- <li>Laundromat / Dry Cleaner</li>
- <li>Dollar Store / Book Store / Gift Shop</li>
- <li class="leading-3">
- <span class="line-through">Sushi Restaurant</span><br />
- <span class="text-xs"
- ><a href="/ica547">ica547</a> for
- <a href="/metallmash">VanLove Sushi</a></span
+ let bounties = [
+ "Hardware Store",
+ "Gas Station",
+ "Dollar Store",
+ "Book Store",
+ "Gift Shop",
+ "Game or Toy Store",
+ "Pet Store",
+ "Pharmacy",
+ ];
+
+ let small = [
+ {
+ type: "Restaurant",
+ amount: 50000,
+ },
+ { type: "Cafe", amount: 30000 },
+ { type: "Tradesperson or Professional", amount: 25000 },
+ { type: "Food Truck", amount: 20000 },
+ { type: "Farmer's Market Vendor", amount: 20000 },
+ ];
+</script>
+
+<div class="space-y-5 pb-20">
+ <h1 class="text-3xl mb-6 md:pt-12 font-bold">Coinos Bounties</h1>
+
+ <p>Earn sats by referring us to local businesses in Vancouver!</p>
+
+ <h2 class="text-2xl font-bold">Currently Available</h2>
+
+ <div class="space-y-0">
+ {#each bounties as b}
+ <div class="flex max-w-xs sm:max-w-xl">
+ <div>First {b}</div>
+
+ <iconify-icon
+ noobserver
+ icon="ph:lightning-fill"
+ class="text-yellow-300 ml-auto"
+ ></iconify-icon>{s(100000, "en-CA")}
+ </div>
+ {/each}
+ </div>
+
+ <hr />
+
+ <div class="space-y-0">
+ {#each small as b}
+ <div class="flex max-w-xs sm:max-w-xl">
+ <div>{b.type}</div>
+
+ <iconify-icon
+ noobserver
+ icon="ph:lightning-fill"
+ class="text-yellow-300 ml-auto"
+ ></iconify-icon>{s(b.amount, "en-CA")}
+ </div>
+ {/each}
+ </div>
+
+ <h2 class="text-2xl font-bold">Previously Awarded</h2>
+
+ <div class="flex flex-col gap-4">
+ {#each users as { user, merchant, type, amount }}
+ <article
+ class="rounded-2xl border border-neutral-200 bg-white/70 p-4 shadow-sm transition hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900/60 sm:max-w-xl"
>
- </li>
- <li>Pizza by the Slice</li>
- <li>Liquor Store / Gas Station</li>
- <li>Bubble Tea / Ice Cream Parlour</li>
- </ul>
+ <div class="flex items-start gap-3 min-w-0 flex-wrap sm:flex-nowrap">
+ <div>
+ <div class="flex">
+ <div class="shrink-0">
+ <Avatar user={merchant} size={12} />
+ </div>
- <p>Eligible businesses:</p>
+ <div class="flex items-center gap-2 min-w-0">
+ <h3
+ class="whitespace-wrap text-base font-semibold max-w-[150px] truncate sm:max-w-full"
+ title={merchant.display || merchant.username}
+ >
+ <a href={`/${merchant.username}`}
+ >{merchant.display || merchant.username}</a
+ >
+ </h3>
- <ul class="text-sm ml-4 space-y-2">
- <li>
- Have a physical location in Vancouver, North Vancouver, West Vancouver,
- Burnaby, New Westminster, or Richmond
- </li>
- <li>Take walk-in customers</li>
- <li>Have regular hours and a website with contact info</li>
- <li>Have a 3 star or better rating on Google</li>
- <li>
- Accept bitcoin during their posted hours without needing to make any
- special arrangements
- </li>
- <li>Use Coinos</li>
- </ul>
+ <span
+ class="shrink-0 rounded-full border border-neutral-200 px-2 py-0.5 text-xs font-medium text-neutral-600 dark:border-neutral-700 dark:text-neutral-300"
+ >
+ {type}
+ </span>
+ </div>
+ </div>
+
+ <div
+ class="mt-2 flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-300 min-w-0"
+ >
+ <Avatar {user} size={8} class="shrink-0" />
+ <span class="truncate max-w-full"
+ >Referred by @{user.username}</span
+ >
+ </div>
+ </div>
+
+ <div class="ml-auto flex shrink-0 items-center gap-1 whitespace">
+ <iconify-icon
+ noobserver
+ icon="ph:lightning-fill"
+ class="text-yellow-300"
+ />
+ <span class="text-sm font-semibold tabular-nums">
+ {s(amount, "en-CA")}
+ </span>
+ </div>
+ </div>
+ </article>
+ {/each}
+ </div>
+
+ <h2 class="text-2xl font-bold">Terms and Conditions</h2>
- <p>Each business category can be claimed only once</p>
<p>One bounty per person</p>
- <p>
- The businesses can expect free ongoing support and promotion from us and our
- community.
- </p>
+ <div>Eligible businesses should:</div>
+
+ <ul class="ml-4 space-y-0 list-disc">
+ <li>Have a physical location in Greater Vancouver</li>
+ <li>Take walk-ins without appointments</li>
+ <li>Have a website with contact info</li>
+ <li>Have at least a 3-star rating on Google</li>
+ </ul>
- <p>The prize money will be offered to the person who makes the referral.</p>
+ <div>They will be expected to:</div>
+
+ <ul class="ml-4 space-y-0 list-disc">
+ <li>Display a window sticker and QR</li>
+ <li>Train staff on Bitcoin procedures</li>
+ <li>Accept Bitcoin during posted business hours</li>
+ </ul>
<p>
- <b>⚡️250,000</b> will be awarded once the business has met with us and is ready
- to take payments
+ 25% of the bounty will be awarded once the business is ready to take
+ payments and has been announced
</p>
+
<p>
- <b>⚡️750,000</b> will be awarded once the merchant has accepted bitcoin for
- 6 months. We'll be sending
- <a
- href="https://btcmap.org/leaderboard"
- class="text-black hover:underline"
- target="_blank"
- rel="noreferrer">shadowy supertaggers</a
- > to check up on them periodically.
+ The rest will be awarded once they've been accepting Bitcoin for 6 months
</p>
+ <p>Bounties will be awarded at our discretion</p>
+
<p>
- Bounties will be awarded solely at our discretion and we reserve the right
- to refuse any applicants without cause
+ We reserve the right to refuse applicants and change these terms without
+ cause or notice
</p>
- <h2 class="text-xl">Ready to begin!?</h2>
+ <h2 class="text-2xl font-bold">Get Started!</h2>
<p>
- To make a referral, reach out on one of our socials or email us at <a
+ Reach out to <a
href="mailto:[email protected]"
- class="text-black hover:underline">[email protected]</a
- > and we'll set up an appointment with you and the business owner to get started.
+ class="text-blue-600 hover:underline">[email protected]</a
+ > and let us know who we should contact
</p>
<p>
@@ -98,11 +174,10 @@
which ones are still available.
</p>
+ <p>
+ Have a lead or idea for something that's not on the list? Reach out! We
+ offer sats for all sorts of referrals!
+ </p>
+
<p>Thanks for participating and helping to orange pill Vancouver!</p>
</div>
-
-<style>
- a {
- @apply text-blue-400;
- }
-</style>
diff --git a/src/routes/(static)/docs/+page.server.ts b/src/routes/(static)/docs/+page.server.ts
index 86f95824..93ba544c 100644
--- a/src/routes/(static)/docs/+page.server.ts
+++ b/src/routes/(static)/docs/+page.server.ts
@@ -1,4 +1,7 @@
+import { auth, get } from "$lib/utils";
export const load = async ({ cookies }) => {
const token = cookies.get("token");
- return { token };
+ let ro;
+ if (token) ro = await get("/ro", auth(cookies));
+ return { ro, token };
};
diff --git a/src/routes/(static)/docs/+page.svelte b/src/routes/(static)/docs/+page.svelte
index 7ac6e013..7cf7e2cb 100644
--- a/src/routes/(static)/docs/+page.svelte
+++ b/src/routes/(static)/docs/+page.svelte
@@ -7,7 +7,7 @@
let { data } = $props();
- let { user, token } = data;
+ let { user, ro, token } = data;
let api = PUBLIC_DOMAIN.includes("localhost")
? `${PUBLIC_COINOS_URL}`
@@ -16,6 +16,8 @@
let tokenSample = token
? `export token="${token}"`
: `export token=<your auth token>`;
+
+ let roSample = `export token="${ro}"`;
</script>
{#snippet cp(something)}
@@ -59,7 +61,7 @@
or get one from the /login endpoint.
{/if}
- Save it in a variable called <b>$token</b> to run the examples.
+ Save it in a variable called <b>$token</b> to run the examples. It gives full access to your account so keep it safe.
</p>
<div
@@ -69,6 +71,19 @@
{@render cp(tokenSample)}
</div>
+ {#if ro}
+ <p class="text-secondary">
+ This read-only token has permission to create invoices and fetch
+ payments but can't be used to make withdrawals
+ </p>
+ <div
+ class="bg-primary text-primary-content rounded-lg p-4 flex gap-4 items-center"
+ >
+ <div class="w-full break-all">{roSample}</div>
+ {@render cp(roSample)}
+ </div>
+ {/if}
+
<h2 class="text-2xl">POST /register</h2>
<p class="text-secondary">
Register a new user account with a username and password
@@ -92,9 +107,13 @@
<div class="font-bold">display</div>
<div class="col-span-2">string - spaces allowed</div>
<div class="font-bold">currency</div>
- <div class="col-span-2">string - three-letter ISO currency code e.g. CAD or USD</div>
+ <div class="col-span-2">
+ string - three-letter ISO currency code e.g. CAD or USD
+ </div>
<div class="font-bold">language</div>
- <div class="col-span-2">string - two-letter language code e.g. en, fr, es</div>
+ <div class="col-span-2">
+ string - two-letter language code e.g. en, fr, es
+ </div>
</div>
<Code sample="update" />
@@ -105,16 +124,22 @@
<div class="text-secondary">
<div>Request params</div>
<div class="grid grid-cols-3 ml-2">
- <div class="font-bold">type</div>
+ <div class="font-bold">type (string, default: lightning)</div>
<div class="col-span-2">bitcoin or lightning</div>
- <div class="font-bold">amount</div>
- <div class="col-span-2">amount in satoshis</div>
- <div class="font-bold">webhook</div>
+ <div class="font-bold">fiat (boolean, default: false)</div>
+ <div class="col-span-2">
+ whether the amount is denominated in satoshis or local currency
+ </div>
+ <div class="font-bold">amount (numeric)</div>
+ <div class="col-span-2">amount to be paid</div>
+ <div class="font-bold">webhook (string, optional)</div>
<div class="col-span-2">
- (optional) endpoint to hit when the invoice is paid
+ remote endpoint to call when the invoice is paid
+ </div>
+ <div class="font-bold">secret (string, optional)</div>
+ <div class="col-span-2">
+ a secret value passed to the webhook to authenticate the request
</div>
- <div class="font-bold">secret</div>
- <div class="col-span-2">a secret for the webhook to check</div>
</div>
</div>
@@ -137,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>
diff --git a/src/routes/(static)/forgot/+page.svelte b/src/routes/(static)/forgot/+page.svelte
index 45180543..49a837ae 100644
--- a/src/routes/(static)/forgot/+page.svelte
+++ b/src/routes/(static)/forgot/+page.svelte
@@ -6,8 +6,6 @@
import Spinner from "$comp/Spinner.svelte";
import { fly } from "svelte/transition";
- let { form } = $props();
- let email = $state();
let loading;
</script>
@@ -16,51 +14,8 @@
>
<h1 class="text-2xl font-bold text-center">{$t("login.reset")}</h1>
- {#if form?.error}
- <div class="text-red-600 text-center" in:fly>
- {form.error}
- </div>
- {/if}
-
- {#if form?.success}
- <div class="text-secondary">
- {$t("login.resetSent")}
- </div>
-
- <div class="flex w-full">
- <a href="/login" class="btn">
- {$t("login.continue")}
- </a>
- </div>
- {:else}
- <form class="space-y-5" method="POST" use:enhance>
- <div>
- <input
- placeholder={$t("login.email")}
- name="email"
- type="text"
- required
- class="bg-primary"
- bind:value={email}
- use:focus
- autocapitalize="none"
- />
- </div>
-
- <div class="text-secondary">
- {$t("login.resetDesc")}
- <a href="mailto:[email protected]" class="font-bold"
- >
- </div>
-
- <button type="submit" class="btn" disabled={loading}>
- {#if loading}
- <Spinner />
- {:else}
- {$t("login.submit")}
- {/if}
- </button>
- </form>
- {/if}
+ <div class="text-secondary">
+ {$t("login.resetDesc")}
+ <a href="mailto:[email protected]" class="font-bold">[email protected]</a>
+ </div>
</div>
diff --git a/src/routes/(static)/login/+page.server.ts b/src/routes/(static)/login/+page.server.ts
index a24abd07..ce343790 100644
--- a/src/routes/(static)/login/+page.server.ts
+++ b/src/routes/(static)/login/+page.server.ts
@@ -1,5 +1,5 @@
import { PUBLIC_COINOS_URL } from "$env/static/public";
-import { fd, get, login } from "$lib/utils";
+import { fd, get, login, post } from "$lib/utils";
import { fail, redirect } from "@sveltejs/kit";
export const load = async ({ parent }) => {
@@ -12,13 +12,20 @@ export const load = async ({ parent }) => {
export const actions = {
login: async ({ cookies, request }) => {
const form = await fd(request);
- let { username, password, token, loginRedirect } = form;
- const user = { username, password, token };
+ let { username, password, token, loginRedirect, recaptcha } = form;
+ username = username.split("@")[0];
+
+ const user = {
+ username,
+ password,
+ token,
+ recaptcha,
+ };
if (loginRedirect === "undefined") loginRedirect = undefined;
try {
- await login(user, cookies, request.headers.get("cf-connecting-ip"));
+ await login(user, cookies, request.headers.get("cf-connecting-ip"), request.headers.get("host"));
} catch (e) {
const { message } = e as Error;
return fail(400, { error: "Login failed", message, ...form });
@@ -29,7 +36,7 @@ export const actions = {
nostr: async ({ cookies, fetch, request }) => {
const form = await fd(request);
- let { challenge, event, loginRedirect } = form;
+ let { challenge, event, loginRedirect, recaptcha } = form;
if (loginRedirect === "null") loginRedirect = undefined;
event = JSON.parse(event);
@@ -37,20 +44,23 @@ export const actions = {
const res = await fetch(`${PUBLIC_COINOS_URL}/nostrAuth`, {
method: "POST",
- body: JSON.stringify({ challenge, event }),
+ body: JSON.stringify({ challenge, event, recaptcha }),
headers: {
"content-type": "application/json",
accept: "application/json",
"cf-connecting-ip": request.headers.get("cf-connecting-ip"),
+ "x-forwarded-host": request.headers.get("host"),
},
});
+ const text = await res.text();
+
if (res.status === 401) {
- const text = await res.text();
if (text.startsWith("2fa")) throw new Error("2fa");
+ throw new Error(text);
}
- const { user, token } = await res.json();
+ const { user, token } = JSON.parse(text);
if (!token) throw new Error("Login failed");
const { username, language } = user;
diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte
index 1a1f745c..fe2f4533 100644
--- a/src/routes/(static)/login/+page.svelte
+++ b/src/routes/(static)/login/+page.svelte
@@ -1,8 +1,9 @@
<script>
+ import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public";
import { browser } from "$app/environment";
import handler from "$lib/handler";
import { applyAction, deserialize } from "$app/forms";
- import { onMount, tick } from "svelte";
+ import { onDestroy, onMount, tick } from "svelte";
import { fly } from "svelte/transition";
import { enhance } from "$app/forms";
import Pinpad from "$comp/Pinpad.svelte";
@@ -14,8 +15,8 @@
import { page } from "$app/stores";
import { sign } from "$lib/nostr";
import { invalidateAll } from "$app/navigation";
- import { sha256 } from "@noble/hashes/sha256";
- import { bytesToHex } from "@noble/hashes/utils";
+ import { sha256 } from "@noble/hashes/sha2.js";
+ import { bytesToHex } from "@noble/hashes/utils.js";
let { data, form } = $props();
@@ -37,8 +38,18 @@
});
let { challenge } = $derived(data);
+ let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY;
+ let isTor = browser && location.hostname.endsWith(".onion");
let token = $state("");
+ onMount(() => {
+ if (!isTor && recaptchaSiteKey) {
+ let s = document.createElement("script");
+ s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey;
+ document.head.appendChild(s);
+ }
+ });
+
let cancel = () => (need2fa = false);
let username = $state(),
@@ -53,28 +64,36 @@
let revealPassword = $state(false);
- async function handleSubmit(e) {
- e.preventDefault();
- let data = new FormData(this);
- let user = Object.fromEntries(data);
-
- for (let k in user) {
- data.set(k, user[k]);
- }
-
- const response = await fetch("?/login", {
- method: "POST",
- body: data,
+ const getRecaptchaToken = () =>
+ new Promise((resolve, reject) => {
+ if (isTor) return resolve("");
+ if (!browser || !grecaptcha) return reject(new Error("captcha unavailable"));
+ grecaptcha.ready(() => {
+ grecaptcha
+ .execute(recaptchaSiteKey, { action: "login" })
+ .then(resolve)
+ .catch(reject);
+ });
});
- const result = deserialize(await response.text());
-
- if (result.type === "success") {
- await invalidateAll();
+ const enhanceLogin = async ({ formData, cancel }) => {
+ try {
+ const recaptcha = await getRecaptchaToken();
+ formData.set("recaptcha", recaptcha);
+ } catch (err) {
+ fail(err.message || "captcha failed");
+ cancel();
+ return;
}
- applyAction(result);
- }
+ return async ({ result }) => {
+ if (result.type === "success") {
+ await invalidateAll();
+ }
+
+ applyAction(result);
+ };
+ };
$effect(() => {
token?.length === 6 &&
@@ -104,10 +123,12 @@
const formData = new FormData();
try {
+ const recaptcha = await getRecaptchaToken();
formData.append("loginRedirect", redirect);
formData.append("token", token);
formData.append("event", JSON.stringify(signedEvent));
formData.append("challenge", challenge);
+ formData.append("recaptcha", recaptcha);
let response = await fetch("/login?/nostr", {
method: "POST",
@@ -121,10 +142,27 @@
fail(e.message);
}
};
+
+ onDestroy(() => {
+ if (!browser) return;
+ const nodeBadge = document.querySelector(".grecaptcha-badge");
+ if (nodeBadge) {
+ document.body.removeChild(nodeBadge.parentNode);
+ }
+
+ const scriptSelector =
+ "script[src='https://www.google.com/recaptcha/api.js?render=" +
+ recaptchaSiteKey +
+ "']";
+ const script = document.querySelector(scriptSelector);
+ if (script) {
+ script.remove();
+ }
+ });
</script>
<div
- class="mx-auto md:shadow-xl rounded-3xl max-w-xl w-full md:w-[480px] md:p-8 mb-20"
+ class="mx-auto md:shadow-xl rounded-3xl max-w-xl w-full md:w-[480px] md:p-8 mb-20 space-y-5"
>
<h1 class="text-2xl font-bold text-center">{$t("login.signIn")}</h1>
{#if form?.error && !form?.message.includes("2fa")}
@@ -134,9 +172,8 @@
{/if}
<form
- use:enhance
+ use:enhance={enhanceLogin}
class="space-y-5"
- onsubmit={handleSubmit}
method="POST"
action="?/login"
>
diff --git a/src/routes/(static)/register/+page.server.ts b/src/routes/(static)/register/+page.server.ts
index 2c2807bd..2bcf6f2d 100644
--- a/src/routes/(static)/register/+page.server.ts
+++ b/src/routes/(static)/register/+page.server.ts
@@ -1,5 +1,5 @@
-import { fd, get, login, post } from "$lib/utils";
-import { fail, redirect } from "@sveltejs/kit";
+import { fd, get, register } from "$lib/utils";
+import { redirect } from "@sveltejs/kit";
export const load = async ({ parent }) => {
const { user } = await parent();
@@ -14,39 +14,13 @@ export const load = async ({ parent }) => {
export const actions = {
default: async ({ cookies, request }) => {
const ip = request.headers.get("cf-connecting-ip");
-
+ const host = request.headers.get("host");
const form = await fd(request);
- let { picture, username, password, pubkey, loginRedirect } = form;
- const user = { picture, username, password, pubkey };
- let error;
-
+ const { picture, username, password, challenge, recaptcha } = form;
+ let { loginRedirect } = form;
if (loginRedirect === "undefined") loginRedirect = undefined;
- let sk;
- try {
- ({ sk } = await post("/register", { user }, { "cf-connecting-ip": ip }));
- } catch (e) {
- ({ message: error } = e as Error);
- }
-
- try {
- await login(user, cookies, ip);
- error = null;
- } catch (e) {
- const { message } = e as Error;
- error ||= message;
- }
-
- const expires = new Date();
- expires.setSeconds(expires.getSeconds() + 21000000);
- cookies.set("sk", sk, {
- path: "/",
- expires,
- httpOnly: false,
- sameSite: "lax",
- });
-
- if (error) return fail(400, { error });
- redirect(303, loginRedirect || `/${user.username}`);
+ const user = { picture, username, password, challenge, recaptcha };
+ return register(user, ip, cookies, loginRedirect, host);
},
};
diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte
index 43cafe6e..bb3af128 100644
--- a/src/routes/(static)/register/+page.svelte
+++ b/src/routes/(static)/register/+page.svelte
@@ -1,5 +1,6 @@
<script>
- import { onMount } from "svelte";
+ import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public";
+ import { onDestroy, onMount } from "svelte";
import { browser } from "$app/environment";
import punks from "$lib/punks";
import { upload } from "$lib/upload";
@@ -28,6 +29,16 @@
let { form, data } = $props();
let { challenge } = $derived(data);
+ let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY;
+ let isTor = browser && location.hostname.endsWith(".onion");
+
+ onMount(() => {
+ if (!isTor && recaptchaSiteKey) {
+ let s = document.createElement("script");
+ s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey;
+ document.head.appendChild(s);
+ }
+ });
onMount(() => {
if (browser) {
@@ -93,6 +104,18 @@
btn = $state();
let loading = $state();
+ const getRecaptchaToken = () =>
+ new Promise((resolve, reject) => {
+ if (isTor) return resolve("");
+ if (!browser || !grecaptcha) return reject(new Error("captcha unavailable"));
+ grecaptcha.ready(() => {
+ grecaptcha
+ .execute(recaptchaSiteKey, { action: "register" })
+ .then(resolve)
+ .catch(reject);
+ });
+ });
+
async function handleSubmit(e) {
e.preventDefault();
@@ -106,6 +129,15 @@
data.set(k, user[k]);
}
+ try {
+ const recaptcha = await getRecaptchaToken();
+ data.set("recaptcha", recaptcha);
+ } catch (err) {
+ fail(err.message || "captcha failed");
+ loading = false;
+ return;
+ }
+
data.set("picture", `${$page.url.origin}/api/public/${punks[index]}.webp`);
if ($avatar) {
try {
@@ -189,10 +221,12 @@
const formData = new FormData();
try {
+ const recaptcha = await getRecaptchaToken();
formData.append("loginRedirect", redirect);
formData.append("token", token);
formData.append("event", JSON.stringify(signedEvent));
formData.append("challenge", challenge);
+ formData.append("recaptcha", recaptcha);
let response = await fetch("/login?/nostr", {
method: "POST",
@@ -210,6 +244,23 @@
fail(e.message);
}
};
+
+ onDestroy(() => {
+ if (!browser) return;
+ const nodeBadge = document.querySelector(".grecaptcha-badge");
+ if (nodeBadge) {
+ document.body.removeChild(nodeBadge.parentNode);
+ }
+
+ const scriptSelector =
+ "script[src='https://www.google.com/recaptcha/api.js?render=" +
+ recaptchaSiteKey +
+ "']";
+ const script = document.querySelector(scriptSelector);
+ if (script) {
+ script.remove();
+ }
+ });
</script>
{#if need2fa}
@@ -222,7 +273,7 @@
<h1 class="text-2xl font-bold text-center">{$t("login.createAccount")}</h1>
<input
type="file"
- class="hidden"
+ class="hidden!"
bind:this={avatarInput}
onchange={(e) => handleFile(e, "picture")}
/>
@@ -263,9 +314,10 @@
</button>
</div>
- {#if form?.error}
+ {#if form?.error || form?.message}
<div class="text-red-600 text-center" in:fly>
- {form.error}
+ {form?.message}
+ {form?.error}
</div>
{/if}
@@ -276,6 +328,7 @@
value={$loginRedirect || $page.url.searchParams.get("redirect")}
/>
<input type="hidden" name="token" value={token} />
+ <input type="hidden" name="challenge" value={challenge} />
<label
for="username"
diff --git a/src/routes/(static)/support/+page.svelte b/src/routes/(static)/support/+page.svelte
index b9028cbf..4cf9706d 100644
--- a/src/routes/(static)/support/+page.svelte
+++ b/src/routes/(static)/support/+page.svelte
@@ -1,6 +1,7 @@
<script>
+ import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public";
import { browser } from "$app/environment";
- import { onDestroy } from "svelte";
+ import { onDestroy, onMount } from "svelte";
import { fail, success, post } from "$lib/utils";
import { scale } from "svelte/transition";
import Icon from "$comp/Icon.svelte";
@@ -13,10 +14,30 @@
let email = $state();
let message = $state();
let sent = $state();
- let recaptchaSiteKey = "6LfCd8YkAAAAANmVJgzN3SQY3n3fv1RhiS5PgMYM";
+ let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY;
+ let isTor = browser && location.hostname.endsWith(".onion");
+
+ onMount(() => {
+ if (!isTor && recaptchaSiteKey) {
+ let s = document.createElement("script");
+ s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey;
+ document.head.appendChild(s);
+ }
+ });
let submit = (e) => {
e.preventDefault();
+ if (isTor) {
+ post("/post/email", {
+ username: user?.username || `${username} (unverified)`,
+ email,
+ message,
+ token: "",
+ })
+ .then(() => (sent = true))
+ .catch(() => fail("problem submitting"));
+ return;
+ }
grecaptcha.ready(() => {
grecaptcha.execute(recaptchaSiteKey, { action: "submit" }).then((token) =>
post("/post/email", {
@@ -49,12 +70,6 @@
});
</script>
-<svelte:head
- ><script
- src="https://www.google.com/recaptcha/api.js?render=6LfCd8YkAAAAANmVJgzN3SQY3n3fv1RhiS5PgMYM"
- ></script></svelte:head
->
-
<div class="container max-w-lg mx-auto space-y-8 p-4 pb-20">
{#if sent}
<h1 class="text-center text-3xl lg:text-4xl font-semibold">Thank you!</h1>
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts
index 584e836c..c1f77695 100644
--- a/src/routes/+layout.server.ts
+++ b/src/routes/+layout.server.ts
@@ -3,7 +3,8 @@ import {
loadTranslations,
locales,
} from "$lib/translations/index";
-import { auth, get, sleep } from "$lib/utils";
+import { auth, get, isInvalidTokenError, sleep } from "$lib/utils";
+import { redirect } from "@sveltejs/kit";
export const load = async ({ cookies, request, url }) => {
const { host, pathname } = url;
@@ -13,8 +14,22 @@ export const load = async ({ cookies, request, url }) => {
try {
user = await get("/me", auth(cookies));
} catch (e) {
- if (e.message.startsWith("Rate")) await sleep(3000);
- user = await get("/me", auth(cookies));
+ const { message } = e as Error;
+ if (message.startsWith("Rate")) {
+ await sleep(3000);
+ try {
+ user = await get("/me", auth(cookies));
+ } catch (retryError) {
+ if (isInvalidTokenError(retryError) && pathname !== "/logout") {
+ redirect(307, "/logout");
+ }
+ throw retryError;
+ }
+ } else if (isInvalidTokenError(e) && pathname !== "/logout") {
+ redirect(307, "/logout");
+ } else {
+ throw e;
+ }
}
}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index d25f570a..d3956c83 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,28 +1,79 @@
<script>
import { run } from "svelte/legacy";
-
import { browser } from "$app/environment";
import { PUBLIC_DOMAIN } from "$env/static/public";
import "../app.css";
import { loading, t } from "$lib/translations";
import { onMount } from "svelte";
+ import V3Banner from "$comp/V3Banner.svelte";
import { installPrompt, theme as themeStore } from "$lib/store";
+
+const map = { light: 'lofi', dark: 'black', system: 'system' };
+
let { data, children } = $props();
let { pathname, theme } = $state(data);
$effect(() => ($themeStore = data.theme));
$effect(() => (theme = $themeStore));
+$effect(() => {
+ const v = map[theme] ?? theme;
+ if (!v || v === 'system') {
+ document.documentElement.removeAttribute('data-theme');
+ } else {
+ document.documentElement.setAttribute('data-theme', v);
+ }
+});
let host = PUBLIC_DOMAIN.includes("localhost")
? `http://${PUBLIC_DOMAIN}`
: `https://${PUBLIC_DOMAIN}`;
+ function clearBadge() {
+ if ("clearAppBadge" in navigator) {
+ navigator.clearAppBadge().catch(() => {});
+ }
+ }
+
onMount(() => {
if (!browser) return;
+
+ // Handle install prompt
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
$installPrompt = event;
});
+
+ clearBadge(); // first load
+ document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState === "visible") clearBadge();
+ });
+
+ let hasNotch = typeof window.AndroidNotch !== "undefined";
+ if (hasNotch) {
+ window.AndroidNotch.getInsetTop(
+ (px) => {
+ document.documentElement.style.setProperty(
+ "--safe-area-inset-top",
+ Math.round(px) + "px",
+ );
+ },
+ (err) => {
+ console.error("Failed to get notch inset:", err);
+ },
+ );
+
+ window.AndroidNotch.getInsetBottom(
+ (px) => {
+ document.documentElement.style.setProperty(
+ "--safe-area-inset-bottom",
+ 24 + "px",
+ );
+ },
+ (err) => {
+ console.error("Failed to get bottom inset:", err);
+ },
+ );
+ }
});
</script>
@@ -32,23 +83,21 @@
<meta name="twitter:title" content="coinos" />
<meta property="og:image" content={`${host}/images/logo.webp`} />
-
<meta property="og:type" content="website" />
<meta property="og:description" content="An easy to use bitcoin web wallet" />
<meta name="description" content="An easy to use bitcoin web wallet" />
<meta name="keywords" content="coinos easy bitcoin web wallet" />
-
<meta name="twitter:image" content={`${host}/images/logo.webp`} />
<meta name="twitter:card" content="summary_large_image" />
-
<meta property="og:url" content={host + pathname} />
<meta name="twitter:site" content="@coinoswallet" />
<meta name="twitter:creator" content="@coinoswallet" />
</svelte:head>
{#if !$loading}
- <main data-theme={theme}>
+ <main>
+ <V3Banner />
{@render children?.()}
</main>
{/if}
@@ -60,5 +109,13 @@
--toastContainerBottom: 8rem;
--toastContainerLeft: calc(50vw - 8rem);
--toastBackground: #292929;
+
+ --safe-area-inset-top: 0px;
+ --safe-area-inset-bottom: 0px;
+ }
+
+ main {
+ padding-top: var(--safe-area-inset-top);
+ padding-bottom: var(--safe-area-inset-bottom);
}
</style>
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts
index 5679ea61..97184d3d 100644
--- a/src/routes/+page.server.ts
+++ b/src/routes/+page.server.ts
@@ -1,3 +1,3 @@
export const load = async () => {
- return { faqs: ["cost", "compatibility", "safety", "pos", "bitcoin"] };
+ return { faqs: ["cost", "compatibility", "pos", "bitcoin"] };
};
diff --git a/src/routes/phtest/+page.svelte b/src/routes/phtest/+page.svelte
new file mode 100644
index 00000000..625f0465
--- /dev/null
+++ b/src/routes/phtest/+page.svelte
@@ -0,0 +1 @@
+<a href="bitcoin:lnurl1dp68gurn8ghj7cm0d9hx7uewd9hj7up0v9kxjcm9lgkapg">Bitcoin link</a>