const {
    Network,
    Transaction,
    Wallet,
    decodeRlp,
    encodeRlp,
} = require("ethers");
const {
    verifySignedTx,
    verifySignature,
    assertNoForbiddenFields,
    assertNothingUnchecked,
    assertCanonicalBytes,
    assertWithinCeilings,
    sameAddress,
    failureIsRetryable,
    isNonceCollision,
    describeTxFailure,
    describeSigningFailure,
    NONCE_COLLISION_MESSAGE,
    ALLOWED_TX_TYPES,
    SERIALIZED_FIELDS,
    FORBIDDEN_FIELDS,
    APPROVED_FIELDS,
    TX_STAGE_SIGN,
    TX_STAGE_VERIFY,
    TX_STAGE_BROADCAST,
    TX_STAGE_NONCE,
    MAX_GAS_LIMIT,
    MAX_FEE_PER_GAS,
} = require("../src/shared/approvalVerify");
const { prepareApprovalTx } = require("../src/shared/approvalTx");
const { getSignerForAddress } = require("../src/shared/wallet");

// Fixed test keys — never used for anything but these tests.
const SIGNER_KEY =
    "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const OTHER_KEY =
    "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a";

const signer = new Wallet(SIGNER_KEY);
const other = new Wallet(OTHER_KEY);

const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";

// The chain id of the selected network, as networks.js carries it.
const SELECTED = "0x1";
const SEPOLIA = "0xaa36a7";

// Parameters as a dApp would supply them over eth_sendTransaction. Note what
// is missing: nonce, gas limit and fees. The background fills those in before
// the approval screen is drawn, which is why the approval below and not this
// object is what every comparison runs against.
const TX_PARAMS = {
    from: signer.address,
    to: RECIPIENT,
    value: "0x2386f26fc10000",
    data: "0xdeadbeef",
    gas: "0x5208",
};

// The values populateTransaction() fills in when the dApp fixed none of them.
const POPULATED = {
    chainId: 1,
    nonce: 7,
    gasLimit: 100000n,
    maxFeePerGas: 2000000000n,
    maxPriorityFeePerGas: 1000000000n,
    type: 2,
};

// Build a signable transaction from a request. The background populates the
// same fields through populateTransaction(); here they are fixed so the test
// needs no provider. `overrides` stands in for what a tampered or misbuilt
// popup would put on the wire.
function txFor(params, overrides) {
    return {
        ...POPULATED,
        to: params.to,
        value: params.value === undefined ? 0n : BigInt(params.value),
        data: params.data || "0x",
        ...(overrides || {}),
    };
}

// The populated transaction the approval screen displayed, which is the object
// the artifact is verified against. Built from the same fields as the signable
// transaction above, because that is the point: displayed and verified are one
// object.
function approvedFor(params, overrides) {
    return {
        from: signer.address,
        accessList: [],
        ...txFor(params, overrides),
    };
}

// The ordinary case: the dApp's request, populated.
const APPROVED = approvedFor(TX_PARAMS);

async function signedFor(params, withWallet, overrides) {
    return (withWallet || signer).signTransaction(txFor(params, overrides));
}

// Sign the approved transaction with one field changed from what was
// populated, which is the shape of every tamper case below.
async function signedWith(overrides) {
    return signedFor(TX_PARAMS, signer, overrides);
}

describe("sameAddress", () => {
    test("compares checksummed and lowercase forms as equal", () => {
        expect(sameAddress(RECIPIENT, RECIPIENT.toLowerCase())).toBe(true);
    });

    test("treats two absent addresses as equal (contract creation)", () => {
        expect(sameAddress(null, undefined)).toBe(true);
        expect(sameAddress("", null)).toBe(true);
    });

    test("treats one absent address as unequal", () => {
        expect(sameAddress(RECIPIENT, null)).toBe(false);
        expect(sameAddress(null, RECIPIENT)).toBe(false);
    });

    test("does not throw on values that are not addresses", () => {
        expect(sameAddress("not-an-address", RECIPIENT)).toBe(false);
    });
});

describe("verifySignedTx", () => {
    test("accepts the approved transaction signed by the approved address", async () => {
        const raw = await signedFor(TX_PARAMS);
        const parsed = verifySignedTx(raw, APPROVED, signer.address, SELECTED);
        expect(parsed.from).toBe(signer.address);
        expect(parsed.hash).toBe(Transaction.from(raw).hash);
    });

    test("accepts a contract creation with no recipient", async () => {
        const params = { to: undefined, value: "0x0", data: "0x600160005500" };
        const raw = await signedFor(params);
        expect(() =>
            verifySignedTx(raw, approvedFor(params), signer.address, SELECTED),
        ).not.toThrow();
    });

    test("accepts an absent value as zero", async () => {
        const params = { to: RECIPIENT, data: "0x" };
        const raw = await signedFor(params);
        expect(() =>
            verifySignedTx(raw, approvedFor(params), signer.address, SELECTED),
        ).not.toThrow();
    });

    test("accepts call data whose case differs from the approval", async () => {
        const params = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" };
        const raw = await signedFor(params);
        expect(() =>
            verifySignedTx(raw, approvedFor(params), signer.address, SELECTED),
        ).not.toThrow();
    });

    test("rejects a swapped recipient", async () => {
        const raw = await signedFor({
            ...TX_PARAMS,
            to: OTHER_RECIPIENT,
        });
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, SELECTED),
        ).toThrow(/approved recipient/);
    });

    test("rejects an inflated value", async () => {
        const raw = await signedFor({
            ...TX_PARAMS,
            value: "0x4563918244f40000",
        });
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, SELECTED),
        ).toThrow(/approved value/);
    });

    test("rejects substituted call data", async () => {
        const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" });
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, SELECTED),
        ).toThrow(/approved call data/);
    });

    test("rejects a transaction signed by a different address", async () => {
        const raw = await signedFor(TX_PARAMS, other);
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, SELECTED),
        ).toThrow(/different address/);
    });

    // The address the approval named, not whichever address is active when the
    // artifact comes back: an approval raised for one account cannot be
    // satisfied by a signature from another, whatever the wallet switched to
    // in between.
    test("rejects a signature from the address that is active now", async () => {
        const raw = await signedFor(TX_PARAMS, other);
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, SELECTED),
        ).toThrow(/different address than the one that was approved/);
        // The same artifact against the same approval, verified for the other
        // address, is what would have happened had expectedFrom been read from
        // the wallet's current state.
        expect(() =>
            verifySignedTx(raw, APPROVED, other.address, SELECTED),
        ).not.toThrow();
    });

    test("rejects an unsigned transaction", () => {
        const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized;
        expect(() =>
            verifySignedTx(unsigned, APPROVED, signer.address, SELECTED),
        ).toThrow(/no valid signature/);
    });

    test("rejects a missing or malformed payload", () => {
        expect(() =>
            verifySignedTx(undefined, APPROVED, signer.address, SELECTED),
        ).toThrow(/missing or malformed/);
        expect(() =>
            verifySignedTx("nope", APPROVED, signer.address, SELECTED),
        ).toThrow(/missing or malformed/);
        expect(() =>
            verifySignedTx("0xc0ffee", APPROVED, signer.address, SELECTED),
        ).toThrow(/could not be decoded/);
    });

    test("every rejection message is a full sentence", async () => {
        const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
        try {
            verifySignedTx(raw, APPROVED, signer.address, SELECTED);
            throw new Error("expected a rejection");
        } catch (e) {
            expect(e.message).toMatch(/^[A-Z].*\.$/);
        }
    });
});

// The defect this file's approvals now stand against: for every field the dApp
// left out, the old comparison had nothing to compare and skipped the field,
// so the fee and the nonce the user read off the screen were checked by the
// ceilings alone. A populated approval fixes all of them, and an approval that
// does not fix one is a refusal rather than a pass.
describe("verifySignedTx against what was displayed", () => {
    test("a fee differing from the displayed one is refused", async () => {
        // Ten times the fee the screen showed, and far below the ceiling: the
        // artifact the old comparison would have accepted.
        const inflated = 20000000000n;
        expect(inflated).toBeLessThan(MAX_FEE_PER_GAS);
        const raw = await signedWith({ maxFeePerGas: inflated });
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, SELECTED),
        ).toThrow(/approved maximum fee per gas/);
    });

    test("a nonce differing from the displayed one is refused", async () => {
        const raw = await signedWith({ nonce: 8 });
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, SELECTED),
        ).toThrow(/approved nonce/);
    });

    test("a gas limit differing from the displayed one is refused", async () => {
        const raw = await signedWith({ gasLimit: 250000n });
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, SELECTED),
        ).toThrow(/approved gas limit/);
    });

    test("an approval fixing no quantity is refused, not waved through", async () => {
        const raw = await signedWith({});
        for (const key of [
            "chainId",
            "nonce",
            "gasLimit",
            "maxFeePerGas",
            "maxPriorityFeePerGas",
        ]) {
            const incomplete = { ...APPROVED };
            delete incomplete[key];
            let thrown;
            try {
                verifySignedTx(raw, incomplete, signer.address, SELECTED);
                throw new Error("expected a rejection for " + key);
            } catch (e) {
                thrown = e;
            }
            expect(thrown.message).toMatch(/fixes no /);
            expect(thrown.approvalMismatch).toBe(true);
        }
    });

    test("no approved transaction at all is refused", async () => {
        const raw = await signedWith({});
        for (const approved of [undefined, null, "0xdeadbeef"]) {
            expect(() =>
                verifySignedTx(raw, approved, signer.address, SELECTED),
            ).toThrow(/no approved transaction/);
        }
    });

    test("an approval fixing no transaction type is refused", async () => {
        const raw = await signedWith({});
        const incomplete = { ...APPROVED };
        delete incomplete.type;
        expect(() =>
            verifySignedTx(raw, incomplete, signer.address, SELECTED),
        ).toThrow(/fixes no transaction type/);
    });

    test("an artifact of a type other than the approved one is refused", async () => {
        // Same fee mechanism on both sides, so only the type differs: a type 1
        // artifact against a type 2 approval.
        const approved = approvedFor(TX_PARAMS, {
            type: 1,
            gasPrice: 2000000000n,
            maxFeePerGas: null,
            maxPriorityFeePerGas: null,
        });
        const raw = await signedWith({
            type: 0,
            gasPrice: 2000000000n,
            maxFeePerGas: null,
            maxPriorityFeePerGas: null,
        });
        expect(() =>
            verifySignedTx(raw, approved, signer.address, SELECTED),
        ).toThrow(/approved transaction type/);
    });
});

// One case per consequential field: the field alone differs from what was
// approved, and that alone must refuse the signature.
describe("verifySignedTx field comparison", () => {
    test("rejects a chain id that is not the selected network", async () => {
        const raw = await signedWith({ chainId: 11155111 });
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, SELECTED),
        ).toThrow(/different network than the one that is selected/);
    });

    test("rejects a chain id that is not the approved one", async () => {
        // Selected network and signed chain id agree; the approval was raised
        // for a different chain, so the artifact is not what was approved.
        const approved = { ...APPROVED, chainId: SEPOLIA };
        const raw = await signedWith({});
        expect(() =>
            verifySignedTx(raw, approved, signer.address, SELECTED),
        ).toThrow(/different network than the one that was approved/);
    });

    test("refuses when the selected network is unknown", async () => {
        const raw = await signedWith({});
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, undefined),
        ).toThrow(/selected network is unknown/);
    });

    test("rejects a substituted nonce", async () => {
        const raw = await signedWith({ nonce: 8 });
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, SELECTED),
        ).toThrow(/approved nonce/);
    });

    test("rejects a substituted gas limit", async () => {
        const raw = await signedWith({ gasLimit: 250000n });
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, SELECTED),
        ).toThrow(/approved gas limit/);
    });

    test("rejects a substituted maximum fee per gas", async () => {
        const raw = await signedWith({ maxFeePerGas: 900000000000n });
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, SELECTED),
        ).toThrow(/approved maximum fee per gas/);
    });

    test("rejects a substituted maximum priority fee per gas", async () => {
        const raw = await signedWith({ maxPriorityFeePerGas: 1500000000n });
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, SELECTED),
        ).toThrow(/approved maximum priority fee per gas/);
    });

    test("rejects a substituted legacy gas price", async () => {
        const legacy = {
            type: 0,
            gasPrice: 2000000000n,
            maxFeePerGas: null,
            maxPriorityFeePerGas: null,
        };
        const approved = approvedFor(TX_PARAMS, legacy);
        const raw = await signedWith({ ...legacy, gasPrice: 9000000000n });
        expect(() =>
            verifySignedTx(raw, approved, signer.address, SELECTED),
        ).toThrow(/approved gas price/);
    });

    test("rejects an approved legacy fee signed as an EIP-1559 fee", async () => {
        const approved = approvedFor(TX_PARAMS, {
            type: 0,
            gasPrice: 2000000000n,
            maxFeePerGas: null,
            maxPriorityFeePerGas: null,
        });
        const raw = await signedWith({});
        expect(() =>
            verifySignedTx(raw, approved, signer.address, SELECTED),
        ).toThrow(/approved fee mechanism/);
    });

    test("rejects an approved EIP-1559 fee signed as a legacy fee", async () => {
        const raw = await signedWith({
            type: 0,
            gasPrice: 2000000000n,
            maxFeePerGas: null,
            maxPriorityFeePerGas: null,
        });
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, SELECTED),
        ).toThrow(/approved fee mechanism/);
    });

    // The ceilings are a backstop against what the RPC node can talk the
    // wallet into populating and displaying, so they are checked against an
    // approval that carries the absurd value too — equality alone would accept
    // it, which is exactly what the ceiling is there for.
    test("rejects a gas limit above anything a supported network accepts", async () => {
        const overrides = { gasLimit: MAX_GAS_LIMIT + 1n };
        const raw = await signedWith(overrides);
        expect(() =>
            verifySignedTx(
                raw,
                approvedFor(TX_PARAMS, overrides),
                signer.address,
                SELECTED,
            ),
        ).toThrow(/gas limit no network this wallet supports/);
    });

    test("rejects an absurd fee per gas even when it was displayed", async () => {
        const overrides = {
            maxFeePerGas: MAX_FEE_PER_GAS + 1n,
            maxPriorityFeePerGas: MAX_FEE_PER_GAS + 1n,
        };
        const raw = await signedWith(overrides);
        expect(() =>
            verifySignedTx(
                raw,
                approvedFor(TX_PARAMS, overrides),
                signer.address,
                SELECTED,
            ),
        ).toThrow(/fee per gas far above any plausible value/);
    });

    test("assertWithinCeilings is the same check on either side of the screen", () => {
        expect(() =>
            assertWithinCeilings({ gasLimit: MAX_GAS_LIMIT + 1n }),
        ).toThrow(/gas limit no network this wallet supports/);
        for (const key of [
            "gasPrice",
            "maxFeePerGas",
            "maxPriorityFeePerGas",
        ]) {
            expect(() =>
                assertWithinCeilings({ [key]: MAX_FEE_PER_GAS + 1n }),
            ).toThrow(/fee per gas far above any plausible value/);
        }
        expect(() =>
            assertWithinCeilings({
                gasLimit: MAX_GAS_LIMIT,
                maxFeePerGas: MAX_FEE_PER_GAS,
                maxPriorityFeePerGas: MAX_FEE_PER_GAS,
            }),
        ).not.toThrow();
        // Nothing to bound is not a failure: a type 2 approval carries no gas
        // price, and a bare object must not be refused for lacking one.
        expect(() => assertWithinCeilings({})).not.toThrow();
    });

    test("every field mismatch is a refusal, not a warning", async () => {
        const raw = await signedWith({ nonce: 8 });
        try {
            verifySignedTx(raw, APPROVED, signer.address, SELECTED);
            throw new Error("expected a rejection");
        } catch (e) {
            expect(e.approvalMismatch).toBe(true);
            expect(e.message).toMatch(/^[A-Z].*\.$/);
        }
    });
});

// The transaction type decides which fields exist, so an artifact of a type
// this wallet does not sign carries consequences the approval cannot describe
// and none of the field comparisons can see. The refusal has to come from the
// type allowlist rather than from a field comparison, so these run against an
// approval whose every other field matches the artifact exactly.
describe("verifySignedTx transaction type", () => {
    const BARE_REQUEST = {
        from: signer.address,
        to: RECIPIENT,
        value: "0x2386f26fc10000",
        data: "0x",
    };
    const BARE_APPROVAL = approvedFor(BARE_REQUEST);

    // An EIP-7702 artifact that pays the approved amount to the approved
    // recipient and, in the same transaction, installs the attacker's code at
    // the signer's own account for good. Every field the approval screen shows
    // matches; only the type and the authorization list do not.
    test("refuses a type 4 artifact that delegates the signer's own account", async () => {
        const authorization = await signer.authorize({
            address: OTHER_RECIPIENT,
            chainId: 1,
            nonce: 8,
        });
        const raw = await signedFor(BARE_REQUEST, signer, {
            type: 4,
            authorizationList: [authorization],
        });
        const parsed = Transaction.from(raw);
        expect(parsed.type).toBe(4);
        expect(parsed.authorizationList[0].address).toBe(OTHER_RECIPIENT);
        expect(() =>
            verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
        ).toThrow(/type this wallet does not sign/);
    });

    test("refuses a type 3 blob artifact", async () => {
        const raw = await signedFor(BARE_REQUEST, signer, {
            type: 3,
            maxFeePerBlobGas: 1000000000n,
            blobVersionedHashes: ["0x01" + "ab".repeat(31)],
        });
        expect(Transaction.from(raw).type).toBe(3);
        expect(() =>
            verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
        ).toThrow(/type this wallet does not sign/);
    });

    test("refuses every type outside the allowlist, not just the known ones", async () => {
        for (const type of [3, 4]) {
            expect(ALLOWED_TX_TYPES).not.toContain(type);
        }
        expect(ALLOWED_TX_TYPES).toEqual([0, 1, 2]);
    });

    test("a type refusal is a refusal, not a warning", async () => {
        const authorization = await signer.authorize({
            address: OTHER_RECIPIENT,
            chainId: 1,
            nonce: 8,
        });
        const raw = await signedFor(BARE_REQUEST, signer, {
            type: 4,
            authorizationList: [authorization],
        });
        try {
            verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED);
            throw new Error("expected a rejection");
        } catch (e) {
            expect(e.approvalMismatch).toBe(true);
            expect(e.message).toMatch(/^[A-Z].*\.$/);
        }
    });

    test("accepts a legacy type 0 transaction", async () => {
        const legacy = {
            type: 0,
            gasPrice: 2000000000n,
            maxFeePerGas: null,
            maxPriorityFeePerGas: null,
        };
        const raw = await signedFor(BARE_REQUEST, signer, legacy);
        expect(() =>
            verifySignedTx(
                raw,
                approvedFor(BARE_REQUEST, legacy),
                signer.address,
                SELECTED,
            ),
        ).not.toThrow();
    });

    test("accepts a type 1 transaction whose access list is the approved one", async () => {
        const overrides = {
            type: 1,
            gasPrice: 2000000000n,
            maxFeePerGas: null,
            maxPriorityFeePerGas: null,
            accessList: [{ address: OTHER_RECIPIENT, storageKeys: [] }],
        };
        const raw = await signedFor(BARE_REQUEST, signer, overrides);
        expect(Transaction.from(raw).type).toBe(1);
        expect(() =>
            verifySignedTx(
                raw,
                approvedFor(BARE_REQUEST, overrides),
                signer.address,
                SELECTED,
            ),
        ).not.toThrow();
    });

    test("refuses an access list the approval never carried", async () => {
        const raw = await signedFor(BARE_REQUEST, signer, {
            accessList: [{ address: OTHER_RECIPIENT, storageKeys: [] }],
        });
        expect(() =>
            verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
        ).toThrow(/approved access list/);
    });

    test("treats an absent access list and an empty one as the same thing", async () => {
        const approved = { ...BARE_APPROVAL };
        delete approved.accessList;
        expect(approved.accessList).toBeUndefined();
        const raw = await signedFor(BARE_REQUEST, signer, {});
        expect(Transaction.from(raw).accessList).toEqual([]);
        expect(() =>
            verifySignedTx(raw, approved, signer.address, SELECTED),
        ).not.toThrow();
    });
});

// The allowlist is only exhaustive while it accounts for every field an
// artifact can carry. These tests are what makes that claim checkable rather
// than asserted.
describe("verifySignedTx exhaustiveness", () => {
    // Every accessor ethers exposes on a parsed transaction, and where this
    // module deals with it. If an ethers upgrade adds a transaction field,
    // this fails and forces a decision about it instead of letting it default
    // to unchecked.
    test("every field ethers can parse is accounted for", () => {
        const derived = [
            // Recovered from the signature or computed from the payload, not
            // independent content: covered by the signer check and by the
            // fields below.
            "from",
            "fromPublicKey",
            "hash",
            "serialized",
            "signature",
            "type",
            "typeName",
            "unsignedHash",
            "unsignedSerialized",
            // Blob sidecar machinery, meaningful only alongside `blobs`,
            // which is refused outright.
            "kzg",
            "blobWrapperVersion",
        ];
        const accounted = new Set([
            ...derived,
            ...FORBIDDEN_FIELDS.map((f) => f.key),
            ...Object.values(SERIALIZED_FIELDS).flat(),
        ]);
        const exposed = Object.getOwnPropertyNames(Transaction.prototype)
            .filter((name) => {
                const d = Object.getOwnPropertyDescriptor(
                    Transaction.prototype,
                    name,
                );
                return d && typeof d.get === "function";
            })
            .sort();
        expect(exposed.filter((name) => !accounted.has(name))).toEqual([]);
    });

    // The comparison loop runs over the fields a type serializes and refuses a
    // field it has no comparator for. That refusal is unreachable only while
    // the table covers the whole of SERIALIZED_FIELDS, so the coverage is
    // pinned here rather than assumed: adding a field to a type without a
    // comparator would otherwise turn every transaction of that type into a
    // refusal, and adding a comparator without the field would be a check that
    // never runs.
    test("every field a type serializes has a comparator", () => {
        const serialized = new Set(
            Object.values(SERIALIZED_FIELDS).flat().sort(),
        );
        expect([...serialized].filter((key) => !APPROVED_FIELDS[key])).toEqual(
            [],
        );
        expect(
            Object.keys(APPROVED_FIELDS).filter((key) => !serialized.has(key)),
        ).toEqual([]);
    });

    // The two layers behind the type allowlist. Nothing reachable through
    // verifySignedTx can trip either of them while the allowlist holds — that
    // is what they are for — so they are exercised directly rather than taken
    // on trust.
    test("a forbidden field is refused even on an allowed type", async () => {
        const authorization = await signer.authorize({
            address: OTHER_RECIPIENT,
            chainId: 1,
            nonce: 8,
        });
        const carriers = {
            authorizationList: [authorization],
            blobVersionedHashes: ["0x01" + "ab".repeat(31)],
            blobs: ["0x00"],
            maxFeePerBlobGas: 1n,
        };
        for (const key of Object.keys(carriers)) {
            expect(FORBIDDEN_FIELDS.map((f) => f.key)).toContain(key);
            let thrown;
            try {
                assertNoForbiddenFields({ type: 2, [key]: carriers[key] });
                throw new Error("expected a rejection");
            } catch (e) {
                thrown = e;
            }
            expect(thrown.approvalMismatch).toBe(true);
            expect(thrown.message).toMatch(/^[A-Z].*\.$/);
        }
        expect(() => assertNoForbiddenFields({ type: 2 })).not.toThrow();
    });

    // Stands in for a future ethers that parses a field this module does not
    // know about onto an allowed type: every field the module checks is
    // identical, and the bytes are not.
    test("an artifact carrying more than the checked fields is refused", async () => {
        const parsed = Transaction.from(await signedWith({}));
        const smuggled = { type: parsed.type };
        for (const key of SERIALIZED_FIELDS[parsed.type]) {
            smuggled[key] = parsed[key];
        }
        smuggled.unsignedSerialized = parsed.unsignedSerialized + "ff";
        expect(() => assertNothingUnchecked(smuggled)).toThrow(
            /beyond the fields that were checked/,
        );
        expect(() => assertNothingUnchecked(parsed)).not.toThrow();
    });

    // The closing check rebuilds the artifact from the fields the module
    // compared and compares the bytes, so an artifact carrying anything else
    // is refused without the module having to name it. Assert the rebuild is
    // faithful for every accepted shape, since a rebuild that dropped a
    // legitimate field would refuse honest transactions.
    test("an accepted artifact of each allowed type rebuilds byte for byte", async () => {
        const shapes = [
            {
                type: 0,
                gasPrice: 2000000000n,
                maxFeePerGas: null,
                maxPriorityFeePerGas: null,
            },
            {
                type: 1,
                gasPrice: 2000000000n,
                maxFeePerGas: null,
                maxPriorityFeePerGas: null,
                accessList: [
                    {
                        address: RECIPIENT,
                        storageKeys: ["0x" + "11".repeat(32)],
                    },
                ],
            },
            {},
        ];
        for (const overrides of shapes) {
            const raw = await signedFor(TX_PARAMS, signer, overrides);
            const parsed = verifySignedTx(
                raw,
                approvedFor(TX_PARAMS, overrides),
                signer.address,
                SELECTED,
            );
            const fields = { type: parsed.type };
            for (const key of SERIALIZED_FIELDS[parsed.type]) {
                fields[key] = parsed[key];
            }
            expect(Transaction.from(fields).unsignedSerialized).toBe(
                parsed.unsignedSerialized,
            );
        }
    });
});

// Every comparison above runs against the decode, but the string that is
// handed to broadcastTransaction() is the artifact. An encoding the decoder
// normalizes away therefore checks as one transaction and broadcasts as
// different bytes, so the artifact must be the canonical encoding of itself.
describe("verifySignedTx canonical encoding", () => {
    // Re-encode a signed type-2 artifact with a leading zero byte on the RLP
    // value field. It decodes to exactly the approved transaction — same
    // value, same signer, same everything the field comparisons look at — and
    // it is not the same string.
    async function nonCanonical() {
        const raw = await signedWith({});
        const items = decodeRlp("0x" + raw.slice(4));
        // type 2 payload order: chainId, nonce, maxPriorityFeePerGas,
        // maxFeePerGas, gasLimit, to, value, data, accessList, then the
        // signature.
        const padded = items.slice();
        padded[6] = "0x00" + items[6].slice(2);
        return "0x02" + encodeRlp(padded).slice(2);
    }

    test("the mutation decodes to the approved transaction and is not it", async () => {
        const raw = await signedWith({});
        const mutated = await nonCanonical();
        const parsed = Transaction.from(mutated);
        expect(mutated).not.toBe(raw);
        expect(mutated.length).toBeGreaterThan(raw.length);
        expect(parsed.value).toBe(BigInt(TX_PARAMS.value));
        expect(parsed.from).toBe(signer.address);
        expect(parsed.serialized).not.toBe(mutated);
    });

    test("refuses an artifact that is not its own canonical encoding", async () => {
        const mutated = await nonCanonical();
        expect(() =>
            verifySignedTx(mutated, APPROVED, signer.address, SELECTED),
        ).toThrow(/not encoded canonically/);
    });

    test("assertCanonicalBytes accepts what ethers itself produced", async () => {
        const raw = await signedWith({});
        expect(() =>
            assertCanonicalBytes(Transaction.from(raw), raw),
        ).not.toThrow();
    });

    test("hex case is not part of the encoding", async () => {
        const raw = await signedWith({});
        const upper = "0x" + raw.slice(2).toUpperCase();
        expect(() =>
            verifySignedTx(upper, APPROVED, signer.address, SELECTED),
        ).not.toThrow();
    });
});

// The approval and the artifact spell the same values differently. None of
// these differences is tampering, so none may refuse the signature.
describe("verifySignedTx normalization", () => {
    test("accepts a decimal chain id against a hex selected network", async () => {
        const raw = await signedWith({});
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, 1),
        ).not.toThrow();
        expect(() =>
            verifySignedTx(raw, APPROVED, signer.address, "1"),
        ).not.toThrow();
    });

    // The approved transaction crosses to the popup as JSON, so it comes back
    // spelled in hex quantities rather than in the bigints it was populated
    // with. None of that is tampering.
    test("accepts an approval spelled as the wire spells it", async () => {
        const raw = await signedWith({});
        const wire = {
            ...APPROVED,
            chainId: "0x1",
            nonce: "0x7",
            gasLimit: "0x186a0",
            maxFeePerGas: "0x77359400",
            maxPriorityFeePerGas: "0x3b9aca00",
            value: "0x2386f26fc10000",
        };
        expect(() =>
            verifySignedTx(raw, wire, signer.address, SELECTED),
        ).not.toThrow();
    });

    test("accepts quantities spelled as hex, decimal, number and bigint", async () => {
        const raw = await signedWith({});
        for (const maxFee of [
            "0x77359400",
            "2000000000",
            2000000000,
            2000000000n,
        ]) {
            expect(() =>
                verifySignedTx(
                    raw,
                    { ...APPROVED, maxFeePerGas: maxFee },
                    signer.address,
                    SELECTED,
                ),
            ).not.toThrow();
        }
    });

    test("accepts an approval whose recipient case differs", async () => {
        const raw = await signedWith({});
        const approved = { ...APPROVED, to: RECIPIENT.toLowerCase() };
        expect(() =>
            verifySignedTx(raw, approved, signer.address, SELECTED),
        ).not.toThrow();
    });

    test("accepts absent call data against 0x", async () => {
        const params = { to: RECIPIENT, value: "0x0" };
        const approved = approvedFor(params);
        delete approved.data;
        const raw = await signedFor({ ...params, data: "0x" });
        expect(() =>
            verifySignedTx(raw, approved, signer.address, SELECTED),
        ).not.toThrow();
    });

    test("refuses an approved quantity that is not a number", async () => {
        const raw = await signedWith({});
        expect(() =>
            verifySignedTx(
                raw,
                { ...APPROVED, maxFeePerGas: "cheap" },
                signer.address,
                SELECTED,
            ),
        ).toThrow(/is not a number/);
    });

    // The value is page-controlled. A refusal is correct; a raw BigInt
    // conversion error is not, because it is not a mismatch, so it would be
    // reported retryable and leave the approval unspent behind a live button
    // that can never succeed.
    test("refuses an approved value that is not a number, as a mismatch", async () => {
        const raw = await signedWith({});
        for (const value of ["cheap", 1.5, "1e18", {}]) {
            let thrown;
            try {
                verifySignedTx(
                    raw,
                    { ...APPROVED, value },
                    signer.address,
                    SELECTED,
                );
                throw new Error("expected a rejection");
            } catch (e) {
                thrown = e;
            }
            expect(thrown.approvalMismatch).toBe(true);
            expect(thrown.message).toMatch(/approved value is not a number/);
            expect(failureIsRetryable(thrown)).toBe(false);
        }
    });

    test("refuses an approved access list that is not an access list", async () => {
        const raw = await signedWith({});
        expect(() =>
            verifySignedTx(
                raw,
                { ...APPROVED, accessList: ["nope"] },
                signer.address,
                SELECTED,
            ),
        ).toThrow(/not a valid access list/);
    });
});

const TYPED_DATA = JSON.stringify({
    domain: {
        name: "AutistMask Test",
        version: "1",
        chainId: 1,
        verifyingContract: OTHER_RECIPIENT,
    },
    primaryType: "Mail",
    types: {
        EIP712Domain: [
            { name: "name", type: "string" },
            { name: "version", type: "string" },
            { name: "chainId", type: "uint256" },
            { name: "verifyingContract", type: "address" },
        ],
        Mail: [
            { name: "from", type: "address" },
            { name: "to", type: "address" },
            { name: "contents", type: "string" },
        ],
    },
    message: {
        from: signer.address,
        to: RECIPIENT,
        contents: "hello",
    },
});

describe("verifySignature", () => {
    // "Hello AutistMask" as the hex string a dApp passes to personal_sign.
    const MESSAGE = "0x48656c6c6f204175746973744d61736b";
    const personalParams = {
        method: "personal_sign",
        message: MESSAGE,
        from: signer.address,
    };
    const typedParams = {
        method: "eth_signTypedData_v4",
        typedData: TYPED_DATA,
        from: signer.address,
    };

    async function signPersonal(withWallet) {
        return (withWallet || signer).signMessage(
            Buffer.from(MESSAGE.slice(2), "hex"),
        );
    }

    async function signTyped(withWallet) {
        const { domain, types, message } = JSON.parse(TYPED_DATA);
        delete types.EIP712Domain;
        return (withWallet || signer).signTypedData(domain, types, message);
    }

    test("accepts a personal_sign signature from the approved address", async () => {
        const signature = await signPersonal();
        expect(verifySignature(personalParams, signature, signer.address)).toBe(
            signer.address,
        );
    });

    test("accepts an eth_sign signature the same way", async () => {
        const signature = await signPersonal();
        const params = { ...personalParams, method: "eth_sign" };
        expect(() =>
            verifySignature(params, signature, signer.address),
        ).not.toThrow();
    });

    test("accepts a typed data signature from the approved address", async () => {
        const signature = await signTyped();
        expect(verifySignature(typedParams, signature, signer.address)).toBe(
            signer.address,
        );
    });

    test("does not mutate the approved typed data while verifying", async () => {
        const signature = await signTyped();
        const before = typedParams.typedData;
        verifySignature(typedParams, signature, signer.address);
        expect(typedParams.typedData).toBe(before);
        expect(
            JSON.parse(typedParams.typedData).types.EIP712Domain,
        ).toBeDefined();
    });

    test("rejects a personal_sign signature from a different address", async () => {
        const signature = await signPersonal(other);
        expect(() =>
            verifySignature(personalParams, signature, signer.address),
        ).toThrow(/different address/);
    });

    test("rejects a typed data signature from a different address", async () => {
        const signature = await signTyped(other);
        expect(() =>
            verifySignature(typedParams, signature, signer.address),
        ).toThrow(/different address/);
    });

    test("rejects a signature over a different message", async () => {
        const signature = await signer.signMessage(
            Buffer.from("00112233", "hex"),
        );
        expect(() =>
            verifySignature(personalParams, signature, signer.address),
        ).toThrow(/different address/);
    });

    test("rejects a missing or malformed signature", async () => {
        expect(() =>
            verifySignature(personalParams, undefined, signer.address),
        ).toThrow(/missing or malformed/);
        expect(() =>
            verifySignature(personalParams, "0x1234", signer.address),
        ).toThrow(/could not be verified/);
    });
});

// What happens after a signing attempt fails: the background keeps the
// approval for anything the user can correct, and the popup only offers the
// button again when it did.
describe("signing failure and retry", () => {
    test("a failure that is not a mismatch leaves the approval retryable", () => {
        expect(failureIsRetryable(new Error("The node is unreachable."))).toBe(
            true,
        );
        expect(failureIsRetryable(undefined)).toBe(true);
    });

    test("a mismatch spends the approval", async () => {
        const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
        try {
            verifySignedTx(raw, APPROVED, signer.address, SELECTED);
            throw new Error("expected a rejection");
        } catch (e) {
            expect(failureIsRetryable(e)).toBe(false);
        }
    });

    test("a retryable failure keeps the button usable and says only what failed", () => {
        const outcome = describeSigningFailure(
            { error: "The node rejected the transaction.", retryable: true },
            "The transaction could not be sent.",
        );
        expect(outcome.retryable).toBe(true);
        expect(outcome.message).toBe("The node rejected the transaction.");
    });

    test("a refusal tells the user to start again from the site", () => {
        const outcome = describeSigningFailure(
            {
                error: "The signed transaction does not go to the approved recipient.",
                retryable: false,
            },
            "The transaction could not be sent.",
        );
        expect(outcome.retryable).toBe(false);
        expect(outcome.message).toMatch(/start it again from the site\.$/);
    });

    test("a refusal for an attempt already running does not say to start again", () => {
        const outcome = describeSigningFailure(
            {
                error: "This request is already being signed.",
                retryable: false,
                stage: "inflight",
            },
            "The message could not be signed.",
        );
        expect(outcome.retryable).toBe(false);
        expect(outcome.message).not.toMatch(/start it again from the site/);
        expect(outcome.message).toMatch(/first attempt is still running/);
    });

    test("a response the background never sent is treated as a spent approval", () => {
        const outcome = describeSigningFailure(
            undefined,
            "The transaction could not be sent.",
        );
        expect(outcome.retryable).toBe(false);
        expect(outcome.message).toMatch(/^The transaction could not be sent\./);
    });

    test("every failure message is a full sentence", () => {
        const outcome = describeSigningFailure(
            { error: "The node is on fire", retryable: true },
            "The transaction could not be sent.",
        );
        expect(outcome.message).toMatch(/^[A-Z].*\.$/);
    });

    test("a popup that could not sign leaves the approval standing", () => {
        const outcome = describeTxFailure(
            TX_STAGE_SIGN,
            "That password is incorrect. Please try again.",
        );
        expect(outcome.retryable).toBe(true);
        expect(outcome.spendApproval).toBe(false);
        expect(outcome.error).toMatch(/password is incorrect/);
    });

    test("a mismatch found at verification spends the approval", async () => {
        const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
        let outcome;
        try {
            verifySignedTx(raw, APPROVED, signer.address, SELECTED);
        } catch (e) {
            outcome = describeTxFailure(TX_STAGE_VERIFY, e);
        }
        expect(outcome.retryable).toBe(false);
        expect(outcome.spendApproval).toBe(true);
    });

    test("a failure before the check ran is still retryable", () => {
        const outcome = describeTxFailure(
            TX_STAGE_VERIFY,
            new Error("The wallet state could not be read."),
        );
        expect(outcome.retryable).toBe(true);
        expect(outcome.spendApproval).toBe(false);
    });

    // A broadcast that throws after the node took the transaction is routine:
    // a timeout, a dropped response, a node answering "already known". The
    // popup's retry does not re-broadcast the same bytes — it re-populates and
    // re-signs at a freshly fetched nonce — so a retryable broadcast failure
    // would put the approved transfer on the chain twice.
    test("a failed broadcast is terminal, whatever the node said", () => {
        for (const message of [
            "already known",
            "timeout of 30000ms exceeded",
            "could not coalesce error",
        ]) {
            const outcome = describeTxFailure(
                TX_STAGE_BROADCAST,
                new Error(message),
            );
            expect(outcome.retryable).toBe(false);
            expect(outcome.spendApproval).toBe(true);
            expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
            expect(outcome.error).toBe(message);
        }
    });

    // The one broadcast failure that is not ambiguous. The node answered, and
    // its answer was that the nonce was already spoken for, so this
    // transaction is not in a mempool anywhere.
    test("a nonce the node refused is classified however it was worded", () => {
        for (const err of [
            new Error("nonce too low"),
            new Error("replacement transaction underpriced"),
            Object.assign(new Error("could not coalesce error"), {
                code: "NONCE_EXPIRED",
            }),
            Object.assign(new Error("could not coalesce error"), {
                code: "REPLACEMENT_UNDERPRICED",
            }),
            // The shape ethers hands up when it could not classify the node's
            // error itself: the node's own words are nested underneath.
            Object.assign(new Error("could not coalesce error"), {
                info: { error: { code: -32000, message: "OldNonce" } },
            }),
        ]) {
            const outcome = describeTxFailure(TX_STAGE_BROADCAST, err);
            expect(
                describeSigningFailure(
                    outcome,
                    "The transaction could not be sent.",
                ).message,
            ).toMatch(/did not reach the network/);
            expect(outcome.retryable).toBe(false);
            expect(outcome.spendApproval).toBe(true);
            expect(outcome.error).toBe(NONCE_COLLISION_MESSAGE);
            expect(outcome.stage).toBe(TX_STAGE_NONCE);
            expect(isNonceCollision(err)).toBe(true);
        }
    });

    // A node that says it knows the transaction has it, so it did reach the
    // network and the ambiguous wording is the correct one.
    test("already known is not a nonce collision", () => {
        const err = new Error("already known");
        const outcome = describeTxFailure(TX_STAGE_BROADCAST, err);
        expect(
            describeSigningFailure(
                outcome,
                "The transaction could not be sent.",
            ).message,
        ).toMatch(/may still have reached the network/);
        expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
        expect(isNonceCollision(err)).toBe(false);
    });

    test("a nonce collision says the transaction did not reach the network", () => {
        const outcome = describeTxFailure(
            TX_STAGE_BROADCAST,
            new Error("nonce too low"),
        );
        const copy = describeSigningFailure(
            outcome,
            "The transaction could not be sent.",
        );
        expect(copy.retryable).toBe(false);
        expect(copy.message).toMatch(/did not reach the network/);
        expect(copy.message).not.toMatch(/may still have reached the network/);
        expect(copy.message).toMatch(/Please send it again from the site\.$/);
        expect(copy.message).toMatch(/^[A-Z].*\.$/);
    });

    test("a failed broadcast does not tell the user to send it again", () => {
        const outcome = describeSigningFailure(
            {
                error: "The node did not answer.",
                retryable: false,
                stage: TX_STAGE_BROADCAST,
            },
            "The transaction could not be sent.",
        );
        expect(outcome.retryable).toBe(false);
        expect(outcome.message).toMatch(/may still have reached the network/);
        expect(outcome.message).not.toMatch(/start it again from the site/);
    });
});

// End-to-end over the messaging boundary, without a browser: the background
// populates the transaction, the object that produces crosses to the popup as
// JSON and is signed there, and the artifact goes back to the exact check the
// background runs before it broadcasts. Only what each side puts on the wire is
// passed along, so this also pins down that the wire payloads are sufficient on
// their own.
describe("background population to popup signing to verification", () => {
    // Stand-in for the JSON-RPC provider. populateTransaction only needs the
    // nonce, the gas estimate, the network and the fee data.
    const fakeProvider = {
        getNetwork: async () => Network.from(1),
        getTransactionCount: async () => 7,
        estimateGas: async () => 21000n,
        getFeeData: async () => ({
            gasPrice: 2000000000n,
            maxFeePerGas: 2000000000n,
            maxPriorityFeePerGas: 1000000000n,
        }),
    };

    // A private-key wallet as it is persisted in state, so the test goes
    // through getSignerForAddress() the way the popup does.
    const walletData = { type: "privkey" };

    // What the background does before the approval window opens.
    async function backgroundPrepares(txParams) {
        const approvedTx = await prepareApprovalTx(
            fakeProvider,
            signer.address,
            txParams,
        );
        // Extension messaging is JSON; the popup sees the other side of it.
        return JSON.parse(JSON.stringify(approvedTx));
    }

    // What the popup does with it: signs it as given, populating nothing.
    async function popupSigns(approvedTx) {
        const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY);
        return localSigner.signTransaction({ ...approvedTx });
    }

    test("the populated transaction is what gets signed and what gets checked", async () => {
        const approvedTx = await backgroundPrepares(TX_PARAMS);
        const rawSignedTx = await popupSigns(approvedTx);
        const parsed = verifySignedTx(
            rawSignedTx,
            approvedTx,
            signer.address,
            SELECTED,
        );
        expect(parsed.nonce).toBe(7);
        expect(parsed.chainId).toBe(1n);
        expect(parsed.gasLimit).toBe(21000n);
        expect(parsed.maxFeePerGas).toBe(2000000000n);
        expect(parsed.to).toBe(RECIPIENT);
        expect(parsed.value).toBe(BigInt(TX_PARAMS.value));
        expect(parsed.data).toBe(TX_PARAMS.data);
        expect(parsed.signature).not.toBeNull();
        // Every field the screen shows, and the artifact, are the same numbers.
        expect(BigInt(approvedTx.nonce)).toBe(BigInt(parsed.nonce));
        expect(BigInt(approvedTx.gasLimit)).toBe(parsed.gasLimit);
        expect(BigInt(approvedTx.maxFeePerGas)).toBe(parsed.maxFeePerGas);
        expect(BigInt(approvedTx.maxPriorityFeePerGas)).toBe(
            parsed.maxPriorityFeePerGas,
        );
    });

    test("the wire payload carries no password and no secret", async () => {
        const approvedTx = await backgroundPrepares(TX_PARAMS);
        const rawSignedTx = await popupSigns(approvedTx);
        const payload = {
            type: "AUTISTMASK_TX_RESPONSE",
            id: "test-approval-id",
            approved: true,
            rawSignedTx,
        };
        expect(Object.keys(payload).sort()).toEqual([
            "approved",
            "id",
            "rawSignedTx",
            "type",
        ]);
        const wire = JSON.stringify(payload).toLowerCase();
        expect(wire).not.toContain("password");
        expect(wire).not.toContain(SIGNER_KEY.slice(2).toLowerCase());
    });

    // The popup is the component whose compromise this check exists to detect,
    // so it is given the approved transaction and signs something else.
    test("a popup that signs a different fee than it was given is refused", async () => {
        const approvedTx = await backgroundPrepares(TX_PARAMS);
        const rawSignedTx = await popupSigns({
            ...approvedTx,
            maxFeePerGas: "0x3b9aca000",
        });
        expect(() =>
            verifySignedTx(rawSignedTx, approvedTx, signer.address, SELECTED),
        ).toThrow(/approved maximum fee per gas/);
    });

    test("a popup that signs a different nonce than it was given is refused", async () => {
        const approvedTx = await backgroundPrepares(TX_PARAMS);
        const rawSignedTx = await popupSigns({ ...approvedTx, nonce: "0x8" });
        expect(() =>
            verifySignedTx(rawSignedTx, approvedTx, signer.address, SELECTED),
        ).toThrow(/approved nonce/);
    });

    test("the background rejects a transaction the popup did not approve", async () => {
        const approvedTx = await backgroundPrepares(TX_PARAMS);
        const rawSignedTx = await popupSigns({
            ...approvedTx,
            to: OTHER_RECIPIENT,
        });
        expect(() =>
            verifySignedTx(rawSignedTx, approvedTx, signer.address, SELECTED),
        ).toThrow(/approved recipient/);
    });

    test("the background rejects a transaction populated on another network", async () => {
        const approvedTx = await backgroundPrepares(TX_PARAMS);
        const rawSignedTx = await popupSigns(approvedTx);
        expect(() =>
            verifySignedTx(rawSignedTx, approvedTx, signer.address, SEPOLIA),
        ).toThrow(/different network than the one that is selected/);
    });

    // ethers refuses to sign for an address that is not the key's own, so a
    // popup working from the approved object cannot quietly sign as whichever
    // address the user has switched to.
    test("the approved from stops the popup signing with another key", async () => {
        const approvedTx = await backgroundPrepares(TX_PARAMS);
        const otherSigner = getSignerForAddress(walletData, 0, OTHER_KEY);
        await expect(
            otherSigner.signTransaction({ ...approvedTx }),
        ).rejects.toThrow(/from address mismatch/);
    });
});
