aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-16 20:56:39 -0800
committerAdam Soltys <[email protected]>2026-02-16 20:56:39 -0800
commitb22c813f9fb3b4d69dade75d22e90e6779919f07 (patch)
tree173fe377e83ad65ac6721dd567d8e90d9d204be8 /src/routes
parent8bb150db1f73164030f9e8085c13765120363644 (diff)
downloadcoinos-ui-b22c813f9fb3b4d69dade75d22e90e6779919f07.tar.gz
coinos-ui-b22c813f9fb3b4d69dade75d22e90e6779919f07.zip
nostr key derivation
Diffstat (limited to 'src/routes')
-rw-r--r--src/routes/(app)/+layout.svelte38
-rw-r--r--src/routes/(app)/[username]/(profile)/+page.svelte2
-rw-r--r--src/routes/(app)/account/[id]/+page.svelte103
-rw-r--r--src/routes/(app)/account/ark/+page.svelte92
-rw-r--r--src/routes/(app)/account/bitcoin/+page.server.ts5
-rw-r--r--src/routes/(app)/account/import/+page.svelte2
-rw-r--r--src/routes/(app)/account/new/+page.server.ts8
-rw-r--r--src/routes/(app)/account/pass/+page.svelte15
-rw-r--r--src/routes/(app)/account/seed/+page.svelte16
-rw-r--r--src/routes/(app)/account/setup/+page.svelte2
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte13
-rw-r--r--src/routes/(app)/settings/security/+page.svelte4
-rw-r--r--src/routes/(static)/login/+page.svelte10
-rw-r--r--src/routes/(static)/register/+page.svelte47
14 files changed, 247 insertions, 110 deletions
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index 6f2e061d..0fde24fa 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -130,7 +130,11 @@
let autoProvisionAccounts = async () => {
if (!user) return;
if (sessionStorage.getItem("accounts:provisioned")) return;
- const prfKey = getCachedPrfKey();
+ let prfKey = getCachedPrfKey();
+ if (!prfKey) {
+ const { getWalletEntropy } = await import("$lib/walletEntropy");
+ prfKey = await getWalletEntropy();
+ }
if (!prfKey) return;
sessionStorage.setItem("accounts:provisioned", "1");
@@ -139,9 +143,8 @@
const res = await fetch("/account", { headers: { accept: "application/json" } });
const accounts = await res.json();
- const hasBitcoin = accounts.some((a: any) => a.type === "bitcoin");
const hasArk = accounts.some((a: any) => a.type === "ark");
- if (hasBitcoin && hasArk) return;
+ if (hasArk) return;
const [{ HDKey }, { entropyToMnemonic, mnemonicToSeed }, { wordlist }] = await Promise.all([
import("@scure/bip32"),
@@ -154,27 +157,14 @@
const seed = await mnemonicToSeed(mnemonic);
const master = HDKey.fromMasterSeed(seed, versions);
- if (!hasArk) {
- const { SingleKey, Wallet } = await import("@arkade-os/sdk");
- const { bytesToHex } = await import("@noble/hashes/utils.js");
- const arkChild = master.derive("m/86'/0'/0'/0/0");
- const arkHex = bytesToHex(arkChild.privateKey!);
- const identity = SingleKey.fromHex(arkHex);
- const wallet = await Wallet.create({ identity, arkServerUrl });
- const arkAddress = await wallet.getAddress();
- await post("/account", { name: "Savings", type: "ark", arkAddress });
- }
-
- if (!hasBitcoin) {
- const child = master.derive("m/84'/0'/0'");
- await post("/account", {
- fingerprint: child.fingerprint.toString(16).padStart(8, "0"),
- pubkey: child.publicExtendedKey,
- name: "Vault",
- type: "bitcoin",
- accountIndex: 0,
- });
- }
+ const { SingleKey, Wallet } = await import("@arkade-os/sdk");
+ const { bytesToHex } = await import("@noble/hashes/utils.js");
+ const arkChild = master.derive("m/86'/0'/0'/0/0");
+ const arkHex = bytesToHex(arkChild.privateKey!);
+ const identity = SingleKey.fromHex(arkHex);
+ const wallet = await Wallet.create({ identity, arkServerUrl });
+ const arkAddress = await wallet.getAddress();
+ await post("/account", { name: $t("accounts.savings"), type: "ark", arkAddress });
invalidate("app:payments");
} catch (e) {
diff --git a/src/routes/(app)/[username]/(profile)/+page.svelte b/src/routes/(app)/[username]/(profile)/+page.svelte
index 4885189b..b9af8756 100644
--- a/src/routes/(app)/[username]/(profile)/+page.svelte
+++ b/src/routes/(app)/[username]/(profile)/+page.svelte
@@ -64,7 +64,7 @@
{#each accounts as account}
<Account {user} {rates} {account} />
{/each}
- <a href="/account/new" class="contents">
+ <a href="/account/seed" class="contents">
<button class="btn btn-accent w-full">
<iconify-icon noobserver icon="ph:plus-bold" width="24"></iconify-icon>
{$t("accounts.addAccount")}
diff --git a/src/routes/(app)/account/[id]/+page.svelte b/src/routes/(app)/account/[id]/+page.svelte
index 0bab9c1b..674cb067 100644
--- a/src/routes/(app)/account/[id]/+page.svelte
+++ b/src/routes/(app)/account/[id]/+page.svelte
@@ -9,9 +9,12 @@
import { enhance } from "$app/forms";
import { t } from "$lib/translations";
import { tick } from "svelte";
- import { entropyToMnemonic, mnemonicToSeed } from "@scure/bip39";
+ import { entropyToMnemonic, 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 Spinner from "$comp/Spinner.svelte";
let { data } = $props();
let account = $derived(data.account);
@@ -70,8 +73,45 @@
let isCustodial = $derived(!seed && account.type !== "ark");
+ let importing = $state(false);
+ let importText = $state("");
+ let importSubmitting = $state(false);
+
+ let importSeed = async () => {
+ let text = importText.replace(/,/g, " ").trim();
+ if (!validateMnemonic(text, wordlist)) {
+ fail($t("accounts.invalidSeedPhrase"));
+ return;
+ }
+
+ importSubmitting = true;
+ try {
+ let s = await mnemonicToSeed(text);
+ let master = HDKey.fromMasterSeed(s, versions);
+ let child = master.derive(`m/84'/0'/${account.accountIndex ?? 0}'`);
+ let pubkey = child.publicExtendedKey;
+ let fp = child.fingerprint.toString(16).padStart(8, "0");
+
+ await post(`/post/account/${id}`, {
+ fingerprint: fp,
+ pubkey,
+ });
+
+ importing = false;
+ importText = "";
+ goto(`/${user.username}`, { invalidateAll: true });
+ } catch (e: any) {
+ fail(e.message || $t("accounts.importFailed"));
+ }
+ importSubmitting = false;
+ };
+
let revealWithPrfKey = async () => {
- const prfKey = getCachedPrfKey();
+ let prfKey = getCachedPrfKey();
+ if (!prfKey) {
+ const { getWalletEntropy } = await import("$lib/walletEntropy");
+ prfKey = await getWalletEntropy();
+ }
if (!prfKey) return false;
try {
const entropy = new Uint8Array(prfKey);
@@ -110,7 +150,7 @@
let entropy = await decrypt(seed, password as string);
mnemonic = entropyToMnemonic(entropy, wordlist);
} catch (e: any) {
- fail("Invalid password, try again");
+ fail($t("accounts.invalidPasswordTryAgain"));
passwordPrompt = true;
}
});
@@ -158,6 +198,7 @@
</select>
</div>
+
{#if isCustodial}
<div>
<div class="flex justify-between items-center">
@@ -192,23 +233,13 @@
</button>
<p class="text-secondary mt-1">{$t("user.settings.thresholdDesc")}</p>
</div>
-
- <div>
- <label for="reserve" class="font-bold mb-1 block">{$t("user.settings.reserve")}</label>
- <button type="button" class="flex w-full" onclick={editReserve}>
- <div class="p-4 border rounded-2xl rounded-r-none border-r-0 bg-base-200">
- <iconify-icon noobserver icon="ph:lightning-fill" class="text-yellow-300"></iconify-icon>
- </div>
- <div class="border-l-0 rounded-l-none pl-2 w-full p-4 border rounded-2xl text-left">
- {reserve}
- </div>
- <input type="hidden" name="reserve" bind:value={reserve} />
- </button>
- <p class="text-secondary mt-1">{$t("user.settings.reserveDesc")}</p>
- </div>
</div>
{/if}
+ <button type="submit" class="btn btn-accent !w-full">
+ <div class="my-auto">{$t("accounts.submit")}</div>
+ </button>
+
<div class="space-y-2">
{#if mnemonic}
<Mnemonic {mnemonic} />
@@ -224,17 +255,45 @@
</button>
{/if}
+ {#if account.type === "bitcoin"}
+ {#if importing}
+ <div class="space-y-2">
+ <textarea
+ placeholder={$t("accounts.enterSeed")}
+ class="w-full p-4 border rounded-xl h-36 text-lg"
+ bind:value={importText}
+ autocapitalize="none"
+ ></textarea>
+ <div class="flex gap-2">
+ <button type="button" class="btn !w-auto grow" onclick={() => (importing = false)}>
+ {$t("accounts.back")}
+ </button>
+ <button type="button" class="btn btn-accent !w-auto grow" onclick={importSeed} disabled={importSubmitting}>
+ {#if importSubmitting}
+ <Spinner />
+ {:else}
+ {$t("accounts.import")}
+ {/if}
+ </button>
+ </div>
+ </div>
+ {:else}
+ <button type="button" class="btn" onclick={() => (importing = true)}>
+ <iconify-icon noobserver icon="ph:warning-bold" width="32"></iconify-icon>
+ <div class="my-auto">{$t("accounts.changeSeedPhrase")}</div>
+ </button>
+ {/if}
+ {/if}
+
{#if seed || account.type === "ark"}
<button onclick={del} type="button" class="btn">
<iconify-icon noobserver icon="ph:trash-bold" width="32"></iconify-icon>
<div class="my-auto">{$t("accounts.deleteAccount")}</div>
</button>
{/if}
+
</div>
- <button type="submit" class="btn btn-accent !w-full">
- <div class="my-auto">{$t("accounts.submit")}</div>
- </button>
</form>
</div>
</div>
@@ -254,7 +313,7 @@
bind:submit={doneReserve}
bind:element={thresholdEl}
/>
- <button bind:this={doneReserve} type="button" onclick={doneEditing} class="btn">Ok</button>
+ <button bind:this={doneReserve} type="button" onclick={doneEditing} class="btn">{$t("payments.ok")}</button>
</div>
</div>
{/if}
@@ -270,7 +329,7 @@
bind:submit={doneReserve}
bind:element={reserveEl}
/>
- <button bind:this={doneReserve} type="button" onclick={doneEditing} class="btn">Ok</button>
+ <button bind:this={doneReserve} type="button" onclick={doneEditing} class="btn">{$t("payments.ok")}</button>
</div>
</div>
{/if}
diff --git a/src/routes/(app)/account/ark/+page.svelte b/src/routes/(app)/account/ark/+page.svelte
index f2bc8991..b6bec5b7 100644
--- a/src/routes/(app)/account/ark/+page.svelte
+++ b/src/routes/(app)/account/ark/+page.svelte
@@ -26,7 +26,8 @@
let user = $derived(data.user);
let hasMasterSeed = $derived(!!user.seed);
- let hasPrfSeed = $derived(!!getCachedPrfKey());
+ let hasPrfSeed = $state(!!getCachedPrfKey());
+ let hasSigner = $state(false);
let privateKey = $state("");
let arkAddress = $state("");
@@ -42,7 +43,7 @@
let revealConfirm = $state(false);
let rememberForMs = $state(defaultRememberForMs);
- let name = "Savings";
+ let name = $t("accounts.savings");
let type = "ark";
let deriveFromPrfKey = async (prfKey: ArrayBuffer) => {
@@ -60,7 +61,19 @@
confirmed = true;
} catch (e: any) {
console.log("PRF seed derivation failed", e);
- fail("Failed to decrypt seed");
+ fail($t("accounts.failedToDecryptSeed"));
+ }
+ };
+
+ let unlockWithNostr = async () => {
+ try {
+ const { deriveNostrEntropy } = await import("$lib/walletEntropy");
+ const entropy = await deriveNostrEntropy();
+ hasPrfSeed = true;
+ await deriveFromPrfKey(entropy);
+ needsPasskeyUnlock = false;
+ } catch (e: any) {
+ fail(e.message || $t("accounts.passkeyUnlockFailed"));
}
};
@@ -72,16 +85,29 @@
await deriveFromPrfKey(prfKey);
needsPasskeyUnlock = false;
} else {
- fail("Passkey does not support PRF");
+ fail($t("accounts.passkeyNoPrf"));
}
} catch (e: any) {
if (e.name !== "NotAllowedError") {
- fail(e.message || "Passkey unlock failed");
+ fail(e.message || $t("accounts.passkeyUnlockFailed"));
}
}
};
onMount(async () => {
+ hasSigner = !!localStorage.getItem("signer");
+
+ // Try wallet entropy (covers both cached prfKey and silent nsec derivation)
+ if (!hasPrfSeed) {
+ const { getWalletEntropy } = await import("$lib/walletEntropy");
+ const entropy = await getWalletEntropy();
+ if (entropy) {
+ hasPrfSeed = true;
+ await deriveFromPrfKey(entropy);
+ return;
+ }
+ }
+
if (hasPrfSeed) {
const prfKey = getCachedPrfKey();
if (prfKey) {
@@ -117,7 +143,7 @@
confirmed = true;
} catch (e: any) {
console.log("master seed derivation failed", e);
- fail("Invalid password");
+ fail($t("accounts.invalidPassword"));
}
};
@@ -164,7 +190,7 @@
if (hasMasterSeed) {
if (!password) {
- fail("Password required");
+ fail($t("accounts.passwordRequired"));
return;
}
@@ -190,7 +216,7 @@
}
if (!confirmed) {
- fail("Please backup your key first");
+ fail($t("accounts.pleaseBackupFirst"));
return;
}
@@ -224,7 +250,7 @@
<div class="space-y-5">
<div class="flex items-center justify-center gap-2">
- <h1 class="text-3xl font-semibold">Create Savings Account</h1>
+ <h1 class="text-3xl font-semibold">{$t("accounts.createSavingsAccount")}</h1>
</div>
<div class="container w-full mx-auto text-lg px-4 max-w-xl space-y-5">
@@ -232,27 +258,33 @@
{#if needsPasskeyUnlock}
<div class="space-y-5">
<p class="text-secondary">
- Your Ark wallet will be derived from your master seed. Unlock with your passkey to continue.
+ {$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>
- Unlock with Passkey
+ {$t("accounts.unlockWithPasskey")}
</button>
</div>
{:else}
<form onsubmit={preventDefault(submit)} class="space-y-5">
<p class="text-secondary">
- Your Ark wallet will be derived from your passkey-protected master seed.
+ {$t("accounts.arkDerivedFromPasskey")}
</p>
<div class="flex gap-2">
<a href={`/${user.username}`} class="contents">
- <button type="button" class="btn !w-auto grow">Back</button>
+ <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>
@@ -261,7 +293,7 @@
{:else if hasMasterSeed}
<form onsubmit={preventDefault(submit)} class="space-y-5">
<p class="text-secondary">
- Your Ark wallet will be derived from your master seed. Enter your wallet password to continue.
+ {$t("accounts.arkEnterPassword")}
</p>
<label for="password" class="input flex items-center justify-center gap-2 w-full">
@@ -273,14 +305,14 @@
bind:value={password}
autocapitalize="none"
class="clean"
- placeholder="Wallet password"
+ placeholder={$t("payments.enterWalletPass")}
/>
{:else}
<input
use:focus
name="password"
type="password"
- placeholder="Wallet password"
+ placeholder={$t("payments.enterWalletPass")}
required
bind:value={password}
autocapitalize="none"
@@ -303,7 +335,7 @@
</label>
<div class="space-y-2">
- <label for="rememberFor" class="text-sm text-secondary">Remember for</label>
+ <label for="rememberFor" class="text-sm text-secondary">{$t("user.settings.rememberFor")}</label>
<select
id="rememberFor"
class="w-full"
@@ -311,7 +343,7 @@
onchange={(e) => (rememberForMs = Number((e.target as HTMLSelectElement).value))}
>
{#each rememberForOptions as option}
- <option value={option.ms}>{option.label}</option>
+ <option value={option.ms}>{$t(option.label)}</option>
{/each}
</select>
</div>
@@ -332,7 +364,7 @@
{:else if !confirmed}
<div class="space-y-4">
<p class="text-secondary">
- Your Ark wallet is secured by a private key. Please copy it down somewhere safe.
+ {$t("accounts.arkSecuredByKey")}
</p>
{#if showNsec}
@@ -350,20 +382,20 @@
<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>
- I've backed it up
+ {$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>
- Reveal backup key
+ {$t("accounts.revealBackupKey")}
</button>
{/if}
</div>
{:else}
<form onsubmit={preventDefault(submit)} class="space-y-5">
<p class="text-secondary">
- Set a password to encrypt your key. You'll need this password to send funds.
+ {$t("accounts.setPasswordToEncrypt")}
</p>
<label for="password" class="input flex items-center justify-center gap-2 w-full">
@@ -375,14 +407,14 @@
bind:value={password}
autocapitalize="none"
class="clean"
- placeholder="Password"
+ placeholder={$t("accounts.password")}
/>
{:else}
<input
use:focus
name="password"
type="password"
- placeholder="Password"
+ placeholder={$t("accounts.password")}
required
bind:value={password}
autocapitalize="none"
@@ -409,7 +441,7 @@
<input
name="confirm"
type="text"
- placeholder="Confirm password"
+ placeholder={$t("accounts.confirmPassword")}
required
bind:value={confirm}
autocapitalize="none"
@@ -418,7 +450,7 @@
{:else}
<input
type="password"
- placeholder="Confirm password"
+ placeholder={$t("accounts.confirmPassword")}
required
bind:value={confirm}
autocapitalize="none"
@@ -440,7 +472,7 @@
</label>
<div class="space-y-2">
- <label for="rememberFor" class="text-sm text-secondary">Remember for</label>
+ <label for="rememberFor" class="text-sm text-secondary">{$t("user.settings.rememberFor")}</label>
<select
id="rememberFor"
class="w-full"
@@ -448,14 +480,14 @@
onchange={(e) => (rememberForMs = Number((e.target as HTMLSelectElement).value))}
>
{#each rememberForOptions as option}
- <option value={option.ms}>{option.label}</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)}>
- Back
+ {$t("accounts.back")}
</button>
<button disabled={submitting} type="submit" class="btn btn-accent !w-auto grow">
{#if submitting}
diff --git a/src/routes/(app)/account/bitcoin/+page.server.ts b/src/routes/(app)/account/bitcoin/+page.server.ts
new file mode 100644
index 00000000..d4a0d0cb
--- /dev/null
+++ b/src/routes/(app)/account/bitcoin/+page.server.ts
@@ -0,0 +1,5 @@
+import { redirect } from "@sveltejs/kit";
+
+export async function load() {
+ redirect(307, "/account/seed");
+}
diff --git a/src/routes/(app)/account/import/+page.svelte b/src/routes/(app)/account/import/+page.svelte
index 1ae0a932..e26f1858 100644
--- a/src/routes/(app)/account/import/+page.svelte
+++ b/src/routes/(app)/account/import/+page.svelte
@@ -48,7 +48,7 @@
autocapitalize="none"
></textarea>
<div class="flex gap-2">
- <a href={`/account/bitcoin`} class="contents">
+ <a href={`/account/seed`} class="contents">
<button type="button" class="btn !w-auto grow">{$t("accounts.back")}</button>
</a>
<button bind:this={el} type="submit" class="btn btn-accent !w-auto grow">
diff --git a/src/routes/(app)/account/new/+page.server.ts b/src/routes/(app)/account/new/+page.server.ts
index 8ecbb61c..d4a0d0cb 100644
--- a/src/routes/(app)/account/new/+page.server.ts
+++ b/src/routes/(app)/account/new/+page.server.ts
@@ -1,7 +1,5 @@
-import { auth, get } from "$lib/utils";
+import { redirect } from "@sveltejs/kit";
-export async function load({ cookies }) {
- const accounts = await get("/accounts", auth(cookies));
- const hasArk = accounts.some((a) => a.type === "ark");
- return { hasArk };
+export async function load() {
+ redirect(307, "/account/seed");
}
diff --git a/src/routes/(app)/account/pass/+page.svelte b/src/routes/(app)/account/pass/+page.svelte
index 23e54ac7..4e754489 100644
--- a/src/routes/(app)/account/pass/+page.svelte
+++ b/src/routes/(app)/account/pass/+page.svelte
@@ -31,12 +31,13 @@
onMount(async () => {
if (!$mnemonic) {
- goto("/account/new");
+ goto("/account/seed");
return;
}
- // PRF users: derive from PRF key directly, skip password
- const prfKey = getCachedPrfKey();
+ // 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();
@@ -53,7 +54,7 @@
await post("/account", {
fingerprint,
pubkey,
- name: "Vault",
+ name: $t("accounts.vault"),
type: "bitcoin",
accountIndex: 0,
});
@@ -171,7 +172,7 @@
<input
name="confirm"
type="text"
- placeholder="Confirm password"
+ placeholder={$t("accounts.confirmPassword")}
required
bind:value={confirm}
autocapitalize="none"
@@ -202,7 +203,7 @@
</label>
<div class="space-y-2">
- <label for="rememberFor" class="text-sm text-secondary">Remember for</label>
+ <label for="rememberFor" class="text-sm text-secondary">{$t("user.settings.rememberFor")}</label>
<select
id="rememberFor"
class="w-full"
@@ -210,7 +211,7 @@
onchange={(e) => (rememberForMs = Number((e.target as HTMLSelectElement).value))}
>
{#each rememberForOptions as option}
- <option value={option.ms}>{option.label}</option>
+ <option value={option.ms}>{$t(option.label)}</option>
{/each}
</select>
</div>
diff --git a/src/routes/(app)/account/seed/+page.svelte b/src/routes/(app)/account/seed/+page.svelte
index adfd5573..43b690b3 100644
--- a/src/routes/(app)/account/seed/+page.svelte
+++ b/src/routes/(app)/account/seed/+page.svelte
@@ -18,7 +18,8 @@
onMount(async () => {
if (!browser) return;
- const prfKey = getCachedPrfKey();
+ const { getWalletEntropy } = await import("$lib/walletEntropy");
+ const prfKey = await getWalletEntropy();
if (prfKey) {
creating = true;
try {
@@ -32,7 +33,7 @@
await post("/account", {
fingerprint,
pubkey,
- name: "Vault",
+ name: $t("accounts.vault"),
type: "bitcoin",
accountIndex: 0,
});
@@ -70,14 +71,9 @@
<div class="my-auto">{$t("payments.copy")}</div>
</button>
- <div class="flex gap-2">
- <a href={`/account/bitcoin`} class="contents">
- <button type="button" class="btn !w-auto grow">{$t("accounts.back")}</button>
- </a>
- <a href={`/account/pass`} class="contents">
- <button type="button" class="btn btn-accent !w-auto grow">{$t("accounts.next")}</button>
- </a>
- </div>
+ <a href={`/account/pass`} class="contents">
+ <button type="button" class="btn btn-accent w-full">{$t("accounts.next")}</button>
+ </a>
</div>
</div>
{/if}
diff --git a/src/routes/(app)/account/setup/+page.svelte b/src/routes/(app)/account/setup/+page.svelte
index faab0938..35c0c9d9 100644
--- a/src/routes/(app)/account/setup/+page.svelte
+++ b/src/routes/(app)/account/setup/+page.svelte
@@ -17,7 +17,7 @@
</div>
</div>
</a>
- <a href="/account/new" class="block">
+ <a href="/account/seed" class="block">
<div class="shadow p-8 rounded-xl flex gap-4 hover:bg-primary">
<img src="/images/keys.png" class="w-20 my-auto" alt="Savings account" />
<div>
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 e14d41ba..1a0df2f2 100644
--- a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
+++ b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
@@ -38,6 +38,17 @@
return false;
}
}
+ // Try silent Nostr derivation
+ const { getWalletEntropy } = await import("$lib/walletEntropy");
+ const entropy = await getWalletEntropy();
+ if (entropy) {
+ try {
+ await signTxWithPrf(entropy);
+ return true;
+ } catch (e: any) {
+ return false;
+ }
+ }
const cached = getRememberedWalletPassword();
if (!cached) return false;
try {
@@ -76,7 +87,7 @@
goto(`/sent/${p.id}`, { invalidateAll: true });
} catch (e: any) {
loading = false;
- error = e.message || "Failed to send";
+ error = e.message || $t("payments.failedToSend");
}
})();
diff --git a/src/routes/(app)/settings/security/+page.svelte b/src/routes/(app)/settings/security/+page.svelte
index 9666b193..33981645 100644
--- a/src/routes/(app)/settings/security/+page.svelte
+++ b/src/routes/(app)/settings/security/+page.svelte
@@ -215,12 +215,12 @@
{/if}
{#if confirming2fa || disabling2fa}
- <Pin bind:value={token} title="Enter 2FA Code" {cancel} notify={false} />
+ <Pin bind:value={token} title={$t("login.enter2faCode")} {cancel} notify={false} />
{/if}
</div>
<div>
- <a href={`/account/new`} class="contents">
+ <a href={`/account/seed`} class="contents">
<button class="btn btn-lg w-full rounded-2xl whitespace-nowrap">
<iconify-icon noobserver icon="ph:plus-circle-bold" width="32"></iconify-icon>
{$t("accounts.addAccount")}
diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte
index fc1a6921..2c4df81e 100644
--- a/src/routes/(static)/login/+page.svelte
+++ b/src/routes/(static)/login/+page.svelte
@@ -173,6 +173,16 @@
const result = deserialize(await response.text());
+ if (result.type === "success" || result.type === "redirect") {
+ try {
+ const { deriveNostrEntropy } = await import("$lib/walletEntropy");
+ await deriveNostrEntropy();
+ } catch (e) {
+ console.log("Nostr entropy derivation failed", e);
+ }
+ await invalidateAll();
+ }
+
applyAction(result);
} catch (e: any) {
fail(e.message);
diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte
index 273d319a..858b9695 100644
--- a/src/routes/(static)/register/+page.svelte
+++ b/src/routes/(static)/register/+page.svelte
@@ -14,7 +14,7 @@
import Spinner from "$comp/Spinner.svelte";
import { focus, fail, versions, post } from "$lib/utils";
- import { registerPasskey } from "$lib/passkey";
+ import { registerPasskey, loginWithPasskey } from "$lib/passkey";
import { rememberPrfKey, defaultRememberForMs } from "$lib/passwordCache";
import { avatar, signer, password, pin, loginRedirect } from "$lib/store";
import { t } from "$lib/translations";
@@ -171,6 +171,35 @@
await activateSession(prfKey);
} else {
+ // Registration failed (e.g. username taken) — try passkey sign-in
+ try {
+ const { credential, challengeId, prfKey } = await loginWithPasskey();
+ const loginData = new FormData();
+ loginData.append("credential", JSON.stringify(credential));
+ loginData.append("challengeId", challengeId);
+ loginData.append("loginRedirect", (redirect ?? "") as string);
+
+ const loginResponse = await fetch("/login?/passkey", {
+ method: "POST",
+ body: loginData,
+ });
+
+ const loginResult = deserialize(await loginResponse.text());
+
+ if (loginResult.type === "success" || loginResult.type === "redirect") {
+ rememberPrfKey(prfKey, defaultRememberForMs);
+ await invalidateAll();
+ applyAction(loginResult);
+ loading = false;
+ return;
+ }
+ } catch (e: any) {
+ if (e.name !== "NotAllowedError") {
+ console.log("Passkey login failed", e);
+ }
+ }
+
+ // Passkey cancelled or failed — show original registration error
applyAction(result);
}
@@ -233,7 +262,7 @@
needsPasskey = false;
await activateSession(prfKey);
} catch (e: any) {
- fail("Passkey creation failed. Please try again.");
+ fail($t("login.passkeyCreationFailed"));
}
loading = false;
}
@@ -302,7 +331,13 @@
const result = deserialize(await response.text());
- if (result.type === "success") {
+ if (result.type === "success" || result.type === "redirect") {
+ try {
+ const { deriveNostrEntropy } = await import("$lib/walletEntropy");
+ await deriveNostrEntropy();
+ } catch (e) {
+ console.log("Nostr entropy derivation failed", e);
+ }
await invalidateAll();
}
@@ -329,7 +364,7 @@
</script>
{#if need2fa}
- <Pin bind:value={token} title="Enter 2FA Code" {cancel} notify={false} />
+ <Pin bind:value={token} title={$t("login.enter2faCode")} {cancel} notify={false} />
{/if}
<div class="mx-auto md:shadow-xl rounded-3xl max-w-xl w-full md:w-[480px] md:p-8 space-y-5 mb-20">
@@ -373,12 +408,12 @@
{#if needsPasskey}
<div class="space-y-5 text-center">
- <p>Account created! You need to set up a passkey to continue.</p>
+ <p>{$t("login.accountCreatedNeedsPasskey")}</p>
<button class="btn btn-accent" onclick={retryPasskey} disabled={loading}>
{#if loading}
<Spinner />
{:else}
- Set up Passkey
+ {$t("login.setupPasskey")}
{/if}
</button>
</div>