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.
This commit is contained in:
kunal-595 2026-01-16 21:53:05 +05:30
parent ace8464e61
commit aa4e4a4a09
5 changed files with 222 additions and 183 deletions

89
package-lock.json generated
View file

@ -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"
}

View file

@ -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",

View file

@ -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<AccountXpubInfo[]> {
async function deriveAccountXpubsFromSeed(
seedPhrase: string,
walletFileName: string,
accountCount: number = 5,
): Promise<AccountXpubInfo[]> {
// 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
</DialogHeader>
<div className="space-y-4">
{displayWalletQuery.isFetching ? (
{seedQuery.isFetching ? (
<div className="text-muted-foreground flex items-center justify-center gap-1 py-8">
<Loader2Icon className="h-4 w-4 animate-spin motion-reduce:hidden" />
{t('global.loading')}
</div>
) : displayWalletQuery.error ? (
) : seedQuery.error ? (
<div className="light:border-red-800 light:bg-red-50 rounded-lg border border-red-200 bg-red-900/20 p-2">
<div className="flex items-start gap-2">
<div className="light:text-red-800 text-sm text-red-200">
@ -311,7 +284,7 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou
<p className="text-md font-medium">{t('settings.xpubs_modal.text_error_title')}</p>
</div>
<p className="p-1 text-xs">
{displayWalletQuery.error.message || t('global.errors.reason_unknown')}
{seedQuery.error.message || t('global.errors.reason_unknown')}
</p>
</div>
</div>
@ -320,7 +293,7 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou
<div className="max-h-[400px] overflow-y-auto">
<Accordion type="single" collapsible className="w-full">
{accountXpubs.map((account) => (
<AccordionItem key={account.accountIndex} value={account.accountIndex}>
<AccordionItem key={account.accountIndex} value={String(account.accountIndex)}>
<AccordionTrigger className="text-sm">
<span className="flex items-center gap-2">
<span className="font-medium">{account.accountName}</span>
@ -331,81 +304,35 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou
</AccordionTrigger>
<AccordionContent>
<div className="space-y-3">
{/* External addresses xpub */}
{account.externalXpub && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-muted-foreground text-xs">
{t('settings.xpubs_modal.label_external_addresses')}
{account.externalPath && (
<span className="text-muted-foreground/70 ml-2 font-mono">
{account.externalPath}
</span>
)}
</Label>
</div>
<div className="bg-muted flex items-center gap-2 rounded-md p-2">
<code className="flex-1 overflow-hidden font-mono text-xs break-all text-ellipsis">
{account.externalXpub}
</code>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => copyToClipboard(account.externalXpub!)}
aria-label={t('settings.xpubs_modal.aria_copy_external', {
account: account.accountName,
})}
>
{copiedXpub === account.externalXpub ? (
<CheckIcon className="h-3 w-3 text-green-500" />
) : (
<CopyIcon className="h-3 w-3" />
)}
</Button>
</div>
{/* Account-level xpub */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-muted-foreground text-xs">
Extended Public Key
<span className="text-muted-foreground/70 ml-2 font-mono">{account.path}</span>
</Label>
</div>
)}
{/* Internal addresses xpub */}
{account.internalXpub && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-muted-foreground text-xs">
{t('settings.xpubs_modal.label_internal_addresses')}
{account.internalPath && (
<span className="text-muted-foreground/70 ml-2 font-mono">
{account.internalPath}
</span>
)}
</Label>
</div>
<div className="bg-muted flex items-center gap-2 rounded-md p-2">
<code className="flex-1 overflow-hidden font-mono text-xs break-all text-ellipsis">
{account.internalXpub}
</code>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => copyToClipboard(account.internalXpub!)}
aria-label={t('settings.xpubs_modal.aria_copy_internal', {
account: account.accountName,
})}
>
{copiedXpub === account.internalXpub ? (
<CheckIcon className="h-3 w-3 text-green-500" />
) : (
<CopyIcon className="h-3 w-3" />
)}
</Button>
</div>
<div className="bg-muted flex items-center gap-2 rounded-md p-2">
<code className="flex-1 overflow-hidden font-mono text-xs break-all text-ellipsis">
{account.xpub}
</code>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => copyToClipboard(account.xpub)}
aria-label={t('settings.xpubs_modal.aria_copy_external', {
account: account.accountName,
})}
>
{copiedXpub === account.xpub ? (
<CheckIcon className="h-3 w-3 text-green-500" />
) : (
<CopyIcon className="h-3 w-3" />
)}
</Button>
</div>
)}
{!account.externalXpub && !account.internalXpub && (
<p className="text-muted-foreground text-sm">{t('settings.xpubs_modal.text_no_xpubs')}</p>
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
@ -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 && (
<div className="light:border-blue-800 light:bg-blue-50 rounded-lg border border-blue-200 bg-blue-900/20 p-2">
<p className="light:text-blue-800 text-xs text-blue-200">{t('settings.xpubs_modal.text_info')}</p>
</div>

View file

@ -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",

79
src/lib/bip32.ts Normal file
View file

@ -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'
}