// The background's approval message wiring, driven end to end: a dApp
// eth_sendTransaction raises a pending approval, and the popup answers it with
// AUTISTMASK_TX_RESPONSE / AUTISTMASK_SIGN_RESPONSE.
//
// What this exists for is the duplicate response. The handler verifies and
// broadcasts asynchronously, and the approval deliberately survives a
// retryable failure so the user can try again with the transaction they
// already saw — which means the entry being present is not by itself proof
// that no attempt is running. A second response carrying the same id (a
// reloaded approval window re-rendering a live Approve button, a popup that
// emits the message twice) must not start a second verify and broadcast: the
// same approved transaction signed twice verifies twice, and the transfer
// would go out twice.
//
// It also covers what the approval is verified against. The approval now
// carries the transaction the background populated and the screen displayed,
// and the address that was active when it was raised — so a fee, a nonce or an
// address that moved between approval and signing is refused rather than
// signed.

const { Network, Wallet } = require("ethers");

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

const ORIGIN = "https://dapp.example";
const HOSTNAME = "dapp.example";
// A page the wallet has never been connected to, whose requests are refused.
const UNCONNECTED_ORIGIN = "https://stranger.example";
const EXT_URL = "chrome-extension://autistmask/";

// What the dApp asks for: no nonce, no gas, no fees. This is the shape that
// makes a duplicate broadcast possible at all.
const TX_PARAMS = {
    from: signer.address,
    to: RECIPIENT,
    value: "0x2386f26fc10000",
    data: "0x",
};

// The nonce the stubbed node reports, and so the nonce the background
// populates the approval with.
const NONCE = 7;

// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
const MESSAGE = "0x48656c6c6f204175746973744d61736b";

// The transaction the background populates and the approval screen displays.
// The nonce is a parameter because the duplicate case turns on two artifacts
// differing in a field the dApp fixed nothing for.
// The two chains the tests switch between, as both forms the code uses: the
// hex chain id the wallet's network record carries, and the number the node
// and the signed artifact carry.
const MAINNET = { hex: "0x1", num: 1 };
const SEPOLIA = { hex: "0xaa36a7", num: 11155111 };

function populated(nonce, chainId) {
    return {
        type: 2,
        chainId: chainId || MAINNET.num,
        nonce,
        gasLimit: 100000n,
        maxFeePerGas: 2000000000n,
        maxPriorityFeePerGas: 1000000000n,
        to: TX_PARAMS.to,
        value: BigInt(TX_PARAMS.value),
        data: TX_PARAMS.data,
    };
}

function signedAtNonce(nonce, withWallet, chainId) {
    return (withWallet || signer).signTransaction(populated(nonce, chainId));
}

// The node the background populates against. Its answers are the numbers the
// approval screen shows, so they are also the numbers every artifact below is
// signed at.
function fakeProvider(broadcastTransaction, overrides, chainId) {
    return {
        broadcastTransaction,
        getNetwork: async () => Network.from(chainId || MAINNET.num),
        getTransactionCount: async () => NONCE,
        estimateGas: async () => 100000n,
        getFeeData: async () => ({
            gasPrice: 2000000000n,
            maxFeePerGas: 2000000000n,
            maxPriorityFeePerGas: 1000000000n,
        }),
        ...(overrides || {}),
    };
}

// A promise whose settlement the test controls, so a broadcast can be held in
// flight while the second response arrives.
function deferred() {
    let resolve;
    let reject;
    const promise = new Promise((res, rej) => {
        resolve = res;
        reject = rej;
    });
    return { promise, resolve, reject };
}

// Load the background worker against stubbed browser and network APIs and
// return the handles the tests drive it through. Everything that would touch
// the network or the browser's own schedulers is mocked; the approval
// verification is the real module, because that is what the handler under
// test is wired to.
function loadBackground(options) {
    const opts = options || {};
    jest.resetModules();

    const broadcastTransaction = jest.fn();
    const loadState = jest.fn(opts.loadState || (async () => {}));

    // The network the wallet is on, which the tests switch under a pending
    // approval. The node the transaction is populated against is on the same
    // one, as it would be: switching networks switches the RPC endpoint too.
    let chain = MAINNET;

    jest.doMock("../src/shared/state", () => ({
        state: { rpcUrl: "https://rpc.invalid", wallets: [] },
        loadState,
        saveState: jest.fn(async () => {}),
        currentNetwork: () => ({ chainId: chain.hex }),
    }));
    jest.doMock("../src/shared/balances", () => ({
        getProvider: () =>
            fakeProvider(broadcastTransaction, opts.provider, chain.num),
        refreshBalances: jest.fn(async () => {}),
    }));
    jest.doMock("../src/shared/phishingDomains", () => ({
        isPhishingDomain: () => false,
        refreshPhishingListOnSchedule: jest.fn(async () => {}),
        initPhishingList: jest.fn(async () => {}),
    }));
    jest.doMock("../src/shared/alarms", () => ({
        BALANCE_REFRESH_ALARM: "balance",
        PHISHING_REFRESH_ALARM: "phishing",
        BALANCE_REFRESH_PERIOD_MINUTES: 1,
        ensureRecurringAlarms: jest.fn(async () => {}),
        registerAlarmHandlers: jest.fn(),
    }));

    const persisted = {
        wallets: [
            { name: "Wallet 1", type: "hd", addresses: [signer.address] },
        ],
        rpcUrl: "https://rpc.invalid",
        activeAddress: signer.address,
        allowedSites: { [signer.address]: [HOSTNAME] },
        deniedSites: {},
    };

    let messageListener = null;
    let windowRemovedListener = null;
    const created = [];
    const removed = [];

    global.chrome = {
        storage: {
            local: {
                get: jest.fn(async () => ({ autistmask: persisted })),
                set: jest.fn(async () => {}),
            },
        },
        runtime: {
            getURL: (path) => EXT_URL + path,
            onMessage: {
                addListener: (fn) => {
                    messageListener = fn;
                },
            },
            onConnect: { addListener: () => {} },
            lastError: null,
        },
        windows: {
            getLastFocused: (cb) => cb(null),
            create: (options2, cb) => {
                created.push(options2);
                // A browser that answers with no window at all. The approval
                // then has no window it can ever be answered in.
                cb(opts.noWindow ? undefined : { id: created.length });
            },
            remove: (id, cb) => {
                removed.push(id);
                if (cb) cb();
            },
            // Captured, not swallowed: closing the approval window is the
            // event that used to retire an approval out from under a live
            // broadcast, and a no-op stub here hides exactly that.
            onRemoved: {
                addListener: (fn) => {
                    windowRemovedListener = fn;
                },
            },
        },
        tabs: {
            query: (q, cb) => cb([]),
            sendMessage: () => {},
        },
        action: { setPopup: () => {} },
    };

    require("../src/background/index");

    // Send a message the way the browser would, and hand back whatever the
    // handler passed to sendResponse.
    function send(msg, sender) {
        const sendResponse = jest.fn();
        const kept = messageListener(msg, sender || {}, sendResponse);
        return { sendResponse, kept };
    }

    // Raise a pending transaction approval the way a dApp does, and dig the
    // approval id back out of the popup URL the background opened.
    function requestTx(txParams, origin) {
        let rpcResult = null;
        // The window this request opens, if it opens one. A request refused
        // before an approval is raised opens none, and the window belonging to
        // some other request must not be handed back as this one's.
        const windowIndex = created.length;
        const sendResponse = jest.fn((r) => {
            rpcResult = r;
        });
        messageListener(
            {
                type: "AUTISTMASK_RPC",
                method: "eth_sendTransaction",
                params: [txParams || TX_PARAMS],
            },
            { origin: origin || ORIGIN },
            sendResponse,
        );
        return {
            id: () =>
                created.length > windowIndex
                    ? new URL(created[windowIndex].url).searchParams.get(
                          "approval",
                      )
                    : null,
            result: () => rpcResult,
        };
    }

    // The same for a message-signing approval, which pins the signing address
    // at approval time in exactly the same way.
    function requestSign(from) {
        let rpcResult = null;
        messageListener(
            {
                type: "AUTISTMASK_RPC",
                method: "personal_sign",
                params: [MESSAGE, from || signer.address],
            },
            { origin: ORIGIN },
            (r) => {
                rpcResult = r;
            },
        );
        return {
            id: () =>
                new URL(created[created.length - 1].url).searchParams.get(
                    "approval",
                ),
            result: () => rpcResult,
        };
    }

    // The user closes the approval popup. `created` is index-aligned with the
    // ids the window stub hands back, so window 1 is the first popup opened.
    function closeWindow(windowId) {
        windowRemovedListener(windowId);
    }

    return {
        send,
        requestTx,
        requestSign,
        closeWindow,
        broadcastTransaction,
        loadState,
        created,
        removed,
        // The user switching account in the toolbar popup, as the background
        // sees it: the persisted active address changes underneath a pending
        // approval.
        setActiveAddress: (address) => {
            persisted.activeAddress = address;
        },
        // The user switching network in the toolbar popup.
        setNetwork: (network) => {
            chain = network;
        },
        fromPopup: { url: EXT_URL + "src/popup/index.html" },
    };
}

// Let the handler's promise chain run to the next suspension point. Raising a
// transaction approval now populates it against the node first, which is
// several awaits deep before the window is opened.
async function settle() {
    for (let i = 0; i < 50; i++) await Promise.resolve();
}

afterEach(() => {
    delete global.chrome;
    jest.resetModules();
});

describe("one approval, one broadcast", () => {
    test("a second AUTISTMASK_TX_RESPONSE for the same id does not broadcast again", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();
        expect(id).toBeTruthy();

        const inFlight = deferred();
        bg.broadcastTransaction.mockReturnValue(inFlight.promise);

        // The popup answers. Verification passes and the broadcast is held
        // open, which is the whole window the second message arrives in.
        const first = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);

        // A reloaded approval window signs the same approval again, at another
        // nonce. The claim is taken before anything is verified, so what this
        // asserts is the interlock and not the nonce comparison: the refusal
        // below is the claim's own message, which a verification failure does
        // not produce.
        const second = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(8),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
        expect(second.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({
                error: expect.stringMatching(/already being sent/),
                retryable: false,
            }),
        );

        inFlight.resolve({ hash: "0xfeed" });
        await settle();
        expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
        expect(pending.result()).toEqual({ result: "0xfeed" });
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
    });

    test("the same artifact sent twice broadcasts once", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const inFlight = deferred();
        bg.broadcastTransaction.mockReturnValue(inFlight.promise);
        const raw = await signedAtNonce(7);
        const msg = {
            type: "AUTISTMASK_TX_RESPONSE",
            id,
            approved: true,
            rawSignedTx: raw,
        };

        bg.send(msg, { url: bg.fromPopup.url });
        bg.send(msg, { url: bg.fromPopup.url });
        await settle();
        inFlight.resolve({ hash: "0xfeed" });
        await settle();

        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
    });

    test("a response arriving after the broadcast finished finds nothing to send", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        const late = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(8),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
        expect(late.sendResponse).not.toHaveBeenCalled();
    });

    test("a second AUTISTMASK_SIGN_RESPONSE for the same id is refused", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        // Hold the transaction approval in flight, then answer it a second
        // time as if it were a sign approval: the sign handler must apply the
        // same interlock rather than running its own verification.
        const inFlight = deferred();
        bg.broadcastTransaction.mockReturnValue(inFlight.promise);
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        const second = bg.send(
            {
                type: "AUTISTMASK_SIGN_RESPONSE",
                id,
                approved: true,
                signature: "0x00",
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(second.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({
                error: expect.stringMatching(/already being signed/),
                retryable: false,
            }),
        );
        inFlight.resolve({ hash: "0xfeed" });
        await settle();
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
    });
});

// Populating the transaction before the approval window opens is what makes
// the displayed object the verified object. It also fixes the nonce before the
// user has answered anything: two requests populated concurrently take the
// same nonce from a node that has seen neither of them broadcast, and the
// second can then never be sent, because the only way to give it a fresh nonce
// is to populate it again after the user has read the old one off the screen.
// So the second request is refused while the first is unanswered.
describe("one transaction approval at a time", () => {
    test("a second eth_sendTransaction while one is pending is refused before it takes a nonce", async () => {
        const getTransactionCount = jest.fn(async () => NONCE);
        const bg = loadBackground({ provider: { getTransactionCount } });

        const first = bg.requestTx();
        await settle();
        expect(first.id()).toBeTruthy();
        expect(getTransactionCount).toHaveBeenCalledTimes(1);

        const second = bg.requestTx();
        await settle();

        expect(second.result()).toEqual({
            error: {
                code: -32002,
                message: expect.stringMatching(
                    /one transaction at a time.+already in progress/,
                ),
            },
        });
        // Where the refusal happened matters as much as that it happened: no
        // second window, and the node was never asked for a second nonce.
        expect(bg.created).toHaveLength(1);
        expect(getTransactionCount).toHaveBeenCalledTimes(1);

        // The refusal leaves the pending approval untouched, and it still
        // sends.
        bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id: first.id(),
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(first.result()).toEqual({ result: "0xfeed" });
    });

    // The slot is only defensible if the wallet was going to raise an approval
    // anyway. Taken any earlier, a request the wallet refuses outright still
    // holds it, and any page at all — connected or not — can deny the user's
    // own transactions for as long as it keeps asking.
    test("a request the wallet refuses does not take the slot from the connected site", async () => {
        const bg = loadBackground();

        // Both delivered before either reaches its first suspension point,
        // which is the interleaving the slot exists for.
        const stranger = bg.requestTx(TX_PARAMS, UNCONNECTED_ORIGIN);
        const connected = bg.requestTx();
        await settle();

        expect(stranger.result()).toEqual({
            error: { code: 4100, message: "Unauthorized" },
        });
        // The connected site's transaction was raised, not refused as one the
        // user already has in progress.
        expect(connected.result()).toBeNull();
        expect(connected.id()).toBeTruthy();
        expect(bg.created).toHaveLength(1);
    });

    // The user closes an approval window that looks hung while the attempt
    // behind it is still running, and that attempt then fails in a way that
    // would normally leave the approval standing for a retry. There is no
    // window left to retry in, so leaving it standing answers the requesting
    // page never — and holds the slot for the life of the worker with it.
    test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
        const stalled = deferred();
        const bg = loadBackground({
            loadState: async () => {
                await stalled.promise;
                throw new Error("The wallet data could not be read.");
            },
        });

        const first = bg.requestTx();
        await settle();
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id: first.id(),
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        // The attempt owns the approval, so closing the window does not settle
        // it: the attempt may yet broadcast, and it is the one that reports.
        bg.closeWindow(1);
        await settle();
        expect(first.result()).toBeNull();

        stalled.resolve();
        await settle();
        expect(first.result()).toEqual({
            error: { code: 4001, message: "User rejected the request." },
        });

        const second = bg.requestTx();
        await settle();
        expect(second.result()).toBeNull();
        expect(second.id()).toBeTruthy();
        expect(bg.created).toHaveLength(2);
    });

    // An approval with no window is one nothing can ever answer.
    test("a request whose approval window cannot be opened is answered rather than left waiting", async () => {
        const bg = loadBackground({ noWindow: true });

        const first = bg.requestTx();
        await settle();
        expect(first.result()).toEqual({
            error: {
                code: -32603,
                message: expect.stringMatching(
                    /could not open its approval window/,
                ),
            },
        });

        // And it did not take the slot with it.
        const second = bg.requestTx();
        await settle();
        expect(second.result()).toEqual({
            error: {
                code: -32603,
                message: expect.stringMatching(
                    /could not open its approval window/,
                ),
            },
        });
    });

    test("an answered approval frees the next request", async () => {
        const bg = loadBackground();
        const first = bg.requestTx();
        await settle();

        // The user closes the approval window, which rejects it.
        bg.closeWindow(1);
        await settle();
        expect(first.result()).toEqual({
            error: { code: 4001, message: "User rejected the request." },
        });

        const second = bg.requestTx();
        await settle();
        expect(second.id()).toBeTruthy();
        expect(bg.created).toHaveLength(2);
    });

    test("a signature request is not held up by a pending transaction", async () => {
        const bg = loadBackground();
        bg.requestTx();
        await settle();

        // A signature consumes no nonce, so it has nothing to collide with.
        const signing = bg.requestSign();
        await settle();
        expect(signing.id()).toBeTruthy();
        expect(signing.result()).toBeNull();
        expect(bg.created).toHaveLength(2);
    });
});

// A nonce collision found before the transaction reaches the network is the
// one send failure the wallet can speak about with certainty. The user is told
// it did not go out and to send it again, rather than being warned it might
// already be on the chain — which would send them looking for a transaction
// that does not exist, and stop them retrying the one that never went.
describe("a nonce collision is reported as a transaction that did not go out", () => {
    test("a broadcast the node refused for the nonce is not reported as possibly sent", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();

        bg.broadcastTransaction.mockRejectedValue(
            Object.assign(new Error("nonce too low"), {
                code: "NONCE_EXPIRED",
            }),
        );
        const answer = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id: pending.id(),
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(answer.sendResponse).toHaveBeenCalledWith({
            error: expect.stringMatching(/nonce had already been used/),
            retryable: false,
            stage: "nonce",
        });
        expect(pending.result()).toEqual({
            error: {
                message: expect.stringMatching(
                    /transaction was not sent, because its nonce/,
                ),
            },
        });
    });

    test("a nonce this wallet already broadcast is refused without asking the node again", async () => {
        const bg = loadBackground();
        const first = bg.requestTx();
        await settle();

        bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id: first.id(),
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(first.result()).toEqual({ result: "0xfeed" });

        // The stubbed node still reports NONCE as the next nonce — a pending
        // count that lags a broadcast the node has already taken — so this
        // second approval is populated at a nonce this worker has spent.
        const second = bg.requestTx();
        await settle();
        const answer = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id: second.id(),
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
        expect(answer.sendResponse).toHaveBeenCalledWith({
            error: expect.stringMatching(/nonce had already been used/),
            retryable: false,
            stage: "nonce",
        });
        expect(second.result()).toEqual({
            error: {
                message: expect.stringMatching(/nonce had already been used/),
            },
        });
    });

    // Nonce spaces are per chain, and the wallet switches networks. A nonce
    // this wallet spent on one chain says nothing about the same nonce on
    // another — and low nonces overlap across chains as a matter of course, so
    // a record that ignored the chain would refuse ordinary transactions,
    // permanently and with a message that is not true of them.
    test("a nonce spent on one chain is not refused on another", async () => {
        const bg = loadBackground();

        const first = bg.requestTx();
        await settle();
        bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id: first.id(),
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(first.result()).toEqual({ result: "0xfeed" });

        // The user switches network. On this chain the address has sent
        // nothing, so the node populates the next transaction at the same
        // nonce — correctly.
        bg.setNetwork(SEPOLIA);
        const second = bg.requestTx();
        await settle();
        bg.broadcastTransaction.mockResolvedValue({ hash: "0xbeef" });
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id: second.id(),
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE, undefined, SEPOLIA.num),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(2);
        expect(second.result()).toEqual({ result: "0xbeef" });
    });
});

// The approval carries the transaction the user was shown and the address it
// was raised for, and the artifact is checked against both. Every case here is
// one the old comparison — against the dApp's request, for the address that is
// active now — would have broadcast.
describe("what the approval is verified against", () => {
    // The approval screen showed the populated fee. An artifact at ten times
    // that fee, still far below the ceilings, is what the ceilings alone could
    // not catch.
    test("a fee differing from the displayed one is refused, not sent", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const raw = await signer.signTransaction({
            ...populated(NONCE),
            maxFeePerGas: 20000000000n,
        });
        const answer = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: raw,
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(answer.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({
                error: expect.stringMatching(/approved maximum fee per gas/),
                retryable: false,
                stage: "verify",
            }),
        );
        expect(pending.result()).toEqual({
            error: {
                message: expect.stringMatching(/approved maximum fee per gas/),
            },
        });
    });

    test("a nonce differing from the displayed one is refused, not sent", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const answer = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE + 1),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(answer.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({
                error: expect.stringMatching(/approved nonce/),
                retryable: false,
                stage: "verify",
            }),
        );
    });

    // The address switch. The approval named one account; the wallet is on
    // another by the time the artifact arrives. Both halves are covered: the
    // popup signing as the account that is active now, and the popup correctly
    // signing as the approved account while the wallet has moved on.
    test("an artifact signed by the address that is active now is refused", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        bg.setActiveAddress(other.address);
        const answer = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE, other),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(answer.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({ retryable: false, stage: "verify" }),
        );
        expect(pending.result()).toEqual({
            error: { message: expect.stringMatching(/active address changed/) },
        });
    });

    test("an address switch refuses even the correctly signed artifact", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        bg.setActiveAddress(other.address);
        const answer = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(answer.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({
                error: expect.stringMatching(/active address changed/),
                retryable: false,
                stage: "verify",
            }),
        );
        // A refusal, so the approval is spent: the same artifact offered again
        // finds nothing to answer.
        const retry = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(retry.sendResponse).not.toHaveBeenCalled();
        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
    });

    test("a switch back to the approved address still sends", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        bg.setActiveAddress(other.address);
        bg.setActiveAddress(signer.address);
        bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(NONCE),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
        expect(pending.result()).toEqual({ result: "0xfeed" });
    });

    // The popup is handed the populated transaction and the address it is for,
    // and nothing else it would have to fetch or decide.
    test("the popup is given the transaction it is to sign", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();

        const details = bg.send(
            { type: "AUTISTMASK_GET_APPROVAL", id: pending.id() },
            { url: bg.fromPopup.url },
        );
        const shown = details.sendResponse.mock.calls[0][0];
        expect(shown.type).toBe("tx");
        expect(shown.approvedFrom).toBe(signer.address);
        expect(shown.approvedTx).toEqual({
            type: 2,
            from: signer.address,
            chainId: "0x1",
            nonce: "0x7",
            gasLimit: "0x186a0",
            maxFeePerGas: "0x77359400",
            maxPriorityFeePerGas: "0x3b9aca00",
            to: RECIPIENT,
            value: TX_PARAMS.value,
            data: "0x",
            accessList: [],
        });
    });

    // A request naming an account the wallet is not on is refused outright
    // rather than signed as whichever account is active.
    test("a request from another address raises no approval at all", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx({ ...TX_PARAMS, from: other.address });
        await settle();

        expect(pending.result()).toEqual({
            error: {
                code: 4100,
                message: expect.stringMatching(/not the active one/),
            },
        });
        expect(bg.created).toEqual([]);
    });

    // Message signing pins the address the same way, and refuses the same way.
    // A signature is not a transaction, but a permit signed by an account the
    // approval did not name spends that account's tokens all the same.
    test("a sign approval refuses a signature after an address switch", async () => {
        const bg = loadBackground();
        const pending = bg.requestSign();
        await settle();

        bg.setActiveAddress(other.address);
        const answer = bg.send(
            {
                type: "AUTISTMASK_SIGN_RESPONSE",
                id: pending.id(),
                approved: true,
                signature: await signer.signMessage(
                    Buffer.from(MESSAGE.slice(2), "hex"),
                ),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(answer.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({
                error: expect.stringMatching(/active address changed/),
                retryable: false,
            }),
        );
        expect(pending.result()).toEqual({
            error: { message: expect.stringMatching(/active address changed/) },
        });
    });

    test("a sign request from another address raises no approval at all", async () => {
        const bg = loadBackground();
        const pending = bg.requestSign(other.address);
        await settle();

        expect(pending.result()).toEqual({
            error: {
                code: 4100,
                message: expect.stringMatching(/not the active one/),
            },
        });
        expect(bg.created).toEqual([]);
    });

    // Population is a network round trip with the user's hands free. An
    // approval raised for the address that was active when it started could
    // never be signed once the wallet has moved off it, so it is never raised.
    test("an address switch during population raises no approval", async () => {
        let bg;
        bg = loadBackground({
            provider: {
                // The user switches account in the toolbar popup while the
                // node is being asked for a gas estimate.
                estimateGas: async () => {
                    bg.setActiveAddress(other.address);
                    return 100000n;
                },
            },
        });
        const pending = bg.requestTx();
        await settle();

        expect(pending.result()).toEqual({
            error: {
                message: expect.stringMatching(
                    /active address changed while this transaction was being prepared/,
                ),
            },
        });
        expect(bg.created).toEqual([]);
    });

    // Population happens before the window exists, so its failure is a failure
    // of the request: no approval, no window, and the error goes back to the
    // page the click came from.
    test("a transaction that cannot be prepared opens no window", async () => {
        const bg = loadBackground({
            provider: {
                estimateGas: async () => {
                    throw new Error("execution reverted");
                },
            },
        });
        const pending = bg.requestTx();
        await settle();

        expect(pending.result()).toEqual({
            error: {
                message: expect.stringMatching(
                    /could not be prepared.*execution reverted/,
                ),
            },
        });
        expect(bg.created).toEqual([]);
    });
});

// The interlock must not cost the retry the approval exists to allow.
describe("the interlock releases a failed attempt", () => {
    test("a retryable failure before the broadcast leaves the approval usable", async () => {
        let failNext = true;
        const bg = loadBackground({
            loadState: async () => {
                if (failNext) {
                    failNext = false;
                    throw new Error("storage unavailable");
                }
            },
        });
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const first = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(first.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({ retryable: true }),
        );

        bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
        const retry = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
        expect(retry.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
        expect(pending.result()).toEqual({ result: "0xfeed" });
    });

    test("a mismatched artifact spends the approval outright", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        // Signed for a different recipient than the one that was approved.
        const wrong = await signer.signTransaction({
            ...populated(7),
            to: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
        });
        const first = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: wrong,
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(first.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({ retryable: false, stage: "verify" }),
        );

        const retry = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(retry.sendResponse).not.toHaveBeenCalled();
    });
});

// The claim is what makes one approval one broadcast, so it has to hold
// against everything else that retires an approval, not just against a second
// AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting
// promise 4001 while the attempt behind it ran to completion: the transaction
// reached the chain and the page was told the user rejected it, which invites
// the user to send it a second time at a fresh nonce.
describe("a claimed approval outlives every other retirement path", () => {
    // The approval popup stays open across the broadcast it is waiting on, so
    // a user closing an apparently-hung window needs no adversary at all.
    test("closing the approval window mid-broadcast still reports the result", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const inFlight = deferred();
        bg.broadcastTransaction.mockReturnValue(inFlight.promise);
        const first = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);

        // The user closes the window while the broadcast is still open.
        bg.closeWindow(1);
        await settle();
        expect(pending.result()).toBeNull();

        inFlight.resolve({ hash: "0xfeed" });
        await settle();

        expect(pending.result()).toEqual({ result: "0xfeed" });
        expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
    });

    test("switching the active address mid-broadcast still reports the result", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const inFlight = deferred();
        bg.broadcastTransaction.mockReturnValue(inFlight.promise);
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);

        // The user switches account in the toolbar popup, which rejects and
        // force-closes every pending approval.
        bg.send(
            { type: "AUTISTMASK_ACTIVE_CHANGED" },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(pending.result()).toBeNull();
        // The window an in-flight attempt reports into is left standing too.
        expect(bg.removed).toEqual([]);

        inFlight.resolve({ hash: "0xfeed" });
        await settle();

        expect(pending.result()).toEqual({ result: "0xfeed" });
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
    });

    test("a reject arriving mid-broadcast is refused, not honoured", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const inFlight = deferred();
        bg.broadcastTransaction.mockReturnValue(inFlight.promise);
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        const reject = bg.send(
            { type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(pending.result()).toBeNull();
        expect(reject.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({
                retryable: false,
                stage: "broadcast",
            }),
        );

        inFlight.resolve({ hash: "0xfeed" });
        await settle();

        expect(pending.result()).toEqual({ result: "0xfeed" });
        expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
    });

    // The refusals above must not cost the rejection its ordinary meaning.
    test("with no attempt running, closing the window still rejects", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();

        bg.closeWindow(1);
        await settle();

        expect(pending.result()).toEqual({
            error: { code: 4001, message: "User rejected the request." },
        });
        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
    });

    test("with no attempt running, an active-address switch still rejects and closes", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();

        bg.send(
            { type: "AUTISTMASK_ACTIVE_CHANGED" },
            { url: bg.fromPopup.url },
        );
        await settle();

        expect(pending.result()).toEqual({
            error: { code: 4001, message: "User rejected the request." },
        });
        expect(bg.removed).toEqual([1]);
    });

    // A sign approval held by a running verification is the same shape, and
    // the refusal must not tell the user to start again from the site while
    // the first attempt may still hand back a signature.
    test("a reject during a sign attempt is refused with the in-flight stage", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const inFlight = deferred();
        bg.broadcastTransaction.mockReturnValue(inFlight.promise);
        bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: bg.fromPopup.url },
        );
        await settle();

        const reject = bg.send(
            { type: "AUTISTMASK_SIGN_RESPONSE", id, approved: false },
            { url: bg.fromPopup.url },
        );
        await settle();
        expect(reject.sendResponse).toHaveBeenCalledWith(
            expect.objectContaining({ retryable: false, stage: "inflight" }),
        );

        inFlight.resolve({ hash: "0xfeed" });
        await settle();
        expect(pending.result()).toEqual({ result: "0xfeed" });
    });
});

describe("popup-only messages", () => {
    test("a page sender cannot answer an approval", async () => {
        const bg = loadBackground();
        const pending = bg.requestTx();
        await settle();
        const id = pending.id();

        const spoof = bg.send(
            {
                type: "AUTISTMASK_TX_RESPONSE",
                id,
                approved: true,
                rawSignedTx: await signedAtNonce(7),
            },
            { url: ORIGIN + "/index.html" },
        );
        await settle();

        expect(bg.broadcastTransaction).not.toHaveBeenCalled();
        expect(spoof.sendResponse).toHaveBeenCalledWith({
            error: "Unauthorized sender",
        });
    });
});
