chore: use base58 of scure lib

This commit is contained in:
theborakompanioni 2026-01-18 10:55:34 +01:00
parent ffa76bfb71
commit 95ea6f566d
No known key found for this signature in database
GPG key ID: E8070AF0053AAC0D
10 changed files with 172 additions and 152 deletions

1
package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -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
}
/**

View file

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

View file

@ -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<string> {
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
}
}

103
src/lib/xpubs.test.ts Normal file
View file

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

54
src/lib/xpubs.ts Normal file
View file

@ -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.'}`)
}
}

View file

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