import { decodeBip39MnemonicNonStandard, deriveSeedFromBip39Mnemonic, encodeBip39MnemonicNonStandard, generateBip39MnemonicNonStandard, // Word lists. bip39WordListChineseSimplified, bip39WordListChineseTraditional, bip39WordListCzech, bip39WordListEnglish, bip39WordListFrench, bip39WordListItalian, bip39WordListJapanese, bip39WordListKorean, bip39WordListPortuguese, bip39WordListSpanish, } from '@bitauth/libauth'; import { Bytes } from './bytes.js'; export type MnemonicRaw = { words: string[]; passphrase?: string; }; /** * Class representing a BIP39 Mnemonic phrase. * Supports 12, 15, 18, 21, and 24 word phrases with optional passphrase. * * @example * * ```ts * // Import our primitives. * import { Mnemonic } from '@xocash/primitives'; * * // Generate a random mnemonic. * const mnemonic = Mnemonic.generateRandom(); * * // Output the mnemonic phrase. * console.log(mnemonic.toPhrase()); * * // Derive the seed from the mnemonic. * console.log(mnemonic.toSeed()); * ``` */ export class Mnemonic { protected static VALID_WORD_COUNTS = new Set([12, 15, 18, 21, 24]); /** * Instantiate Mnemonic from a phrase string. * * @param phrase The complete phrase (mnemonic words + optional passphrase). * @param wordList Optional wordlist (defaults to English BIP-39 wordlist). * * @throws {Error} If the mnemonic is invalid. */ protected constructor(private mnemonic: MnemonicRaw) {} /** * Instantiates a mnemonic from a string or array of words * * @param phraseOrWords The mnemonic phrase as a string or array of words * @param wordList Optional wordlist (defaults to English BIP-39 wordlist). * * @throws {Error} If mnemonic could not be generated. * * @returns Instance of Mnemonic. */ public static from( phraseOrWords: string | Array, wordList: Array = bip39WordListEnglish, ) { return Array.isArray(phraseOrWords) ? this.fromWords(phraseOrWords, wordList) : this.fromPhrase(phraseOrWords, wordList); } /** * Instantiates a mnemonic from entropy. * * @param entropy The entropy to use to derive the mnemonic words. * @param wordList Optional wordlist (defaults to English BIP-39 wordlist). * * @throws {Error} If mnemonic could not be generated. * * @returns Instance of Mnemonic. */ public static fromEntropy( entropy: Uint8Array, wordList: Array = bip39WordListEnglish, passphrase?: string, ) { const mnemonic = encodeBip39MnemonicNonStandard(entropy, wordList); if (typeof mnemonic === 'string') { throw new Error(mnemonic); } // 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); } /** * Instantiates a mnemonic from a string * * @param phrase The mnemonic phrase as a string * @param wordList Optional wordlist (defaults to English BIP-39 wordlist). * * @throws {Error} If mnemonic could not be generated. * * @returns Instance of Mnemonic. */ public static fromPhrase( phrase: string, wordList: Array = bip39WordListEnglish, ) { // Split into words and remove any whitespace. const allWords = phrase .trim() .split(/\s+/) .map((word) => word.trim()); return this.fromWords(allWords, wordList); } /** * Instantiates a mnemonic from an array of words. * * @param words Array of words (strings) that represent the mnemonic * @param wordList Optional wordlist (defaults to English BIP-39 wordlist). * * @throws {Error} If mnemonic could not be generated. * * @returns Instance of Mnemonic. */ public static fromWords( words: Array, wordList: Array = bip39WordListEnglish, ) { // Check if last word is a passphrase by looking at total count const hasPassphrase = words.length === 13 || words.length === 16 || words.length === 19 || words.length === 22 || words.length === 25; // Parse words and optional passphrase const mnemonicRaw: MnemonicRaw = { words: hasPassphrase ? words.slice(0, -1) : words, }; if (hasPassphrase) { mnemonicRaw.passphrase = words[words.length - 1]; } // Validate word count if (!Mnemonic.VALID_WORD_COUNTS.has(mnemonicRaw.words.length)) { throw new Error( `Invalid word count ${mnemonicRaw.words.length}. BIP-39 requires 12, 15, 18, 21, or 24 words` + `${hasPassphrase ? ' (excluding passphrase)' : ''}`, ); } // Normalize and validate words against dictionary const wordSet = new Set(wordList); // Convert all words to lower-case. mnemonicRaw.words = mnemonicRaw.words.map((word) => word.toLowerCase()); // Find any words that are not present in our dictionary (i.e. invalid words). const invalidWords = mnemonicRaw.words.reduce((invalidWords, word) => { if (!wordSet.has(word)) { invalidWords.push(word); } return invalidWords; }, [] as Array); // If there are invalid words that are not present in the provided dictionary, throw an error. if (invalidWords.length) { throw new Error( `The following words are not valid with the provided dictionary: ${invalidWords.join(' ')}`, ); } // Ensure the checksum is correct by attempting to decode the mnemonic. // NOTE: LibAuth doesn't currently expose a convenient function to check this by itself. const decodeResult = decodeBip39MnemonicNonStandard( mnemonicRaw.words.join(' '), wordList, ); // NOTE: The above function will return a string if there is an error. if (typeof decodeResult === 'string') { throw new Error(decodeResult); } return new this(mnemonicRaw); } /** * Instantiates a mnemonic from its raw (serialized) mnemonic representation. * * @remarks this method is unsafe and should only use trusted data. * * @param mnemonicRaw The raw menmonic data. * @throws {Error} If mnemonic could not be generated. * * @returns Instance of Mnemonic. */ public static fromRaw(mnemonicRaw: MnemonicRaw) { return new this(mnemonicRaw); } /** * Generates a random mnemonic phrase. * * @param dictionary Optional wordlist (defaults to English BIP-39 wordlist). * @throws {Error} If mnemonic could not be generated. * * @returns Instance of Mnemonic. */ public static generateRandom( passphrase?: string, dictionary: string[] = bip39WordListEnglish, ): Mnemonic { // Attempt to generate mnemonic. const mnemonicResult = generateBip39MnemonicNonStandard(dictionary); if (typeof mnemonicResult === 'string') { throw new Error(mnemonicResult); } // Turn phrase parts (phrase) into an array so that we can append the passphrase later (if present). const words = mnemonicResult.phrase.split(/\s+/); // If there is a passphrase, append it. if (passphrase) { words.push(passphrase); } // Return the generated phrase (with passphrase appended - if present). return this.fromWords(words, dictionary); } /** * Validates a mnemonic phrase. * * @param phraseOrWords The mnemonic phrase or words to validate. * @param wordList Optional wordlist (defaults to English BIP-39 wordlist). * * @returns boolean indicating if the phrase is valid. */ public static isValid( phraseOrWords: string | Array, wordList: string[] = bip39WordListEnglish, ): boolean { try { this.from(phraseOrWords, wordList); return true; } catch { return false; } } /** * Checks if a word exists in the provided dictionary. * * @param word Word to check. * @param wordList Optional wordlist (defaults to English BIP-39 wordlist). * * @returns boolean indicating if the word is valid. */ public static isValidWord( word: string, wordList: string[] = bip39WordListEnglish, ): boolean { return wordList.includes(word.toLowerCase()); } /** * Detect which languages (word-lists) the given mnemonic matches. * * @param words The mnemonic words to check * @param wordLists The BIP39 word lists to check against, keyed by language name * @param allowPartialMatches If true, treats incomplete words as valid matches (useful for autocomplete) * * @returns Object mapping language names to their word lists for all matching languages */ public static detectLanguages< WordLists extends Record>, >( words: Array, wordLists: WordLists, allowPartialMatches = false, ): Partial { // Define our match function. const isWordMatch = (word: string, wordList: Array): boolean => { if (allowPartialMatches && word.length === 0) return true; if (wordList.includes(word)) return true; return ( allowPartialMatches && wordList.some((fullWord) => fullWord.startsWith(word)) ); }; // Iterate over each word-list and check whether it's a match. return Object.fromEntries( Object.entries(wordLists).filter(([, wordList]) => words.every((word) => isWordMatch(word, wordList)), ), ) as Partial; } /** * Get all valid BIP39 words from languages that match the given mnemonic words. * * Uses partial matching to determine eligible languages, then returns all words * from those languages (useful for autocomplete and word validation). * * @param words The mnemonic words to check (can be partial/incomplete) * @param wordLists The BIP39 word lists to check against, keyed by language name * @returns Array of all unique words from matching languages, sorted alphabetically * * @example * ```typescript * // Get all valid words for languages that match partial input * const eligibleWords = Mnemonic.getEligibleWords(['aban', 'abil'], wordLists); * // Returns all words from languages where 'aban*' and 'abil*' are valid prefixes * * // Filter for autocomplete * const completions = eligibleWords.filter(word => word.startsWith('acc')); * ``` */ 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); // Merge all eligible word completions into an Array. // NOTE: We use a Set to de-deuplicate words that might appear in multiple word-lists. return [...new Set(Object.values(matchingLanguages).flat())]; } /** * Returns the resulting seed for this mnemonic. * * @returns The seed (for use with HDPrivateNodes). */ public toSeed(): Uint8Array { return deriveSeedFromBip39Mnemonic(this.toPhrase(false), { passphrase: this.getPassphrase(), }); } /** * Returns the complete phrase as a string. * * @param includePassphrase Whether to include the passphrase in the output. * * @returns Complete phrase as a string. */ public toPhrase(includePassphrase: boolean = true): string { // Join the menmonic words, separating them using a space character. const mnemonicWords = this.mnemonic.words.join(' '); // If we should include the passphrase - and a passphrase is present... if (includePassphrase && this.mnemonic.passphrase) { return `${mnemonicWords} ${this.mnemonic.passphrase}`; } // Return the words. return mnemonicWords; } /** * Gets the number of words in the mnemonic (excluding passphrase). * * @returns Number of words. */ public toWords(includePassphrase: boolean = true): Array { const words = [...this.mnemonic.words]; if (this.mnemonic.passphrase && includePassphrase) { words.push(this.mnemonic.passphrase); } return words; } /** * Returns the entropy for this mnemonic. * * @param wordList Optional wordlist (defaults to English BIP-39 wordlist). * * @throws {Error} If entropy could not be decoded from the mnemonic using the provided wordlist. * * @returns The entropy used to derive the mnemonic words. */ public toEntropy(wordList: Array = bip39WordListEnglish): Bytes { const entropy = decodeBip39MnemonicNonStandard(this.toPhrase(), wordList); if (typeof entropy === 'string') { throw new Error(entropy); } return Bytes.fromBytes(entropy); } /** * Returns a serializable represntation of the class * * @returns A serializable reprentation of the class. */ public toRaw(): MnemonicRaw { return { ...this.mnemonic }; } /** * Gets the passphrase if one exists. * * @returns The passphrase or undefined if none exists. */ public getPassphrase(): string | undefined { return this.mnemonic.passphrase; } } // NOTE: This is handled exceptionally due to tree-shaking considerations. // Word lists incur a large cost on the bundle size and LibAuth considers non-English word-lists as non-standard: // https://github.com/bitauth/libauth/blob/master/src/lib/key/bip39.ts#L178 // 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 getAllBIP39WordListsMappedToLanguageNames() { return { 'Chinese Simplified': bip39WordListChineseSimplified, 'Chinese Traditional': bip39WordListChineseTraditional, Czech: bip39WordListCzech, English: bip39WordListEnglish, French: bip39WordListFrench, Italian: bip39WordListItalian, Japanese: bip39WordListJapanese, Korean: bip39WordListKorean, Portuguese: bip39WordListPortuguese, 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, }; }