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(), }); }