From 95ea6f566da205f30920fcdff2aa35306775ab33 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 18 Jan 2026 10:55:34 +0100 Subject: [PATCH] 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 b0d5d5c8..6555f6c4 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 e7adc943..6792e122 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 56644c27..3809a6eb 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 773b9865..d326df91 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 efc53a24..1e48b317 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 884d27ea..00000000 --- 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 00000000..133cfa86 --- /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 00000000..ea341e4c --- /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 152ba3cf..04aa3c6e 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