From aa4e4a4a09511e06540f00e14665aff3071bc591 Mon Sep 17 00:00:00 2001 From: kunal-595 Date: Fri, 16 Jan 2026 21:53:05 +0530 Subject: [PATCH] feat: derive account xpubs from seed instead of API - Add @scure/bip32 and @scure/bip39 for BIP32/BIP39 derivation - Create bip32.ts utility for deriving account-level xpubs from mnemonic - Update AccountXpubsDialog to fetch seed and derive correct BIP84 account xpubs - Derive account-level xpubs (m/84'/coin_type'/account') not child xpubs - Convert to native segwit format (zpub/vpub) using SLIP-0132 - Simplify UI to show single account-level xpub per account - Update translations to reflect account-level xpubs for watch-only import Fixes issue where API returns wrong xpub derivation paths (address-level instead of account-level). Now calculates correct xpubs locally from seed as suggested by maintainer. --- package-lock.json | 89 ++++--- package.json | 8 +- .../settings/AccountXpubsDialog.tsx | 219 ++++++------------ src/i18n/locales/en/translation.json | 10 +- src/lib/bip32.ts | 79 +++++++ 5 files changed, 222 insertions(+), 183 deletions(-) create mode 100644 src/lib/bip32.ts diff --git a/package-lock.json b/package-lock.json index 892791af..3a9befc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,8 @@ "@radix-ui/react-separator": "1.1.8", "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-tooltip": "1.2.8", + "@scure/bip32": "^2.0.1", + "@scure/bip39": "^2.0.1", "@tailwindcss/vite": "4.1.17", "@tanstack/react-query": "5.90.10", "@tanstack/react-table": "8.21.3", @@ -114,7 +116,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1225,6 +1226,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", @@ -2554,6 +2570,42 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@simple-libs/child-process-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.1.tgz", @@ -3302,7 +3354,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3426,7 +3479,6 @@ "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3437,7 +3489,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3495,7 +3546,6 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -3748,7 +3798,6 @@ "integrity": "sha512-OWN4ZgOIV2+T9cR4qfoajtjZDFoxcLa6qUpgDkviXZFUNkZ7XTVKvL/16X+gz5dtpqdZwXf3m0qIj72Ge/vytw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/mocker": "4.0.9", "@vitest/utils": "4.0.9", @@ -3772,7 +3821,6 @@ "integrity": "sha512-ayr0vCxvJIvodzfUTVzifFMT3bmcMeKzEWoPt7mtgrZsqJhMbYaftifuBZRQeF/glogsVr+jhtIePHw6g+0YRQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/browser": "4.0.9", "@vitest/mocker": "4.0.9", @@ -4106,7 +4154,6 @@ "integrity": "sha512-aF77tsXdEvIJRkj9uJZnHtovsVIx22Ambft9HudC+XuG/on1NY/bf5dlDti1N35eJT+QZLb4RF/5dTIG18s98w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.9", "pathe": "^2.0.3" @@ -4224,7 +4271,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4441,7 +4487,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", @@ -4804,7 +4849,6 @@ "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4815,7 +4859,6 @@ "integrity": "sha512-uLnoLeIW4XaoFtH37qEcg/SXMJmKF4vi7V0H2rnPueg+VEtFGA/asSCNTcq4M/GQ6QmlzchAEtOoDTtKqWeHag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "meow": "^13.0.0" }, @@ -4964,7 +5007,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -5036,7 +5080,6 @@ "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5100,7 +5143,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6597,6 +6639,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7185,7 +7228,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7281,6 +7323,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7296,6 +7339,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -7370,7 +7414,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7412,7 +7455,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7452,7 +7494,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -7701,7 +7744,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7986,7 +8028,6 @@ "integrity": "sha512-vQMufKKA9TxgoEDHJv3esrqUkjszuuRiDkThiHxENFPdQawHhm2Dei+iwNRwH5W671zTDy9iRT9P1KDjcU5Iyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^1.6.0", @@ -8289,7 +8330,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8412,7 +8452,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8614,7 +8653,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -8706,7 +8744,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8720,7 +8757,6 @@ "integrity": "sha512-E0Ja2AX4th+CG33yAFRC+d1wFx2pzU5r6HtG6LiPSE04flaE0qB6YyjSw9ZcpJAtVPfsvZGtJlKWZpuW7EHRxg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.9", "@vitest/mocker": "4.0.9", @@ -9271,7 +9307,6 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index ab545b71..7576b8a3 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,8 @@ "@radix-ui/react-separator": "1.1.8", "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-tooltip": "1.2.8", + "@scure/bip32": "^2.0.1", + "@scure/bip39": "^2.0.1", "@tailwindcss/vite": "4.1.17", "@tanstack/react-query": "5.90.10", "@tanstack/react-table": "8.21.3", @@ -88,6 +90,8 @@ "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "5.1.1", "@vitest/browser": "4.0.9", + "@vitest/browser-playwright": "4.0.9", + "@vitest/coverage-v8": "4.0.9", "conventional-changelog": "7.1.1", "eslint": "9.39.1", "eslint-plugin-react-hooks": "7.0.1", @@ -102,9 +106,7 @@ "typescript": "~5.9.3", "typescript-eslint": "8.47.0", "vite": "7.2.4", - "vitest": "4.0.9", - "@vitest/browser-playwright": "4.0.9", - "@vitest/coverage-v8": "4.0.9" + "vitest": "4.0.9" }, "overrides": { "storybook": "$storybook", diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 659a948d..c58f1c88 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useMemo, useCallback } from 'react' -import { displaywalletOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query' -import type { WalletDisplayResponse } from '@joinmarket-webui/joinmarket-api-ts/jm' +import { getseedOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query' import { cx } from 'class-variance-authority' import { EyeIcon, EyeOffIcon, AlertTriangleIcon, ClockIcon, Loader2Icon, CopyIcon, CheckIcon } from 'lucide-react' @@ -23,71 +22,45 @@ import { Label } from '@/components/ui/label' import { JAM_SEED_MODAL_TIMEOUT } from '@/constants/jam' import { useApiClient } from '@/hooks/useApiClient' import { hashPassword } from '@/lib/hash' -import { extractXpubFromBranch, extractDerivationPath, toNativeSegwitPub } from '@/lib/xpub' +import { deriveAccountXpubs, detectNetwork } from '@/lib/bip32' +import { toNativeSegwitPub } from '@/lib/xpub' import { authStore } from '@/store/authStore' interface AccountXpubInfo { - accountIndex: string + accountIndex: number accountName: string - externalXpub: string | null - externalPath: string | null - internalXpub: string | null - internalPath: string | null + xpub: string + path: string } /** - * Parse wallet display response to extract xpub information for each account + * Derive account-level xpubs from seed phrase + * This derives the correct BIP84 account-level xpubs: m/84'/coin_type'/account' + * not the child xpubs that the API incorrectly returns */ -async function parseAccountXpubs(walletDisplay: WalletDisplayResponse): Promise { +async function deriveAccountXpubsFromSeed( + seedPhrase: string, + walletFileName: string, + accountCount: number = 5, +): Promise { + // Detect network from wallet name + const network = detectNetwork(walletFileName) + const coinType = network === 'mainnet' ? 0 : 1 + + // Derive xpubs for all accounts + const rawXpubs = deriveAccountXpubs(seedPhrase, accountCount, network) + + // Convert to native segwit format (zpub/vpub) and build account info const accounts: AccountXpubInfo[] = [] - - for (const account of walletDisplay.walletinfo?.accounts || []) { - const accountIndex = account.account || '0' - const accountNum = parseInt(accountIndex, 10) - const accountName = accountNum < jarTemplates.length ? jarTemplates[accountNum].name : `Account ${accountIndex}` - - let externalXpub: string | null = null - let externalPath: string | null = null - let internalXpub: string | null = null - let internalPath: string | null = null - - // Collect all xpubs that need conversion - const conversions: Array<{ rawXpub: string; isExternal: boolean; path: string | null }> = [] - - for (const branch of account.branches || []) { - const branchStr = branch.branch || '' - const rawXpub = extractXpubFromBranch(branchStr) - const path = extractDerivationPath(branchStr) - - if (rawXpub) { - conversions.push({ - rawXpub, - isExternal: branchStr.toLowerCase().includes('external'), - path, - }) - } - } - - // Convert all xpubs in parallel for better performance - const converted = await Promise.all(conversions.map((c) => toNativeSegwitPub(c.rawXpub))) - - conversions.forEach((c, i) => { - if (c.isExternal) { - externalXpub = converted[i] - externalPath = c.path - } else { - internalXpub = converted[i] - internalPath = c.path - } - }) + for (let i = 0; i < rawXpubs.length; i++) { + const convertedXpub = await toNativeSegwitPub(rawXpubs[i]) + const accountName = i < jarTemplates.length ? jarTemplates[i].name : `Account ${i}` accounts.push({ - accountIndex, + accountIndex: i, accountName, - externalXpub, - externalPath, - internalXpub, - internalPath, + xpub: convertedXpub, + path: `m/84'/${coinType}'/${i}'`, }) } @@ -117,8 +90,8 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou const authState = useStore(authStore, (state) => state.state) const queryClient = useQueryClient() - const displayWalletQuery = useQuery({ - ...displaywalletOptions({ + const seedQuery = useQuery({ + ...getseedOptions({ client, path: { walletname: walletFileName }, }), @@ -128,21 +101,21 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou retry: false, }) - const displayWalletRefetch = useMemo(() => displayWalletQuery.refetch, [displayWalletQuery.refetch]) + const seedRefetch = useMemo(() => seedQuery.refetch, [seedQuery.refetch]) - // Fetch wallet display data immediately after password verification + // Fetch seed phrase immediately after password verification useEffect(() => { if (open && isPasswordVerified) { - displayWalletRefetch() + seedRefetch() } - }, [open, isPasswordVerified, displayWalletRefetch]) + }, [open, isPasswordVerified, seedRefetch]) - // Parse xpub data when wallet display data is available + // Derive xpubs when seed data is available useEffect(() => { - if (displayWalletQuery.data) { - parseAccountXpubs(displayWalletQuery.data).then(setAccountXpubs) + if (seedQuery.data?.seedphrase) { + deriveAccountXpubsFromSeed(seedQuery.data.seedphrase, walletFileName).then(setAccountXpubs) } - }, [displayWalletQuery.data]) + }, [seedQuery.data, walletFileName]) useEffect(() => { if (passwordVerifiedAt === undefined) { @@ -151,7 +124,7 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou } setTimeLeft(JAM_SEED_MODAL_TIMEOUT) - const xpubsDisplayedAt = Math.max(displayWalletQuery.dataUpdatedAt, passwordVerifiedAt) + const xpubsDisplayedAt = Math.max(seedQuery.dataUpdatedAt, passwordVerifiedAt) const interval = setInterval(() => { setTimeLeft(Math.max(0, xpubsDisplayedAt + JAM_SEED_MODAL_TIMEOUT - Date.now())) }, 333) @@ -159,7 +132,7 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou return () => { clearInterval(interval) } - }, [displayWalletQuery.dataUpdatedAt, passwordVerifiedAt]) + }, [seedQuery.dataUpdatedAt, passwordVerifiedAt]) useEffect(() => { if (timeLeft <= 0) { @@ -225,7 +198,7 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou setCopiedXpub(null) // Clear the cached query data to ensure fresh fetch on next open queryClient.removeQueries({ - queryKey: displaywalletOptions({ client, path: { walletname: walletFileName } }).queryKey, + queryKey: getseedOptions({ client, path: { walletname: walletFileName } }).queryKey, }) onOpenChange(false) } @@ -297,12 +270,12 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou
- {displayWalletQuery.isFetching ? ( + {seedQuery.isFetching ? (
{t('global.loading')}
- ) : displayWalletQuery.error ? ( + ) : seedQuery.error ? (
@@ -311,7 +284,7 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou

{t('settings.xpubs_modal.text_error_title')}

- {displayWalletQuery.error.message || t('global.errors.reason_unknown')} + {seedQuery.error.message || t('global.errors.reason_unknown')}

@@ -320,7 +293,7 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou
{accountXpubs.map((account) => ( - + {account.accountName} @@ -331,81 +304,35 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou
- {/* External addresses xpub */} - {account.externalXpub && ( -
-
- -
-
- - {account.externalXpub} - - -
+ {/* Account-level xpub */} +
+
+
- )} - - {/* Internal addresses xpub */} - {account.internalXpub && ( -
-
- -
-
- - {account.internalXpub} - - -
+
+ + {account.xpub} + +
- )} - - {!account.externalXpub && !account.internalXpub && ( -

{t('settings.xpubs_modal.text_no_xpubs')}

- )} +
@@ -419,7 +346,7 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou )} {/* Info message about xpubs */} - {!displayWalletQuery.isFetching && !displayWalletQuery.error && accountXpubs.length > 0 && ( + {!seedQuery.isFetching && !seedQuery.error && accountXpubs.length > 0 && (

{t('settings.xpubs_modal.text_info')}

diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 706b2067..b3147f91 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -269,18 +269,14 @@ "text_button_submitting": "Verifying..." }, "title": "Account Extended Public Keys", - "subtitle": "Extended public keys (xpubs) for your wallet accounts. Use these to monitor addresses without spending ability.", + "subtitle": "Account-level extended public keys (xpubs/zpubs) for watch-only wallet import. These are BIP84 account keys, not address-level keys.", "label_account": "Account", - "label_external_addresses": "External addresses (receiving)", - "label_internal_addresses": "Internal addresses (change)", "text_error_title": "Error while retrieving account information", - "text_no_xpubs": "No extended public keys available for this account.", "text_no_accounts": "No accounts found.", "text_copied": "Copied to clipboard!", "text_copy_failed": "Failed to copy to clipboard.", - "text_info": "Extended public keys (xpubs) can be used to generate receiving addresses and monitor your wallet balance without the ability to spend. Share them with caution. They reveal all addresses in the derivation path.", - "aria_copy_external": "Copy external address xpub for Account {{account}}", - "aria_copy_internal": "Copy internal address xpub for Account {{account}}" + "text_info": "Account-level extended public keys can be imported into watch-only wallets for monitoring. They reveal all addresses in the derivation path. Share them with caution.", + "aria_copy_external": "Copy xpub for {{account}}" }, "documentation": "Documentation", "github": "GitHub", diff --git a/src/lib/bip32.ts b/src/lib/bip32.ts new file mode 100644 index 00000000..33aa4201 --- /dev/null +++ b/src/lib/bip32.ts @@ -0,0 +1,79 @@ +import { mnemonicToSeedSync } from '@scure/bip39' +import { HDKey } from '@scure/bip32' + +/** + * Derive account-level xpub from mnemonic phrase + * JoinMarket uses BIP84 (Native SegWit) with paths: + * - Mainnet: m/84'/0'/account' + * - Testnet: m/84'/1'/account' + * + * @param mnemonic - BIP39 mnemonic phrase (12 or 24 words) + * @param account - Account index (default: 0) + * @param network - 'mainnet' or 'testnet' (default: 'mainnet') + * @returns Extended public key (xpub for mainnet, tpub for testnet) + */ +export function deriveAccountXpub(mnemonic: string, account: number = 0, network: 'mainnet' | 'testnet' = 'mainnet'): string { + // Convert mnemonic to seed + const seed = mnemonicToSeedSync(mnemonic) + + // Create HD key from seed + const root = HDKey.fromMasterSeed(seed) + + // Derive account level key: m/84'/coin_type'/account' + const coinType = network === 'mainnet' ? 0 : 1 + const path = `m/84'/${coinType}'/${account}'` + const accountKey = root.derive(path) + + if (!accountKey.publicExtendedKey) { + throw new Error(`Failed to derive extended public key for path ${path}`) + } + + return accountKey.publicExtendedKey +} + +/** + * Derive xpubs for multiple accounts + * + * @param mnemonic - BIP39 mnemonic phrase + * @param accountCount - Number of accounts to derive (default: 5, JoinMarket's default mixdepths) + * @param network - 'mainnet' or 'testnet' + * @returns Array of xpubs, one for each account + */ +export function deriveAccountXpubs(mnemonic: string, accountCount: number = 5, network: 'mainnet' | 'testnet' = 'mainnet'): string[] { + const xpubs: string[] = [] + + for (let i = 0; i < accountCount; i++) { + xpubs.push(deriveAccountXpub(mnemonic, i, network)) + } + + return xpubs +} + +/** + * Detect network type from wallet name or xpub prefix + * JoinMarket testnet wallets typically use regtest for development + * + * @param walletFileName - Wallet file name + * @param xpubSample - Optional sample xpub to detect from prefix + * @returns 'mainnet' or 'testnet' + */ +export function detectNetwork(walletFileName: string, xpubSample?: string): 'mainnet' | 'testnet' { + // Check xpub prefix if provided + if (xpubSample) { + if (xpubSample.startsWith('tpub') || xpubSample.startsWith('vpub')) { + return 'testnet' + } + if (xpubSample.startsWith('xpub') || xpubSample.startsWith('zpub')) { + return 'mainnet' + } + } + + // Check wallet filename for testnet/regtest indicators + const lowerName = walletFileName.toLowerCase() + if (lowerName.includes('testnet') || lowerName.includes('regtest') || lowerName.includes('test')) { + return 'testnet' + } + + // Default to mainnet + return 'mainnet' +}