Files
xo-stack/packages/primitives/src/mnemonic.ts
T
2025-06-27 06:44:14 +00:00

468 lines
14 KiB
TypeScript

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<string>,
wordList: Array<string> = 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<string> = 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<string> = 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<string>,
wordList: Array<string> = 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<string>);
// 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<string>,
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<string, Array<string>>,
>(
words: Array<string>,
wordLists: WordLists,
allowPartialMatches = false,
): Partial<WordLists> {
// Define our match function.
const isWordMatch = (word: string, wordList: Array<string>): 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<WordLists>;
}
/**
* 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<string, Array<string>>,
>(words: Array<string>, wordLists: WordLists): Array<string> {
// 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<string>(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<string> {
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<string> = 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,
};
}