Add bch-mnemonic protocol
This commit is contained in:
@@ -87,6 +87,7 @@ export class Mnemonic {
|
||||
public static fromEntropy(
|
||||
entropy: Uint8Array,
|
||||
wordList: Array<string> = 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<string, Array<string>>,
|
||||
>(
|
||||
words: Array<string>,
|
||||
wordLists: { [name: string]: Array<string> },
|
||||
wordLists: WordLists,
|
||||
allowPartialMatches = false,
|
||||
): { [name: string]: Array<string> } {
|
||||
): Partial<WordLists> {
|
||||
// Define our match function.
|
||||
const isWordMatch = (word: string, wordList: Array<string>): 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<WordLists>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,10 +335,9 @@ export class Mnemonic {
|
||||
* const completions = eligibleWords.filter(word => word.startsWith('acc'));
|
||||
* ```
|
||||
*/
|
||||
public static getEligibleWords(
|
||||
words: Array<string>,
|
||||
wordLists: { [name: string]: Array<string> },
|
||||
): Array<string> {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './bch-mnemonic-url.js';
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user