// Preparation of the transaction the approval screen displays.
//
// This is the half of the fix that makes the verification in
// approvalVerify.test.js mean anything: the numbers the user reads have to be
// produced before the screen is drawn and be the numbers that get signed. What
// is asserted here is that the object leaving this module is complete (nothing
// is left for the popup to fill in), that it survives the messaging boundary
// (extension messaging is JSON, which has no bigint), and that nothing the
// requesting page or the RPC node can say turns it into an approval that
// should never have been raised.

const { Network, Wallet } = require("ethers");
const {
    prepareApprovalTx,
    serializeApprovedTx,
    POPULATE_TIMEOUT_MS,
} = require("../src/shared/approvalTx");
const {
    SERIALIZED_FIELDS,
    MAX_FEE_PER_GAS,
    MAX_GAS_LIMIT,
} = require("../src/shared/approvalVerify");

const SIGNER_KEY =
    "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";

// The ordinary dApp request: recipient, value, call data, and nothing else.
const TX_PARAMS = {
    from: signer.address,
    to: RECIPIENT,
    value: "0x2386f26fc10000",
    data: "0xdeadbeef",
};

function providerWith(overrides) {
    return {
        getNetwork: async () => Network.from(1),
        getTransactionCount: async () => 7,
        estimateGas: async () => 21000n,
        getFeeData: async () => ({
            gasPrice: 2000000000n,
            maxFeePerGas: 2000000000n,
            maxPriorityFeePerGas: 1000000000n,
        }),
        ...(overrides || {}),
    };
}

// A node that only quotes a flat gas price, so populateTransaction produces a
// legacy transaction rather than an EIP-1559 one.
const legacyProvider = providerWith({
    getFeeData: async () => ({
        gasPrice: 2000000000n,
        maxFeePerGas: null,
        maxPriorityFeePerGas: null,
    }),
});

describe("prepareApprovalTx", () => {
    test("fills in everything the request left out", async () => {
        const approved = await prepareApprovalTx(
            providerWith(),
            signer.address,
            TX_PARAMS,
        );
        expect(approved).toEqual({
            type: 2,
            from: signer.address,
            chainId: "0x1",
            nonce: "0x7",
            gasLimit: "0x5208",
            maxPriorityFeePerGas: "0x3b9aca00",
            maxFeePerGas: "0x77359400",
            to: RECIPIENT,
            value: TX_PARAMS.value,
            data: TX_PARAMS.data,
            accessList: [],
        });
    });

    // The object is displayed, signed and verified against on the far side of
    // chrome.runtime.sendMessage, which is JSON: a bigint would throw on the
    // way out and a field that did not survive the trip would be a field the
    // user was shown and nothing compared.
    test("survives the messaging boundary unchanged", async () => {
        const approved = await prepareApprovalTx(
            providerWith(),
            signer.address,
            TX_PARAMS,
        );
        expect(JSON.parse(JSON.stringify(approved))).toEqual(approved);
        for (const value of Object.values(approved)) {
            expect(typeof value).not.toBe("bigint");
        }
    });

    test("carries exactly the fields its type serializes, and the signer", async () => {
        const approved = await prepareApprovalTx(
            providerWith(),
            signer.address,
            TX_PARAMS,
        );
        expect(Object.keys(approved).sort()).toEqual(
            ["type", "from", ...SERIALIZED_FIELDS[2]].sort(),
        );
    });

    test("produces a legacy transaction when that is all the node quotes", async () => {
        const approved = await prepareApprovalTx(
            legacyProvider,
            signer.address,
            TX_PARAMS,
        );
        expect(approved.type).toBe(0);
        expect(approved.gasPrice).toBe("0x77359400");
        expect(approved.maxFeePerGas).toBeUndefined();
        expect(Object.keys(approved).sort()).toEqual(
            ["type", "from", ...SERIALIZED_FIELDS[0]].sort(),
        );
    });

    test("keeps a nonce, gas limit and fee the request did fix", async () => {
        const approved = await prepareApprovalTx(
            providerWith(),
            signer.address,
            {
                ...TX_PARAMS,
                nonce: "0x2",
                gasLimit: "0x30d40",
                maxFeePerGas: "0x12a05f200",
                maxPriorityFeePerGas: "0x3b9aca00",
            },
        );
        expect(approved.nonce).toBe("0x2");
        expect(approved.gasLimit).toBe("0x30d40");
        expect(approved.maxFeePerGas).toBe("0x12a05f200");
    });

    test("carries an access list the request asked for", async () => {
        const approved = await prepareApprovalTx(
            providerWith(),
            signer.address,
            {
                ...TX_PARAMS,
                accessList: [{ address: RECIPIENT, storageKeys: [] }],
            },
        );
        expect(approved.accessList).toEqual([
            { address: RECIPIENT, storageKeys: [] },
        ]);
    });

    // The request is page-controlled. Anything this wallet does not act on is
    // dropped before ethers sees it, so a field a future ethers learns to
    // carry cannot be picked up out of it without this module knowing.
    test("drops request fields this wallet does not act on", async () => {
        const approved = await prepareApprovalTx(
            providerWith(),
            signer.address,
            {
                ...TX_PARAMS,
                authorizationList: [{ address: RECIPIENT }],
                blobVersionedHashes: ["0x01" + "ab".repeat(31)],
                customData: { anything: true },
            },
        );
        expect(approved.authorizationList).toBeUndefined();
        expect(approved.blobVersionedHashes).toBeUndefined();
        expect(approved.customData).toBeUndefined();
        expect(approved.type).toBe(2);
    });

    test("refuses a transaction type this wallet does not sign", async () => {
        await expect(
            prepareApprovalTx(providerWith(), signer.address, {
                ...TX_PARAMS,
                type: 4,
            }),
        ).rejects.toThrow(/type this wallet does not sign/);
    });

    test("refuses to raise an approval with no active address", async () => {
        await expect(
            prepareApprovalTx(providerWith(), null, TX_PARAMS),
        ).rejects.toThrow(/no active address/);
    });

    // The ceilings as a backstop: equality with the screen cannot bound what
    // the node talks the wallet into putting on the screen, so it is refused
    // before the user is shown anything.
    test("refuses a fee the node quoted above the ceiling", async () => {
        const gouging = providerWith({
            getFeeData: async () => ({
                gasPrice: MAX_FEE_PER_GAS + 1n,
                maxFeePerGas: MAX_FEE_PER_GAS + 1n,
                maxPriorityFeePerGas: 1000000000n,
            }),
        });
        await expect(
            prepareApprovalTx(gouging, signer.address, TX_PARAMS),
        ).rejects.toThrow(/fee per gas far above any plausible value/);
    });

    test("refuses a gas limit the node estimated above the ceiling", async () => {
        const absurd = providerWith({
            estimateGas: async () => MAX_GAS_LIMIT + 1n,
        });
        await expect(
            prepareApprovalTx(absurd, signer.address, TX_PARAMS),
        ).rejects.toThrow(/gas limit no network this wallet supports/);
    });

    // No approval and no window: the failure goes back to the page the click
    // came from, in a sentence.
    test("reports a failed estimate as a full sentence", async () => {
        const reverting = providerWith({
            estimateGas: async () => {
                throw new Error("execution reverted: ERC20: transfer amount");
            },
        });
        let thrown;
        try {
            await prepareApprovalTx(reverting, signer.address, TX_PARAMS);
        } catch (e) {
            thrown = e;
        }
        expect(thrown.message).toMatch(
            /^The transaction could not be prepared/,
        );
        expect(thrown.message).toMatch(/execution reverted/);
        expect(thrown.message).toMatch(/^[A-Z].*\.$/);
    });

    // Without a bound, an unreachable node leaves the page's promise pending
    // with nothing on screen to explain it.
    test("gives up on a node that never answers", async () => {
        jest.useFakeTimers();
        try {
            const hanging = providerWith({
                estimateGas: () => new Promise(() => {}),
            });
            const pending = prepareApprovalTx(
                hanging,
                signer.address,
                TX_PARAMS,
            );
            const settled = expect(pending).rejects.toThrow(
                /did not answer in time/,
            );
            await jest.advanceTimersByTimeAsync(POPULATE_TIMEOUT_MS + 1);
            await settled;
        } finally {
            jest.useRealTimers();
        }
    });
});

describe("serializeApprovedTx", () => {
    // Unreachable through prepareApprovalTx while the request type is checked
    // first, which is what it is for: a node or an ethers upgrade that
    // populates a type this wallet does not sign must not produce an approval.
    test("refuses a populated transaction of a type this wallet does not sign", () => {
        expect(() =>
            serializeApprovedTx(
                { type: 3, to: RECIPIENT, nonce: 7 },
                signer.address,
            ),
        ).toThrow(/type this wallet does not sign/);
    });

    test("refuses a populated transaction missing a quantity", () => {
        expect(() =>
            serializeApprovedTx(
                {
                    type: 2,
                    chainId: 1n,
                    nonce: 7,
                    gasLimit: 21000n,
                    maxFeePerGas: 2000000000n,
                    to: RECIPIENT,
                    value: 0n,
                    data: "0x",
                },
                signer.address,
            ),
        ).toThrow(/did not supply a maxPriorityFeePerGas/);
    });

    test("keeps a contract creation's absent recipient absent", () => {
        const approved = serializeApprovedTx(
            {
                type: 0,
                chainId: 1n,
                nonce: 7,
                gasPrice: 2000000000n,
                gasLimit: 21000n,
                to: null,
                value: 0n,
                data: "0x600160005500",
            },
            signer.address,
        );
        expect(approved.to).toBeNull();
        expect(approved.value).toBe("0x0");
        expect(approved.data).toBe("0x600160005500");
    });
});
