/** * @file seedqr.ts * @summary Implements encoding and decoding for Seedsigner's SeedQR format. * @description This module provides functions to convert BIP39 mnemonics to and from the * SeedQR format, supporting both the Standard (numeric) and Compact (hex) variations. * The logic is adapted from the official Seedsigner specification and test vectors. */ import { BIP39_WORDLIST, WORD_INDEX, mnemonicToEntropy, entropyToMnemonic } from './seedblend'; // Helper to convert a hex string to a Uint8Array in a browser-compatible way. function hexToUint8Array(hex: string): Uint8Array { if (hex.length % 2 !== 0) { throw new Error('Hex string must have an even number of characters'); } const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(hex.substr(i * 2, 2), 16); } return bytes; } /** * Decodes a Standard SeedQR (numeric digit stream) into a mnemonic phrase. * @param digitStream A string containing 4-digit numbers representing BIP39 word indices. * @returns The decoded BIP39 mnemonic. */ function decodeStandardSeedQR(digitStream: string): string { if (digitStream.length % 4 !== 0) { throw new Error('Invalid Standard SeedQR: Length must be a multiple of 4.'); } const wordIndices: number[] = []; for (let i = 0; i < digitStream.length; i += 4) { const indexStr = digitStream.slice(i, i + 4); const index = parseInt(indexStr, 10); if (isNaN(index) || index >= 2048) { throw new Error(`Invalid word index in SeedQR: ${indexStr}`); } wordIndices.push(index); } if (wordIndices.length !== 12 && wordIndices.length !== 24) { throw new Error(`Invalid word count from SeedQR: ${wordIndices.length}. Must be 12 or 24.`); } const mnemonicWords = wordIndices.map(index => BIP39_WORDLIST[index]); return mnemonicWords.join(' '); } /** * Decodes a Compact SeedQR (hex-encoded entropy) into a mnemonic phrase. * @param hexEntropy The hex-encoded entropy string. * @returns A promise that resolves to the decoded BIP39 mnemonic. */ async function decodeCompactSeedQR(hexEntropy: string): Promise { const entropy = hexToUint8Array(hexEntropy); if (entropy.length !== 16 && entropy.length !== 32) { throw new Error(`Invalid entropy length for Compact SeedQR: ${entropy.length}. Must be 16 or 32 bytes.`); } return entropyToMnemonic(entropy); } /** * A unified decoder that automatically detects and parses a SeedQR string. * @param qrData The raw data from the QR code. * @returns A promise that resolves to the decoded BIP39 mnemonic. */ export async function decodeSeedQR(qrData: string): Promise { const trimmed = qrData.trim(); // Standard SeedQR is a string of only digits. if (/^\d+$/.test(trimmed)) { return decodeStandardSeedQR(trimmed); } // Compact SeedQR is a hex string. if (/^[0-9a-fA-F]+$/.test(trimmed)) { return decodeCompactSeedQR(trimmed); } throw new Error('Unsupported or invalid SeedQR format.'); } /** * Encodes a mnemonic into the Standard SeedQR format (numeric digit stream). * @param mnemonic The BIP39 mnemonic string. * @returns A promise that resolves to the Standard SeedQR string. */ export async function encodeStandardSeedQR(mnemonic: string): Promise { const words = mnemonic.trim().toLowerCase().split(/\s+/); if (words.length !== 12 && words.length !== 24) { throw new Error("Mnemonic must be 12 or 24 words to generate a SeedQR."); } const digitStream = words.map(word => { const index = WORD_INDEX.get(word); if (index === undefined) { throw new Error(`Invalid word in mnemonic: ${word}`); } return index.toString().padStart(4, '0'); }).join(''); return digitStream; } /** * Encodes a mnemonic into the Compact SeedQR format (raw entropy bytes). * @param mnemonic The BIP39 mnemonic string. * @returns A promise that resolves to the Compact SeedQR entropy as a Uint8Array. */ export async function encodeCompactSeedQREntropy(mnemonic: string): Promise { return await mnemonicToEntropy(mnemonic); }