diff options
| author | Adam Soltys <[email protected]> | 2026-02-26 21:28:50 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-26 21:28:50 -0800 |
| commit | e5406ef66f2e3f43e45fcf92401f6f4b87dce796 (patch) | |
| tree | 25927ce2b130973aceac13e0e8f679b180ea5cad /src | |
| parent | 18df062a099811355cafc2024e30a7c2b68c7984 (diff) | |
| download | coinos-ui-e5406ef66f2e3f43e45fcf92401f6f4b87dce796.tar.gz coinos-ui-e5406ef66f2e3f43e45fcf92401f6f4b87dce796.zip | |
ui tweaks
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/Account.svelte | 144 | ||||
| -rw-r--r-- | src/locales/en.json | 5 | ||||
| -rw-r--r-- | src/routes/(app)/account/[id]/+page.svelte | 115 | ||||
| -rw-r--r-- | src/routes/(app)/account/[id]/seed/+page.server.ts | 6 | ||||
| -rw-r--r-- | src/routes/(app)/account/[id]/seed/+page.svelte | 82 | ||||
| -rw-r--r-- | src/routes/(app)/account/ark/+page.svelte | 384 | ||||
| -rw-r--r-- | src/routes/(app)/account/pass/+page.svelte | 207 | ||||
| -rw-r--r-- | src/routes/(app)/payment/[id]/+page.svelte | 73 | ||||
| -rw-r--r-- | src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte | 97 |
9 files changed, 271 insertions, 842 deletions
diff --git a/src/components/Account.svelte b/src/components/Account.svelte index 6f3d48d8..45ffdd8b 100644 --- a/src/components/Account.svelte +++ b/src/components/Account.svelte @@ -1,7 +1,5 @@ <script lang="ts"> - import { hex } from "@scure/base"; import { bytesToHex } from "@noble/hashes/utils.js"; - import WalletPass from "$comp/WalletPass.svelte"; import { run } from "svelte/legacy"; import { onMount } from "svelte"; import { goto } from "$app/navigation"; @@ -10,7 +8,7 @@ import { versions, s, f, loc, toFiat } from "$lib/utils"; import { fiat, importing } from "$lib/store"; import { arkkey, arkaid } from "$lib/ark"; - import { getRememberedWalletPassword, forgetWalletPassword, getCachedPrfKey } from "$lib/passwordCache"; + import { prfDecrypt, isPrfEncrypted } from "$lib/crypto"; let { user, rates, account }: any = $props(); let currency = $derived(account.currency || user.currency); @@ -19,90 +17,48 @@ let arkBalance = $state(0); let arkLoading = $state(false); - let passwordPrompt = $state(false); - let password: string | undefined = $state(); - let pendingUrl: string | undefined = $state(); - let cancel = $state(() => { - passwordPrompt = false; - pendingUrl = undefined; - }); let tryUnlockArk = async () => { - // Try PRF key first - const prfKey = getCachedPrfKey(); - if (prfKey) { + const deriveArkKey = async (key: ArrayBuffer) => { + const [{ HDKey }, { entropyToMnemonic, mnemonicToSeed }, { wordlist }] = await Promise.all([ + import("@scure/bip32"), + import("@scure/bip39"), + import("@scure/bip39/wordlists/english.js"), + ]); + let mnemonicStr: string; + if (account.seed && isPrfEncrypted(account.seed)) { + const ent = await prfDecrypt(key, account.seed); + mnemonicStr = entropyToMnemonic(ent, wordlist); + } else { + const entropy = new Uint8Array(key).slice(0, 16); + mnemonicStr = entropyToMnemonic(entropy, wordlist); + } + const s = await mnemonicToSeed(mnemonicStr); + const master = HDKey.fromMasterSeed(s, versions); + const arkChild = master.derive("m/86'/0'/0'/0/0"); + $arkkey = bytesToHex(arkChild.privateKey!); + $arkaid = id; + }; + + // Try cached PRF key or silent nsec + const { getWalletEntropy, deriveNostrEntropy } = await import("$lib/walletEntropy"); + const entropy = await getWalletEntropy(); + if (entropy) { try { - const [{ HDKey }, { entropyToMnemonic, mnemonicToSeed }, { wordlist }] = await Promise.all([ - import("@scure/bip32"), - import("@scure/bip39"), - import("@scure/bip39/wordlists/english.js"), - ]); - const entropy = new Uint8Array(prfKey).slice(0, 16); - const mnemonic = entropyToMnemonic(entropy, wordlist); - const s = await mnemonicToSeed(mnemonic); - const master = HDKey.fromMasterSeed(s, versions); - const arkChild = master.derive("m/86'/0'/0'/0/0"); - $arkkey = bytesToHex(arkChild.privateKey!); - $arkaid = id; + await deriveArkKey(entropy); return true; } catch (e) { - console.log("PRF unlock failed", e); + console.log("Wallet entropy unlock failed", e); } } - // Try silent Nostr derivation - if (!prfKey) { - const { getWalletEntropy } = await import("$lib/walletEntropy"); - const nostrKey = await getWalletEntropy(); - if (nostrKey) { - try { - const [{ HDKey }, { entropyToMnemonic, mnemonicToSeed }, { wordlist }] = await Promise.all([ - import("@scure/bip32"), - import("@scure/bip39"), - import("@scure/bip39/wordlists/english.js"), - ]); - const entropy = new Uint8Array(nostrKey).slice(0, 16); - const mnemonic = entropyToMnemonic(entropy, wordlist); - const s = await mnemonicToSeed(mnemonic); - const master = HDKey.fromMasterSeed(s, versions); - const arkChild = master.derive("m/86'/0'/0'/0/0"); - $arkkey = bytesToHex(arkChild.privateKey!); - $arkaid = id; - return true; - } catch (e) { - console.log("Nostr entropy unlock failed", e); - } - } - } - - // Then try cached wallet password - const cached = getRememberedWalletPassword(); - if (!cached) return false; + // Try interactive nostr derivation try { - const { decrypt } = await import("nostr-tools/nip49"); - if (seed) { - // Legacy: per-account seed is raw hex key - $arkkey = hex.encode(decrypt(seed, cached) as Uint8Array); - } else if (user.seed) { - // Master seed: derive m/86'/0'/0'/0/0 - const [{ HDKey }, { entropyToMnemonic, mnemonicToSeed }, { wordlist }] = await Promise.all([ - import("@scure/bip32"), - import("@scure/bip39"), - import("@scure/bip39/wordlists/english.js"), - ]); - let entropy = await decrypt(user.seed, cached); - let mnemonic = entropyToMnemonic(entropy, wordlist); - let s = await mnemonicToSeed(mnemonic, cached); - let master = HDKey.fromMasterSeed(s, versions); - let arkChild = master.derive("m/86'/0'/0'/0/0"); - $arkkey = bytesToHex(arkChild.privateKey!); - } else { - return false; - } - $arkaid = id; + const interactiveEntropy = await deriveNostrEntropy(); + await deriveArkKey(interactiveEntropy); return true; } catch (e) { - forgetWalletPassword(); + console.log("Interactive nostr unlock failed", e); return false; } }; @@ -112,13 +68,10 @@ event.stopPropagation(); document.cookie = `aid=${id}; path=/; max-age=86400`; if (isArk) { - pendingUrl = url; if ($arkkey && $arkaid === id) { - goto(pendingUrl!); + goto(url); } else if (await tryUnlockArk()) { - goto(pendingUrl!); - } else { - passwordPrompt = true; + goto(url); } } else { $arkkey = undefined; @@ -126,31 +79,6 @@ } }; - let submitPassword = async () => { - const { decrypt } = await import("nostr-tools/nip49"); - if (seed) { - $arkkey = hex.encode(decrypt(seed, password!) as Uint8Array); - } else if (user.seed) { - const [{ HDKey }, { entropyToMnemonic, mnemonicToSeed }, { wordlist }] = await Promise.all([ - import("@scure/bip32"), - import("@scure/bip39"), - import("@scure/bip39/wordlists/english.js"), - ]); - let entropy = await decrypt(user.seed, password!); - let mnemonic = entropyToMnemonic(entropy, wordlist); - let s = await mnemonicToSeed(mnemonic, password!); - let master = HDKey.fromMasterSeed(s, versions); - let arkChild = master.derive("m/86'/0'/0'/0/0"); - $arkkey = bytesToHex(arkChild.privateKey!); - } - $arkaid = id; - passwordPrompt = false; - if (pendingUrl) { - goto(pendingUrl); - pendingUrl = undefined; - } - }; - let displayType = $derived( accountType === "ark" ? $t("accounts.ark") @@ -249,7 +177,3 @@ </a> </div> </div> - -{#if passwordPrompt} - <WalletPass bind:password bind:cancel submit={submitPassword} /> -{/if} diff --git a/src/locales/en.json b/src/locales/en.json index 3babc54a..196c55df 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -51,8 +51,13 @@ "copy": "Copy", "copyNwc": "Copy NWC", "revealMnemonic": "Reveal seed phrase", + "viewMnemonic": "View seed phrase", + "hideMnemonic": "Hide seed phrase", "regenerate": "Regenerate", "deleteAccount": "Delete account", + "deleteConfirm": "Are you sure you want to delete this account? This action cannot be undone.", + "delete": "Delete", + "cancel": "Cancel", "addAccount": "Add account", "create": "Create", "import": "Import", diff --git a/src/routes/(app)/account/[id]/+page.svelte b/src/routes/(app)/account/[id]/+page.svelte index c8085c09..0d652479 100644 --- a/src/routes/(app)/account/[id]/+page.svelte +++ b/src/routes/(app)/account/[id]/+page.svelte @@ -1,20 +1,19 @@ <script lang="ts"> import { page } from "$app/stores"; - import Mnemonic from "$comp/Mnemonic.svelte"; import Numpad from "$comp/Numpad.svelte"; import Toggle from "$comp/Toggle.svelte"; - import WalletPass from "$comp/WalletPass.svelte"; import { goto } from "$app/navigation"; - import { copy, fail, focus, loc, post } from "$lib/utils"; + import { fail, focus, loc, post } from "$lib/utils"; import { importing } from "$lib/store"; import { enhance } from "$app/forms"; import { t } from "$lib/translations"; import { tick } from "svelte"; - import { entropyToMnemonic, mnemonicToSeed, validateMnemonic, mnemonicToEntropy } from "@scure/bip39"; + import { mnemonicToSeed, validateMnemonic, mnemonicToEntropy } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english.js"; import { HDKey } from "@scure/bip32"; import { versions } from "$lib/utils"; - import { getRememberedWalletPassword, forgetWalletPassword, getCachedPrfKey } from "$lib/passwordCache"; + import { getCachedPrfKey } from "$lib/passwordCache"; + import { prfEncrypt } from "$lib/crypto"; import Spinner from "$comp/Spinner.svelte"; let { data } = $props(); @@ -49,12 +48,6 @@ let rate = $derived(rates[currency]); let fiats = $derived(Object.keys(rates).sort((a, b) => a.localeCompare(b))); - let mnemonic: string | undefined = $state(), - password: string | undefined = $state(); - let passwordPrompt = $state(); - let cancel = $state(() => (passwordPrompt = false)); - let toggle = () => (passwordPrompt = !passwordPrompt); - let editingReserve = $state(false), editingThreshold = $state(false), doneReserve = $state(), @@ -96,6 +89,17 @@ importSubmitting = true; try { + const { getWalletEntropy } = await import("$lib/walletEntropy"); + let prfKey = getCachedPrfKey() || await getWalletEntropy(); + if (!prfKey) { + fail($t("accounts.failedToDecryptSeed")); + importSubmitting = false; + return; + } + + let entropy = mnemonicToEntropy(text, wordlist); + let seed = await prfEncrypt(prfKey, entropy); + let s = await mnemonicToSeed(text); let master = HDKey.fromMasterSeed(s, versions); let child = master.derive(`m/84'/0'/${account.accountIndex ?? 0}'`); @@ -105,6 +109,7 @@ await post(`/post/account/${id}`, { fingerprint: fp, pubkey, + seed, }); $importing = new Set([...$importing, id]); @@ -117,54 +122,7 @@ importSubmitting = false; }; - let revealWithPrfKey = async () => { - let prfKey = getCachedPrfKey(); - if (!prfKey) { - const { getWalletEntropy } = await import("$lib/walletEntropy"); - prfKey = await getWalletEntropy(); - } - if (!prfKey) return false; - try { - const entropy = new Uint8Array(prfKey).slice(0, 16); - mnemonic = entropyToMnemonic(entropy, wordlist); - return true; - } catch (e) { - return false; - } - }; - - let revealWithCachedPassword = async () => { - const cached = getRememberedWalletPassword(); - if (!cached) return false; - try { - const { decrypt } = await import("nostr-tools/nip49"); - let entropy = await decrypt(seed, cached); - mnemonic = entropyToMnemonic(entropy, wordlist); - password = cached; - return true; - } catch (e: any) { - forgetWalletPassword(); - return false; - } - }; - - let requestMnemonic = async () => { - if (await revealWithPrfKey()) return; - if (await revealWithCachedPassword()) return; - toggle(); - }; - - let submit = $state(async () => { - passwordPrompt = false; - try { - const { decrypt } = await import("nostr-tools/nip49"); - let entropy = await decrypt(seed, password as string); - mnemonic = entropyToMnemonic(entropy, wordlist); - } catch (e: any) { - fail($t("accounts.invalidPasswordTryAgain")); - passwordPrompt = true; - } - }); + let showDeleteConfirm = $state(false); let del = async () => { try { @@ -258,17 +216,10 @@ </button> <div class="space-y-2"> - {#if mnemonic} - <Mnemonic {mnemonic} /> - - <button onclick={() => copy(mnemonic as string)} type="button" class="btn"> - <iconify-icon noobserver icon="ph:copy-bold" width="32"></iconify-icon> - <div class="my-auto">{$t("accounts.copy")}</div> - </button> - {:else if seed} - <button onclick={requestMnemonic} type="button" class="btn"> + {#if seed || account.type === "ark"} + <button onclick={() => goto(`/account/${id}/seed`)} type="button" class="btn"> <iconify-icon noobserver icon="ph:eye-bold" width="32"></iconify-icon> - <div class="my-auto">{$t("accounts.revealMnemonic")}</div> + <div class="my-auto">{$t("accounts.viewMnemonic")}</div> </button> {/if} @@ -298,8 +249,8 @@ {/if} {#if seed || account.type === "ark"} - <button onclick={del} type="button" class="btn"> - <iconify-icon noobserver icon="ph:trash-bold" width="32"></iconify-icon> + <button onclick={() => (showDeleteConfirm = true)} type="button" class="btn"> + <iconify-icon noobserver icon="ph:trash-bold" width="32" class="text-error"></iconify-icon> <div class="my-auto">{$t("accounts.deleteAccount")}</div> </button> {/if} @@ -310,10 +261,6 @@ </div> </div> -{#if passwordPrompt} - <WalletPass bind:password bind:cancel {submit} /> -{/if} - {#if editingThreshold} <div class="fixed bg-base-100/90 inset-0 overflow-y-auto h-full w-full z-50 max-w-lg mx-auto"> <div class="relative p-5 border shadow-lg rounded-md bg-base-100 space-y-5"> @@ -347,3 +294,21 @@ </div> </div> {/if} + +{#if showDeleteConfirm} + <div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onclick={() => (showDeleteConfirm = false)}> + <div class="bg-base-100 rounded-2xl p-6 max-w-sm w-full space-y-4 text-center" onclick={(e) => e.stopPropagation()}> + <iconify-icon noobserver icon="ph:warning-bold" width="48" class="text-error"></iconify-icon> + <h2 class="text-xl font-semibold">{$t("accounts.deleteAccount")}</h2> + <p class="text-secondary">{$t("accounts.deleteConfirm")}</p> + <div class="flex gap-2"> + <button type="button" class="btn !w-auto grow" onclick={() => (showDeleteConfirm = false)}> + {$t("accounts.cancel")} + </button> + <button type="button" class="btn btn-error !w-auto grow" onclick={del}> + {$t("accounts.delete")} + </button> + </div> + </div> + </div> +{/if} diff --git a/src/routes/(app)/account/[id]/seed/+page.server.ts b/src/routes/(app)/account/[id]/seed/+page.server.ts new file mode 100644 index 00000000..ec271ef5 --- /dev/null +++ b/src/routes/(app)/account/[id]/seed/+page.server.ts @@ -0,0 +1,6 @@ +import { auth, get } from "$lib/utils"; + +export const load = async ({ cookies, params }) => { + const account = await get(`/account/${params.id}`, auth(cookies)); + return { account }; +}; diff --git a/src/routes/(app)/account/[id]/seed/+page.svelte b/src/routes/(app)/account/[id]/seed/+page.svelte new file mode 100644 index 00000000..3b7f8c56 --- /dev/null +++ b/src/routes/(app)/account/[id]/seed/+page.svelte @@ -0,0 +1,82 @@ +<script lang="ts"> + import { page } from "$app/stores"; + import Mnemonic from "$comp/Mnemonic.svelte"; + import { goto } from "$app/navigation"; + import { copy, fail } from "$lib/utils"; + import { t } from "$lib/translations"; + import { entropyToMnemonic } from "@scure/bip39"; + import { wordlist } from "@scure/bip39/wordlists/english.js"; + import { getCachedPrfKey } from "$lib/passwordCache"; + import { prfDecrypt, isPrfEncrypted } from "$lib/crypto"; + + let { data } = $props(); + let account = $derived(data.account); + let id = $derived(account.id); + + let mnemonic: string | undefined = $state(); + + let derivationPath = $derived( + account.type === "ark" + ? "m/86'/0'/0'/0/0" + : `m/84'/0'/${account.accountIndex ?? 0}'`, + ); + + let revealWithPrfKey = async () => { + let prfKey = getCachedPrfKey(); + if (!prfKey) { + const { getWalletEntropy } = await import("$lib/walletEntropy"); + prfKey = await getWalletEntropy(); + } + if (!prfKey) return false; + try { + if (account.seed && isPrfEncrypted(account.seed)) { + const entropy = await prfDecrypt(prfKey, account.seed); + mnemonic = entropyToMnemonic(entropy, wordlist); + } else { + const entropy = new Uint8Array(prfKey).slice(0, 16); + mnemonic = entropyToMnemonic(entropy, wordlist); + } + return true; + } catch (e) { + return false; + } + }; + + let requestMnemonic = async () => { + if (await revealWithPrfKey()) return; + try { + const { deriveNostrEntropy } = await import("$lib/walletEntropy"); + const entropy = await deriveNostrEntropy(); + const ent = new Uint8Array(entropy).slice(0, 16); + mnemonic = entropyToMnemonic(ent, wordlist); + } catch (e) { + fail($t("accounts.failedToDecryptSeed")); + } + }; + + requestMnemonic(); +</script> + +<div class="space-y-5"> + <h1 class="text-center text-3xl font-semibold">{$t("accounts.seedPhrase")}</h1> + + <div class="container w-full mx-auto text-lg px-4 max-w-xl space-y-4"> + {#if mnemonic} + <p class="text-center text-secondary"> + Derivation path: <span class="font-mono font-bold">{derivationPath}</span> + </p> + + <Mnemonic {mnemonic} /> + + <button onclick={() => copy(mnemonic as string)} type="button" class="btn"> + <iconify-icon noobserver icon="ph:copy-bold" width="32"></iconify-icon> + <div class="my-auto">{$t("accounts.copy")}</div> + </button> + {/if} + + <button onclick={() => goto(`/account/${id}`)} type="button" class="btn btn-primary"> + <iconify-icon noobserver icon="ph:arrow-left-bold" width="32"></iconify-icon> + <div class="my-auto">{$t("accounts.back")}</div> + </button> + </div> +</div> diff --git a/src/routes/(app)/account/ark/+page.svelte b/src/routes/(app)/account/ark/+page.svelte index 243931fe..398cfc71 100644 --- a/src/routes/(app)/account/ark/+page.svelte +++ b/src/routes/(app)/account/ark/+page.svelte @@ -3,20 +3,15 @@ import { tick, onMount } from "svelte"; import { t } from "$lib/translations"; import Spinner from "$comp/Spinner.svelte"; - import { bytesToHex, randomBytes } from "@noble/hashes/utils.js"; - import { hex } from "@scure/base"; - import { focus, fail, post, copy, versions } from "$lib/utils"; + import { bytesToHex } from "@noble/hashes/utils.js"; + import { fail, post, versions } from "$lib/utils"; import { goto } from "$app/navigation"; import { arkServerUrl } from "$lib/ark"; import { HDKey } from "@scure/bip32"; import { entropyToMnemonic, mnemonicToSeed } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english.js"; import { - rememberForOptions, defaultRememberForMs, - rememberWalletPassword, - forgetWalletPassword, - getRememberedWalletPassword, getCachedPrfKey, rememberPrfKey, } from "$lib/passwordCache"; @@ -25,24 +20,13 @@ let { data } = $props(); let user = $derived(data.user); - let hasMasterSeed = $derived(!!user.seed); let hasPrfSeed = $state(!!getCachedPrfKey()); let hasSigner = $state(false); - let privateKey = $state(""); let arkAddress = $state(""); - let nsec = $state(""); - let showNsec = $state(false); - let confirmed = $state(false); let submitting = $state(false); let needsPasskeyUnlock = $state(false); - let password = $state(""); - let confirm = $state(""); - let revealPassword = $state(false); - let revealConfirm = $state(false); - let rememberForMs = $state(defaultRememberForMs); - let name = $t("accounts.savings"); let type = "ark"; @@ -58,7 +42,6 @@ const identity = SingleKey.fromHex(arkHex); const wallet = await Wallet.create({ identity, arkServerUrl }); arkAddress = await wallet.getAddress(); - confirmed = true; } catch (e: any) { console.log("PRF seed derivation failed", e); fail($t("accounts.failedToDecryptSeed")); @@ -115,135 +98,26 @@ } else { needsPasskeyUnlock = true; } - } else if (hasMasterSeed) { - const cached = getRememberedWalletPassword(); - if (cached) { - password = cached; - confirm = cached; - await deriveFromMasterSeed(cached); - } } else { - await generateKey(); + needsPasskeyUnlock = true; } }); - let deriveFromMasterSeed = async (pw) => { - try { - const { decrypt } = await import("nostr-tools/nip49"); - const { SingleKey, Wallet } = await import("@arkade-os/sdk"); - let entropy = await decrypt(user.seed, pw); - let mnemonic = entropyToMnemonic(entropy, wordlist); - let seed = await mnemonicToSeed(mnemonic, pw); - let master = HDKey.fromMasterSeed(seed, versions); - let arkChild = master.derive("m/86'/0'/0'/0/0"); - let arkHex = bytesToHex(arkChild.privateKey!); - const identity = SingleKey.fromHex(arkHex); - const wallet = await Wallet.create({ identity, arkServerUrl }); - arkAddress = await wallet.getAddress(); - confirmed = true; - } catch (e: any) { - console.log("master seed derivation failed", e); - fail($t("accounts.invalidPassword")); - } - }; - - let generateKey = async () => { - const { nip19 } = await import("nostr-tools"); - const { SingleKey, Wallet } = await import("@arkade-os/sdk"); - privateKey = bytesToHex(randomBytes(32)); - nsec = nip19.nsecEncode(hex.decode(privateKey)); - showNsec = false; - confirmed = false; - - const identity = SingleKey.fromHex(privateKey); - const wallet = await Wallet.create({ identity, arkServerUrl }); - arkAddress = await wallet.getAddress(); - }; - - let revealNsec = () => { - showNsec = true; - }; - - let confirmBackup = () => { - confirmed = true; - }; - let submit = async () => { - if (hasPrfSeed) { - submitting = true; - await tick(); - try { - if (!arkAddress) { - const prfKey = getCachedPrfKey(); - if (prfKey) await deriveFromPrfKey(prfKey); - else throw new Error("PRF key not available"); - } - await post("/account", { name, type, arkAddress }); - goto(`/${user.username}`); - } catch (e: any) { - console.log(e); - fail(e.message); - } - submitting = false; - return; - } - - if (hasMasterSeed) { - if (!password) { - fail($t("accounts.passwordRequired")); - return; - } - - submitting = true; - await tick(); - - try { - if (!arkAddress) await deriveFromMasterSeed(password); - await post("/account", { name, type, arkAddress }); - if (rememberForMs) { - rememberWalletPassword(password, rememberForMs); - } else { - forgetWalletPassword(); - } - goto(`/${user.username}`); - } catch (e: any) { - console.log(e); - fail(e.message); - } - - submitting = false; - return; - } - - if (!confirmed) { - fail($t("accounts.pleaseBackupFirst")); - return; - } - - if (!password || password !== confirm) { - fail($t("accounts.passwordMismatch")); - return; - } - submitting = true; await tick(); - try { - const { encrypt } = await import("nostr-tools/nip49"); - let seed = await encrypt(hex.decode(privateKey), password); - - await post("/account", { name, seed, type, arkAddress }); - if (rememberForMs) { - rememberWalletPassword(password, rememberForMs); - } else { - forgetWalletPassword(); + if (!arkAddress) { + const prfKey = getCachedPrfKey(); + if (prfKey) await deriveFromPrfKey(prfKey); + else throw new Error("PRF key not available"); } + await post("/account", { name, type, arkAddress }); goto(`/${user.username}`); } catch (e: any) { console.log(e); fail(e.message); } - submitting = false; }; </script> @@ -254,246 +128,36 @@ </div> <div class="container w-full mx-auto text-lg px-4 max-w-xl space-y-5"> - {#if hasPrfSeed} - {#if needsPasskeyUnlock} - <div class="space-y-5"> - <p class="text-secondary"> - {$t("accounts.arkUnlockWithPasskey")} - </p> - {#if hasSigner} - <button type="button" class="btn btn-accent" onclick={unlockWithNostr}> - <img src="/images/nostr.png" class="w-6" alt="Nostr" /> - {$t("accounts.unlockWithNostr")} - </button> - {/if} - <button type="button" class="btn btn-accent" onclick={unlockWithPasskey}> - <iconify-icon noobserver icon="ph:fingerprint-bold" width="24"></iconify-icon> - {$t("accounts.unlockWithPasskey")} - </button> - </div> - {:else} - <form onsubmit={preventDefault(submit)} class="space-y-5"> - <p class="text-secondary"> - {$t("accounts.arkDerivedFromPasskey")} - </p> - <div class="flex gap-2"> - <a href={`/${user.username}`} class="contents"> - <button type="button" class="btn !w-auto grow">{$t("accounts.back")}</button> - </a> - <button disabled={submitting} type="submit" class="btn btn-accent !w-auto grow"> - {#if submitting} - <Spinner /> - {:else} - {$t("accounts.createWallet")} - {/if} - </button> - </div> - </form> - {/if} - {:else if hasMasterSeed} - <form onsubmit={preventDefault(submit)} class="space-y-5"> - <p class="text-secondary"> - {$t("accounts.arkEnterPassword")} - </p> - - <label for="password" class="input flex items-center justify-center gap-2 w-full"> - {#if revealPassword} - <input - name="password" - type="text" - required - bind:value={password} - autocapitalize="none" - class="clean" - placeholder={$t("payments.enterWalletPass")} - /> - {:else} - <input - use:focus - name="password" - type="password" - placeholder={$t("payments.enterWalletPass")} - required - bind:value={password} - autocapitalize="none" - class="clean" - /> - {/if} - - <button - type="button" - class="ml-auto" - aria-label="Toggle password visibility" - onclick={() => (revealPassword = !revealPassword)} - > - <iconify-icon - noobserver - icon={revealPassword ? "ph:eye-bold" : "ph:eye-slash-bold"} - width="32" - ></iconify-icon> - </button> - </label> - - <div class="space-y-2"> - <label for="rememberFor" class="text-sm text-secondary">{$t("user.settings.rememberFor")}</label> - <select - id="rememberFor" - class="w-full" - value={rememberForMs} - onchange={(e) => (rememberForMs = Number((e.target as HTMLSelectElement).value))} - > - {#each rememberForOptions as option} - <option value={option.ms}>{$t(option.label)}</option> - {/each} - </select> - </div> - - <div class="flex gap-2"> - <a href={`/${user.username}`} class="contents"> - <button type="button" class="btn !w-auto grow">Back</button> - </a> - <button disabled={submitting} type="submit" class="btn btn-accent !w-auto grow"> - {#if submitting} - <Spinner /> - {:else} - Create Wallet - {/if} - </button> - </div> - </form> - {:else if !confirmed} - <div class="space-y-4"> + {#if needsPasskeyUnlock} + <div class="space-y-5"> <p class="text-secondary"> - {$t("accounts.arkSecuredByKey")} + {$t("accounts.arkUnlockWithPasskey")} </p> - - {#if showNsec} - <div class="space-y-2"> - <button - type="button" - class="break-all p-4 rounded-lg cursor-pointer border text-left w-full" - onclick={() => copy(nsec)} - aria-label="Copy backup key" - > - {nsec} - </button> - </div> - - <div class="flex gap-2"> - <button type="button" class="btn btn-accent flex-grow" onclick={confirmBackup}> - <iconify-icon noobserver icon="ph:check-bold" width="24"></iconify-icon> - {$t("accounts.backedItUp")} - </button> - </div> - {:else} - <button type="button" class="btn btn-warning w-full" onclick={revealNsec}> - <iconify-icon noobserver icon="ph:eye-bold" width="24"></iconify-icon> - {$t("accounts.revealBackupKey")} + {#if hasSigner} + <button type="button" class="btn btn-accent" onclick={unlockWithNostr}> + <img src="/images/nostr.png" class="w-6" alt="Nostr" /> + {$t("accounts.unlockWithNostr")} </button> {/if} + <button type="button" class="btn btn-accent" onclick={unlockWithPasskey}> + <iconify-icon noobserver icon="ph:fingerprint-bold" width="24"></iconify-icon> + {$t("accounts.unlockWithPasskey")} + </button> </div> {:else} <form onsubmit={preventDefault(submit)} class="space-y-5"> <p class="text-secondary"> - {$t("accounts.setPasswordToEncrypt")} + {$t("accounts.arkDerivedFromPasskey")} </p> - - <label for="password" class="input flex items-center justify-center gap-2 w-full"> - {#if revealPassword} - <input - name="password" - type="text" - required - bind:value={password} - autocapitalize="none" - class="clean" - placeholder={$t("accounts.password")} - /> - {:else} - <input - use:focus - name="password" - type="password" - placeholder={$t("accounts.password")} - required - bind:value={password} - autocapitalize="none" - class="clean" - /> - {/if} - - <button - type="button" - class="ml-auto" - aria-label="Toggle password visibility" - onclick={() => (revealPassword = !revealPassword)} - > - <iconify-icon - noobserver - icon={revealPassword ? "ph:eye-bold" : "ph:eye-slash-bold"} - width="32" - ></iconify-icon> - </button> - </label> - - <label for="confirm" class="input flex items-center justify-center gap-2 w-full"> - {#if revealConfirm} - <input - name="confirm" - type="text" - placeholder={$t("accounts.confirmPassword")} - required - bind:value={confirm} - autocapitalize="none" - class="clean" - /> - {:else} - <input - type="password" - placeholder={$t("accounts.confirmPassword")} - required - bind:value={confirm} - autocapitalize="none" - class="clean" - /> - {/if} - <button - type="button" - class="ml-auto" - aria-label="Toggle confirmation visibility" - onclick={() => (revealConfirm = !revealConfirm)} - > - <iconify-icon - noobserver - icon={revealConfirm ? "ph:eye-bold" : "ph:eye-slash-bold"} - width="32" - ></iconify-icon> - </button> - </label> - - <div class="space-y-2"> - <label for="rememberFor" class="text-sm text-secondary">{$t("user.settings.rememberFor")}</label> - <select - id="rememberFor" - class="w-full" - value={rememberForMs} - onchange={(e) => (rememberForMs = Number((e.target as HTMLSelectElement).value))} - > - {#each rememberForOptions as option} - <option value={option.ms}>{$t(option.label)}</option> - {/each} - </select> - </div> - <div class="flex gap-2"> - <button type="button" class="btn !w-auto grow" onclick={() => (confirmed = false)}> - {$t("accounts.back")} - </button> + <a href={`/${user.username}`} class="contents"> + <button type="button" class="btn !w-auto grow">{$t("accounts.back")}</button> + </a> <button disabled={submitting} type="submit" class="btn btn-accent !w-auto grow"> {#if submitting} <Spinner /> {:else} - Create Wallet + {$t("accounts.createWallet")} {/if} </button> </div> diff --git a/src/routes/(app)/account/pass/+page.svelte b/src/routes/(app)/account/pass/+page.svelte index 2e1c711f..2a327a88 100644 --- a/src/routes/(app)/account/pass/+page.svelte +++ b/src/routes/(app)/account/pass/+page.svelte @@ -1,232 +1,69 @@ <script lang="ts"> - import { preventDefault } from "svelte/legacy"; - import { tick, onMount } from "svelte"; import { t } from "$lib/translations"; import { mnemonic } from "$lib/store"; import Spinner from "$comp/Spinner.svelte"; - import { mnemonicToSeed, mnemonicToEntropy, entropyToMnemonic } from "@scure/bip39"; + import { entropyToMnemonic, mnemonicToSeed } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english.js"; - import { focus, versions, fail, post } from "$lib/utils"; + import { versions, fail, post } from "$lib/utils"; import { goto } from "$app/navigation"; import { HDKey } from "@scure/bip32"; - import { - rememberForOptions, - defaultRememberForMs, - rememberWalletPassword, - forgetWalletPassword, - getCachedPrfKey, - } from "$lib/passwordCache"; let { data }: any = $props(); let user = $derived(data.user); let submitting = $state(false); - let confirm: string = $state(""), - password: string = $state(""), - revealPassword = $state(), - revealConfirm = $state(); - let rememberForMs = $state(defaultRememberForMs); - onMount(async () => { if (!$mnemonic) { goto("/account/seed"); return; } - // PRF/Nostr users: derive from entropy directly, skip password - const { getWalletEntropy } = await import("$lib/walletEntropy"); - const prfKey = await getWalletEntropy(); - if (prfKey) { - submitting = true; - await tick(); + const { getWalletEntropy, deriveNostrEntropy } = await import("$lib/walletEntropy"); + let prfKey = await getWalletEntropy(); + if (!prfKey) { try { - const entropy = new Uint8Array(prfKey).slice(0, 16); - const mn = entropyToMnemonic(entropy, wordlist); - const master = HDKey.fromMasterSeed( - await mnemonicToSeed(mn), - versions, - ); - const child = master.derive("m/84'/0'/0'"); - const pubkey = child.publicExtendedKey; - const fingerprint = child.fingerprint.toString(16).padStart(8, "0"); - await post("/account", { - fingerprint, - pubkey, - name: $t("accounts.vault"), - type: "bitcoin", - accountIndex: 0, - }); - - $mnemonic = ""; + prfKey = await deriveNostrEntropy(); + } catch (e) { + fail($t("accounts.failedToDecryptSeed")); goto(`/${user.username}`); - } catch (e: any) { - console.log(e); - fail(e.message); + return; } - submitting = false; } - }); - let submit = async () => { submitting = true; await tick(); - if (!password || password !== confirm) { - fail($t("accounts.passwordMismatch")); - return; - } - try { - const { encrypt } = await import("nostr-tools/nip49"); - let seed = await encrypt(mnemonicToEntropy($mnemonic as string, wordlist), password); - - // Save master seed on user - await post("/post/user", { seed }); - - let master = HDKey.fromMasterSeed( - await mnemonicToSeed($mnemonic as string, password), + const entropy = new Uint8Array(prfKey).slice(0, 16); + const mn = entropyToMnemonic(entropy, wordlist); + const master = HDKey.fromMasterSeed( + await mnemonicToSeed(mn), versions, ); - - // Bitcoin account at m/84'/0'/0' - let child = master.derive("m/84'/0'/0'"); - let pubkey = child.publicExtendedKey; - let fingerprint = child.fingerprint.toString(16).padStart(8, "0"); + const child = master.derive("m/84'/0'/0'"); + const pubkey = child.publicExtendedKey; + const fingerprint = child.fingerprint.toString(16).padStart(8, "0"); await post("/account", { fingerprint, pubkey, - name: $t("accounts.savings"), + name: $t("accounts.vault"), type: "bitcoin", accountIndex: 0, }); - if (rememberForMs) { - rememberWalletPassword(password, rememberForMs); - } else { - forgetWalletPassword(); - } $mnemonic = ""; goto(`/${user.username}`); } catch (e: any) { console.log(e); fail(e.message); } - submitting = false; - }; + }); </script> -<div class="space-y-5"> - <div> - <h1 class="text-center text-3xl font-semibold">{$t("accounts.accountPassword")}</h1> +{#if submitting} + <div class="flex justify-center py-20"> + <Spinner /> </div> - - <form - onsubmit={preventDefault(submit)} - class="container w-full mx-auto text-lg px-4 max-w-xl space-y-5" - > - <p class="text-secondary p-4 bg-warning/80 rounded-2xl border-warning border-2"> - {$t("accounts.passwordWarning")} - </p> - - <label for="password" class="input flex items-center justify-center gap-2 w-full"> - {#if revealPassword} - <input - name="password" - type="text" - required - bind:value={password} - autocapitalize="none" - class="clean" - /> - {:else} - <input - use:focus - name="password" - type="password" - placeholder={$t("accounts.password")} - required - bind:value={password} - autocapitalize="none" - class="clean" - /> - {/if} - - <button - type="button" - class="ml-auto" - aria-label="Toggle password visibility" - onclick={() => (revealPassword = !revealPassword)} - > - <iconify-icon - noobserver - icon={revealPassword ? "ph:eye-bold" : "ph:eye-slash-bold"} - width="32" - ></iconify-icon> - </button> - </label> - - <label for="confirm" class="input flex items-center justify-center gap-2 w-full"> - {#if revealConfirm} - <input - name="confirm" - type="text" - placeholder={$t("accounts.confirmPassword")} - required - bind:value={confirm} - autocapitalize="none" - class="clean" - /> - {:else} - <input - type="password" - placeholder={$t("accounts.confirmPassword")} - required - bind:value={confirm} - autocapitalize="none" - class="clean" - /> - {/if} - <button - type="button" - class="ml-auto" - aria-label="Toggle confirmation visibility" - onclick={() => (revealConfirm = !revealConfirm)} - > - <iconify-icon - noobserver - icon={revealConfirm ? "ph:eye-bold" : "ph:eye-slash-bold"} - width="32" - ></iconify-icon> - </button> - </label> - - <div class="space-y-2"> - <label for="rememberFor" class="text-sm text-secondary">{$t("user.settings.rememberFor")}</label> - <select - id="rememberFor" - class="w-full" - value={rememberForMs} - onchange={(e) => (rememberForMs = Number((e.target as HTMLSelectElement).value))} - > - {#each rememberForOptions as option} - <option value={option.ms}>{$t(option.label)}</option> - {/each} - </select> - </div> - - <div class="flex gap-2"> - <a href={`/account/seed`} class="contents"> - <button type="button" class="btn !w-auto grow">{$t("accounts.back")}</button> - </a> - <button disabled={submitting} type="submit" class="btn btn-accent !w-auto grow"> - {#if submitting} - <Spinner /> - {:else} - {$t("accounts.finish")} - {/if} - </button> - </div> - </form> -</div> +{/if} diff --git a/src/routes/(app)/payment/[id]/+page.svelte b/src/routes/(app)/payment/[id]/+page.svelte index ca3d5b6a..80474a55 100644 --- a/src/routes/(app)/payment/[id]/+page.svelte +++ b/src/routes/(app)/payment/[id]/+page.svelte @@ -95,40 +95,41 @@ {/snippet} {#snippet copyable(label, value, href)} - <div class="flex justify-between items-center gap-4 py-4 border-b border-base-300 min-h-20"> - <span class="text-secondary shrink-0">{label}</span> - <div class="flex items-center gap-2 min-w-0"> - <button - class="font-mono text-sm text-right break-all hover:opacity-80" - onclick={() => { - if (expanded.has(label)) { - expanded.delete(label); - } else { - expanded.add(label); - } - expanded = new Set(expanded); - }} - aria-label={expanded.has(label) ? "Collapse" : "Expand"} - > - {#if expanded.has(label)} - {value} - {:else} - {value.slice(0, 8)}...{value.slice(-8)} + {@const toggle = () => { if (expanded.has(label)) expanded.delete(label); else expanded.add(label); expanded = new Set(expanded); }} + <div + class="py-4 border-b border-base-300 min-h-20 cursor-pointer" + onclick={toggle} + role="button" + tabindex="0" + onkeydown={(e) => e.key === 'Enter' && toggle()} + > + <div class="flex justify-between items-center gap-4"> + <span class="text-secondary shrink-0">{label}</span> + <div class="flex items-center gap-2 min-w-0"> + {#if !expanded.has(label)} + <span class="text-right break-all"> + {value.slice(0, 8)}...{value.slice(-8)} + </span> {/if} - </button> - <button - class="shrink-0 hover:opacity-80" - onclick={() => copy(value)} - aria-label="Copy {label}" - > - <iconify-icon noobserver icon="ph:copy-bold" width="20" class="text-secondary"></iconify-icon> - </button> - {#if href} - <a {href} target="_blank" rel="noreferrer" class="shrink-0 hover:opacity-80" aria-label="View in explorer"> - <iconify-icon noobserver icon="ph:arrow-square-out-bold" width="20" class="text-secondary"></iconify-icon> - </a> - {/if} + <button + class="shrink-0 hover:opacity-80" + onclick={(e) => { e.stopPropagation(); copy(value); }} + aria-label="Copy {label}" + > + <iconify-icon noobserver icon="ph:copy-bold" width="28" class="text-secondary"></iconify-icon> + </button> + {#if href} + <a {href} target="_blank" rel="noreferrer" class="shrink-0 hover:opacity-80" onclick={(e) => e.stopPropagation()} aria-label="View in explorer"> + <iconify-icon noobserver icon="ph:arrow-square-out-bold" width="20" class="text-secondary"></iconify-icon> + </a> + {/if} + </div> </div> + {#if expanded.has(label)} + <div class="break-all mt-2"> + {value} + </div> + {/if} </div> {/snippet} @@ -153,7 +154,7 @@ {/if} </button> <div class="text-secondary"> - {format(new Date(created), "MMM d, yyyy", { locale })} · {format(new Date(created), "h:mmaaa", { locale })} + {format(new Date(created), "MMM d, yyyy", { locale })}<span class="mx-3">{format(new Date(created), "h:mmaaa", { locale })}</span> </div> </div> @@ -167,7 +168,7 @@ </a> {/if} - <div class="mt-2"> + <div class="mt-2 [&>*:last-child]:border-b-0"> {#if tip} {@render field("payments.amount", a)} {@render field("invoice.tip", tip)} @@ -215,8 +216,8 @@ </a> </div> {:else if memo} - <div class="flex justify-between items-start gap-4 py-3 border-b border-base-300"> - <span class="text-sm text-secondary shrink-0">{$t("payments.memo")}</span> + <div class="flex justify-between items-center gap-4 py-4 border-b border-base-300 min-h-20"> + <span class="text-secondary shrink-0">{$t("payments.memo")}</span> <div class="text-right break-all"> {#if memo.includes("text/plain")} {JSON.parse(memo)[0][1]} 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 4cb22155..3d4dc139 100644 --- a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte +++ b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte @@ -4,71 +4,51 @@ import { enhance } from "$app/forms"; import Toggle from "$comp/Toggle.svelte"; import Spinner from "$comp/Spinner.svelte"; - import WalletPass from "$comp/WalletPass.svelte"; import { page } from "$app/stores"; - import { toFiat, f, focus, loc, s, sat, closest, network } from "$lib/utils"; + import { toFiat, f, fail, focus, loc, s, sat, closest, network } from "$lib/utils"; import { pin } from "$lib/store"; import { goto, invalidate } from "$app/navigation"; import { rate } from "$lib/store"; import { applyAction } from "$app/forms"; import { hex as hexUtil } from "@scure/base"; import * as btc from "@scure/btc-signer"; - import { getRememberedWalletPassword, forgetWalletPassword, getCachedPrfKey } from "$lib/passwordCache"; + import { getCachedPrfKey } from "$lib/passwordCache"; import { sendArkViaForward } from "$lib/ark"; + import { prfDecrypt, isPrfEncrypted } from "$lib/crypto"; import Amount from "$comp/Amount.svelte"; let { data, form }: any = $props(); let { encode, decode } = hexUtil; - let passwordPrompt = $state(); - let password: any = $state(); - let cancel = $state(() => (passwordPrompt = false)); let signed = $state(); let loading = $state(false); let error = $state(""); - let trySignWithCachedPassword = async () => { + let trySign = async () => { const prfKey = getCachedPrfKey(); if (prfKey) { try { await signTxWithPrf(prfKey); - return true; - } catch (e: any) { - return false; - } + return; + } catch (e: any) {} } - // Try silent Nostr derivation - const { getWalletEntropy } = await import("$lib/walletEntropy"); + const { getWalletEntropy, deriveNostrEntropy } = await import("$lib/walletEntropy"); const entropy = await getWalletEntropy(); if (entropy) { try { await signTxWithPrf(entropy); - return true; - } catch (e: any) { - return false; - } + return; + } catch (e: any) {} } - const cached = getRememberedWalletPassword(); - if (!cached) return false; try { - password = cached; - await signTx(); - return true; + const interactiveEntropy = await deriveNostrEntropy(); + await signTxWithPrf(interactiveEntropy); } catch (e: any) { - forgetWalletPassword(); - return false; + error = e.message || $t("payments.failedToSend"); } }; - let togglePassword = async () => { - if (passwordPrompt) { - passwordPrompt = false; - return; - } - if (await trySignWithCachedPassword()) return; - passwordPrompt = true; - }; let handler = ({ cancel }) => { if (account.type === "ark") { cancel(); @@ -94,7 +74,7 @@ return; } - if ((account.seed || account.fingerprint) && !signed) (cancel(), void togglePassword()); + if ((account.seed || account.fingerprint) && !signed) (cancel(), void trySign()); return async ({ result }) => { if (result.type === "redirect") { goto(result.location); @@ -113,46 +93,15 @@ import("@scure/bip39"), import("@scure/bip39/wordlists/english.js"), ]); - let entropy = new Uint8Array(prfKey).slice(0, 16); - let mnemonic = entropyToMnemonic(entropy, wordlist); - let seed = await mnemonicToSeed(mnemonic); - let master = HDKey.fromMasterSeed(seed, network as any); - let child = master.derive(`m/84'/0'/${account.accountIndex ?? 0}'`); - - let raw = decode(hex); - let isPSBT = raw[0] === 0x70 && raw[1] === 0x73 && raw[2] === 0x62 && raw[3] === 0x74; - let tx: any = isPSBT ? Transaction.fromPSBT(raw) : Transaction.fromRaw(raw); - - for (let [i, input] of tx.inputs.entries()) { - let { witnessUtxo, path } = inputs[i]; - if (!isPSBT) { - witnessUtxo.amount = BigInt(witnessUtxo.amount); - witnessUtxo.script = decode(witnessUtxo.script); - tx.updateInput(i, { witnessUtxo }); - } - let key = child.derive(path); - tx.signIdx(key.privateKey!, i); + let mnemonicStr: string; + if (account.seed && isPrfEncrypted(account.seed)) { + const entropy = await prfDecrypt(prfKey, account.seed); + mnemonicStr = entropyToMnemonic(entropy, wordlist); + } else { + const entropy = new Uint8Array(prfKey).slice(0, 16); + mnemonicStr = entropyToMnemonic(entropy, wordlist); } - - tx.finalize(); - hex = tx.hex; - signed = true; - await tick(); - (submit as any).click(); - }; - - let signTx = async () => { - const { decrypt } = await import("nostr-tools/nip49"); - const [{ Transaction }, { HDKey }, { entropyToMnemonic, mnemonicToSeed }, { wordlist }] = - await Promise.all([ - import("@scure/btc-signer"), - import("@scure/bip32"), - import("@scure/bip39"), - import("@scure/bip39/wordlists/english.js"), - ]); - let entropy = await decrypt(account.seed || data.user.seed, password as string); - let mnemonic = entropyToMnemonic(entropy, wordlist); - let seed = await mnemonicToSeed(mnemonic, password as string); + let seed = await mnemonicToSeed(mnemonicStr); let master = HDKey.fromMasterSeed(seed, network as any); let child = master.derive(`m/84'/0'/${account.accountIndex ?? 0}'`); @@ -285,10 +234,6 @@ {/if} </div> -{#if passwordPrompt} - <WalletPass bind:password {cancel} submit={signTx} /> -{/if} - <style> .no-transition { view-transition-name: fee; |
