From d6441cc4fea1ce56b0e5aa151b15fd3e64aa7454 Mon Sep 17 00:00:00 2001 From: kunal-595 Date: Sun, 21 Dec 2025 11:04:33 +0530 Subject: [PATCH 01/23] feat(setting): added show account-xpubs --- .../settings/AccountXpubsDialog.tsx | 438 ++++++++++++++++++ src/components/settings/Settings.tsx | 11 + src/i18n/locales/en/translation.json | 24 + src/lib/xpub.ts | 24 + 4 files changed, 497 insertions(+) create mode 100644 src/components/settings/AccountXpubsDialog.tsx create mode 100644 src/lib/xpub.ts diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx new file mode 100644 index 000000000..3968cc076 --- /dev/null +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -0,0 +1,438 @@ +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 { useQuery, useQueryClient } from '@tanstack/react-query' +import { cx } from 'class-variance-authority' +import { EyeIcon, EyeOffIcon, AlertTriangleIcon, ClockIcon, Loader2Icon, CopyIcon, CheckIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { useStore } from 'zustand' +import { jarTemplates } from '@/components/layout/display-mode-context' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +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 { authStore } from '@/store/authStore' + +interface AccountXpubInfo { + accountIndex: string + accountName: string + externalXpub: string | null + externalPath: string | null + internalXpub: string | null + internalPath: string | null +} + +/** + * Parse wallet display response to extract xpub information for each account + */ +async function parseAccountXpubs(walletDisplay: WalletDisplayResponse): Promise { + 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 + + for (const branch of account.branches || []) { + const branchStr = branch.branch || '' + const rawXpub = extractXpubFromBranch(branchStr) + const path = extractDerivationPath(branchStr) + + if (rawXpub) { + // Convert tpub/xpub to vpub/zpub for BIP84 display + const convertedXpub = await toNativeSegwitPub(rawXpub) + + if (branchStr.toLowerCase().includes('external')) { + externalXpub = convertedXpub + externalPath = path + } else if (branchStr.toLowerCase().includes('internal')) { + internalXpub = convertedXpub + internalPath = path + } + } + } + + accounts.push({ + accountIndex, + accountName, + externalXpub, + externalPath, + internalXpub, + internalPath, + }) + } + + return accounts +} + +interface AccountXpubsDialogProps { + walletFileName: string + open: boolean + onOpenChange: (open: boolean) => void +} + +export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: AccountXpubsDialogProps) => { + const { t } = useTranslation() + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [passwordVerifiedAt, setPasswordVerifiedAt] = useState() + const isPasswordVerified = useMemo(() => passwordVerifiedAt !== undefined, [passwordVerifiedAt]) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState() + const [timeLeft, setTimeLeft] = useState(JAM_SEED_MODAL_TIMEOUT) + const secondsLeft = useMemo(() => Math.max(0, Math.round(timeLeft / 1_000)), [timeLeft]) + const [accountXpubs, setAccountXpubs] = useState([]) + const [copiedXpub, setCopiedXpub] = useState(null) + + const client = useApiClient() + const authState = useStore(authStore, (state) => state.state) + const queryClient = useQueryClient() + + const displayWalletQuery = useQuery({ + ...displaywalletOptions({ + client, + path: { walletname: walletFileName }, + }), + staleTime: 1, + gcTime: 1, + enabled: false, + retry: false, + }) + + const displayWalletRefetch = useMemo(() => displayWalletQuery.refetch, [displayWalletQuery.refetch]) + + // Fetch wallet display data immediately after password verification + useEffect(() => { + if (open && isPasswordVerified) { + displayWalletRefetch() + } + }, [open, isPasswordVerified, displayWalletRefetch]) + + // Parse xpub data when wallet display data is available + useEffect(() => { + if (displayWalletQuery.data) { + parseAccountXpubs(displayWalletQuery.data).then(setAccountXpubs) + } + }, [displayWalletQuery.data]) + + useEffect(() => { + if (passwordVerifiedAt === undefined) { + setTimeLeft(0) + return + } + setTimeLeft(JAM_SEED_MODAL_TIMEOUT) + + const xpubsDisplayedAt = Math.max(displayWalletQuery.dataUpdatedAt, passwordVerifiedAt) + const interval = setInterval(() => { + setTimeLeft(Math.max(0, xpubsDisplayedAt + JAM_SEED_MODAL_TIMEOUT - Date.now())) + }, 333) + + return () => { + clearInterval(interval) + } + }, [displayWalletQuery.dataUpdatedAt, passwordVerifiedAt]) + + useEffect(() => { + if (timeLeft <= 0) { + setPassword('') + setPasswordVerifiedAt(undefined) + setError(undefined) + setAccountXpubs([]) + } + }, [timeLeft]) + + const copyToClipboard = useCallback( + async (text: string) => { + try { + await navigator.clipboard.writeText(text) + setCopiedXpub(text) + toast.success(t('settings.xpubs_modal.text_copied')) + setTimeout(() => setCopiedXpub(null), 2000) + } catch { + toast.error(t('settings.xpubs_modal.text_copy_failed')) + } + }, + [t], + ) + + const handlePasswordSubmit = async () => { + if (!password) return + if (walletFileName !== authState?.walletFileName) { + setError('Session error. Please login again.') + return + } + + if (!authState?.hashed_password) { + setError('Password verification unavailable. Please login again.') + return + } + + setIsSubmitting(true) + setTimeout(() => { + try { + const hashed = hashPassword(password, walletFileName) + if (hashed === authState?.hashed_password) { + setPasswordVerifiedAt(Date.now()) + setError(undefined) + } else { + setError(t('settings.xpubs_modal.verification.text_error_password_incorrect')) + } + } catch (error) { + setError(t('settings.xpubs_modal.verification.text_error')) + console.error('Password verification error:', error) + } finally { + setIsSubmitting(false) + } + }, 4) + } + + const handleClose = () => { + setPassword('') + setPasswordVerifiedAt(undefined) + setError(undefined) + setShowPassword(false) + setTimeLeft(JAM_SEED_MODAL_TIMEOUT) + setAccountXpubs([]) + setCopiedXpub(null) + // Clear the cached query data to ensure fresh fetch on next open + queryClient.removeQueries({ + queryKey: displaywalletOptions({ client, path: { walletname: walletFileName } }).queryKey, + }) + onOpenChange(false) + } + + const handleKeyDown = async (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && password && !isPasswordVerified) { + await handlePasswordSubmit() + } + } + + return ( + + + {!isPasswordVerified ? ( + <> + + + + {t('settings.xpubs_modal.verification.title')} + + {t('settings.xpubs_modal.verification.subtitle')} + + +
+
+ +
+ setPassword(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t('settings.xpubs_modal.verification.placeholder_password')} + className={error ? 'border-destructive' : ''} + /> + +
+ {error &&

{error}

} +
+
+ + + + + + + ) : ( + <> + + + {t('settings.xpubs_modal.title')} + + {t('settings.xpubs_modal.subtitle')} + + +
+ {displayWalletQuery.isFetching ? ( +
+ + {t('global.loading')} +
+ ) : displayWalletQuery.error ? ( +
+
+
+
+ +

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

+
+

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

+
+
+
+ ) : accountXpubs.length > 0 ? ( +
+ + {accountXpubs.map((account) => ( + + + + {account.accountName} + + ({t('settings.xpubs_modal.label_account')} {account.accountIndex}) + + + + +
+ {/* External addresses xpub */} + {account.externalXpub && ( +
+
+ +
+
+ + {account.externalXpub} + + +
+
+ )} + + {/* Internal addresses xpub */} + {account.internalXpub && ( +
+
+ +
+
+ + {account.internalXpub} + + +
+
+ )} + + {!account.externalXpub && !account.internalXpub && ( +

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

+ )} +
+
+
+ ))} +
+
+ ) : ( +
+ {t('settings.xpubs_modal.text_no_accounts')} +
+ )} + + {/* Info message about xpubs */} + {!displayWalletQuery.isFetching && !displayWalletQuery.error && accountXpubs.length > 0 && ( +
+

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

+
+ )} +
+ + +
+
+ + + {secondsLeft}s + +
+ +
+
+ + )} +
+
+ ) +} diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index 4046cb8d0..a04df114a 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -15,6 +15,7 @@ import { BookIcon, ExternalLinkIcon, TerminalIcon, + KeyRoundIcon, } from 'lucide-react' import { useTheme } from 'next-themes' import { useTranslation } from 'react-i18next' @@ -32,6 +33,7 @@ import { useFeatures } from '@/hooks/useFeatures' import type { WalletFileName } from '@/lib/utils' import { authStore } from '@/store/authStore' import { jamSettingsStore } from '@/store/jamSettingsStore' +import { AccountXpubsDialog } from './AccountXpubsDialog' import { FeeLimitDialog } from './FeeLimitDialog' import { LanguageSelector } from './LanguageSelector' import { SeedPhraseDialog } from './SeedPhraseDialog' @@ -48,6 +50,7 @@ export const SettingsPage = ({ walletFileName }: SettingPageProps) => { const jamSettings = useStore(jamSettingsStore) const [showSeedDialog, setShowSeedDialog] = useState(false) + const [showXpubsDialog, setShowXpubsDialog] = useState(false) const [showFeeLimitDialog, setShowFeeLimitDialog] = useState(false) const navigate = useNavigate() const client = useApiClient() @@ -144,6 +147,13 @@ export const SettingsPage = ({ walletFileName }: SettingPageProps) => { disabled={hashedPassword === undefined} /> + setShowXpubsDialog(true)} + disabled={hashedPassword === undefined} + /> + { )} + ) diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 500f524b9..c028dafa6 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -228,6 +228,7 @@ "text_help_translate": "Missing your language? Help us out!", "show_seed": "Show seed phrase", "hide_seed": "Hide seed phrase", + "show_xpubs": "Show account xpubs", "reveal_seed": "Reveal seed phrase", "show_logs": "Show logs", "show_fee_config": "Adjust fee limits", @@ -253,6 +254,29 @@ "text_warning_title": "Important Security Warning", "text_warning_message": "Never share your seed phrase! Anyone with access to these words can control your wallet and will steal all of your funds." }, + "xpubs_modal": { + "verification": { + "title": "Verify Password", + "subtitle": "Please enter your wallet password to view account xpubs.", + "label_password": "Password", + "placeholder_password": "Enter your password", + "text_error_password_incorrect": "Incorrect password. Please try again.", + "text_error": "Error while verifying given password.", + "text_button_submit": "Verify", + "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.", + "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 with caution - they reveal all addresses in the derivation path." + }, "documentation": "Documentation", "github": "GitHub", "matrix": "Matrix", diff --git a/src/lib/xpub.ts b/src/lib/xpub.ts new file mode 100644 index 000000000..75617565e --- /dev/null +++ b/src/lib/xpub.ts @@ -0,0 +1,24 @@ +/** + * Extract xpub/tpub from a branch string + * Example: "m/84'/1'/0'/0 tpubDCXYZ..." -> "tpubDCXYZ..." + */ +export function extractXpubFromBranch(branchStr: string): string | null { + const match = branchStr.match(/([xtyvz]pub[a-zA-Z0-9]+)/) + return match ? match[1] : null +} + +/** + * Extract derivation path from a branch string + * Example: "m/84'/1'/0'/0 tpubDCXYZ..." -> "m/84'/1'/0'/0" + */ +export function extractDerivationPath(branchStr: string): string | null { + const match = branchStr.match(/(m\/[\d'/]+)/) + return match ? match[1] : null +} + +/** + * Convert xpub/tpub to native segwit format (zpub/vpub) for BIP84 + */ +export async function toNativeSegwitPub(xpub: string): Promise { + return xpub +} From c6a87bce183851965b9afa91fc9f7339a2bdcc52 Mon Sep 17 00:00:00 2001 From: kunal-595 Date: Sun, 21 Dec 2025 12:19:46 +0530 Subject: [PATCH 02/23] feat(setting):added show account-xpubs --- src/components/settings/Settings.tsx | 26 +++++-- src/i18n/locales/en/translation.json | 1 + src/lib/xpub.ts | 108 ++++++++++++++++++++++++++- src/store/jamSettingsStore.ts | 2 + 4 files changed, 129 insertions(+), 8 deletions(-) diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index a04df114a..46bad1e77 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -115,6 +115,14 @@ export const SettingsPage = ({ walletFileName }: SettingPageProps) => { /> + + jamSettings.update({ powerUserMode: checked })} + displayToggle={false} + /> @@ -146,13 +154,17 @@ export const SettingsPage = ({ walletFileName }: SettingPageProps) => { action={async () => setShowSeedDialog(true)} disabled={hashedPassword === undefined} /> - - setShowXpubsDialog(true)} - disabled={hashedPassword === undefined} - /> + {jamSettings.state.powerUserMode && ( + <> + + setShowXpubsDialog(true)} + disabled={hashedPassword === undefined} + /> + + )} >= 8 + } + + while (carry > 0) { + bytes.push(carry & 0xff) + carry >>= 8 + } + } + + // Add leading zeros + for (let i = 0; i < str.length && str[i] === '1'; i++) { + bytes.push(0) + } + + return new Uint8Array(bytes.reverse()) +} + +/** + * Encode bytes to base58check string + */ +function base58Encode(buffer: Uint8Array): string { + const digits = [0] + + for (let i = 0; i < buffer.length; i++) { + let carry = buffer[i] + for (let j = 0; j < digits.length; j++) { + carry += digits[j] << 8 + digits[j] = carry % 58 + carry = (carry / 58) | 0 + } + + while (carry > 0) { + digits.push(carry % 58) + carry = (carry / 58) | 0 + } + } + + // Add leading zeros + for (let i = 0; i < buffer.length && buffer[i] === 0; i++) { + digits.push(0) + } + + return digits + .reverse() + .map((d) => BASE58_ALPHABET[d]) + .join('') +} + /** * Convert xpub/tpub to native segwit format (zpub/vpub) for BIP84 + * Uses SLIP-0132 version bytes: + * - xpub (0x0488b21e) -> zpub (0x04b24746) for mainnet P2WPKH + * - tpub (0x043587cf) -> vpub (0x045f1cf6) for testnet P2WPKH */ export async function toNativeSegwitPub(xpub: string): Promise { - return xpub + try { + // Decode the extended public key + const decoded = base58Decode(xpub) + + if (decoded.length !== 82) { + // Invalid length, return original + return xpub + } + + // Extract version bytes (first 4 bytes) + const version = (decoded[0] << 24) | (decoded[1] << 16) | (decoded[2] << 8) | decoded[3] + + // SLIP-0132 version mapping + let newVersion: number + if (version === 0x0488b21e) { + // xpub -> zpub (mainnet) + newVersion = 0x04b24746 + } else if (version === 0x043587cf) { + // tpub -> vpub (testnet) + newVersion = 0x045f1cf6 + } else { + // Already in native segwit format or unknown, return original + return xpub + } + + // Create new buffer with updated version + const newDecoded = new Uint8Array(decoded) + newDecoded[0] = (newVersion >> 24) & 0xff + newDecoded[1] = (newVersion >> 16) & 0xff + newDecoded[2] = (newVersion >> 8) & 0xff + newDecoded[3] = newVersion & 0xff + + // Re-encode with new version + return base58Encode(newDecoded) + } catch (error) { + console.error('Error converting xpub to native segwit format:', error) + // If conversion fails, return the original xpub + return xpub + } } diff --git a/src/store/jamSettingsStore.ts b/src/store/jamSettingsStore.ts index ada46f6b5..08a0dfa50 100644 --- a/src/store/jamSettingsStore.ts +++ b/src/store/jamSettingsStore.ts @@ -7,6 +7,7 @@ export type JamSettings = { developerMode: boolean privateMode: boolean currencyUnit: Currency + powerUserMode: boolean } interface JamSettingsStoreState { @@ -19,6 +20,7 @@ const initial: JamSettings = { developerMode: isDevMode(), privateMode: false, currencyUnit: 'sats', + powerUserMode: false, } export const jamSettingsStore = createStore()( From ace8464e61f204a46ccbe2b2b4614bfb6a067a62 Mon Sep 17 00:00:00 2001 From: kunal-595 Date: Thu, 1 Jan 2026 03:01:51 +0530 Subject: [PATCH 03/23] fix: address code review feedback - improve validation, accessibility, and translations --- .../settings/AccountXpubsDialog.tsx | 71 ++++++++++++------- src/components/settings/Settings.tsx | 1 - src/i18n/locales/en/translation.json | 6 +- src/lib/xpub.ts | 10 ++- 4 files changed, 57 insertions(+), 31 deletions(-) diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 3968cc076..659a948d6 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -51,25 +51,36 @@ async function parseAccountXpubs(walletDisplay: WalletDisplayResponse): Promise< 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) { - // Convert tpub/xpub to vpub/zpub for BIP84 display - const convertedXpub = await toNativeSegwitPub(rawXpub) - - if (branchStr.toLowerCase().includes('external')) { - externalXpub = convertedXpub - externalPath = path - } else if (branchStr.toLowerCase().includes('internal')) { - internalXpub = convertedXpub - internalPath = path - } + 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 + } + }) + accounts.push({ accountIndex, accountName, @@ -156,6 +167,8 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou setPasswordVerifiedAt(undefined) setError(undefined) setAccountXpubs([]) + setShowPassword(false) + setCopiedXpub(null) } }, [timeLeft]) @@ -176,32 +189,30 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou const handlePasswordSubmit = async () => { if (!password) return if (walletFileName !== authState?.walletFileName) { - setError('Session error. Please login again.') + setError(t('settings.xpubs_modal.verification.text_session_error')) return } if (!authState?.hashed_password) { - setError('Password verification unavailable. Please login again.') + setError(t('settings.xpubs_modal.verification.text_unavailable')) return } setIsSubmitting(true) - setTimeout(() => { - try { - const hashed = hashPassword(password, walletFileName) - if (hashed === authState?.hashed_password) { - setPasswordVerifiedAt(Date.now()) - setError(undefined) - } else { - setError(t('settings.xpubs_modal.verification.text_error_password_incorrect')) - } - } catch (error) { - setError(t('settings.xpubs_modal.verification.text_error')) - console.error('Password verification error:', error) - } finally { - setIsSubmitting(false) + try { + const hashed = hashPassword(password, walletFileName) + if (hashed === authState?.hashed_password) { + setPasswordVerifiedAt(Date.now()) + setError(undefined) + } else { + setError(t('settings.xpubs_modal.verification.text_error_password_incorrect')) } - }, 4) + } catch (error) { + setError(t('settings.xpubs_modal.verification.text_error')) + console.error('Password verification error:', error) + } finally { + setIsSubmitting(false) + } } const handleClose = () => { @@ -342,6 +353,9 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou 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 ? ( @@ -375,6 +389,9 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou 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 ? ( diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index 46bad1e77..7adff7187 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -121,7 +121,6 @@ export const SettingsPage = ({ walletFileName }: SettingPageProps) => { title={t('settings.power_user_mode')} checked={jamSettings.state.powerUserMode} onCheckedChange={(checked) => jamSettings.update({ powerUserMode: checked })} - displayToggle={false} /> diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 3bc8ade11..706b20670 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -263,6 +263,8 @@ "placeholder_password": "Enter your password", "text_error_password_incorrect": "Incorrect password. Please try again.", "text_error": "Error while verifying given password.", + "text_session_error": "Session error. Please login again.", + "text_unavailable": "Password verification unavailable. Please login again.", "text_button_submit": "Verify", "text_button_submitting": "Verifying..." }, @@ -276,7 +278,9 @@ "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 with caution - they reveal all addresses in the derivation path." + "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}}" }, "documentation": "Documentation", "github": "GitHub", diff --git a/src/lib/xpub.ts b/src/lib/xpub.ts index df2e35822..884d27ea8 100644 --- a/src/lib/xpub.ts +++ b/src/lib/xpub.ts @@ -1,10 +1,16 @@ /** * Extract xpub/tpub from a branch string * Example: "m/84'/1'/0'/0 tpubDCXYZ..." -> "tpubDCXYZ..." + * Matches full extended public keys (xpub/ypub/zpub/tpub/vpub) of exactly 111 base58 characters */ export function extractXpubFromBranch(branchStr: string): string | null { - const match = branchStr.match(/([xtyvz]pub[a-zA-Z0-9]+)/) - return match ? match[1] : null + // Match full extended public keys (xpub/ypub/zpub/tpub/vpub) of exactly 111 base58 characters + const match = branchStr.match(/\b([xtyvz]pub[1-9A-HJ-NP-Za-km-z]{107})\b/) + if (!match) return null + + const xpub = match[1] + // Defensive check in case the regex is modified in the future + return xpub.length === 111 ? xpub : null } /** From aa4e4a4a09511e06540f00e14665aff3071bc591 Mon Sep 17 00:00:00 2001 From: kunal-595 Date: Fri, 16 Jan 2026 21:53:05 +0530 Subject: [PATCH 04/23] 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 892791af8..3a9befc76 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 ab545b71b..7576b8a3b 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 659a948d6..c58f1c883 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 706b20670..b3147f914 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 000000000..33aa42014 --- /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' +} From 3b838554710d1c58f0eecc2973ef9c32e398413f Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sat, 17 Jan 2026 20:41:31 +0100 Subject: [PATCH 05/23] chore: pin scure deps --- package-lock.json | 40 +++++++++++++++++++++++++++++----------- package.json | 4 ++-- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index b0152f936..e01bd6fb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,8 +25,8 @@ "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tooltip": "1.2.8", - "@scure/bip32": "^2.0.1", - "@scure/bip39": "^2.0.1", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", "@tailwindcss/vite": "4.1.18", "@tanstack/match-sorter-utils": "8.19.4", "@tanstack/react-query": "5.90.18", @@ -126,6 +126,7 @@ "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", @@ -3673,8 +3674,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3798,6 +3798,7 @@ "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3808,6 +3809,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3864,6 +3866,7 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -4115,6 +4118,7 @@ "integrity": "sha512-OWN4ZgOIV2+T9cR4qfoajtjZDFoxcLa6qUpgDkviXZFUNkZ7XTVKvL/16X+gz5dtpqdZwXf3m0qIj72Ge/vytw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/mocker": "4.0.9", "@vitest/utils": "4.0.9", @@ -4138,6 +4142,7 @@ "integrity": "sha512-ayr0vCxvJIvodzfUTVzifFMT3bmcMeKzEWoPt7mtgrZsqJhMbYaftifuBZRQeF/glogsVr+jhtIePHw6g+0YRQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/browser": "4.0.9", "@vitest/mocker": "4.0.9", @@ -4449,6 +4454,7 @@ "integrity": "sha512-aF77tsXdEvIJRkj9uJZnHtovsVIx22Ambft9HudC+XuG/on1NY/bf5dlDti1N35eJT+QZLb4RF/5dTIG18s98w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.9", "pathe": "^2.0.3" @@ -4566,6 +4572,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4782,6 +4789,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", @@ -5160,6 +5168,7 @@ "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -5170,6 +5179,7 @@ "integrity": "sha512-uLnoLeIW4XaoFtH37qEcg/SXMJmKF4vi7V0H2rnPueg+VEtFGA/asSCNTcq4M/GQ6QmlzchAEtOoDTtKqWeHag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "meow": "^13.0.0" }, @@ -5365,8 +5375,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -5439,6 +5448,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5502,6 +5512,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6985,7 +6996,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7572,6 +7582,7 @@ "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7667,7 +7678,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7683,7 +7693,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -7729,6 +7738,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7770,6 +7780,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7826,8 +7837,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -8077,6 +8087,7 @@ "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" }, @@ -8343,6 +8354,7 @@ "integrity": "sha512-pKP5jXJYM4OjvNklGuHKO53wOCAwfx79KvZyOWHoi9zXUH5WVMFUe/ZfWyxXG/GTcj0maRgHGUjq/0I43r0dDQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.0", @@ -8652,6 +8664,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8792,6 +8805,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8993,6 +9007,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -9539,6 +9554,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9552,6 +9568,7 @@ "integrity": "sha512-E0Ja2AX4th+CG33yAFRC+d1wFx2pzU5r6HtG6LiPSE04flaE0qB6YyjSw9ZcpJAtVPfsvZGtJlKWZpuW7EHRxg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.9", "@vitest/mocker": "4.0.9", @@ -10130,6 +10147,7 @@ "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 f7271f908..3dc1c9597 100644 --- a/package.json +++ b/package.json @@ -60,8 +60,8 @@ "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tooltip": "1.2.8", - "@scure/bip32": "^2.0.1", - "@scure/bip39": "^2.0.1", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", "@tailwindcss/vite": "4.1.18", "@tanstack/match-sorter-utils": "8.19.4", "@tanstack/react-query": "5.90.18", From 51a94557fd8c6102e3e99d16c766c95eaeae783d Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sat, 17 Jan 2026 20:53:58 +0100 Subject: [PATCH 06/23] chore: fix imports after v2 merge --- .../settings/AccountXpubsDialog.tsx | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 2235abe49..47c629edb 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -1,12 +1,10 @@ import { useState, useEffect, useMemo, useCallback } from 'react' 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' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useStore } from 'zustand' -import { jarTemplates } from '@/components/layout/display-mode-context' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' import { Button } from '@/components/ui/button' import { @@ -20,14 +18,17 @@ import { import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { JAM_SEED_MODAL_TIMEOUT } from '@/constants/jam' +import { useJars, type Jar } from '@/context/JamWalletInfoContext' import { useApiClient } from '@/hooks/useApiClient' -import { deriveAccountXpubs, detectNetwork } from '@/lib/bip32' +import { deriveAccountXpub, detectNetwork } from '@/lib/bip32' import { hashPassword } from '@/lib/hash' +import { cn } from '@/lib/utils' import { toNativeSegwitPub } from '@/lib/xpub' import { authStore } from '@/store/authStore' +import type { JarIndex } from '@/types/global' interface AccountXpubInfo { - accountIndex: number + accountIndex: JarIndex accountName: string xpub: string path: string @@ -41,24 +42,27 @@ interface AccountXpubInfo { async function deriveAccountXpubsFromSeed( seedPhrase: string, walletFileName: string, - accountCount: number = 5, + jars: Jar[], ): 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) + const jarsWithXpub = jars.map((jar) => ({ + jar, + xpub: deriveAccountXpub(seedPhrase, jar.jarIndex, network), + })) // Convert to native segwit format (zpub/vpub) and build account info const accounts: AccountXpubInfo[] = [] - for (let i = 0; i < rawXpubs.length; i++) { - const convertedXpub = await toNativeSegwitPub(rawXpubs[i]) - const accountName = i < jarTemplates.length ? jarTemplates[i].name : `Account ${i}` + for (let i = 0; i < jarsWithXpub.length; i++) { + const jarWithXpub = jarsWithXpub[i] + const convertedXpub = await toNativeSegwitPub(jarWithXpub.xpub) accounts.push({ - accountIndex: i, - accountName, + accountIndex: jarWithXpub.jar.jarIndex, + accountName: jarWithXpub.jar.name, xpub: convertedXpub, path: `m/84'/${coinType}'/${i}'`, }) @@ -89,6 +93,7 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou const client = useApiClient() const authState = useStore(authStore, (state) => state.state) const queryClient = useQueryClient() + const { jars } = useJars() const seedQuery = useQuery({ ...getseedOptions({ @@ -112,10 +117,10 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou // Derive xpubs when seed data is available useEffect(() => { - if (seedQuery.data?.seedphrase) { - deriveAccountXpubsFromSeed(seedQuery.data.seedphrase, walletFileName).then(setAccountXpubs) + if (seedQuery.data?.seedphrase !== undefined) { + deriveAccountXpubsFromSeed(seedQuery.data.seedphrase, walletFileName, jars).then(setAccountXpubs) } - }, [seedQuery.data, walletFileName]) + }, [seedQuery.data, walletFileName, jars]) useEffect(() => { if (passwordVerifiedAt === undefined) { @@ -354,13 +359,13 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou
From 0556b483f9e21e1fed0bcc9660e0730970e0cf4c Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sat, 17 Jan 2026 21:06:45 +0100 Subject: [PATCH 07/23] chore: remove power-user mode --- src/components/settings/SettingsPage.tsx | 29 ++++++++---------------- src/store/jamSettingsStore.ts | 2 -- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index d17d6bbd2..9d82ee897 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -6,7 +6,6 @@ import { SunIcon, MoonIcon, DollarSignIcon, - KeyIcon, FileTextIcon, BookIcon, ExternalLinkIcon, @@ -15,6 +14,7 @@ import { PackageSearchIcon, ArrowLeftRightIcon, LockKeyholeIcon, + BookKeyIcon, } from 'lucide-react' import { useTheme } from 'next-themes' import { useTranslation } from 'react-i18next' @@ -102,13 +102,6 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) /> - - jamSettings.update({ powerUserMode: checked })} - /> @@ -135,22 +128,18 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) setShowSeedDialog(true)} disabled={hashedPassword === undefined} /> - {jamSettings.state.powerUserMode && ( - <> - - setShowXpubsDialog(true)} - disabled={hashedPassword === undefined} - /> - - )} + + setShowXpubsDialog(true)} + disabled={hashedPassword === undefined} + /> Date: Sat, 17 Jan 2026 21:18:32 +0100 Subject: [PATCH 08/23] chore: add dependency bitcoin-address-validation --- package-lock.json | 34 +++++++++++++++++++ package.json | 1 + .../settings/AccountXpubsDialog.tsx | 3 +- src/lib/bip32.ts | 19 +++++------ 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index e01bd6fb9..6555f6c43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@tanstack/match-sorter-utils": "8.19.4", "@tanstack/react-query": "5.90.18", "@tanstack/react-table": "8.21.3", + "bitcoin-address-validation": "3.0.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "i18next-browser-languagedetector": "8.2.0", @@ -4745,6 +4746,33 @@ "dev": true, "license": "MIT" }, + "node_modules/base58-js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/base58-js/-/base58-js-3.0.3.tgz", + "integrity": "sha512-3hf42BysHnUqmZO7mK6e5X/hs1AvyEJIhdVLbG/Mxn/fhFnhGxOO37mWbMHg1RT4TxqcPKXgqj9/bp1YG0GBXA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", + "license": "MIT" + }, + "node_modules/bitcoin-address-validation": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bitcoin-address-validation/-/bitcoin-address-validation-3.0.0.tgz", + "integrity": "sha512-R1X1c9EdgjgjTpjshjk5e16TbgF7HYasxBcu7l5ScWMxVs53845vMUg5PvnQ/R/3h8Grly6Y52DgH6/77gazLQ==", + "license": "MIT", + "dependencies": { + "base58-js": "^3.0.2", + "bech32": "^2.0.0", + "sha256-uint8array": "^0.10.3" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -8165,6 +8193,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/sha256-uint8array": { + "version": "0.10.7", + "resolved": "https://registry.npmjs.org/sha256-uint8array/-/sha256-uint8array-0.10.7.tgz", + "integrity": "sha512-1Q6JQU4tX9NqsDGodej6pkrUVQVNapLZnvkwIhddH/JqzBZF1fSaxSWNY6sziXBE8aEa2twtGkXUrwzGeZCMpQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 3dc1c9597..6792e1228 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@tanstack/match-sorter-utils": "8.19.4", "@tanstack/react-query": "5.90.18", "@tanstack/react-table": "8.21.3", + "bitcoin-address-validation": "3.0.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "i18next-browser-languagedetector": "8.2.0", diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 47c629edb..59ff8ef33 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react' import { getseedOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Network } from 'bitcoin-address-validation' import { EyeIcon, EyeOffIcon, AlertTriangleIcon, ClockIcon, Loader2Icon, CopyIcon, CheckIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -46,7 +47,7 @@ async function deriveAccountXpubsFromSeed( ): Promise { // Detect network from wallet name const network = detectNetwork(walletFileName) - const coinType = network === 'mainnet' ? 0 : 1 + const coinType = network === Network.mainnet ? 0 : 1 // Derive xpubs for all accounts const jarsWithXpub = jars.map((jar) => ({ diff --git a/src/lib/bip32.ts b/src/lib/bip32.ts index 6d618b76b..a4e04fca0 100644 --- a/src/lib/bip32.ts +++ b/src/lib/bip32.ts @@ -1,5 +1,6 @@ import { HDKey } from '@scure/bip32' import { mnemonicToSeedSync } from '@scure/bip39' +import { Network } from 'bitcoin-address-validation' /** * Derive account-level xpub from mnemonic phrase @@ -12,11 +13,7 @@ import { mnemonicToSeedSync } from '@scure/bip39' * @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 { +export function deriveAccountXpub(mnemonic: string, account: number = 0, network: Network = Network.mainnet): string { // Convert mnemonic to seed const seed = mnemonicToSeedSync(mnemonic) @@ -46,7 +43,7 @@ export function deriveAccountXpub( export function deriveAccountXpubs( mnemonic: string, accountCount: number = 5, - network: 'mainnet' | 'testnet' = 'mainnet', + network: Network = Network.mainnet, ): string[] { const xpubs: string[] = [] @@ -65,23 +62,23 @@ export function deriveAccountXpubs( * @param xpubSample - Optional sample xpub to detect from prefix * @returns 'mainnet' or 'testnet' */ -export function detectNetwork(walletFileName: string, xpubSample?: string): 'mainnet' | 'testnet' { +export function detectNetwork(walletFileName: string, xpubSample?: string): Network { // Check xpub prefix if provided if (xpubSample) { if (xpubSample.startsWith('tpub') || xpubSample.startsWith('vpub')) { - return 'testnet' + return Network.testnet } if (xpubSample.startsWith('xpub') || xpubSample.startsWith('zpub')) { - return 'mainnet' + return Network.mainnet } } // Check wallet filename for testnet/regtest indicators const lowerName = walletFileName.toLowerCase() if (lowerName.includes('testnet') || lowerName.includes('regtest') || lowerName.includes('test')) { - return 'testnet' + return Network.testnet } // Default to mainnet - return 'mainnet' + return Network.mainnet } From 634fb328be53929fcfdbdde05169f981ceb5d4ca Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sat, 17 Jan 2026 21:40:37 +0100 Subject: [PATCH 09/23] chore: derive seed once per mnemonic phrase --- .../settings/AccountXpubsDialog.tsx | 23 +++++------ src/lib/bip32.ts | 38 ++----------------- 2 files changed, 15 insertions(+), 46 deletions(-) diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 59ff8ef33..182c6dbbd 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo, useCallback } from 'react' import { getseedOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query' +import { mnemonicToSeed } from '@scure/bip39' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Network } from 'bitcoin-address-validation' import { EyeIcon, EyeOffIcon, AlertTriangleIcon, ClockIcon, Loader2Icon, CopyIcon, CheckIcon } from 'lucide-react' @@ -28,6 +29,8 @@ import { toNativeSegwitPub } from '@/lib/xpub' import { authStore } from '@/store/authStore' import type { JarIndex } from '@/types/global' +const HD_PATH_PURPOSE: number = 84 + interface AccountXpubInfo { accountIndex: JarIndex accountName: string @@ -49,23 +52,21 @@ async function deriveAccountXpubsFromSeed( const network = detectNetwork(walletFileName) const coinType = network === Network.mainnet ? 0 : 1 - // Derive xpubs for all accounts - const jarsWithXpub = jars.map((jar) => ({ - jar, - xpub: deriveAccountXpub(seedPhrase, jar.jarIndex, network), - })) + const seed = await mnemonicToSeed(seedPhrase) // Convert to native segwit format (zpub/vpub) and build account info const accounts: AccountXpubInfo[] = [] - for (let i = 0; i < jarsWithXpub.length; i++) { - const jarWithXpub = jarsWithXpub[i] - const convertedXpub = await toNativeSegwitPub(jarWithXpub.xpub) + for (let i = 0; i < jars.length; i++) { + const jar = jars[i] + const path = `m/${HD_PATH_PURPOSE}'/${coinType}'/${jar.jarIndex}'` + const xpub = deriveAccountXpub(seed, path) + const convertedXpub = await toNativeSegwitPub(xpub) accounts.push({ - accountIndex: jarWithXpub.jar.jarIndex, - accountName: jarWithXpub.jar.name, + accountIndex: jar.jarIndex, + accountName: jar.name, xpub: convertedXpub, - path: `m/84'/${coinType}'/${i}'`, + path, }) } diff --git a/src/lib/bip32.ts b/src/lib/bip32.ts index a4e04fca0..773b98652 100644 --- a/src/lib/bip32.ts +++ b/src/lib/bip32.ts @@ -1,5 +1,4 @@ import { HDKey } from '@scure/bip32' -import { mnemonicToSeedSync } from '@scure/bip39' import { Network } from 'bitcoin-address-validation' /** @@ -8,21 +7,12 @@ import { Network } from 'bitcoin-address-validation' * - 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') + * @param seed BIP39 mnemonic phrase (12 or 24 words) + * @param path HD key path (m / purpose' / coin_type' / account' / change / address_index), e.g. `m/84'/0'/0'` * @returns Extended public key (xpub for mainnet, tpub for testnet) */ -export function deriveAccountXpub(mnemonic: string, account: number = 0, network: Network = Network.mainnet): string { - // Convert mnemonic to seed - const seed = mnemonicToSeedSync(mnemonic) - - // Create HD key from seed +export function deriveAccountXpub(seed: Uint8Array, path: string): string { 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) { @@ -32,28 +22,6 @@ export function deriveAccountXpub(mnemonic: string, account: number = 0, network 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: Network = Network.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 From 7c2f2502d4a9e3161822bd0d99bdd2dafd86ed93 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sat, 17 Jan 2026 22:11:46 +0100 Subject: [PATCH 10/23] chore: support multiple xpubs per account --- .../settings/AccountXpubsDialog.tsx | 84 +++++++++++++------ 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 182c6dbbd..56644c277 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -31,11 +31,18 @@ import type { JarIndex } from '@/types/global' const HD_PATH_PURPOSE: number = 84 +type Xpub = { + name: string + network: Network + path: string + xpub: string +} + interface AccountXpubInfo { accountIndex: JarIndex accountName: string - xpub: string path: string + xpubs: Xpub[] } /** @@ -45,11 +52,9 @@ interface AccountXpubInfo { */ async function deriveAccountXpubsFromSeed( seedPhrase: string, - walletFileName: string, + network: Network, jars: Jar[], ): Promise { - // Detect network from wallet name - const network = detectNetwork(walletFileName) const coinType = network === Network.mainnet ? 0 : 1 const seed = await mnemonicToSeed(seedPhrase) @@ -58,15 +63,34 @@ async function deriveAccountXpubsFromSeed( const accounts: AccountXpubInfo[] = [] for (let i = 0; i < jars.length; i++) { const jar = jars[i] + const path = `m/${HD_PATH_PURPOSE}'/${coinType}'/${jar.jarIndex}'` const xpub = deriveAccountXpub(seed, path) - const convertedXpub = await toNativeSegwitPub(xpub) + + const xpubs = [] + + if (HD_PATH_PURPOSE !== 84) { + xpubs.push({ + name: 'xpub', + path, + network, + xpub: xpub, + }) + } else { + const nativeSegwitXpub = await toNativeSegwitPub(xpub) + xpubs.push({ + name: 'zpub', + path, + network, + xpub: nativeSegwitXpub, + }) + } accounts.push({ accountIndex: jar.jarIndex, accountName: jar.name, - xpub: convertedXpub, path, + xpubs: xpubs, }) } @@ -120,7 +144,9 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou // Derive xpubs when seed data is available useEffect(() => { if (seedQuery.data?.seedphrase !== undefined) { - deriveAccountXpubsFromSeed(seedQuery.data.seedphrase, walletFileName, jars).then(setAccountXpubs) + // Detect network from wallet name + const network = detectNetwork(walletFileName) + deriveAccountXpubsFromSeed(seedQuery.data.seedphrase, network, jars).then(setAccountXpubs) } }, [seedQuery.data, walletFileName, jars]) @@ -317,26 +343,30 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou {account.path}
-
- - {account.xpub} - - -
+ {account.xpubs.map((xpub, index) => ( + <> +
+ + {xpub.xpub} + + +
+ + ))}
From ffa76bfb71589177453f78ce08e7c7c3e4125d9c Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sat, 17 Jan 2026 22:12:39 +0100 Subject: [PATCH 11/23] chore: add dependency base58-js --- package-lock.json | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/package-lock.json b/package-lock.json index 6555f6c43..b0d5d5c83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@tanstack/match-sorter-utils": "8.19.4", "@tanstack/react-query": "5.90.18", "@tanstack/react-table": "8.21.3", + "base58-js": "3.0.3", "bitcoin-address-validation": "3.0.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", diff --git a/package.json b/package.json index 6792e1228..e7adc9435 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@tanstack/match-sorter-utils": "8.19.4", "@tanstack/react-query": "5.90.18", "@tanstack/react-table": "8.21.3", + "base58-js": "3.0.3", "bitcoin-address-validation": "3.0.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", From 95ea6f566da205f30920fcdff2aa35306775ab33 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 18 Jan 2026 10:55:34 +0100 Subject: [PATCH 12/23] chore: use base58 of scure lib --- package-lock.json | 1 - package.json | 1 - .../settings/AccountXpubsDialog.tsx | 5 +- ...ummary.test.tsx => balanceSummary.test.ts} | 0 src/lib/bip32.ts | 17 +-- src/lib/utils.ts | 5 +- src/lib/xpub.ts | 136 ------------------ src/lib/xpubs.test.ts | 103 +++++++++++++ src/lib/xpubs.ts | 54 +++++++ src/types/global.d.ts | 2 + 10 files changed, 172 insertions(+), 152 deletions(-) rename src/lib/{balanceSummary.test.tsx => balanceSummary.test.ts} (100%) delete mode 100644 src/lib/xpub.ts create mode 100644 src/lib/xpubs.test.ts create mode 100644 src/lib/xpubs.ts diff --git a/package-lock.json b/package-lock.json index b0d5d5c83..6555f6c43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,6 @@ "@tanstack/match-sorter-utils": "8.19.4", "@tanstack/react-query": "5.90.18", "@tanstack/react-table": "8.21.3", - "base58-js": "3.0.3", "bitcoin-address-validation": "3.0.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", diff --git a/package.json b/package.json index e7adc9435..6792e1228 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,6 @@ "@tanstack/match-sorter-utils": "8.19.4", "@tanstack/react-query": "5.90.18", "@tanstack/react-table": "8.21.3", - "base58-js": "3.0.3", "bitcoin-address-validation": "3.0.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 56644c277..3809a6eb3 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -25,7 +25,7 @@ import { useApiClient } from '@/hooks/useApiClient' import { deriveAccountXpub, detectNetwork } from '@/lib/bip32' import { hashPassword } from '@/lib/hash' import { cn } from '@/lib/utils' -import { toNativeSegwitPub } from '@/lib/xpub' +import { convertExtendedPublicKey } from '@/lib/xpubs' import { authStore } from '@/store/authStore' import type { JarIndex } from '@/types/global' @@ -77,12 +77,11 @@ async function deriveAccountXpubsFromSeed( xpub: xpub, }) } else { - const nativeSegwitXpub = await toNativeSegwitPub(xpub) xpubs.push({ name: 'zpub', path, network, - xpub: nativeSegwitXpub, + xpub: convertExtendedPublicKey(xpub, 'zpub'), }) } diff --git a/src/lib/balanceSummary.test.tsx b/src/lib/balanceSummary.test.ts similarity index 100% rename from src/lib/balanceSummary.test.tsx rename to src/lib/balanceSummary.test.ts diff --git a/src/lib/bip32.ts b/src/lib/bip32.ts index 773b98652..d326df91f 100644 --- a/src/lib/bip32.ts +++ b/src/lib/bip32.ts @@ -2,24 +2,21 @@ import { HDKey } from '@scure/bip32' import { Network } from 'bitcoin-address-validation' /** - * Derive account-level xpub from mnemonic phrase - * JoinMarket uses BIP84 (Native SegWit) with paths: - * - Mainnet: m/84'/0'/account' - * - Testnet: m/84'/1'/account' + * Derive account-level xpub from seed * - * @param seed BIP39 mnemonic phrase (12 or 24 words) + * @param seed BIP32 seed * @param path HD key path (m / purpose' / coin_type' / account' / change / address_index), e.g. `m/84'/0'/0'` - * @returns Extended public key (xpub for mainnet, tpub for testnet) + * @returns Extended public key (xpub) */ export function deriveAccountXpub(seed: Uint8Array, path: string): string { const root = HDKey.fromMasterSeed(seed) - const accountKey = root.derive(path) + const key = root.derive(path) - if (!accountKey.publicExtendedKey) { - throw new Error(`Failed to derive extended public key for path ${path}`) + if (!key.publicExtendedKey) { + throw new Error(`Failed to derive extended public key for path ${path}.`) } - return accountKey.publicExtendedKey + return key.publicExtendedKey } /** diff --git a/src/lib/utils.ts b/src/lib/utils.ts index efc53a246..1e48b317e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,7 +1,7 @@ import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' import type { OfferType } from '@/constants/jm' -import type { Milliseconds } from '@/types/global' +import type { Milliseconds, SeedPhrase } from '@/types/global' const HORIZONTAL_ELLIPSIS = '\u2026' // Horizontal Ellipsis `…` @@ -82,6 +82,9 @@ export const btcToSats = (value: string) => Math.round(parseFloat(value) * 100_0 export const SEGWIT_ACTIVATION_BLOCK = 481_824 // https://github.com/bitcoin/bitcoin/blob/v25.0/src/kernel/chainparams.cpp#L86 +export const DUMMY_SEED_PHRASE: SeedPhrase = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'.split(' ') + export const percentageToFactor = (val: number, precision = 6) => { return Number((val / 100).toFixed(precision)) } diff --git a/src/lib/xpub.ts b/src/lib/xpub.ts deleted file mode 100644 index 884d27ea8..000000000 --- a/src/lib/xpub.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Extract xpub/tpub from a branch string - * Example: "m/84'/1'/0'/0 tpubDCXYZ..." -> "tpubDCXYZ..." - * Matches full extended public keys (xpub/ypub/zpub/tpub/vpub) of exactly 111 base58 characters - */ -export function extractXpubFromBranch(branchStr: string): string | null { - // Match full extended public keys (xpub/ypub/zpub/tpub/vpub) of exactly 111 base58 characters - const match = branchStr.match(/\b([xtyvz]pub[1-9A-HJ-NP-Za-km-z]{107})\b/) - if (!match) return null - - const xpub = match[1] - // Defensive check in case the regex is modified in the future - return xpub.length === 111 ? xpub : null -} - -/** - * Extract derivation path from a branch string - * Example: "m/84'/1'/0'/0 tpubDCXYZ..." -> "m/84'/1'/0'/0" - */ -export function extractDerivationPath(branchStr: string): string | null { - const match = branchStr.match(/(m\/[\d'/]+)/) - return match ? match[1] : null -} - -/** - * Base58 alphabet for Bitcoin addresses - */ -const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' - -/** - * Decode base58check string to bytes - */ -function base58Decode(str: string): Uint8Array { - const bytes: number[] = [] - for (let i = 0; i < str.length; i++) { - let carry = BASE58_ALPHABET.indexOf(str[i]) - if (carry < 0) throw new Error('Invalid base58 character') - - for (let j = 0; j < bytes.length; j++) { - carry += bytes[j] * 58 - bytes[j] = carry & 0xff - carry >>= 8 - } - - while (carry > 0) { - bytes.push(carry & 0xff) - carry >>= 8 - } - } - - // Add leading zeros - for (let i = 0; i < str.length && str[i] === '1'; i++) { - bytes.push(0) - } - - return new Uint8Array(bytes.reverse()) -} - -/** - * Encode bytes to base58check string - */ -function base58Encode(buffer: Uint8Array): string { - const digits = [0] - - for (let i = 0; i < buffer.length; i++) { - let carry = buffer[i] - for (let j = 0; j < digits.length; j++) { - carry += digits[j] << 8 - digits[j] = carry % 58 - carry = (carry / 58) | 0 - } - - while (carry > 0) { - digits.push(carry % 58) - carry = (carry / 58) | 0 - } - } - - // Add leading zeros - for (let i = 0; i < buffer.length && buffer[i] === 0; i++) { - digits.push(0) - } - - return digits - .reverse() - .map((d) => BASE58_ALPHABET[d]) - .join('') -} - -/** - * Convert xpub/tpub to native segwit format (zpub/vpub) for BIP84 - * Uses SLIP-0132 version bytes: - * - xpub (0x0488b21e) -> zpub (0x04b24746) for mainnet P2WPKH - * - tpub (0x043587cf) -> vpub (0x045f1cf6) for testnet P2WPKH - */ -export async function toNativeSegwitPub(xpub: string): Promise { - try { - // Decode the extended public key - const decoded = base58Decode(xpub) - - if (decoded.length !== 82) { - // Invalid length, return original - return xpub - } - - // Extract version bytes (first 4 bytes) - const version = (decoded[0] << 24) | (decoded[1] << 16) | (decoded[2] << 8) | decoded[3] - - // SLIP-0132 version mapping - let newVersion: number - if (version === 0x0488b21e) { - // xpub -> zpub (mainnet) - newVersion = 0x04b24746 - } else if (version === 0x043587cf) { - // tpub -> vpub (testnet) - newVersion = 0x045f1cf6 - } else { - // Already in native segwit format or unknown, return original - return xpub - } - - // Create new buffer with updated version - const newDecoded = new Uint8Array(decoded) - newDecoded[0] = (newVersion >> 24) & 0xff - newDecoded[1] = (newVersion >> 16) & 0xff - newDecoded[2] = (newVersion >> 8) & 0xff - newDecoded[3] = newVersion & 0xff - - // Re-encode with new version - return base58Encode(newDecoded) - } catch (error) { - console.error('Error converting xpub to native segwit format:', error) - // If conversion fails, return the original xpub - return xpub - } -} diff --git a/src/lib/xpubs.test.ts b/src/lib/xpubs.test.ts new file mode 100644 index 000000000..133cfa861 --- /dev/null +++ b/src/lib/xpubs.test.ts @@ -0,0 +1,103 @@ +import { HDKey } from '@scure/bip32' +import { mnemonicToSeedSync } from '@scure/bip39' +import { describe, it, expect } from 'vitest' +import { DUMMY_SEED_PHRASE } from './utils' +import { convertExtendedPublicKey } from './xpubs' + +// from https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vector-1 +const BIP32_TEST_VECTOR_1 = { + 0: 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + 1: 'xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw', +} + +describe('xpubs', () => { + it('convert xpub to other formats from BIP32 test vectors', () => { + const vector0 = BIP32_TEST_VECTOR_1[0] + const vector1 = BIP32_TEST_VECTOR_1[1] + + expect(convertExtendedPublicKey(vector0, 'xpub')).toBe( + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8', + ) + expect(convertExtendedPublicKey(vector0, 'ypub')).toBe( + 'ypub6QqdH2c5z7967BioGSfAWFHM1EHzHPBZK7wrND3ZpEWFtzmCqvsD1bgpaE6pSAPkiSKhkuWPCJV6mZTSNMd2tK8xYTcJ48585pZecmSUzWp', + ) + expect(convertExtendedPublicKey(vector0, 'zpub')).toBe( + 'zpub6jftahH18ngZxUuv6oSniLNrBCSSE1B4EEU59bwTCEt8x6aS6b2mdfLxbS4QS53g85SWWP6wexqeer516433gYpZQoJie2tcMYdJ1SYYYAL', + ) + expect(convertExtendedPublicKey(vector0, 'tpub')).toBe( + 'tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp', + ) + expect(convertExtendedPublicKey(vector0, 'upub')).toBe( + 'upub57Wa4MvRPNyAhzxKw1WfftuLKMiCWuDZefryEdU2JCzjgbWHqJCxXM4GVQGUSXn55srUm189Mf4uER1BVZxyhNQZ56pbiUoAzvK54VEYrWu', + ) + expect(convertExtendedPublicKey(vector0, 'vpub')).toBe( + 'vpub5SLqN2bLY4WeZJ9SmNJHsyzqVKreTXD4ZnPC22MugDNcjhKX5xNX9QiQWcE4SSRzVWyHWUihpKRT7hckDGNzVc69wSX2JPcfGeNiT5c2XZy', + ) + + expect(convertExtendedPublicKey(vector1, 'xpub')).toBe( + 'xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw', + ) + expect(convertExtendedPublicKey(vector1, 'ypub')).toBe( + 'ypub6T73GjuZ5NG5FnrWUCXoPHPTL3rLfTfZzjNkLJRgnRhYGH4PGAQJ8k3EMVfXBUJHiecGd93ovwZBjxRaKPMQxCbgk6QYyRyLbkhCvXJ8PtA', + ) + expect(convertExtendedPublicKey(vector1, 'zpub')).toBe( + 'zpub6mwJaQaUE3oZ763dJZKRbNUxW1znc5f4uqty7hKaAS5RKNscWpZrkohNNhd7BNxD8Hj5NceNPbujdF3935mRkSHHcS6yZLnpsUkrK1XoMLr', + ) + expect(convertExtendedPublicKey(vector1, 'tpub')).toBe( + 'tpubD8eQVK4Kdxg3gHrF62jGP7dKVCoYiEB8dFSpuTawkL5YxTus5j5pf83vaKnii4bc6v2NVEy81P2gYrJczYne3QNNwMTS53p5uzDyHvnw2jm', + ) + expect(convertExtendedPublicKey(vector1, 'upub')).toBe( + 'upub59mz45DtUe69rc638mPJYw1SeBGYtyhaLHHsCir9GQC23soUFXk3eVQgGfqBBqgc6693dEfa6J8zCoyKSbhMmFsHGjcrdnhPWrSdN8uUxKb', + ) + expect(convertExtendedPublicKey(vector1, 'vpub')).toBe( + 'vpub5UcFMjtodKddhuH9y8Avm26wp9Qzqbh5FPp5z7k2eQZu6ychWBucGZ4pHsnmBkLXVjFrNiG8YxVY66atAJ7NZVYt95KHDhWsnaWGkhF4DrT', + ) + }) + + it('convert xpub to other formats and back from BIP32 test vectors', () => { + const vector0 = BIP32_TEST_VECTOR_1[0] + + expect( + [vector0] + .map((it) => convertExtendedPublicKey(it, 'xpub')) + .map((it) => convertExtendedPublicKey(it, 'ypub')) + .map((it) => convertExtendedPublicKey(it, 'Ypub')) + .map((it) => convertExtendedPublicKey(it, 'zpub')) + .map((it) => convertExtendedPublicKey(it, 'Zpub')) + .map((it) => convertExtendedPublicKey(it, 'tpub')) + .map((it) => convertExtendedPublicKey(it, 'upub')) + .map((it) => convertExtendedPublicKey(it, 'Upub')) + .map((it) => convertExtendedPublicKey(it, 'vpub')) + .map((it) => convertExtendedPublicKey(it, 'Vpub')) + .map((it) => convertExtendedPublicKey(it, 'xpub'))[0], + ).toBe(vector0) + }) + + it('convert xpub to zpub from dummy seed phrase', () => { + const seed = mnemonicToSeedSync(DUMMY_SEED_PHRASE.join(' ')) + const root = HDKey.fromMasterSeed(seed) + const xpub32_0 = root.derive(`m/44'/0'/0'`).publicExtendedKey + const xpub84_0 = root.derive(`m/84'/0'/0'`).publicExtendedKey + const xpub84_4 = root.derive(`m/84'/0'/4'`).publicExtendedKey + + expect(xpub32_0, 'sanity check').toBe( + 'xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj', + ) + expect(xpub84_0, 'sanity check').toBe( + 'xpub6CatWdiZiodmUeTDp8LT5or8nmbKNcuyvz7WyksVFkKB4RHwCD3XyuvPEbvqAQY3rAPshWcMLoP2fMFMKHPJ4ZeZXYVUhLv1VMrjPC7PW6V', + ) + expect(xpub84_4, 'sanity check').toBe( + 'xpub6CatWdiZiodmeXswr13Gd5aNtNqr2UHCBEsCoL3eEFVaM7n8kY5kS4daaP83gWQncmzL3Wzt79mEiLix6XZs6XQmGcQNeQ4HcjfVTn9TuXE', + ) + + expect(convertExtendedPublicKey(xpub32_0, 'zpub')).toBe( + 'zpub6qUQGY8YyN3ZxYEgf8J6KCQBqQAbdSWaT9RK54L5FWTTh8na8NkCkZpYHnWt7zEwNhqd6p9Utq562cSZsqGqFE87NNsUKnyZeJ5KvbhfC8E', + ) + expect(convertExtendedPublicKey(xpub84_0, 'zpub')).toBe( + 'zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs', + ) + expect(convertExtendedPublicKey(xpub84_4, 'zpub')).toBe( + 'zpub6rFR7y4Q2AijM8GBWicX3FmPEK8juiGC1TueN7qQzGFLTKQbFrQsgBwrco3DgKidS4DwYUC12UULUux5XvPtgzmy1HoDpDhGABnnEyBQzsL', + ) + }) +}) diff --git a/src/lib/xpubs.ts b/src/lib/xpubs.ts new file mode 100644 index 000000000..ea341e4c6 --- /dev/null +++ b/src/lib/xpubs.ts @@ -0,0 +1,54 @@ +import { sha256 } from '@noble/hashes/sha2.js' +import { createBase58check } from '@scure/base' + +const base58check = createBase58check(sha256) + +const uint8ArrayfromHex = (hex: string) => { + const match = hex.match(/.{1,2}/g) + if (match === null) { + throw new Error('Cannot convert hex to Uint8Array: Invalid hex string.') + } + return Uint8Array.from(hex.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []) +} + +// version bytes for extended serialization of public and private keys. +// taken from https://github.com/satoshilabs/slips/blob/master/slip-0132.md +const XPUB_VERSION_BYTES = { + xpub: uint8ArrayfromHex('0488b21e'), + ypub: uint8ArrayfromHex('049d7cb2'), + Ypub: uint8ArrayfromHex('0295b43f'), + zpub: uint8ArrayfromHex('04b24746'), + Zpub: uint8ArrayfromHex('02aa7ed3'), + tpub: uint8ArrayfromHex('043587cf'), + upub: uint8ArrayfromHex('044a5262'), + Upub: uint8ArrayfromHex('024289ef'), + vpub: uint8ArrayfromHex('045f1cf6'), + Vpub: uint8ArrayfromHex('02575483'), +} + +export type XpubFormat = keyof typeof XPUB_VERSION_BYTES + +/* + * This function takes an extended public key (with any version bytes, it doesn't need to be an xpub) + * and converts it to an extended public key formatted with the desired version bytes + * @param xpub: an extended public key in base58 format. Example: xpub6CpihtY9HVc1jNJWCiXnRbpXm5BgVNKqZMsM4XqpDcQigJr6AHNwaForLZ3kkisDcRoaXSUms6DJNhxFtQGeZfWAQWCZQe1esNetx5Wqe4M + * @param targetFormat: a string representing the desired format; must exist in the XPUB_VERSION_BYTES mapping defined above. Example: Zpub + */ +export function convertExtendedPublicKey(xpub: string, targetFormat: XpubFormat) { + const versionBytes = XPUB_VERSION_BYTES[targetFormat] + if (!versionBytes) { + throw new Error('Invalid target format: Unknown version bytes.') + } + + try { + const decodedXpub = base58check.decode(xpub.trim()) + const decodedXpubWithoutVersion = decodedXpub.slice(versionBytes.length) + const merged = new Uint8Array(versionBytes.length + decodedXpubWithoutVersion.length) + merged.set(versionBytes) + merged.set(decodedXpubWithoutVersion, versionBytes.length) + return base58check.encode(merged) + } catch (error: unknown) { + const reason = error instanceof Error ? error.message : undefined + throw new Error(`Invalid extended public key: ${reason ?? 'Unknown reason.'}`) + } +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 152ba3cf0..04aa3c6e0 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -3,6 +3,8 @@ export type AmountSats = number export type BitcoinAddress = string export type JarIndex = number +export type SeedPhrase = string[] + export type Milliseconds = number export type Seconds = number export type Days = number From 1ae5c6957156979128984bdcdf173552f5579300 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 18 Jan 2026 11:41:36 +0100 Subject: [PATCH 13/23] chore: generalize seed phrase query --- .../settings/AccountXpubsDialog.tsx | 73 +++++++++---------- src/components/settings/SeedPhraseDialog.tsx | 12 +-- src/components/ui/jam/SeedPhraseGrid.tsx | 5 +- 3 files changed, 42 insertions(+), 48 deletions(-) diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 3809a6eb3..fddb37f14 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -27,7 +27,7 @@ import { hashPassword } from '@/lib/hash' import { cn } from '@/lib/utils' import { convertExtendedPublicKey } from '@/lib/xpubs' import { authStore } from '@/store/authStore' -import type { JarIndex } from '@/types/global' +import type { JarIndex, SeedPhrase } from '@/types/global' const HD_PATH_PURPOSE: number = 84 @@ -51,13 +51,13 @@ interface AccountXpubInfo { * not the child xpubs that the API incorrectly returns */ async function deriveAccountXpubsFromSeed( - seedPhrase: string, + seedPhrase: SeedPhrase, network: Network, jars: Jar[], ): Promise { const coinType = network === Network.mainnet ? 0 : 1 - const seed = await mnemonicToSeed(seedPhrase) + const seed = await mnemonicToSeed(seedPhrase.join(' ')) // Convert to native segwit format (zpub/vpub) and build account info const accounts: AccountXpubInfo[] = [] @@ -120,15 +120,18 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou const queryClient = useQueryClient() const { jars } = useJars() + const seedQueryOptions = getseedOptions({ + client, + path: { walletname: encodeURIComponent(walletFileName) }, + }) + const seedQuery = useQuery({ - ...getseedOptions({ - client, - path: { walletname: walletFileName }, - }), - staleTime: 1, - gcTime: 1, + ...seedQueryOptions, + staleTime: Infinity, + gcTime: Infinity, enabled: false, retry: false, + select: (data) => data.seedphrase.split(/\s+/) as SeedPhrase, }) const seedRefetch = useMemo(() => seedQuery.refetch, [seedQuery.refetch]) @@ -142,10 +145,10 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou // Derive xpubs when seed data is available useEffect(() => { - if (seedQuery.data?.seedphrase !== undefined) { + if (seedQuery.data !== undefined) { // Detect network from wallet name const network = detectNetwork(walletFileName) - deriveAccountXpubsFromSeed(seedQuery.data.seedphrase, network, jars).then(setAccountXpubs) + deriveAccountXpubsFromSeed(seedQuery.data, network, jars).then(setAccountXpubs) } }, [seedQuery.data, walletFileName, jars]) @@ -221,6 +224,7 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou } const handleClose = () => { + onOpenChange(false) setPassword('') setPasswordVerifiedAt(undefined) setError(undefined) @@ -229,10 +233,7 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou setAccountXpubs([]) setCopiedXpub(null) // Clear the cached query data to ensure fresh fetch on next open - queryClient.removeQueries({ - queryKey: getseedOptions({ client, path: { walletname: walletFileName } }).queryKey, - }) - onOpenChange(false) + queryClient.removeQueries({ queryKey: seedQueryOptions.queryKey }) } const handleKeyDown = async (e: React.KeyboardEvent) => { @@ -343,28 +344,26 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou
{account.xpubs.map((xpub, index) => ( - <> -
- - {xpub.xpub} - - -
- +
+ + {xpub.xpub} + + +
))}
diff --git a/src/components/settings/SeedPhraseDialog.tsx b/src/components/settings/SeedPhraseDialog.tsx index e22880da1..c8ccc6f97 100644 --- a/src/components/settings/SeedPhraseDialog.tsx +++ b/src/components/settings/SeedPhraseDialog.tsx @@ -22,7 +22,7 @@ import { useApiClient } from '@/hooks/useApiClient' import { hashPassword } from '@/lib/hash' import type { WalletFileName } from '@/lib/utils' import { authStore } from '@/store/authStore' -import type { WithRequiredProperty } from '@/types/global' +import type { SeedPhrase, WithRequiredProperty } from '@/types/global' import { SeedPhraseGrid } from '../ui/jam/SeedPhraseGrid' import { Switch } from '../ui/switch' @@ -61,7 +61,7 @@ export const SeedPhraseDialog = ({ walletFileName, open, onOpenChange }: SeedPhr gcTime: Infinity, enabled: false, retry: false, - select: (data) => data.seedphrase, + select: (data) => data.seedphrase.split(/\s+/) as SeedPhrase, }) useEffect(() => { @@ -133,13 +133,13 @@ export const SeedPhraseDialog = ({ walletFileName, open, onOpenChange }: SeedPhr } const handleClose = () => { + onOpenChange(false) setPassword('') setPasswordVerifiedAt(undefined) setError(undefined) setShowPassword(false) setTimeLeft(JAM_SEED_MODAL_TIMEOUT) setRevealSeed(false) - onOpenChange(false) queryClient.removeQueries({ queryKey: seedQueryOptions.queryKey }) } @@ -224,11 +224,7 @@ export const SeedPhraseDialog = ({ walletFileName, open, onOpenChange }: SeedPhr {t('global.loading')} ) : seedQuery.data ? ( - + ) : (
{t('settings.seed_modal.text_error_no_data')} diff --git a/src/components/ui/jam/SeedPhraseGrid.tsx b/src/components/ui/jam/SeedPhraseGrid.tsx index 8669d2055..e7fb97902 100644 --- a/src/components/ui/jam/SeedPhraseGrid.tsx +++ b/src/components/ui/jam/SeedPhraseGrid.tsx @@ -1,9 +1,8 @@ import { cn } from '@/lib/utils' - -type MnemonicPhrase = string[] +import type { SeedPhrase } from '@/types/global' interface SeedPhraseGridProps { - value: MnemonicPhrase + value: SeedPhrase className?: string blurred: boolean blurredText?: string From 967049275881f058df7062e3615a7181a57128d8 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 18 Jan 2026 12:42:32 +0100 Subject: [PATCH 14/23] chore: add CopyButton --- .../settings/AccountXpubsDialog.tsx | 45 ++---- src/components/ui/jam/CopyButton.tsx | 139 ++++++++++++++++++ 2 files changed, 154 insertions(+), 30 deletions(-) create mode 100644 src/components/ui/jam/CopyButton.tsx diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index fddb37f14..2623e0390 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from 'react' +import { useState, useEffect, useMemo } from 'react' import { getseedOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query' import { mnemonicToSeed } from '@scure/bip39' import { useQuery, useQueryClient } from '@tanstack/react-query' @@ -28,6 +28,8 @@ import { cn } from '@/lib/utils' import { convertExtendedPublicKey } from '@/lib/xpubs' import { authStore } from '@/store/authStore' import type { JarIndex, SeedPhrase } from '@/types/global' +import { buttonVariants } from '../ui/button-variants' +import { CopyButton } from '../ui/jam/CopyButton' const HD_PATH_PURPOSE: number = 84 @@ -113,7 +115,6 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou const [timeLeft, setTimeLeft] = useState(JAM_SEED_MODAL_TIMEOUT) const secondsLeft = useMemo(() => Math.max(0, Math.round(timeLeft / 1_000)), [timeLeft]) const [accountXpubs, setAccountXpubs] = useState([]) - const [copiedXpub, setCopiedXpub] = useState(null) const client = useApiClient() const authState = useStore(authStore, (state) => state.state) @@ -176,24 +177,9 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou setError(undefined) setAccountXpubs([]) setShowPassword(false) - setCopiedXpub(null) } }, [timeLeft]) - const copyToClipboard = useCallback( - async (text: string) => { - try { - await navigator.clipboard.writeText(text) - setCopiedXpub(text) - toast.success(t('settings.xpubs_modal.text_copied')) - setTimeout(() => setCopiedXpub(null), 2000) - } catch { - toast.error(t('settings.xpubs_modal.text_copy_failed')) - } - }, - [t], - ) - const handlePasswordSubmit = async () => { if (!password) return if (walletFileName !== authState?.walletFileName) { @@ -231,7 +217,6 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou setShowPassword(false) setTimeLeft(JAM_SEED_MODAL_TIMEOUT) setAccountXpubs([]) - setCopiedXpub(null) // Clear the cached query data to ensure fresh fetch on next open queryClient.removeQueries({ queryKey: seedQueryOptions.queryKey }) } @@ -348,21 +333,21 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou {xpub.xpub} - + />
))} diff --git a/src/components/ui/jam/CopyButton.tsx b/src/components/ui/jam/CopyButton.tsx new file mode 100644 index 000000000..dfa2e2f75 --- /dev/null +++ b/src/components/ui/jam/CopyButton.tsx @@ -0,0 +1,139 @@ +import { useState, useEffect, useRef, type PropsWithChildren, type ReactNode } from 'react' +import type { Milliseconds } from '@/types/global' + +const copyToClipboardFallback = ( + inputField: HTMLInputElement, + errorMessage = 'Cannot copy value to clipboard', +): Promise => + new Promise((resolve, reject) => { + inputField.select() + const success = document.execCommand && document.execCommand('copy') + inputField.blur() + if (success) { + resolve(success) + } else { + reject(new Error(errorMessage)) + } + }) + +const copyToClipboard = async ( + text: string, + fallbackInputField: HTMLInputElement, + errorMessage?: string, +): Promise => { + // The `navigator.clipboard` API might not be available, e.g. on sites served over HTTP. + if (!navigator.clipboard) { + return copyToClipboardFallback(fallbackInputField) + } + + try { + await navigator.clipboard.writeText(text) + return true + } catch (e) { + if (fallbackInputField) { + return copyToClipboardFallback(fallbackInputField, errorMessage) + } else { + throw e + } + } +} + +interface CopyableProps { + value: string + onSuccess?: () => void + onError?: (e: Error) => void + className?: string + disabled?: boolean +} + +function Copyable({ + value, + onSuccess, + onError, + className, + children, + disabled, + ...props +}: PropsWithChildren) { + const valueFallbackInputRef = useRef(null) + + return ( + <> + + + + ) +} + +interface CopyButtonProps extends CopyableProps { + text: ReactNode + successText?: ReactNode + successTextTimeout?: Milliseconds + disabled?: boolean +} + +export function CopyButton({ + value, + onSuccess, + onError, + text, + successText = text, + successTextTimeout = 1_500, + className, + disabled, + ...props +}: CopyButtonProps) { + const [showValueCopiedConfirmation, setShowValueCopiedConfirmation] = useState(false) + const [valueCopiedFlag, setValueCopiedFlag] = useState(0) + + useEffect(() => { + if (valueCopiedFlag < 1) return + + let timer = setTimeout(() => { + setShowValueCopiedConfirmation(true) + timer = setTimeout(() => { + setShowValueCopiedConfirmation(false) + }, successTextTimeout) + }, 4) + + return () => clearTimeout(timer) + }, [valueCopiedFlag, successTextTimeout]) + + return ( + { + setValueCopiedFlag((current) => current + 1) + if (onSuccess !== undefined) { + onSuccess() + } + }} + > +
+ {showValueCopiedConfirmation ? successText : text} +
+
+ ) +} From 764362202867456c348da5dd8938d9b214e93e07 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 18 Jan 2026 12:48:16 +0100 Subject: [PATCH 15/23] chore: do not unnecessarily refetch seed --- src/components/settings/AccountXpubsDialog.tsx | 9 +++------ src/i18n/locales/en/translation.json | 1 - 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 2623e0390..31431f47c 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -135,14 +135,11 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou select: (data) => data.seedphrase.split(/\s+/) as SeedPhrase, }) - const seedRefetch = useMemo(() => seedQuery.refetch, [seedQuery.refetch]) - - // Fetch seed phrase immediately after password verification useEffect(() => { - if (open && isPasswordVerified) { - seedRefetch() + if (open && isPasswordVerified && seedQuery.data === undefined) { + seedQuery.refetch() } - }, [open, isPasswordVerified, seedRefetch]) + }, [open, isPasswordVerified, seedQuery]) // Derive xpubs when seed data is available useEffect(() => { diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index b3147f914..8edaf2f1a 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -226,7 +226,6 @@ "use_light_theme": "Switch to light theme", "label_select_language": "Language", "text_help_translate": "Missing your language? Help us out!", - "power_user_mode": "Enable power user mode", "show_seed": "Show seed phrase", "hide_seed": "Hide seed phrase", "show_xpubs": "Show account xpubs", From 965c81c7dba0c39504f7c7568c434594d27726a4 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 18 Jan 2026 13:24:55 +0100 Subject: [PATCH 16/23] chore: use query for xpubs --- .../settings/AccountXpubsDialog.tsx | 51 ++++++++++++++----- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 31431f47c..0864f38bf 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -24,6 +24,7 @@ import { useJars, type Jar } from '@/context/JamWalletInfoContext' import { useApiClient } from '@/hooks/useApiClient' import { deriveAccountXpub, detectNetwork } from '@/lib/bip32' import { hashPassword } from '@/lib/hash' +import { withQueryDelay } from '@/lib/queryClient' import { cn } from '@/lib/utils' import { convertExtendedPublicKey } from '@/lib/xpubs' import { authStore } from '@/store/authStore' @@ -114,7 +115,7 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou const [error, setError] = useState() const [timeLeft, setTimeLeft] = useState(JAM_SEED_MODAL_TIMEOUT) const secondsLeft = useMemo(() => Math.max(0, Math.round(timeLeft / 1_000)), [timeLeft]) - const [accountXpubs, setAccountXpubs] = useState([]) + //const [accountXpubs, setAccountXpubs] = useState() const client = useApiClient() const authState = useStore(authStore, (state) => state.state) @@ -135,20 +136,41 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou select: (data) => data.seedphrase.split(/\s+/) as SeedPhrase, }) + const accountXpubsQueryKey = [walletFileName, 'xpubs'] + + const accountXpubs = useQuery({ + queryKey: accountXpubsQueryKey, + queryFn: withQueryDelay( + async () => { + if (!seedQuery.data) { + return undefined + } + // Detect network from wallet name + const network = detectNetwork(walletFileName) + return await deriveAccountXpubsFromSeed(seedQuery.data, network, jars) + }, + { + delayAfter: 210, + }, + ), + staleTime: Infinity, + gcTime: Infinity, + enabled: !!seedQuery.data, + }) + useEffect(() => { if (open && isPasswordVerified && seedQuery.data === undefined) { seedQuery.refetch() } }, [open, isPasswordVerified, seedQuery]) - // Derive xpubs when seed data is available - useEffect(() => { - if (seedQuery.data !== undefined) { + /*useEffect(() => { + if (accountXpubs === undefined && seedQuery.data !== undefined) { // Detect network from wallet name const network = detectNetwork(walletFileName) deriveAccountXpubsFromSeed(seedQuery.data, network, jars).then(setAccountXpubs) } - }, [seedQuery.data, walletFileName, jars]) + }, [accountXpubs, seedQuery.data, walletFileName, jars])*/ useEffect(() => { if (passwordVerifiedAt === undefined) { @@ -169,11 +191,10 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou useEffect(() => { if (timeLeft <= 0) { + setShowPassword(false) setPassword('') setPasswordVerifiedAt(undefined) setError(undefined) - setAccountXpubs([]) - setShowPassword(false) } }, [timeLeft]) @@ -208,14 +229,16 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou const handleClose = () => { onOpenChange(false) + setShowPassword(false) setPassword('') setPasswordVerifiedAt(undefined) setError(undefined) - setShowPassword(false) setTimeLeft(JAM_SEED_MODAL_TIMEOUT) - setAccountXpubs([]) - // Clear the cached query data to ensure fresh fetch on next open + + // Remove sensitive data from query cache on close: If a user verifies the + // password again without closing the dialog, no re-fetching takes place. queryClient.removeQueries({ queryKey: seedQueryOptions.queryKey }) + queryClient.removeQueries({ queryKey: accountXpubsQueryKey }) } const handleKeyDown = async (e: React.KeyboardEvent) => { @@ -302,11 +325,11 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou - ) : accountXpubs.length > 0 ? ( + ) : accountXpubs.data && accountXpubs.data.length > 0 ? (
- {accountXpubs.map((account) => ( - + {accountXpubs.data.map((account, index) => ( + {account.accountName} @@ -361,7 +384,7 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou )} {/* Info message about xpubs */} - {!seedQuery.isFetching && !seedQuery.error && accountXpubs.length > 0 && ( + {!seedQuery.isFetching && !seedQuery.error && accountXpubs.data && accountXpubs.data.length > 0 && (

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

From 9da8c1b6d8e2bdad302e63a1fd1797fd983ffe00 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 18 Jan 2026 13:55:10 +0100 Subject: [PATCH 17/23] chore: align seed and xpubs dialog --- .../settings/AccountXpubsDialog.tsx | 169 +++++++++--------- 1 file changed, 89 insertions(+), 80 deletions(-) diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 0864f38bf..d66e84c66 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -29,6 +29,7 @@ import { cn } from '@/lib/utils' import { convertExtendedPublicKey } from '@/lib/xpubs' import { authStore } from '@/store/authStore' import type { JarIndex, SeedPhrase } from '@/types/global' +import { Alert, AlertDescription, AlertTitle } from '../ui/alert' import { buttonVariants } from '../ui/button-variants' import { CopyButton } from '../ui/jam/CopyButton' @@ -99,6 +100,62 @@ async function deriveAccountXpubsFromSeed( return accounts } +interface AccountXpubsAccordionProps { + values: AccountXpubInfo[] +} + +const AccountXpubsAccordion = ({ values }: AccountXpubsAccordionProps) => { + const { t } = useTranslation() + return ( + + {values.map((account, index) => ( + + + + {account.accountName} + + ({t('settings.xpubs_modal.label_account')} {account.accountIndex}) + + + + +
+
+ + {account.xpubs.map((xpub, index) => ( +
+ + {xpub.xpub} + + } + successText={} + onSuccess={() => toast.success(t('settings.xpubs_modal.text_copied'))} + onError={() => toast.error(t('settings.xpubs_modal.text_copy_failed'))} + aria-label={t('settings.xpubs_modal.aria_copy_external', { + account: account.accountName, + })} + /> +
+ ))} +
+
+
+
+ ))} +
+ ) +} + interface AccountXpubsDialogProps { walletFileName: string open: boolean @@ -158,20 +215,14 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou enabled: !!seedQuery.data, }) + const isFetching = seedQuery.isFetching || accountXpubs.isFetching + useEffect(() => { if (open && isPasswordVerified && seedQuery.data === undefined) { seedQuery.refetch() } }, [open, isPasswordVerified, seedQuery]) - /*useEffect(() => { - if (accountXpubs === undefined && seedQuery.data !== undefined) { - // Detect network from wallet name - const network = detectNetwork(walletFileName) - deriveAccountXpubsFromSeed(seedQuery.data, network, jars).then(setAccountXpubs) - } - }, [accountXpubs, seedQuery.data, walletFileName, jars])*/ - useEffect(() => { if (passwordVerifiedAt === undefined) { setTimeLeft(0) @@ -308,83 +359,41 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou
- {seedQuery.isFetching ? ( -
- - {t('global.loading')} -
- ) : seedQuery.error ? ( -
-
-
-
- -

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

-
-

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

+ {!accountXpubs.error && ( +
+ {isFetching ? ( +
+ + {t('global.loading')}
-
-
- ) : accountXpubs.data && accountXpubs.data.length > 0 ? ( -
- - {accountXpubs.data.map((account, index) => ( - - - - {account.accountName} - - ({t('settings.xpubs_modal.label_account')} {account.accountIndex}) - - - - -
- {/* Account-level xpub */} -
-
- -
- {account.xpubs.map((xpub, index) => ( -
- - {xpub.xpub} - - } - successText={} - onSuccess={() => toast.success(t('settings.xpubs_modal.text_copied'))} - onError={() => toast.error(t('settings.xpubs_modal.text_copy_failed'))} - aria-label={t('settings.xpubs_modal.aria_copy_external', { - account: account.accountName, - })} - /> -
- ))} -
-
-
-
- ))} -
-
- ) : ( -
- {t('settings.xpubs_modal.text_no_accounts')} + ) : accountXpubs.data && accountXpubs.data.length > 0 ? ( +
+ +
+ ) : ( +
+ {t('settings.seed_modal.text_error_no_data')} +
+ )}
)} + {!seedQuery.isFetching && seedQuery.error && ( + + + {t('settings.seed_modal.text_error_title')} + {seedQuery.error.message || t('global.errors.reason_unknown')} + + )} + {!accountXpubs.isFetching && accountXpubs.error && ( + + + {t('settings.xpubs_modal.text_error_title')} + {accountXpubs.error.message || t('global.errors.reason_unknown')} + + )} {/* Info message about xpubs */} - {!seedQuery.isFetching && !seedQuery.error && accountXpubs.data && accountXpubs.data.length > 0 && ( + {!isFetching && !accountXpubs.error && accountXpubs.data && accountXpubs.data.length > 0 && (

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

From 9930050a79126310eb9a2b34e18037a0b0ae9e6d Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 18 Jan 2026 14:37:09 +0100 Subject: [PATCH 18/23] chore: reorder xpub dialog elements --- .../settings/AccountXpubsDialog.tsx | 36 ++++++++++++------- src/stories/Alert.stories.tsx | 28 +++++++++++++-- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index d66e84c66..498761dab 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -3,7 +3,16 @@ import { getseedOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/re import { mnemonicToSeed } from '@scure/bip39' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Network } from 'bitcoin-address-validation' -import { EyeIcon, EyeOffIcon, AlertTriangleIcon, ClockIcon, Loader2Icon, CopyIcon, CheckIcon } from 'lucide-react' +import { + EyeIcon, + EyeOffIcon, + AlertTriangleIcon, + ClockIcon, + Loader2Icon, + CopyIcon, + CheckIcon, + AlertCircleIcon, +} from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useStore } from 'zustand' @@ -110,9 +119,11 @@ const AccountXpubsAccordion = ({ values }: AccountXpubsAccordionProps) => { {values.map((account, index) => ( - + - {account.accountName} + + {account.accountName} + ({t('settings.xpubs_modal.label_account')} {account.accountIndex}) @@ -367,9 +378,15 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou {t('global.loading')}
) : accountXpubs.data && accountXpubs.data.length > 0 ? ( -
- -
+ <> + + + {t('settings.xpubs_modal.text_info')} + +
+ +
+ ) : (
{t('settings.seed_modal.text_error_no_data')} @@ -391,13 +408,6 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou {accountXpubs.error.message || t('global.errors.reason_unknown')} )} - - {/* Info message about xpubs */} - {!isFetching && !accountXpubs.error && accountXpubs.data && accountXpubs.data.length > 0 && ( -
-

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

-
- )}
diff --git a/src/stories/Alert.stories.tsx b/src/stories/Alert.stories.tsx index f136de7fe..7ec37cd96 100644 --- a/src/stories/Alert.stories.tsx +++ b/src/stories/Alert.stories.tsx @@ -29,6 +29,15 @@ export const Default: Story = { ), } +export const WithoutDescription: Story = { + render: () => ( + + + This Alert has a title and an icon. No description. + + ), +} + export const WithoutIcon: Story = { render: () => ( @@ -38,11 +47,26 @@ export const WithoutIcon: Story = { ), } -export const WithoutDescription: Story = { +export const WithoutTitle: Story = { render: () => ( - This Alert has a title and an icon. No description. + This alert doesn't have a title, just an icon and description. + + ), +} + +export const WithoutIconAndTitle: Story = { + render: () => ( + + This alert doesn't have a title or icon, just a description. + + ), +} +export const WithoutIconAndDescription: Story = { + render: () => ( + + Alert Title ), } From efc0e3d9f81403758c69cd1ab8616e8b65a8fd6e Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 18 Jan 2026 15:15:31 +0100 Subject: [PATCH 19/23] chore: adapt xpub dialog translations --- src/App.tsx | 4 +- src/components/SwitchWalletPage.tsx | 4 +- .../settings/AccountXpubsDialog.tsx | 115 +++++++++++------- src/components/ui/jam/CopyButton.tsx | 37 ++---- src/i18n/locales/en/translation.json | 13 +- 5 files changed, 90 insertions(+), 83 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 76dc82b41..6ded9f1b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -90,8 +90,8 @@ function App() { ) await doOnLogout(navigate) } catch (error: unknown) { - const errorMessage = (error instanceof Error ? (error.message ?? '') : '') || t('global.errors.reason_unknown') - toast.error(t('global.errors.error_reloading_wallet_failed', { reason: errorMessage })) + const reason = (error instanceof Error ? error.message : undefined) || t('global.errors.reason_unknown') + toast.error(t('global.errors.error_reloading_wallet_failed', { reason })) console.error('Failed to lock wallet:', error) } } diff --git a/src/components/SwitchWalletPage.tsx b/src/components/SwitchWalletPage.tsx index f62e5325f..4334c665d 100644 --- a/src/components/SwitchWalletPage.tsx +++ b/src/components/SwitchWalletPage.tsx @@ -90,8 +90,8 @@ const SwitchWalletPage = ({ walletFileName }: SwitchWalletPageProps) => { t('wallets.wallet_preview.alert_wallet_locked_successfully', { walletName: walletDisplayName(walletFileName) }), ) } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : t('global.errors.reason_unknown') - toast.error(/* TODO: i18n*/ `Failed to lock current wallet: ${errorMessage}`) + const reason = (error instanceof Error ? error.message : undefined) || t('global.errors.reason_unknown') + toast.error(/* TODO: i18n*/ `Failed to lock current wallet: ${reason}`) console.error('Failed to lock wallet:', error) } } diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 498761dab..45645b400 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -117,52 +117,74 @@ const AccountXpubsAccordion = ({ values }: AccountXpubsAccordionProps) => { const { t } = useTranslation() return ( - {values.map((account, index) => ( - - - - - {account.accountName} + {values.map((account, index) => { + const accountLabel = t('settings.xpubs_modal.label_account', { + accountIndex: account.accountIndex, + }) + return ( + + + + + {account.accountName} + + ({accountLabel}) - - ({t('settings.xpubs_modal.label_account')} {account.accountIndex}) - - - - -
-
- - {account.xpubs.map((xpub, index) => ( -
- - {xpub.xpub} - - } - successText={} - onSuccess={() => toast.success(t('settings.xpubs_modal.text_copied'))} - onError={() => toast.error(t('settings.xpubs_modal.text_copy_failed'))} - aria-label={t('settings.xpubs_modal.aria_copy_external', { - account: account.accountName, - })} - /> -
- ))} + + +
+
+ + {account.xpubs.map((xpub, index) => { + const accountNameAndLabel = `${account.accountName} (${accountLabel})` + return ( +
+ + {xpub.xpub} + + } + successText={} + title={t('settings.xpubs_modal.button_copy_title', { + account: accountNameAndLabel, + })} + aria-label={t('settings.xpubs_modal.button_copy_title', { + account: accountNameAndLabel, + })} + onSuccess={() => + toast.success( + t('settings.xpubs_modal.alert_success_account_xpub_copied_message', { + account: accountNameAndLabel, + }), + ) + } + onError={(e) => + toast.error( + t('global.errors.error_copy_to_clipboard_failed', { + reason: + (e instanceof Error ? e.message : undefined) || t('global.errors.reason_unknown'), + }), + ) + } + /> +
+ ) + })} +
-
- - - ))} + + + ) + })} ) } @@ -381,7 +403,8 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou <> - {t('settings.xpubs_modal.text_info')} + {t('settings.xpubs_modal.text_info_title')} + {t('settings.xpubs_modal.text_info_message')}
@@ -389,7 +412,7 @@ export const AccountXpubsDialog = ({ walletFileName, open, onOpenChange }: Accou ) : (
- {t('settings.seed_modal.text_error_no_data')} + {t('settings.xpubs_modal.text_error_no_data')}
)}
diff --git a/src/components/ui/jam/CopyButton.tsx b/src/components/ui/jam/CopyButton.tsx index dfa2e2f75..01eec8ed8 100644 --- a/src/components/ui/jam/CopyButton.tsx +++ b/src/components/ui/jam/CopyButton.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, type PropsWithChildren, type ReactNode } from 'react' +import { useState, useEffect, useRef, type PropsWithChildren, type ReactNode, type ComponentProps } from 'react' import type { Milliseconds } from '@/types/global' const copyToClipboardFallback = ( @@ -29,7 +29,7 @@ const copyToClipboard = async ( try { await navigator.clipboard.writeText(text) return true - } catch (e) { + } catch (e: unknown) { if (fallbackInputField) { return copyToClipboardFallback(fallbackInputField, errorMessage) } else { @@ -38,12 +38,10 @@ const copyToClipboard = async ( } } -interface CopyableProps { +interface CopyableProps extends Omit, 'onClick' | 'type'> { value: string onSuccess?: () => void - onError?: (e: Error) => void - className?: string - disabled?: boolean + onError?: (e: unknown) => void } function Copyable({ @@ -51,8 +49,8 @@ function Copyable({ onSuccess, onError, className, - children, disabled, + children, ...props }: PropsWithChildren) { const valueFallbackInputRef = useRef(null) @@ -62,8 +60,8 @@ function Copyable({
- {error &&

{error}

} + {passwordVerificationError &&

{passwordVerificationError}

}
diff --git a/src/components/settings/SeedPhraseDialog.tsx b/src/components/settings/SeedPhraseDialog.tsx index c8ccc6f97..a0d04909e 100644 --- a/src/components/settings/SeedPhraseDialog.tsx +++ b/src/components/settings/SeedPhraseDialog.tsx @@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { cx } from 'class-variance-authority' import { EyeIcon, EyeOffIcon, AlertTriangleIcon, ClockIcon, Loader2Icon } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { useStore } from 'zustand' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { @@ -21,7 +20,6 @@ import { JAM_SEED_MODAL_TIMEOUT } from '@/constants/jam' import { useApiClient } from '@/hooks/useApiClient' import { hashPassword } from '@/lib/hash' import type { WalletFileName } from '@/lib/utils' -import { authStore } from '@/store/authStore' import type { SeedPhrase, WithRequiredProperty } from '@/types/global' import { SeedPhraseGrid } from '../ui/jam/SeedPhraseGrid' import { Switch } from '../ui/switch' @@ -31,24 +29,24 @@ type SeedPhraseDialogProps = WithRequiredProperty< 'open' | 'onOpenChange' > & { walletFileName: WalletFileName + hashedPassword: string } // TODO: use react-hook-form and yup schema -export const SeedPhraseDialog = ({ walletFileName, open, onOpenChange }: SeedPhraseDialogProps) => { +export const SeedPhraseDialog = ({ open, onOpenChange, walletFileName, hashedPassword }: SeedPhraseDialogProps) => { const { t } = useTranslation() const [password, setPassword] = useState('') const [showPassword, setShowPassword] = useState(false) const [passwordVerifiedAt, setPasswordVerifiedAt] = useState() const isPasswordVerified = useMemo(() => passwordVerifiedAt !== undefined, [passwordVerifiedAt]) const [isSubmitting, setIsSubmitting] = useState(false) - const [error, setError] = useState() + const [passwordVerificationError, setPasswordVerificationError] = useState() const [timeLeft, setTimeLeft] = useState(JAM_SEED_MODAL_TIMEOUT) const secondsLeft = useMemo(() => Math.max(0, Math.round(timeLeft / 1_000)), [timeLeft]) const [revealSeed, setRevealSeed] = useState(false) const queryClient = useQueryClient() const client = useApiClient() - const authState = useStore(authStore, (state) => state.state) const seedQueryOptions = getseedOptions({ client, @@ -93,38 +91,26 @@ export const SeedPhraseDialog = ({ walletFileName, open, onOpenChange }: SeedPhr setPasswordVerifiedAt(undefined) setShowPassword(false) setRevealSeed(false) - setError(undefined) + setPasswordVerificationError(undefined) } }, [timeLeft]) const handlePasswordSubmit = async () => { if (!password) return - if (walletFileName !== authState?.walletFileName) { - // TODO: Needs translation? - setError('Session error. Please login again.') - return - } - - // Check if hash verification is available - if (!authState?.hashed_password) { - // TODO: Needs translation? - setError('Password verification unavailable. Please login again.') - return - } setIsSubmitting(true) setTimeout(() => { try { const hashed = hashPassword(password, walletFileName) - if (hashed === authState?.hashed_password) { + if (hashed === hashedPassword) { setPassword('') setPasswordVerifiedAt(Date.now()) - setError(undefined) + setPasswordVerificationError(undefined) } else { - setError(t('settings.seed_modal.verification.text_error_password_incorrect')) + setPasswordVerificationError(t('settings.seed_modal.verification.text_error_password_incorrect')) } } catch (error) { - setError(t('settings.seed_modal.verification.text_error')) + setPasswordVerificationError(t('settings.seed_modal.verification.text_error')) console.error('Password verification error:', error) } finally { setIsSubmitting(false) @@ -136,7 +122,7 @@ export const SeedPhraseDialog = ({ walletFileName, open, onOpenChange }: SeedPhr onOpenChange(false) setPassword('') setPasswordVerifiedAt(undefined) - setError(undefined) + setPasswordVerificationError(undefined) setShowPassword(false) setTimeLeft(JAM_SEED_MODAL_TIMEOUT) setRevealSeed(false) @@ -173,7 +159,7 @@ export const SeedPhraseDialog = ({ walletFileName, open, onOpenChange }: SeedPhr onChange={(e) => setPassword(e.target.value)} onKeyDown={handleKeyDown} placeholder={t('settings.seed_modal.verification.placeholder_password')} - className={error ? 'border-destructive' : undefined} + className={passwordVerificationError ? 'border-destructive' : undefined} />
- {error &&

{error}

} + {passwordVerificationError &&

{passwordVerificationError}

} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 9d82ee897..5ade75bc6 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -254,9 +254,23 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) )} - - + {hashedPassword && ( + <> + + + + )} ) } diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 4ea080378..9534d7598 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -263,8 +263,6 @@ "placeholder_password": "Enter your password", "text_error_password_incorrect": "Incorrect password. Please try again.", "text_error": "Error while verifying given password.", - "text_session_error": "Session error. Please login again.", - "text_unavailable": "Password verification unavailable. Please login again.", "text_button_submit": "Verify", "text_button_submitting": "Verifying..." }, From a7adfe6b4c6341917db526e221e5f2da67fef505 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 18 Jan 2026 16:10:20 +0100 Subject: [PATCH 22/23] chore: hash password async --- src/components/CreateWalletPage.tsx | 2 +- src/components/LoginPage.tsx | 2 +- .../settings/AccountXpubsDialog.tsx | 31 ++++++++++--------- src/components/settings/SeedPhraseDialog.tsx | 9 +++--- src/i18n/locales/en/translation.json | 4 +-- src/lib/hash.test.ts | 24 ++++++++++++++ src/lib/hash.ts | 14 ++++++--- 7 files changed, 60 insertions(+), 26 deletions(-) create mode 100644 src/lib/hash.test.ts diff --git a/src/components/CreateWalletPage.tsx b/src/components/CreateWalletPage.tsx index 6ed620fd7..489e70ff2 100644 --- a/src/components/CreateWalletPage.tsx +++ b/src/components/CreateWalletPage.tsx @@ -142,7 +142,7 @@ const CreateWalletPage = () => { let hashedPassword: string | undefined = undefined try { - hashedPassword = hashPassword(password, createData?.walletname) + hashedPassword = await hashPassword(password, createData?.walletname) } catch (hashError) { console.warn('Failed to hash password, continuing without hash verification:', hashError) } diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index 145370a7a..a4233afb2 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -185,7 +185,7 @@ const LoginPage = () => { let hashedPassword: string | undefined try { - hashedPassword = hashPassword(data.password, data.walletFileName) + hashedPassword = await hashPassword(data.password, data.walletFileName) } catch (hashError) { console.warn('Failed to hash password, continuing without hash verification:', hashError) } diff --git a/src/components/settings/AccountXpubsDialog.tsx b/src/components/settings/AccountXpubsDialog.tsx index 37eca7030..9b1682e97 100644 --- a/src/components/settings/AccountXpubsDialog.tsx +++ b/src/components/settings/AccountXpubsDialog.tsx @@ -281,24 +281,27 @@ export const AccountXpubsDialog = ({ open, onOpenChange, walletFileName, hashedP } }, [timeLeft]) - const handlePasswordSubmit = async () => { + const handlePasswordSubmit = () => { if (!password) return setIsSubmitting(true) - try { - const hashed = hashPassword(password, walletFileName) - if (hashed === hashedPassword) { - setPasswordVerifiedAt(Date.now()) - setPasswordVerificationError(undefined) - } else { - setPasswordVerificationError(t('settings.xpubs_modal.verification.text_error_password_incorrect')) + setTimeout(async () => { + try { + const hashed = await hashPassword(password, walletFileName) + if (hashed === hashedPassword) { + setPasswordVerifiedAt(Date.now()) + setPasswordVerificationError(undefined) + } else { + setPasswordVerificationError(t('settings.xpubs_modal.verification.text_error_password_incorrect')) + } + } catch (error) { + const reason = (error instanceof Error ? error.message : undefined) || t('global.errors.reason_unknown') + setPasswordVerificationError(t('settings.xpubs_modal.verification.text_error', { reason })) + console.error('Password verification error:', error) + } finally { + setIsSubmitting(false) } - } catch (error) { - setPasswordVerificationError(t('settings.xpubs_modal.verification.text_error')) - console.error('Password verification error:', error) - } finally { - setIsSubmitting(false) - } + }, 4) } const handleClose = () => { diff --git a/src/components/settings/SeedPhraseDialog.tsx b/src/components/settings/SeedPhraseDialog.tsx index a0d04909e..14db97159 100644 --- a/src/components/settings/SeedPhraseDialog.tsx +++ b/src/components/settings/SeedPhraseDialog.tsx @@ -95,13 +95,13 @@ export const SeedPhraseDialog = ({ open, onOpenChange, walletFileName, hashedPas } }, [timeLeft]) - const handlePasswordSubmit = async () => { + const handlePasswordSubmit = () => { if (!password) return setIsSubmitting(true) - setTimeout(() => { + setTimeout(async () => { try { - const hashed = hashPassword(password, walletFileName) + const hashed = await hashPassword(password, walletFileName) if (hashed === hashedPassword) { setPassword('') setPasswordVerifiedAt(Date.now()) @@ -110,7 +110,8 @@ export const SeedPhraseDialog = ({ open, onOpenChange, walletFileName, hashedPas setPasswordVerificationError(t('settings.seed_modal.verification.text_error_password_incorrect')) } } catch (error) { - setPasswordVerificationError(t('settings.seed_modal.verification.text_error')) + const reason = (error instanceof Error ? error.message : undefined) || t('global.errors.reason_unknown') + setPasswordVerificationError(t('settings.seed_modal.verification.text_error', { reason })) console.error('Password verification error:', error) } finally { setIsSubmitting(false) diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 9534d7598..4562f0ca9 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -244,7 +244,7 @@ "label_password": "Password", "placeholder_password": "Enter your password", "text_error_password_incorrect": "Incorrect password. Please try again.", - "text_error": "Error while verifying given password.", + "text_error": "Error while verifying given password. {{ reason }}", "text_button_submit": "Verify", "text_button_submitting": "Verifying..." }, @@ -262,7 +262,7 @@ "label_password": "Password", "placeholder_password": "Enter your password", "text_error_password_incorrect": "Incorrect password. Please try again.", - "text_error": "Error while verifying given password.", + "text_error": "Error while verifying given password. {{ reason }}", "text_button_submit": "Verify", "text_button_submitting": "Verifying..." }, diff --git a/src/lib/hash.test.ts b/src/lib/hash.test.ts new file mode 100644 index 000000000..62a46df76 --- /dev/null +++ b/src/lib/hash.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { DEFAULT_PBKDF_ITERATIONS, hashPassword } from './hash' + +describe('hash', () => { + it('DEFAULT_PBKDF_ITERATIONS', async () => { + expect(DEFAULT_PBKDF_ITERATIONS).toBe(210_000) + }) + + it('hashPassword', async () => { + expect(await hashPassword('password', 'salt', 1)).toBe( + '120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b', + ) + expect(await hashPassword('test', 'Satoshi.jmdat', 1)).toBe( + '6785848abc7bd4d99f0c39f6c731094bea9b1090c59b4009f466b455aa15a2c1', + ) + expect(await hashPassword('test', 'Satoshi.jmdat', 21)).toBe( + 'a89fbf06eeab7c4a147203cc69eb064c0221f9cf16c293af8dc7e382307b3774', + ) + + expect(await hashPassword('test', 'Satoshi.jmdat', DEFAULT_PBKDF_ITERATIONS)).toBe( + '60e35c0b8567402c1d8b804e986e3f2c0648f46f68171c8a3e7eb9e98bfb5d4d', + ) + }) +}) diff --git a/src/lib/hash.ts b/src/lib/hash.ts index 2ea18b8c9..2ef9d6dcb 100644 --- a/src/lib/hash.ts +++ b/src/lib/hash.ts @@ -1,20 +1,26 @@ -import { pbkdf2 } from '@noble/hashes/pbkdf2.js' +import { pbkdf2Async } from '@noble/hashes/pbkdf2.js' import { sha256 } from '@noble/hashes/sha2.js' +export const DEFAULT_PBKDF_ITERATIONS = 210_000 + /** * Securely hashes a password using PBKDF2 with SHA-256. * @param password The password to hash. * @param salt A salt value (should be unique per user). - * @param iterations Number of PBKDF2 iterations (default: 210,000). + * @param iterations Number of PBKDF2 iterations (default: 210_000). * @returns The derived key as a hex string. * @throws {Error} If password hashing fails for any reason. */ -export function hashPassword(password: string, salt: string, iterations = 210_000): string { +export async function hashPassword( + password: string, + salt: string, + iterations = DEFAULT_PBKDF_ITERATIONS, +): Promise { try { const passwordBuffer = new TextEncoder().encode(password) const saltBuffer = new TextEncoder().encode(salt) - const derivedKey = pbkdf2(sha256, passwordBuffer, saltBuffer, { c: iterations, dkLen: 32 }) + const derivedKey = await pbkdf2Async(sha256, passwordBuffer, saltBuffer, { c: iterations, dkLen: 32 }) // Convert Uint8Array to hex string return Array.from(derivedKey) From 1b974ff2d3e1de00b36fab0872027700c994b265 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 18 Jan 2026 16:20:26 +0100 Subject: [PATCH 23/23] chore: change password hash to pbkdf2-sha512 --- src/lib/hash.test.ts | 11 ++++------- src/lib/hash.ts | 7 ++++--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/lib/hash.test.ts b/src/lib/hash.test.ts index 62a46df76..ff4a4caa3 100644 --- a/src/lib/hash.test.ts +++ b/src/lib/hash.test.ts @@ -7,18 +7,15 @@ describe('hash', () => { }) it('hashPassword', async () => { + expect(await hashPassword('', '', 1)).toBe('6d2ecbbbfb2e6dcd7056faf9af6aa06eae594391db983279a6bf27e0eb228614') expect(await hashPassword('password', 'salt', 1)).toBe( - '120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b', - ) - expect(await hashPassword('test', 'Satoshi.jmdat', 1)).toBe( - '6785848abc7bd4d99f0c39f6c731094bea9b1090c59b4009f466b455aa15a2c1', + '867f70cf1ade02cff3752599a3a53dc4af34c7a669815ae5d513554e1c8cf252', ) expect(await hashPassword('test', 'Satoshi.jmdat', 21)).toBe( - 'a89fbf06eeab7c4a147203cc69eb064c0221f9cf16c293af8dc7e382307b3774', + '1acb29f6e7c841823a9a2369d2f2cc7e9ee19c78621c4d7194d1f45eb0d5e8ed', ) - expect(await hashPassword('test', 'Satoshi.jmdat', DEFAULT_PBKDF_ITERATIONS)).toBe( - '60e35c0b8567402c1d8b804e986e3f2c0648f46f68171c8a3e7eb9e98bfb5d4d', + 'da41454ecc40c48499decbca7b1df4595f0a856caada3f182d47293fbad03004', ) }) }) diff --git a/src/lib/hash.ts b/src/lib/hash.ts index 2ef9d6dcb..fee68479c 100644 --- a/src/lib/hash.ts +++ b/src/lib/hash.ts @@ -1,10 +1,11 @@ import { pbkdf2Async } from '@noble/hashes/pbkdf2.js' -import { sha256 } from '@noble/hashes/sha2.js' +import { sha512 } from '@noble/hashes/sha2.js' +// see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 (last check: 2026-01) export const DEFAULT_PBKDF_ITERATIONS = 210_000 /** - * Securely hashes a password using PBKDF2 with SHA-256. + * Securely hashes a password using PBKDF2 with SHA-512. * @param password The password to hash. * @param salt A salt value (should be unique per user). * @param iterations Number of PBKDF2 iterations (default: 210_000). @@ -20,7 +21,7 @@ export async function hashPassword( const passwordBuffer = new TextEncoder().encode(password) const saltBuffer = new TextEncoder().encode(salt) - const derivedKey = await pbkdf2Async(sha256, passwordBuffer, saltBuffer, { c: iterations, dkLen: 32 }) + const derivedKey = await pbkdf2Async(sha512, passwordBuffer, saltBuffer, { c: iterations, dkLen: 32 }) // Convert Uint8Array to hex string return Array.from(derivedKey)