468 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|