mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-16 13:01:05 +02:00
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:
parent
ace8464e61
commit
aa4e4a4a09
5 changed files with 222 additions and 183 deletions
89
package-lock.json
generated
89
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
79
src/lib/bip32.ts
Normal 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'
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue