// Wallet operations: mnemonic generation, HD derivation, signing. // All crypto delegated to ethers.js. const { Mnemonic, HDNodeWallet, Wallet } = require("ethers"); const { DEBUG, DEBUG_MNEMONIC, BIP44_ETH_PATH } = require("./constants"); function generateMnemonic() { // This must stay the compile-time DEBUG constant. Do NOT switch it to // isDebug() from log.js: that also ORs in the runtime debugMode flag the // settings toggle drives, which would let a user of a release build turn // the hardcoded, publicly known test phrase back on for real wallets. if (DEBUG) return DEBUG_MNEMONIC; const m = Mnemonic.fromEntropy( globalThis.crypto.getRandomValues(new Uint8Array(16)), ); return m.phrase; } // Every extended key (xprv or xpub) entering the app goes through this. // // ethers' HDNodeWallet.fromExtendedKey does NOT verify the base58 checksum // when the decoded payload is the usual 82 bytes, which is exactly the case // the checksum exists to catch: a key with a one-character typo parses into a // *different* wallet instead of being rejected. Re-encoding the parsed node // reproduces a well-formed key byte for byte, checksum included, so comparing // the round trip against the input rejects any altered character. Measured by // the sweep in tests/wallet.test.js over every single-character substitution // of the BIP-32 vector 1 master key: 199 parse without the round-trip // comparison, 0 with it. // // Returns the parsed node, or null if the key is not a well-formed extended // key. Callers turn null into a user-facing error; none of them may fall back // to fromExtendedKey directly. function parseExtendedKey(key) { if (typeof key !== "string") return null; try { const node = HDNodeWallet.fromExtendedKey(key); return node.extendedKey === key ? node : null; } catch { return null; } } // A master key is at depth 0. Only from there is BIP44_ETH_PATH the absolute // path it names; deriving it under an account-level or child key yields // addresses that correspond to nothing the user holds. const MASTER_DEPTH = 0; // Parse an extended private key that the BIP-44 Ethereum account path can be // derived from, or throw. Both callers derive BIP44_ETH_PATH from the result. function masterXprvOrThrow(key) { const node = parseExtendedKey(key); if (!node) { throw new Error("Not a valid extended private key (xprv)."); } if (!node.privateKey) { throw new Error("Not an extended private key (xprv)."); } if (node.depth !== MASTER_DEPTH) { throw new Error( "Not a master extended private key (xprv): an account-level or " + "child key cannot be imported.", ); } return node; } function deriveAddressFromXpub(xpub, index) { const node = parseExtendedKey(xpub); if (!node) { throw new Error("Not a valid extended key."); } return node.deriveChild(index).address; } function hdWalletFromMnemonic(mnemonic) { const node = HDNodeWallet.fromPhrase(mnemonic, "", BIP44_ETH_PATH); const xpub = node.neuter().extendedKey; const firstAddress = node.deriveChild(0).address; return { xpub, firstAddress }; } function hdWalletFromXprv(xprv) { // BIP44_ETH_PATH is absolute ("m/..."), which ethers will only derive from // a depth-0 node. The relative form this used to derive would have been // applied *beneath* an account-level key instead of being refused. const node = masterXprvOrThrow(xprv).derivePath(BIP44_ETH_PATH); const xpub = node.neuter().extendedKey; const firstAddress = node.deriveChild(0).address; return { xpub, firstAddress }; } // Well-formed extended private key. Says nothing about depth: the import view // reports a non-master key separately, since "check it for a typo" is the // wrong advice for a key the user copied correctly. function isValidXprv(key) { const node = parseExtendedKey(key); return !!(node && node.privateKey); } // Whether an extended key is a master key, i.e. the one BIP44_ETH_PATH can be // derived from. False for anything parseExtendedKey rejects. function isMasterExtendedKey(key) { const node = parseExtendedKey(key); return !!node && node.depth === MASTER_DEPTH; } function addressFromPrivateKey(key) { const w = new Wallet(key); return w.address; } function getSignerForAddress(walletData, addrIndex, decryptedSecret) { if (walletData.type === "hd") { const node = HDNodeWallet.fromPhrase( decryptedSecret, "", BIP44_ETH_PATH, ); return node.deriveChild(addrIndex); } if (walletData.type === "xprv") { const node = masterXprvOrThrow(decryptedSecret).derivePath(BIP44_ETH_PATH); return node.deriveChild(addrIndex); } return new Wallet(decryptedSecret); } function isValidMnemonic(mnemonic) { return Mnemonic.isValidMnemonic(mnemonic); } // Only an HD wallet has a recovery phrase. A "key" wallet holds a bare // private key and an "xprv" wallet an extended private key; neither can be // turned back into words, so neither may ever be offered the phrase display. // Written as an allowlist on purpose: a wallet type added later is excluded // until someone decides otherwise. function walletHasRecoveryPhrase(walletData) { return !!walletData && walletData.type === "hd"; } module.exports = { generateMnemonic, deriveAddressFromXpub, hdWalletFromMnemonic, hdWalletFromXprv, isValidXprv, isMasterExtendedKey, addressFromPrivateKey, getSignerForAddress, isValidMnemonic, walletHasRecoveryPhrase, };