// The built-in, in-browser, self-custody wallet for the registration flow. // // This exists so a newcomer with no BCH wallet at all can still buy a name. // The seed phrase is generated in the buyer's browser, encrypted with their // password (PBKDF2 -> AES-GCM via WebCrypto) and kept in localStorage. It is // never transmitted anywhere. There is no reset: whoever holds the phrase holds // the names. The UI must say so before the phrase is generated. // // Runs in the browser and in Node 18+ (both provide WebCrypto + TextEncoder). import { generateBip39Mnemonic, deriveSeedFromBip39Mnemonic, deriveHdPrivateNodeFromSeed, deriveHdPath, secp256k1, hash160, encodeCashAddress, lockingBytecodeToCashAddress, cashAddressToLockingBytecode, binToHex, hexToBin, utf8ToBin, binToBase64, base64ToBin, } from "@bitauth/libauth"; export const CHIPNET_PREFIX = "bchtest"; export const MAINNET_PREFIX = "bitcoincash"; // BCH coin type 145; account 0. Branch 0 = receive, 1 = change. export const ACCOUNT_PATH = "m/44'/145'/0'"; // How many receiving addresses to watch. The flow funds and changes back to // index 0, so this window only matters for coins sent in from elsewhere. export const ADDRESS_WINDOW = 5; const STORAGE_KEY = "bns.wallet.v1"; function p2pkhLockingBytecode(publicKey) { const pkh = hash160(publicKey); return Uint8Array.from([0x76, 0xa9, 0x14, ...pkh, 0x88, 0xac]); } function encodeAddress(publicKey, prefix, tokenSupport) { const r = lockingBytecodeToCashAddress({ bytecode: p2pkhLockingBytecode(publicKey), prefix, tokenSupport, }); if (typeof r === "string") throw new Error(`address encoding failed: ${r}`); return r.address; } export class BuiltInWallet { /** * @param {string} mnemonic * @param {Uint8Array} seed * @param {string} prefix * @param {string} accountPath BIP44 account path (default: BCH `m/44'/145'/0'`). * Override with e.g. `m/44'/0'/0'` for operator wallets that were derived * on the BTC coin type, so BuiltInWallet can drive keys minted from the * CLI/mainnet-js path too. */ constructor(mnemonic, seed, prefix = CHIPNET_PREFIX, accountPath = ACCOUNT_PATH) { this.mnemonic = mnemonic; this.prefix = prefix; this.accountPath = accountPath; this._root = deriveHdPrivateNodeFromSeed(seed); this._keys = new Map(); // lockingBytecodeHex -> {privateKey, publicKey, path} this._derive(); } static async create(prefix = CHIPNET_PREFIX, accountPath = ACCOUNT_PATH) { const mnemonic = generateBip39Mnemonic(); return new BuiltInWallet(mnemonic, await deriveSeedFromBip39Mnemonic(mnemonic), prefix, accountPath); } static async fromMnemonic(mnemonic, prefix = CHIPNET_PREFIX, accountPath = ACCOUNT_PATH) { const clean = String(mnemonic).trim().toLowerCase().replace(/\s+/g, " "); const seed = await deriveSeedFromBip39Mnemonic(clean); if (typeof seed === "string") throw new Error(`invalid recovery phrase: ${seed}`); return new BuiltInWallet(clean, seed, prefix, accountPath); } _derive() { this.receive = []; this.change = []; for (const [branch, list] of [[0, this.receive], [1, this.change]]) { for (let i = 0; i < ADDRESS_WINDOW; i++) { const path = `${this.accountPath}/${branch}/${i}`; const node = deriveHdPath(this._root, path); if (typeof node === "string") throw new Error(`HD derivation failed at ${path}: ${node}`); const publicKey = secp256k1.derivePublicKeyCompressed(node.privateKey); if (typeof publicKey === "string") throw new Error(`pubkey derivation failed: ${publicKey}`); const lockingBytecode = p2pkhLockingBytecode(publicKey); const entry = { path, branch, index: i, privateKey: node.privateKey, publicKey, lockingBytecode, address: encodeAddress(publicKey, this.prefix, false), tokenAddress: encodeAddress(publicKey, this.prefix, true), }; list.push(entry); this._keys.set(binToHex(lockingBytecode), entry); } } } /** The address the buyer funds and receives change on. */ get address() { return this.receive[0].address; } /** Token-aware form — where the name certificate is minted to. */ get tokenAddress() { return this.receive[0].tokenAddress; } /** Every address worth scanning for coins. */ get watchedAddresses() { return [...this.receive, ...this.change].map((e) => e.address); } /** Signer callback for sign-tx.js. */ keyFor(lockingBytecodeHex) { const e = this._keys.get(lockingBytecodeHex); return e ? { privateKey: e.privateKey, publicKey: e.publicKey } : null; } /** * Sign a 32-byte digest with the key for `address` (default: the wallet's * primary receive address) and return a 65-byte BCH message signature: * [recovery flag, r|s] * flag = 27 + recovery + 4 (compressed pubkey — standard) * * Used by the signed-records-manifest flow: the caller sha256s the * canonical manifest bytes, hands the digest here, gets back a signature * the gateway's POST endpoint verifies against the on-chain NFT owner. * See DESIGN-signed-records-manifest.md. */ signMessage(digest, address) { if (!(digest instanceof Uint8Array) || digest.length !== 32) { throw new Error("signMessage expects a 32-byte Uint8Array digest"); } const addr = address || this.address; const entry = [...this.receive, ...this.change].find((k) => k.address === addr); if (!entry) throw new Error(`no key for address ${addr}`); const result = secp256k1.signMessageHashRecoverableCompact(entry.privateKey, digest); if (typeof result === "string") throw new Error(`signMessage failed: ${result}`); const { signature } = result; // libauth names the field recoveryId; older builds used recovery. A wrong // read here silently produced flag byte 0 and half of all signatures // failed verification. const recovery = result.recoveryId ?? result.recovery; if (typeof recovery !== "number") throw new Error("signMessage: no recovery id in signature result"); const flag = 27 + 4 + recovery; // 4 = "public key was compressed" const bytes = new Uint8Array(65); bytes[0] = flag; bytes.set(signature, 1); return bytes; } // ---------- encrypted storage ---------- async encrypt(password) { const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await deriveKey(password, salt); const ct = new Uint8Array( await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, utf8ToBin(this.mnemonic)), ); return JSON.stringify({ v: 1, kdf: "PBKDF2-SHA256", iterations: PBKDF2_ITERATIONS, prefix: this.prefix, salt: binToBase64(salt), iv: binToBase64(iv), ct: binToBase64(ct), }); } static async decrypt(blob, password) { const o = typeof blob === "string" ? JSON.parse(blob) : blob; const key = await deriveKey(password, base64ToBin(o.salt), o.iterations ?? PBKDF2_ITERATIONS); let plain; try { plain = await crypto.subtle.decrypt( { name: "AES-GCM", iv: base64ToBin(o.iv) }, key, base64ToBin(o.ct), ); } catch { throw new Error("wrong password"); } return BuiltInWallet.fromMnemonic(new TextDecoder().decode(plain), o.prefix ?? CHIPNET_PREFIX); } async save(password, storage = globalThis.localStorage) { storage.setItem(STORAGE_KEY, await this.encrypt(password)); } static async load(password, storage = globalThis.localStorage) { const blob = storage.getItem(STORAGE_KEY); if (!blob) return null; return BuiltInWallet.decrypt(blob, password); } static exists(storage = globalThis.localStorage) { return !!storage?.getItem(STORAGE_KEY); } static forget(storage = globalThis.localStorage) { storage.removeItem(STORAGE_KEY); } } const PBKDF2_ITERATIONS = 250_000; async function deriveKey(password, salt, iterations = PBKDF2_ITERATIONS) { const material = await crypto.subtle.importKey("raw", utf8ToBin(String(password)), "PBKDF2", false, [ "deriveKey", ]); return crypto.subtle.deriveKey( { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); } export { encodeCashAddress, cashAddressToLockingBytecode, binToHex, hexToBin };