// Tests for src/shared/wallet.js: the DEBUG build flag as it gates mnemonic // generation (first two describes), and HD key derivation against published // known-answer vectors (rest of the file). // // The modules read the __BUILD_DEBUG__ global that esbuild replaces at bundle // time. Under jest the global is absent, which is exactly the release-build // case; the debug-build case is exercised by defining the global and // re-requiring the modules with a fresh registry. const WORDS_IN_12_WORD_PHRASE = 12; function loadWallet() { const constants = require("../src/shared/constants"); const wallet = require("../src/shared/wallet"); const log = require("../src/shared/log"); return { constants, wallet, log }; } describe("generateMnemonic in a release build", () => { beforeEach(() => { jest.resetModules(); delete globalThis.__BUILD_DEBUG__; }); test("DEBUG defaults to false when the build define is absent", () => { const { constants } = loadWallet(); expect(constants.DEBUG).toBe(false); }); test("returns fresh, valid 12-word phrases that are not the test phrase", () => { const { constants, wallet } = loadWallet(); const first = wallet.generateMnemonic(); const second = wallet.generateMnemonic(); expect(first).not.toBe(second); for (const phrase of [first, second]) { expect(wallet.isValidMnemonic(phrase)).toBe(true); expect(phrase.split(" ")).toHaveLength(WORDS_IN_12_WORD_PHRASE); expect(phrase).not.toBe(constants.DEBUG_MNEMONIC); } }); test("derives a usable HD wallet from the generated phrase", () => { const { wallet } = loadWallet(); const { xpub, firstAddress } = wallet.hdWalletFromMnemonic( wallet.generateMnemonic(), ); expect(xpub.startsWith("xpub")).toBe(true); expect(firstAddress).toMatch(/^0x[0-9a-fA-F]{40}$/); }); test("the runtime debug toggle cannot re-enable the test phrase", () => { const { constants, wallet, log } = loadWallet(); // What the settings easter-egg toggle does at runtime. log.setRuntimeDebug(true); expect(log.isDebug()).toBe(true); const phrase = wallet.generateMnemonic(); expect(phrase).not.toBe(constants.DEBUG_MNEMONIC); expect(wallet.isValidMnemonic(phrase)).toBe(true); expect(phrase).not.toBe(wallet.generateMnemonic()); log.setRuntimeDebug(false); }); }); describe("generateMnemonic in a debug build", () => { beforeEach(() => { jest.resetModules(); globalThis.__BUILD_DEBUG__ = true; }); afterEach(() => { delete globalThis.__BUILD_DEBUG__; }); test("DEBUG is true and the test phrase is returned", () => { const { constants, wallet } = loadWallet(); expect(constants.DEBUG).toBe(true); expect(wallet.generateMnemonic()).toBe(constants.DEBUG_MNEMONIC); }); test("the test phrase is itself a valid 12-word BIP-39 phrase", () => { const { constants, wallet } = loadWallet(); expect(wallet.isValidMnemonic(constants.DEBUG_MNEMONIC)).toBe(true); expect(constants.DEBUG_MNEMONIC.split(" ")).toHaveLength( WORDS_IN_12_WORD_PHRASE, ); }); }); // --------------------------------------------------------------------------- // Key derivation. // // Every address below is a published constant, not something this codebase // produced. Asserting against what the implementation happens to return today // would pass just as happily with the wrong coin type, the wrong path depth or // a non-empty seed passphrase, all of which silently send funds to addresses // no other wallet can recover. // // Vector sources: // // VECTOR_PHRASE / VECTOR_ADDRESSES / VECTOR_PRIVATE_KEYS — the standard // development recovery phrase and the first three accounts it yields at // m/44'/60'/0'/0/n with an empty seed passphrase, as published in the // Hardhat and Ganache documentation. Publicly known; never fund it. // // ZERO_ENTROPY_PHRASE / ZERO_ENTROPY_ADDRESS — the BIP-39 all-zero-entropy // phrase (Trezor's official BIP-39 vector set, first entry) and its // m/44'/60'/0'/0/0 Ethereum address with an empty seed passphrase. A second, // independently published phrase so the pin is not one vector deep. // // BIP32_VECTOR_1_XPRV — the master key of BIP-32 test vector 1 // (seed 000102030405060708090a0b0c0d0e0f). // // The two Hardhat facts cross-check each other: VECTOR_PRIVATE_KEYS[n] is the // published key for VECTOR_ADDRESSES[n], so addressFromPrivateKey and the HD // path must meet at the same address from two different directions. const { HDNodeWallet, Mnemonic, verifyMessage } = require("ethers"); const wallet = require("../src/shared/wallet"); const { BIP44_ETH_PATH } = require("../src/shared/constants"); const VECTOR_PHRASE = "test test test test test test test test test test test junk"; const VECTOR_ADDRESSES = [ "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", ]; const VECTOR_PRIVATE_KEYS = [ "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", ]; const ZERO_ENTROPY_PHRASE = "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about"; const ZERO_ENTROPY_ADDRESS = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"; const BIP32_VECTOR_1_XPRV = "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqji" + "ChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; // The master (depth-0) extended private key for a phrase, which is what the // import-an-xprv flow is handed. Built with ethers rather than with the module // under test, so hdWalletFromXprv is not being checked against itself. function masterXprv(phrase, passphrase = "") { return HDNodeWallet.fromSeed( Mnemonic.fromPhrase(phrase, passphrase).computeSeed(), ).extendedKey; } // The account-level (depth-3) extended private key m/44'/60'/0' for a phrase. // A normal thing for a user to hold, and not something the import flow can // derive the BIP-44 account path from. function accountXprv(phrase) { return HDNodeWallet.fromSeed( Mnemonic.fromPhrase(phrase, "").computeSeed(), ).derivePath("m/44'/60'/0'").extendedKey; } // Every single-character substitution of `key`, using base58 characters that // are not the original. Base58 has no visually ambiguous characters, so each // of these is a plausible typo rather than a contrived string. const TYPO_CHARS = ["a", "b", "2", "Z"]; function singleCharacterTypos(key) { const out = []; for (let i = 0; i < key.length; i++) { for (const c of TYPO_CHARS) { if (c === key[i]) continue; out.push(key.slice(0, i) + c + key.slice(i + 1)); } } return out; } describe("hdWalletFromMnemonic", () => { test("first address matches the published vector for m/44'/60'/0'/0/0", () => { expect(wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress).toBe( VECTOR_ADDRESSES[0], ); }); test("second published phrase derives its published address", () => { expect( wallet.hdWalletFromMnemonic(ZERO_ENTROPY_PHRASE).firstAddress, ).toBe(ZERO_ENTROPY_ADDRESS); }); test("returns the account-level xpub, which is watch-only", () => { const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); expect(xpub.startsWith("xpub")).toBe(true); // A neutered ethers node exposes no private key at all, so accept // either absent or null rather than pinning which. expect( HDNodeWallet.fromExtendedKey(xpub).privateKey ?? null, ).toBeNull(); expect(wallet.isValidXprv(xpub)).toBe(false); }); test("the account path is the documented BIP-44 Ethereum path", () => { expect(BIP44_ETH_PATH).toBe("m/44'/60'/0'/0"); }); test("rejects an invalid recovery phrase rather than deriving from it", () => { expect(() => wallet.hdWalletFromMnemonic("not a phrase")).toThrow(); }); }); describe("deriveAddressFromXpub", () => { const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); test.each([0, 1, 2])( "child %i matches the published vector address", (index) => { expect(wallet.deriveAddressFromXpub(xpub, index)).toBe( VECTOR_ADDRESSES[index], ); }, ); test("agrees with hdWalletFromMnemonic at index 0", () => { expect(wallet.deriveAddressFromXpub(xpub, 0)).toBe( wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress, ); }); test("rejects garbage instead of returning an address", () => { expect(() => wallet.deriveAddressFromXpub("xpub-nonsense", 0), ).toThrow(); }); }); describe("hdWalletFromMnemonic seed passphrase handling", () => { // The vectors above are only reproducible with an empty BIP-39 seed // passphrase. This pins that the empty string reaching // HDNodeWallet.fromPhrase is load-bearing: with any passphrase applied the // published address is unreachable, and a wallet derived that way could // not be restored anywhere else from the phrase alone. test("a non-empty seed passphrase would yield a different address", () => { const withPassphrase = HDNodeWallet.fromPhrase( VECTOR_PHRASE, "TREZOR", BIP44_ETH_PATH, ).deriveChild(0).address; expect(withPassphrase).not.toBe(VECTOR_ADDRESSES[0]); }); }); describe("hdWalletFromXprv", () => { // hdWalletFromMnemonic derives the absolute path "m/44'/60'/0'/0" while // hdWalletFromXprv derives the relative path "44'/60'/0'/0". For a // depth-0 master key the two are the same derivation; these tests pin that // equivalence to a published address rather than assuming it. test("master xprv for the vector phrase yields the vector address", () => { expect( wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE)).firstAddress, ).toBe(VECTOR_ADDRESSES[0]); }); test("agrees with hdWalletFromMnemonic on xpub and address", () => { const fromPhrase = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); const fromXprv = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE)); expect(fromXprv).toEqual(fromPhrase); }); test("derived xpub generates the same child addresses", () => { const { xpub } = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE)); expect( [0, 1, 2].map((i) => wallet.deriveAddressFromXpub(xpub, i)), ).toEqual(VECTOR_ADDRESSES); }); test("accepts the BIP-32 test vector 1 master key", () => { const { xpub, firstAddress } = wallet.hdWalletFromXprv(BIP32_VECTOR_1_XPRV); expect(xpub.startsWith("xpub")).toBe(true); expect(firstAddress).toMatch(/^0x[0-9a-fA-F]{40}$/); }); test("rejects a watch-only xpub", () => { const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); expect(() => wallet.hdWalletFromXprv(xpub)).toThrow(); }); test("rejects garbage", () => { expect(() => wallet.hdWalletFromXprv("nonsense")).toThrow(); }); }); describe("isValidXprv", () => { test.each([ ["BIP-32 test vector 1 master key", BIP32_VECTOR_1_XPRV, true], ["the empty string", "", false], ["garbage", "not-a-key", false], ["a bare private key", VECTOR_PRIVATE_KEYS[0], false], ["a truncated xprv", BIP32_VECTOR_1_XPRV.slice(0, -6), false], ["an xprv with an extra character", BIP32_VECTOR_1_XPRV + "a", false], ])("%s -> %s", (_name, key, expected) => { expect(wallet.isValidXprv(key)).toBe(expected); }); test("a watch-only xpub is not an xprv", () => { const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); expect(wallet.isValidXprv(xpub)).toBe(false); }); test("rejects an extended key with a one-character typo", () => { const index = BIP32_VECTOR_1_XPRV.length - 8; const typo = BIP32_VECTOR_1_XPRV.slice(0, index) + (BIP32_VECTOR_1_XPRV[index] === "a" ? "b" : "a") + BIP32_VECTOR_1_XPRV.slice(index + 1); expect(wallet.isValidXprv(typo)).toBe(false); }); // The base58 checksum exists to make a mistyped key impossible to use, and // ethers does not enforce it: HDNodeWallet.fromExtendedKey skips checksum // verification whenever the decoded payload is the usual 82 bytes, which // is precisely the case it is there to catch. A typo anywhere in the key // must be refused, not silently turned into someone else's wallet. test("no single-character typo anywhere in the key is accepted", () => { const accepted = singleCharacterTypos(BIP32_VECTOR_1_XPRV).filter( (typo) => wallet.isValidXprv(typo), ); expect(accepted).toEqual([]); }); test("a typo never yields a wallet, let alone a different one", () => { const correct = wallet.hdWalletFromXprv(BIP32_VECTOR_1_XPRV); const derived = []; for (const typo of singleCharacterTypos(BIP32_VECTOR_1_XPRV)) { try { derived.push(wallet.hdWalletFromXprv(typo).firstAddress); } catch { // Rejected, which is the required behaviour. } } expect(derived).toEqual([]); expect(correct.firstAddress).toBe( "0x022b971dFF0C43305e691DEd7a14367AF19D6407", ); }); }); describe("extended key depth", () => { // hdWalletFromXprv derives the BIP-44 Ethereum account path from the key // it is given. That is only the path it names when the key is the master // key. Under an account-level key the same derivation lands at // m/44'/60'/0'/44'/60'/0'/0, whose addresses correspond to nothing the // user holds, so a non-master key is refused rather than derived from. test("a master key is a master key", () => { expect(wallet.isMasterExtendedKey(masterXprv(VECTOR_PHRASE))).toBe( true, ); expect(wallet.isMasterExtendedKey(BIP32_VECTOR_1_XPRV)).toBe(true); }); test("an account-level key is not a master key", () => { expect(wallet.isMasterExtendedKey(accountXprv(VECTOR_PHRASE))).toBe( false, ); }); test("a derived xpub is not a master key", () => { expect( wallet.isMasterExtendedKey( wallet.hdWalletFromMnemonic(VECTOR_PHRASE).xpub, ), ).toBe(false); }); test("a mistyped key is not a master key either", () => { expect(wallet.isMasterExtendedKey(BIP32_VECTOR_1_XPRV + "a")).toBe( false, ); }); test("hdWalletFromXprv rejects an account-level key", () => { expect(() => wallet.hdWalletFromXprv(accountXprv(VECTOR_PHRASE)), ).toThrow(/master/i); }); test("getSignerForAddress rejects an account-level key", () => { expect(() => wallet.getSignerForAddress( { type: "xprv" }, 0, accountXprv(VECTOR_PHRASE), ), ).toThrow(/master/i); }); test("the account-level key is well-formed, so only depth rejects it", () => { expect(wallet.isValidXprv(accountXprv(VECTOR_PHRASE))).toBe(true); }); test("a master key still imports and derives the published addresses", () => { const { xpub, firstAddress } = wallet.hdWalletFromXprv( masterXprv(VECTOR_PHRASE), ); expect(firstAddress).toBe(VECTOR_ADDRESSES[0]); expect( [0, 1, 2].map((i) => wallet.deriveAddressFromXpub(xpub, i)), ).toEqual(VECTOR_ADDRESSES); }); }); describe("deriveAddressFromXpub checksum enforcement", () => { // The xpub path shares the hole: fromExtendedKey accepts a mistyped xpub // just as readily, and deriveAddressFromXpub would hand back addresses // from a different tree. const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); test("the correct xpub still derives the published addresses", () => { expect(wallet.deriveAddressFromXpub(xpub, 0)).toBe(VECTOR_ADDRESSES[0]); }); test("no single-character typo anywhere in an xpub is accepted", () => { const derived = []; for (const typo of singleCharacterTypos(xpub)) { try { derived.push(wallet.deriveAddressFromXpub(typo, 0)); } catch { // Rejected, which is the required behaviour. } } expect(derived).toEqual([]); }); }); describe("isValidMnemonic", () => { test.each([ ["the vector phrase", VECTOR_PHRASE, true], ["the BIP-39 zero-entropy phrase", ZERO_ENTROPY_PHRASE, true], [ "a 12-word phrase with a bad checksum", "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon abandon", false, ], ["an 11-word phrase", "abandon ".repeat(10) + "about", false], ["a word outside the wordlist", VECTOR_PHRASE + " zzzzzz", false], ["the empty string", "", false], ["garbage", "correct horse battery staple", false], ])("%s -> %s", (_name, phrase, expected) => { expect(wallet.isValidMnemonic(phrase)).toBe(expected); }); }); describe("addressFromPrivateKey", () => { test.each([0, 1, 2])( "published key %i yields its published address", (index) => { expect( wallet.addressFromPrivateKey(VECTOR_PRIVATE_KEYS[index]), ).toBe(VECTOR_ADDRESSES[index]); }, ); test("rejects a key of the wrong length", () => { expect(() => wallet.addressFromPrivateKey("0xdeadbeef")).toThrow(); }); test("rejects the empty string", () => { expect(() => wallet.addressFromPrivateKey("")).toThrow(); }); }); describe("getSignerForAddress", () => { test.each([0, 1, 2])("hd wallet, address index %i", (index) => { const signer = wallet.getSignerForAddress( { type: "hd" }, index, VECTOR_PHRASE, ); expect(signer.address).toBe(VECTOR_ADDRESSES[index]); expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]); }); test.each([0, 1, 2])("xprv wallet, address index %i", (index) => { const signer = wallet.getSignerForAddress( { type: "xprv" }, index, masterXprv(VECTOR_PHRASE), ); expect(signer.address).toBe(VECTOR_ADDRESSES[index]); expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]); }); test("single private key ignores the address index", () => { for (const index of [0, 1, 2]) { const signer = wallet.getSignerForAddress( { type: "privkey" }, index, VECTOR_PRIVATE_KEYS[1], ); expect(signer.address).toBe(VECTOR_ADDRESSES[1]); } }); test("the returned signer signs recoverably as the expected address", async () => { const signer = wallet.getSignerForAddress( { type: "hd" }, 1, VECTOR_PHRASE, ); const message = "AutistMask derivation test"; const signature = await signer.signMessage(message); expect(verifyMessage(message, signature)).toBe(VECTOR_ADDRESSES[1]); }); });