diff options
| author | Adam Soltys <[email protected]> | 2026-02-05 23:05:32 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-11 15:34:33 -0800 |
| commit | a418477c3ee9bdd567cb1625f23abbd94758983d (patch) | |
| tree | 258b7fead7f23a7a6d5f3361096824d56cb89312 /src | |
| parent | 806baacb2f126191068a4a7e3fccf9684a339d07 (diff) | |
| download | coinos-ui-a418477c3ee9bdd567cb1625f23abbd94758983d.tar.gz coinos-ui-a418477c3ee9bdd567cb1625f23abbd94758983d.zip | |
use master seed for ark accounts
Diffstat (limited to 'src')
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; } } |
