diff --git a/packages/primitives/src/mnemonic.ts b/packages/primitives/src/mnemonic.ts index 041f8a9..c0321b9 100644 --- a/packages/primitives/src/mnemonic.ts +++ b/packages/primitives/src/mnemonic.ts @@ -87,6 +87,7 @@ export class Mnemonic { public static fromEntropy( entropy: Uint8Array, wordList: Array = bip39WordListEnglish, + passphrase?: string, ) { const mnemonic = encodeBip39MnemonicNonStandard(entropy, wordList); @@ -94,7 +95,13 @@ export class Mnemonic { throw new Error(mnemonic); } - return this.fromPhrase(mnemonic.phrase, wordList); + // If there is a passphrase, amend it to the whole phrase. + // TODO: Consider whether passphrase should just be a separate parameter. It will probably simplify things. + const finalPhrase = passphrase + ? `${mnemonic.phrase} ${passphrase}` + : mnemonic.phrase; + + return this.fromPhrase(finalPhrase, wordList); } /** @@ -283,11 +290,13 @@ export class Mnemonic { * * @returns Object mapping language names to their word lists for all matching languages */ - public static detectLanguages( + public static detectLanguages< + WordLists extends Record>, + >( words: Array, - wordLists: { [name: string]: Array }, + wordLists: WordLists, allowPartialMatches = false, - ): { [name: string]: Array } { + ): Partial { // Define our match function. const isWordMatch = (word: string, wordList: Array): boolean => { if (allowPartialMatches && word.length === 0) return true; @@ -303,7 +312,7 @@ export class Mnemonic { Object.entries(wordLists).filter(([, wordList]) => words.every((word) => isWordMatch(word, wordList)), ), - ); + ) as Partial; } /** @@ -326,10 +335,9 @@ export class Mnemonic { * const completions = eligibleWords.filter(word => word.startsWith('acc')); * ``` */ - public static getEligibleWords( - words: Array, - wordLists: { [name: string]: Array }, - ): Array { + public static getEligibleWords< + WordLists extends Record>, + >(words: Array, wordLists: WordLists): Array { // Given the words provided, find eligible languages/word-lists. const matchingLanguages = this.detectLanguages(words, wordLists, true); @@ -428,7 +436,7 @@ export class Mnemonic { // And the BIP39 spec warns against their usage: // https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki#wordlists // If we include this in the class, it cannot be tree-shaken by most tooling, so we define a stand-alone function instead. -export function getAllBIP39WordLists() { +export function getAllBIP39WordListsMappedToLanguageNames() { return { 'Chinese Simplified': bip39WordListChineseSimplified, 'Chinese Traditional': bip39WordListChineseTraditional, @@ -442,3 +450,18 @@ export function getAllBIP39WordLists() { Spanish: bip39WordListSpanish, }; } + +export function getAllBIP39WordListsMappedToLanguageCodes() { + return { + 'zh-CN': bip39WordListChineseSimplified, // Mainland China uses Simplified + 'zh-TW': bip39WordListChineseTraditional, // Taiwan uses Traditional + cs: bip39WordListCzech, + en: bip39WordListEnglish, + fr: bip39WordListFrench, + it: bip39WordListItalian, + ja: bip39WordListJapanese, + ko: bip39WordListKorean, + pt: bip39WordListPortuguese, + es: bip39WordListSpanish, + }; +} diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 9344975..9ec9247 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -3,6 +3,7 @@ export * from '@xocash/primitives'; // Export all of our stack-specific modules. export * from './blockchain/index.js'; +export * from './protocols/index.js'; export * from './rates/index.js'; export * from './storage/index.js'; export * from './utils/index.js'; diff --git a/packages/stack/src/protocols/bch-mnemonic-url.ts b/packages/stack/src/protocols/bch-mnemonic-url.ts new file mode 100644 index 0000000..4b9c239 --- /dev/null +++ b/packages/stack/src/protocols/bch-mnemonic-url.ts @@ -0,0 +1,167 @@ +import { Bytes } from '@xocash/stack'; +import { z } from 'zod'; + +export type BCHMnemonicURLRaw = { + entropy: Uint8Array; + passphrase?: string; + language?: (typeof BCHMnemonicURL.SUPPORTED_LANGUAGES)[number]; + comment?: string; + path?: string; + startHeight?: number; +}; + +/** + * Handles BCHMnemonic URLs + */ +export class BCHMnemonicURL { + static PROTOCOL = 'bch-mnemonic'; + + /** + * Check if a URL is a valid wallet backup URL + * + * @param url The URL to check + * @returns True if the URL is a valid wallet backup URL, false otherwise + */ + public static canHandle(urlStr: string): boolean { + try { + BCHMnemonicURL.fromURL(urlStr); + return true; + } catch (_error) { + return false; + } + } + + /** + * Creates a BCHMnemonic from a URL-encoded string + * @param urlStr - The URL-encoded mnemonic string + * @returns A new BCHMnemonic instance + * @throws Error if the URL format is invalid or entropy is invalid + */ + static fromURL(urlStr: string): BCHMnemonicURL { + const url = new URL(urlStr); + + if (url.protocol !== `${BCHMnemonicURL.PROTOCOL}:`) { + throw new Error(`Invalid URL protocol: ${url.protocol}`); + } + + // Decode the entropy. + const entropy = Bytes.fromBase64(url.pathname).toUint8Array(); + + // Pick out our encoding keys from the URL + const params = BCHMnemonicURL.schema.parse( + Object.fromEntries(url.searchParams.entries()), + ); + + // Create and return the backup with validated parameters + return BCHMnemonicURL.fromRaw({ + entropy, + language: params[BCHMnemonicURL.ENCODING_KEYS.language], + comment: params[BCHMnemonicURL.ENCODING_KEYS.comment], + path: params[BCHMnemonicURL.ENCODING_KEYS.path], + startHeight: params[BCHMnemonicURL.ENCODING_KEYS.startHeight], + }); + } + + /** + * Create a new WalletBackup from a raw object + * + * @param raw - The raw object to create the WalletBackup from + * @returns The created WalletBackup + */ + static fromRaw(raw: BCHMnemonicURLRaw): BCHMnemonicURL { + // Add entropy validation + if (!raw.entropy || raw.entropy.length === 0) { + throw new Error('Invalid entropy: must be non-empty'); + } + + // Validate entropy length (typically 16, 20, 24, 28, or 32 bytes for BIP39) + const validLengths = [16, 20, 24, 28, 32]; + if (!validLengths.includes(raw.entropy.length)) { + throw new Error(`Invalid entropy length: ${raw.entropy.length} bytes`); + } + + return new BCHMnemonicURL(raw); + } + + constructor(protected raw: BCHMnemonicURLRaw) {} + + toObject() { + return this.raw; + } + + /** + * Encode the backup into a URL encoding + * + * @param prefix - The prefix to use for the URL encoding + * @returns The URL encoding of the backup + */ + toURL(): string { + // Conver the mnemonic words into the entropy used to derive the mnemonic words + const entropyBase64 = Bytes.from(this.raw.entropy).toBase64(); + + // Create a new URL object with the prefix and the base64 encoded mnemonic + const url = new URL(`${BCHMnemonicURL.PROTOCOL}:${entropyBase64}`); + + // Add the raw values to the url encoded string. Only add the values that are defined. + if (this.raw.language !== undefined) { + url.searchParams.set( + BCHMnemonicURL.ENCODING_KEYS.language, + this.raw.language, + ); + } + + if (this.raw.comment !== undefined) { + url.searchParams.set( + BCHMnemonicURL.ENCODING_KEYS.comment, + this.raw.comment, + ); + } + + if (this.raw.path !== undefined) { + url.searchParams.set(BCHMnemonicURL.ENCODING_KEYS.path, this.raw.path); + } + + if (this.raw.startHeight !== undefined) { + url.searchParams.set( + BCHMnemonicURL.ENCODING_KEYS.startHeight, + this.raw.startHeight.toString(), + ); + } + + return url.toString(); + } + + static ENCODING_KEYS = { + language: 'l', + passphrase: 'p', + comment: 'c', + path: 'd', + startHeight: 'h', + } as const; + + static SUPPORTED_LANGUAGES = [ + 'en', + 'zh-CN', + 'zh-TW', + 'ja', + 'es', + 'pt', + 'ko', + 'fr', + 'it', + 'cs', + ] as const; + + /** + * Zod schema for validating URL parameters + */ + static schema = z.object({ + [BCHMnemonicURL.ENCODING_KEYS.language]: z + .enum(BCHMnemonicURL.SUPPORTED_LANGUAGES) + .optional(), + [BCHMnemonicURL.ENCODING_KEYS.passphrase]: z.string().optional(), + [BCHMnemonicURL.ENCODING_KEYS.comment]: z.string().optional(), + [BCHMnemonicURL.ENCODING_KEYS.path]: z.string().optional(), + [BCHMnemonicURL.ENCODING_KEYS.startHeight]: z.coerce.number().optional(), + }); +} diff --git a/packages/stack/src/protocols/index.ts b/packages/stack/src/protocols/index.ts new file mode 100644 index 0000000..7311eb3 --- /dev/null +++ b/packages/stack/src/protocols/index.ts @@ -0,0 +1 @@ +export * from './bch-mnemonic-url.js'; diff --git a/packages/stack/src/wallet/wallet-hd.ts b/packages/stack/src/wallet/wallet-hd.ts index a7e1621..ab0822c 100644 --- a/packages/stack/src/wallet/wallet-hd.ts +++ b/packages/stack/src/wallet/wallet-hd.ts @@ -27,7 +27,11 @@ export type WalletHDGenesisData = { type: 'WalletHD'; derivationPath: string; addressGap?: number; -} & ({ seed: string } | { mnemonic: string } | { xpriv: string }); +} & ( + | { seed: string } + | { mnemonic: string; passphrase?: string } + | { xpriv: string } +); /** * A Hierarhical Determinstic (BIP44) Wallet. @@ -111,9 +115,16 @@ export class WalletHD extends BaseWallet { if ('seed' in genesisData) { this.hdPrivateNode = HDPrivateNode.fromSeed(genesisData.seed); } else if ('mnemonic' in genesisData) { - this.hdPrivateNode = HDPrivateNode.fromMnemonic( - Mnemonic.fromPhrase(genesisData.mnemonic), - ); + // TODO: We really want to support multiple word-lists somehow. + // But this is not trivial because other word-lists are technically not standard. + // For now, we are allowing any phrase (BIP39 derives seed from literal phrase, not word indexes). + const mnemonic = Mnemonic.fromRaw({ + words: genesisData.mnemonic.split(' '), + passphrase: genesisData.passphrase, + }); + + // Create the HD Node from our Mnemonic instance. + this.hdPrivateNode = HDPrivateNode.fromMnemonic(mnemonic); } else { this.hdPrivateNode = HDPrivateNode.fromXPriv(genesisData.xpriv); }