Merge branch 'add_bchmnemonic_protocol' into 'development'

Add bch-mnemonic protocol

See merge request GeneralProtocols/xo/stack!104
This commit is contained in:
Jim Hamill
2025-06-27 06:44:14 +00:00
5 changed files with 217 additions and 14 deletions
+33 -10
View File
@@ -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,
};
}
+1
View File
@@ -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(),
});
}
+1
View File
@@ -0,0 +1 @@
export * from './bch-mnemonic-url.js';
+15 -4
View File
@@ -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);
}