// 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]);
    });
});
