aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-05 23:05:32 -0800
committerAdam Soltys <[email protected]>2026-02-11 15:34:33 -0800
commita418477c3ee9bdd567cb1625f23abbd94758983d (patch)
tree258b7fead7f23a7a6d5f3361096824d56cb89312 /src
parent806baacb2f126191068a4a7e3fccf9684a339d07 (diff)
downloadcoinos-ui-a418477c3ee9bdd567cb1625f23abbd94758983d.tar.gz
coinos-ui-a418477c3ee9bdd567cb1625f23abbd94758983d.zip
use master seed for ark accounts
Diffstat (limited to 'src')
-rw-r--r--src/components/Account.svelte50
-rw-r--r--src/routes/(app)/account/[id]/+page.svelte3
-rw-r--r--src/routes/(app)/account/ark/+page.svelte158
-rw-r--r--src/routes/(app)/account/pass/+page.svelte15
-rw-r--r--src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.server.ts2
-rw-r--r--src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte6
-rw-r--r--src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts2
7 files changed, 217 insertions, 19 deletions
diff --git a/src/components/Account.svelte b/src/components/Account.svelte
index aaef428b..b7ae58ee 100644
--- a/src/components/Account.svelte
+++ b/src/components/Account.svelte
@@ -1,10 +1,12 @@
<script>
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"; import Balance from "$comp/Balance.svelte";
import { t } from "$lib/translations";
+ import { versions } from "$lib/utils";
import { arkkey } from "$lib/ark";
import {
getRememberedWalletPassword,
@@ -36,7 +38,29 @@
if (!cached) return false;
try {
const { decrypt } = await import("nostr-tools/nip49");
- $arkkey = hex.encode(decrypt(seed, cached));
+ if (seed) {
+ // Legacy: per-account seed is raw hex key
+ $arkkey = hex.encode(decrypt(seed, cached));
+ } 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;
+ }
return true;
} catch (e) {
forgetWalletPassword();
@@ -65,7 +89,25 @@
let submitPassword = async () => {
const { decrypt } = await import("nostr-tools/nip49");
- $arkkey = hex.encode(decrypt(seed, password));
+ if (seed) {
+ $arkkey = hex.encode(decrypt(seed, password));
+ } 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);
+ }
passwordPrompt = false;
if (pendingUrl) {
goto(pendingUrl);
@@ -76,7 +118,7 @@
let displayType = $derived(
accountType === "ark"
? $t("accounts.ark")
- : seed
+ : seed || (user.seed && accountType === "bitcoin")
? $t("accounts.bitcoin")
: $t("accounts.custodial"),
);
@@ -118,7 +160,7 @@
class="w-8 h-8 rounded-full object-cover"
alt="Ark"
/>
- {:else if seed}
+ {:else if seed || (user.seed && accountType === "bitcoin")}
<iconify-icon
noobserver
icon="cryptocurrency-color:btc"
diff --git a/src/routes/(app)/account/[id]/+page.svelte b/src/routes/(app)/account/[id]/+page.svelte
index db93a04d..897bbe62 100644
--- a/src/routes/(app)/account/[id]/+page.svelte
+++ b/src/routes/(app)/account/[id]/+page.svelte
@@ -15,7 +15,8 @@
let { data } = $props();
let { account, user } = data;
- let { id, seed } = account;
+ let { id } = account;
+ let seed = $derived(account.seed || user.seed);
let displayType = $derived(
account.type === "ark"
? $t("accounts.ark")
diff --git a/src/routes/(app)/account/ark/+page.svelte b/src/routes/(app)/account/ark/+page.svelte
index aee80837..32dbfe13 100644
--- a/src/routes/(app)/account/ark/+page.svelte
+++ b/src/routes/(app)/account/ark/+page.svelte
@@ -5,19 +5,28 @@
import Spinner from "$comp/Spinner.svelte";
import { bytesToHex, randomBytes } from "@noble/hashes/utils.js";
import { hex } from "@scure/base";
- import { focus, fail, post, copy } from "$lib/utils";
+ import { focus, fail, post, copy, 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,
} from "$lib/passwordCache";
let { data } = $props();
let { user } = data;
+ let hasMasterSeed = $derived(!!user.seed);
+
let privateKey = $state("");
let arkAddress = $state("");
let nsec = $state("");
@@ -34,10 +43,40 @@
let name = "Ark Vault";
let type = "ark";
- onMount(() => {
- generateKey();
+ onMount(async () => {
+ if (hasMasterSeed) {
+ // Try cached password first
+ const cached = getRememberedWalletPassword();
+ if (cached) {
+ password = cached;
+ confirm = cached;
+ await deriveFromMasterSeed(cached);
+ }
+ } else {
+ await generateKey();
+ }
});
+ 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) {
+ console.log("master seed derivation failed", e);
+ fail("Invalid password");
+ }
+ };
+
let generateKey = async () => {
const { nip19 } = await import("nostr-tools");
const { SingleKey, Wallet } = await import("@arkade-os/sdk");
@@ -46,7 +85,6 @@
showNsec = false;
confirmed = false;
- // Derive the ARK address from the private key
const identity = SingleKey.fromHex(privateKey);
const wallet = await Wallet.create({ identity, arkServerUrl });
arkAddress = await wallet.getAddress();
@@ -61,6 +99,33 @@
};
let submit = async () => {
+ if (hasMasterSeed) {
+ if (!password) {
+ fail("Password required");
+ 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) {
+ console.log(e);
+ fail(e.message);
+ }
+
+ submitting = false;
+ return;
+ }
+
if (!confirmed) {
fail("Please backup your key first");
return;
@@ -75,7 +140,6 @@
await tick();
try {
- // Encrypt the private key using NIP-49
const { encrypt } = await import("nostr-tools/nip49");
let seed = await encrypt(hex.decode(privateKey), password);
@@ -101,7 +165,89 @@
</div>
<div class="container w-full mx-auto text-lg px-4 max-w-xl space-y-5">
- {#if !confirmed}
+ {#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.
+ </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="Wallet password"
+ />
+ {:else}
+ <input
+ use:focus
+ name="password"
+ type="password"
+ placeholder="Wallet 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>
+
+ <div class="space-y-2">
+ <label for="rememberFor" class="text-sm text-secondary">
+ Remember for
+ </label>
+ <select
+ id="rememberFor"
+ class="w-full"
+ value={rememberForMs}
+ onchange={(e) => (rememberForMs = Number(e.target.value))}
+ >
+ {#each rememberForOptions as option}
+ <option value={option.ms}>{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">
<p class="text-secondary">
Your Ark wallet is secured by a private key. Please copy it down somewhere safe.
diff --git a/src/routes/(app)/account/pass/+page.svelte b/src/routes/(app)/account/pass/+page.svelte
index 74e45f17..a64d95ee 100644
--- a/src/routes/(app)/account/pass/+page.svelte
+++ b/src/routes/(app)/account/pass/+page.svelte
@@ -31,8 +31,6 @@
revealPassword = $state(),
revealConfirm = $state();
let rememberForMs = $state(defaultRememberForMs);
- let type = "bitcoin";
- let name = $t("accounts.savings");
onMount(() => {
if (!$mnemonic) goto("/account/new");
@@ -53,15 +51,26 @@
password,
);
+ // Save master seed on user
+ await post("/post/user", { seed });
+
let master = HDKey.fromMasterSeed(
await mnemonicToSeed($mnemonic, password),
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");
- await post("/account", { fingerprint, pubkey, name, seed, type });
+ await post("/account", {
+ fingerprint,
+ pubkey,
+ name: $t("accounts.savings"),
+ type: "bitcoin",
+ accountIndex: 0,
+ });
+
if (rememberForMs) {
rememberWalletPassword(password, rememberForMs);
} else {
diff --git a/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.server.ts b/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.server.ts
index acdd77a4..309d75fa 100644
--- a/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.server.ts
+++ b/src/routes/(app)/send/ark/[address]/[amount]/[...feeRate]/+page.server.ts
@@ -9,7 +9,7 @@ export async function load({ params: { address, amount }, cookies, parent }) {
const account = await get(`/account/${aid}`, auth(cookies));
- if (account.seed) {
+ if (account.type === "bitcoin") {
const inv = await post(
"/invoice",
{ invoice: { type: "bitcoin", amount: parseInt(amount), forward: address }, user },
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 acbaf3f9..ed225d00 100644
--- a/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
+++ b/src/routes/(app)/send/bitcoin/[address]/[amount]/[...feeRate]/+page.svelte
@@ -87,7 +87,7 @@
return;
}
- if (account.seed && !signed) cancel(), void togglePassword();
+ if ((account.seed || data.user.seed) && !signed) cancel(), void togglePassword();
return async ({ result }) => {
if (result.type === "redirect") {
goto(result.location);
@@ -111,11 +111,11 @@
import("@scure/bip39"),
import("@scure/bip39/wordlists/english.js"),
]);
- let entropy = await decrypt(account.seed, password);
+ let entropy = await decrypt(account.seed || data.user.seed, password);
let mnemonic = entropyToMnemonic(entropy, wordlist);
let seed = await mnemonicToSeed(mnemonic, password);
let master = HDKey.fromMasterSeed(seed, network);
- let child = master.derive("m/84'/0'/0'");
+ 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;
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 52f767f0..fef0a359 100644
--- a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts
+++ b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.server.ts
@@ -18,7 +18,7 @@ export async function load({ cookies, params, parent }) {
if (account.type === "ark") {
data.account = account;
data.serverArkAddress = await get("/ark/address");
- } else if (account.seed) {
+ } else if (account.type === "bitcoin") {
data.account = account;
}
}