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