// AutistMask background service worker
// Handles EIP-1193 RPC requests from content scripts and proxies
// non-sensitive calls to the configured Ethereum JSON-RPC endpoint.

const {
    SUPPORTED_CHAIN_IDS,
    networkById,
    networkByChainId,
} = require("../shared/networks");
const { applyChainSwitchFields } = require("../shared/chainSwitchFields");
// The background's own storage layer. src/shared/state.js — the module-level
// `state` singleton, loadState() and saveState() — is deliberately NOT
// imported here and must never be: see the header of src/background/state.js.
// The build enforces it, not review: build.js fails when esbuild's metafile
// reports that module as an input of this bundle (FORBIDDEN_INPUTS in
// script/lib/forbiddenBundleInputs.js). The ESLint rule of the same name is
// the same prohibition reported early, not the guarantee.
const { getState, updateState } = require("./state");
const { StateUnusableError } = require("../shared/stateSchema");
const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch, log } = require("../shared/log");
const {
    verifySignedTx,
    verifySignature,
    failureIsRetryable,
    describeTxFailure,
    sameAddress,
    ApprovalMismatchError,
    TX_STAGE_SIGN,
    TX_STAGE_VERIFY,
    TX_STAGE_BROADCAST,
    TX_STAGE_INFLIGHT,
    TX_STAGE_NONCE,
} = require("../shared/approvalVerify");
const { prepareApprovalTx } = require("../shared/approvalTx");
const { isPhishingDomain } = require("../shared/phishingDomains");
const {
    BALANCE_REFRESH_ALARM,
    BALANCE_REFRESH_PERIOD_MINUTES,
    ensureRecurringAlarms,
    registerAlarmHandlers,
} = require("../shared/alarms");

const {
    actionApi,
    runtimeApi,
    tabsQuery,
    tabsSendMessage,
    windowsApi,
    windowsCreate,
    windowsGetLastFocused,
    windowsRemove,
} = require("../shared/browserApi");

const runtime = runtimeApi();
const windowsNs = windowsApi();
const actionNs = actionApi();

// Connected sites (in-memory, non-persisted): { "origin:address": true }
const connectedSites = {};

// Pending approval requests: { id: { origin, hostname, resolve } }
const pendingApprovals = {};

// One transaction approval at a time, wallet-wide.
//
// The transaction a site asks for is populated before its approval window
// opens, so that the object the user is shown is the object the signed
// artifact is verified against. Populating fixes the nonce. Two requests
// populated concurrently therefore take the SAME nonce — the node reports the
// same pending count to both, neither having been broadcast — and whichever is
// broadcast second is refused by the network for a nonce it can never be
// re-signed at, because re-signing it would mean signing something other than
// what was displayed.
//
// So the second request is refused while the first is unanswered. It is
// refused before anything is populated, so no second nonce is allocated at
// all, and while the page is still waiting with nothing on screen. The
// alternatives were considered and rejected in
// https://git.eeqj.de/sneak/AutistMask/issues/271: populating again at Confirm
// puts a nonce on screen that is not the nonce that gets signed, and
// allocating around in-flight approvals makes the wallet's own bookkeeping the
// authority on a nonce the network has not accepted, which an abandoned
// approval then leaves a hole in.
//
// Sign approvals are not gated: a signature consumes no nonce.
//
// The slot is null when free, and otherwise the handle of the request holding
// it. Once that request has raised its approval the handle carries the
// approval's id, so that retiring the approval frees the slot: every exit from
// pendingApprovals goes through settleApproval(), which makes that one hook
// complete. The holder's own finally is the backstop for the interval before
// the approval exists.
let txApprovalSlot = null;

// EIP-1474 "resource unavailable": the standard code for a request that is
// refused because another one is already pending.
const TX_APPROVAL_PENDING_CODE = -32002;

// True at every moment this can be sent: the slot is taken immediately before
// the transaction is populated, so the other request is either being prepared
// or on screen. It does not claim the other one is displayed yet, because for
// the length of one network round trip it is not.
const TX_APPROVAL_PENDING_MESSAGE =
    "AutistMask handles one transaction at a time, and another one is" +
    " already in progress, so this one was not sent. Please finish that" +
    " transaction, then send this one again.";

// Take the slot, or refuse. Nothing awaits between the test and the set, so
// two requests that reach this in the same tick cannot both pass it — the
// position of the call in the handler is irrelevant to that, which is why it
// sits after the authorization checks. A page the wallet is going to refuse
// anyway must not be able to take the slot away from the connected site.
function reserveTxApprovalSlot() {
    if (txApprovalSlot) return null;
    txApprovalSlot = { approvalId: null };
    return txApprovalSlot;
}

// Free the slot, if this handle is still the one holding it.
function releaseTxApprovalSlot(handle) {
    if (handle && txApprovalSlot !== handle) return;
    txApprovalSlot = null;
}

// Free the slot held on behalf of a retired approval. Called from
// settleApproval() for every approval, and a no-op for the ones the slot was
// not taken for.
function releaseTxApprovalSlotFor(approvalId) {
    if (txApprovalSlot && txApprovalSlot.approvalId === approvalId) {
        txApprovalSlot = null;
    }
}

// Nonces this worker has already handed to the node, per chain and address.
// This is the wallet's own knowledge that a nonce is spent, and it is checked
// before a broadcast rather than after: a node's pending count can lag a
// transaction it has itself just accepted, and a request populated inside that
// window would otherwise be signed and sent at a nonce this wallet has already
// used.
//
// The chain is part of the key because nonce spaces are per chain and the
// wallet switches networks. Without it a nonce spent on one chain would refuse
// that nonce on every other chain — and low nonces overlap across chains as a
// matter of course, so the refusal would be both routine and false.
//
// The record dies with the worker, which is correct rather than merely
// convenient: after a restart the node's count is the only answer available,
// and a transaction of this wallet's that the node has forgotten is one the
// user does want to be able to send again.
const broadcastNonces = {};

function broadcastNoncesFor(chainId, address) {
    const key =
        String(chainId).toLowerCase() +
        ":" +
        String(address || "").toLowerCase();
    if (!broadcastNonces[key]) broadcastNonces[key] = new Set();
    return broadcastNonces[key];
}

// An approved transaction's nonce as a decimal string, or null if it cannot be
// read as a number. Verification refuses an unreadable nonce before this is
// ever reached; null here only keeps the record from holding junk.
function approvedNonce(approvedTx) {
    try {
        return BigInt(approvedTx.nonce).toString();
    } catch {
        return null;
    }
}

// What the page is told when a request failed in a way the wallet has no
// specific answer for. -32603 is the JSON-RPC internal error EIP-1474 defines
// and EIP-1193 defers to for RPC-layer failures; no EIP-1193 4xxx code
// describes "the wallet broke", and one is not invented here. The cause is
// logged rather than put in the message: the page gets a stable sentence, the
// background console gets the throw.
const INTERNAL_ERROR_CODE = -32603;
const INTERNAL_ERROR_MESSAGE =
    "AutistMask could not complete this request because of an internal error.";

// What the page is told when the wallet's own stored profile cannot be read.
//
// This used to be the generic answer above: getActiveAddress() dereferenced
// the stored wallet list on nearly every method, so a corrupt or
// newer-than-this-build record turned EVERY request from EVERY page into
// "internal error", which is also what a failed signing attempt answers. The
// page cannot tell those apart, and the user is told nothing about the one
// thing that is actually wrong or where to fix it
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// Its own code rather than -32603, because the condition is specific,
// diagnosable and has a user action attached — none of which "internal error"
// conveys.
//
// -32007 specifically: EIP-1474 sets aside -32000..-32099 for
// implementation-defined server errors, but it ASSIGNS meanings to -32000
// through -32006 (Invalid input, Resource not found, Resource unavailable,
// Transaction rejected, Method not supported, Limit exceeded, JSON-RPC version
// not supported). -32007..-32099 are the unassigned ones, and this condition
// is not any of the seven. Nothing above it is free to be overloaded either:
// this wallet already answers EIP-1474's -32002 "Resource unavailable" for a
// pending approval, the conventional way, so a page is entitled to read these
// codes by that table.
const STATE_UNUSABLE_CODE = -32007;
const STATE_UNUSABLE_MESSAGE =
    "AutistMask cannot read its saved data, so nothing was signed or sent." +
    " Open the AutistMask extension to export or reset it.";

// The EIP-1193 error for a handler that threw, by cause. Everything that
// consults the profile goes through getState(), so this one mapping covers
// every method rather than each one having to know about the condition.
function failureError(err) {
    if (err instanceof StateUnusableError) {
        log.errorf("state is unusable:", err.problem);
        return { code: STATE_UNUSABLE_CODE, message: STATE_UNUSABLE_MESSAGE };
    }
    return { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE };
}

// The active address of a profile snapshot. Pure, and taking the snapshot as
// an argument rather than reading storage itself: a handler that has already
// read state must not answer "which account is this" from a SECOND, later read
// — the two can disagree, and the checks that compare them would then be
// comparing two different moments.
function activeAddressOf(s) {
    if (s.activeAddress) return s.activeAddress;
    // Fall back to first address
    if (s.wallets.length > 0 && s.wallets[0].addresses.length > 0) {
        return s.wallets[0].addresses[0].address;
    }
    return null;
}

// For the few call sites that need only the address and hold no snapshot.
async function getActiveAddress() {
    return activeAddressOf(await getState());
}

// Whether a request names a signing address other than the active one. Such a
// request is refused rather than quietly signed as whichever address happens
// to be active: the page asked for account A and would otherwise be handed
// something from account B.
function namesAnotherAddress(requested, activeAddress) {
    return !!requested && !sameAddress(requested, activeAddress);
}

// The endpoint alone, for the one caller that needs nothing else. Anything
// that also needs the network the endpoint belongs to must take both from ONE
// snapshot — see handleSendTransaction() — because a chain switch moves them
// together and a provider built from two different reads can end up pointed at
// one chain and told it is on another
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
async function getRpcUrl() {
    return (await getState()).rpcUrl;
}

function extractHostname(origin) {
    try {
        return new URL(origin).hostname;
    } catch {
        return origin;
    }
}

// Proxy an RPC call to the Ethereum node
async function proxyRpc(method, params) {
    const rpcUrl = await getRpcUrl();
    const resp = await debugFetch(rpcUrl, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            jsonrpc: "2.0",
            id: 1,
            method,
            params,
        }),
    });
    const json = await resp.json();
    if (json.error) {
        throw new Error(json.error.message || "RPC error");
    }
    return json.result;
}

function resetPopupUrl() {
    if (actionNs && typeof actionNs.setPopup === "function") {
        actionNs.setPopup({ popup: "src/popup/index.html" });
    }
}

// Settle a pending approval: hand `result` to the promise the requesting page
// is waiting on and retire the approval. This is the ONLY place an approval is
// resolved or removed — the popup closing, an active-address switch, a reject
// from the popup and the attempt that signs and broadcasts all come through
// here — because a settlement that bypasses the claim below is a fund-loss bug
// and enumerating the call sites has repeatedly missed one.
//
// A claimed approval belongs to the attempt holding the claim, and only that
// attempt may settle it. Anything else settling first would leave the attempt
// running to completion against an already-settled promise: the transaction
// reaches the chain while the page is told "User rejected the request", and the
// user's natural response is to send it again at a fresh nonce.
//
// Returns false when the approval is gone or claimed by someone else, so the
// caller can refuse instead of assuming it settled.
function settleApproval(id, result, options) {
    const approval = pendingApprovals[id];
    if (!approval) return false;
    const holdsClaim = !!(options && options.holdsClaim);
    if (approval.attemptInFlight && !holdsClaim) return false;
    delete pendingApprovals[id];
    // The transaction-approval slot is held for exactly as long as the
    // approval it was taken for is alive, and this is the one place an
    // approval stops being alive.
    releaseTxApprovalSlotFor(id);
    approval.resolve(result);
    resetPopupUrl();
    return true;
}

// What a pending approval resolves to when it is given up on rather than
// answered: the window was closed, or could not be opened at all. A tx or sign
// approval answers the requesting page in EIP-1193 shape; a site-connection
// approval answers the connection handler in its own.
function abandonedResult(approval, code, message) {
    if (approval.type === "tx" || approval.type === "sign") {
        return { error: { code, message } };
    }
    return { approved: false, remember: false };
}

// A window the user closed without answering is a refusal by the user, which
// is 4001 and the wording every other rejection path already uses.
const APPROVAL_REJECTED_CODE = 4001;
const APPROVAL_REJECTED_MESSAGE = "User rejected the request.";

// The window could not be opened, so the user was never asked. This is the
// wallet failing, not the user refusing, so it does not claim to be a
// rejection: -32603 is the JSON-RPC code for the wallet's own internal
// failure, and the page is told plainly that nothing was shown.
const APPROVAL_WINDOW_FAILED_CODE = -32603;

const APPROVAL_WINDOW_FAILED_MESSAGE =
    "AutistMask could not open its approval window, so this request was not" +
    " shown to you and nothing was sent.";

// Take exclusive hold of a pending approval for one attempt, or refuse.
//
// An approval that failed retryably has to stay in pendingApprovals, so its
// presence cannot be the interlock against a second attempt; this flag is. It
// is set synchronously, before the handler's first await, so a second response
// carrying the same id — a reloaded approval window re-rendering a live
// Approve button, a popup that emits the message twice — finds the attempt
// already running instead of starting an independent verify and broadcast.
// Without it one approval can put two transactions on the chain: with the
// ordinary dApp approval shape the page fixes no nonce, so two artifacts
// signed at different nonces both verify.
function claimApproval(approval) {
    if (approval.attemptInFlight) return false;
    approval.attemptInFlight = true;
    return true;
}

// Release an approval whose attempt failed in a way the user can retry.
// Nothing was broadcast, so the next attempt may claim it.
//
// Unless the window it would be retried in is already gone. The user closed it
// while the attempt was running and settleApproval() declined then, correctly,
// because the attempt still owned the approval; the attempt has now failed, so
// nothing owns it and nothing can reach it. Left standing it would hold the
// requesting page's promise open forever and, with it, the transaction
// approval slot. It is settled here as the rejection the closed window
// already meant.
function releaseApproval(approval) {
    approval.attemptInFlight = false;
    if (approval.windowClosed) {
        settleApproval(
            approval.id,
            abandonedResult(
                approval,
                APPROVAL_REJECTED_CODE,
                APPROVAL_REJECTED_MESSAGE,
            ),
        );
    }
}

// Open approval in a separate popup window.
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
// not from a user gesture) and the fallback for site-connection approvals.
// Never rejects. Its callers raise it from inside a Promise executor and drop
// the result on the floor, so a rejection here would be unhandled.
async function openApprovalWindow(id) {
    const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
    const popupWidth = 360;
    const popupHeight = 600;

    let currentWin = null;
    try {
        currentWin = await windowsGetLastFocused();
    } catch {
        // Nothing focused to centre on. The window still opens, at whatever
        // position the browser picks.
    }

    const opts = {
        url: popupUrl,
        type: "popup",
        width: popupWidth,
        height: popupHeight,
    };
    if (currentWin) {
        opts.left = Math.round(
            currentWin.left + (currentWin.width - popupWidth) / 2,
        );
        opts.top = Math.round(
            currentWin.top + (currentWin.height - popupHeight) / 2,
        );
    }

    let win = null;
    try {
        win = await windowsCreate(opts);
    } catch (e) {
        // The promise namespace reports the failure by rejecting where the
        // callback namespace reported it by handing back no window; both land
        // on the !win branch below, which settles the approval.
        log.errorf("could not open the approval window:", e);
    }

    const approval = pendingApprovals[id];
    if (!approval) {
        // Settled while the window was opening — an address switch, say.
        // Nothing is waiting on it, and a window showing an approval that no
        // longer exists is not left on screen. The await above makes this a
        // real race: writing the id back would resurrect a bare entry that
        // nothing would ever resolve.
        if (win) windowsRemove(win.id).catch(() => {});
        return;
    }
    if (!win) {
        // No window means no way to ever answer this approval, and an
        // approval nothing can answer holds the requesting page's promise
        // open forever. Settle it now instead.
        settleApproval(
            id,
            abandonedResult(
                approval,
                APPROVAL_WINDOW_FAILED_CODE,
                APPROVAL_WINDOW_FAILED_MESSAGE,
            ),
        );
        return;
    }
    approval.windowId = win.id;
}

// Open an approval popup and return a promise that resolves with the user decision.
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
function requestApproval(origin, hostname) {
    return new Promise((resolve) => {
        const id = crypto.randomUUID();
        pendingApprovals[id] = { id, origin, hostname, resolve };

        if (actionNs && typeof actionNs.openPopup === "function") {
            actionNs.setPopup({
                popup: "src/popup/index.html?approval=" + id,
            });
            try {
                const result = actionNs.openPopup();
                if (result && typeof result.catch === "function") {
                    result.catch(() => openApprovalWindow(id));
                }
            } catch {
                openApprovalWindow(id);
            }
        } else {
            openApprovalWindow(id);
        }
    });
}

// Open a tx-approval popup and return a promise that resolves with txHash or error.
// Uses windows.create() directly because tx approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context.
//
// `approvedTx` is the fully populated transaction (see approvalTx.js): the
// object the popup displays, the object it signs, and the object the artifact
// is verified against. `approvedFrom` is the address that is active now, and
// it is pinned here rather than read again at signing time — an address switch
// between approval and signing must refuse, not sign from an account this
// screen never named.
// `slot` is the transaction-approval slot its caller holds. Handing the
// approval's id to it is what makes retiring the approval free the slot.
function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) {
    return new Promise((resolve) => {
        const id = crypto.randomUUID();
        pendingApprovals[id] = {
            id,
            origin,
            hostname,
            approvedTx,
            approvedFrom,
            resolve,
            type: "tx",
        };
        if (slot) slot.approvalId = id;

        openApprovalWindow(id);
    });
}

// Open a sign-approval popup and return a promise that resolves with { signature } or { error }.
// Uses windows.create() directly because sign approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context.
function requestSignApproval(origin, hostname, signParams, approvedFrom) {
    return new Promise((resolve) => {
        const id = crypto.randomUUID();
        pendingApprovals[id] = {
            id,
            origin,
            hostname,
            signParams,
            approvedFrom,
            resolve,
            type: "sign",
        };

        openApprovalWindow(id);
    });
}

// Anything only the extension's own pages may say. A content script speaks
// with the page's URL, so this is what separates the popup from the site the
// popup is being asked about.
function isExtensionSender(sender) {
    const extUrl = runtime.getURL("");
    return !!(sender && sender.url && sender.url.startsWith(extUrl));
}

// The approval popup's port: it carries the user's decision on a
// site-connection approval, and its disconnect is how that approval learns the
// popup closed without one.
//
// The decision travels this port rather than a one-off runtime.sendMessage()
// for exactly one reason: the port is also what the popup's window.close()
// disconnects. A message posted on a port is delivered before that port's
// disconnect, so approve-then-close settles as an approval no matter how fast
// the teardown is. Sent as a one-off message the two crossed on independent
// channels with nothing ordering them, and the teardown won every time when
// the prompt was driven in a tab: the user approved and the dApp was told they
// had refused.
//
// TX and sign approvals do not decide here. They stay pending across a
// disconnect — the user can reopen the toolbar popup — and are rejected by the
// windows.onRemoved listener below.
runtime.onConnect.addListener((port) => {
    if (port.name.startsWith("approval:")) {
        const id = port.name.split(":")[1];
        if (pendingApprovals[id] && isExtensionSender(port.sender)) {
            // The extension's own popup is on the other end, so its disconnect
            // is a trustworthy "closed" and onRemoved below stands down. The
            // sender check is what keeps that from being an off switch: a
            // content script that guessed the id and held its port open would
            // otherwise disable the only settlement path a prompt whose popup
            // never connected has left, and the dApp would wait forever.
            pendingApprovals[id].portConnected = true;
        }
        port.onMessage.addListener((msg) => {
            if (!msg || msg.type !== "AUTISTMASK_APPROVAL_DECISION") return;
            if (!isExtensionSender(port.sender)) return;
            const approval = pendingApprovals[id];
            if (!approval || approval.type === "tx" || approval.type === "sign")
                return;
            settleApproval(id, {
                approved: !!msg.approved,
                remember: !!msg.remember,
            });
        });
        port.onDisconnect.addListener(() => {
            const approval = pendingApprovals[id];
            if (approval) {
                if (approval.type === "tx" || approval.type === "sign") {
                    // Keep pending — user can reopen the toolbar popup
                    return;
                }
                settleApproval(id, { approved: false, remember: false });
            }
        });
    }
});

// Record a remembered site decision under one address.
//
// A read-modify-write against storage, not a load-mutate-save of a shared
// singleton: the user takes seconds to answer the prompt, and everything else
// in the worker — a balance refresh in flight, another site's approval — has
// gone on running the whole time. Loading here used to replace the very
// objects that work was holding.
async function rememberSiteChoice(field, address, hostname) {
    await updateState((s) => {
        if (!s[field][address]) s[field][address] = [];
        if (!s[field][address].includes(hostname)) {
            s[field][address].push(hostname);
        }
    });
}

// Handle connection requests (eth_requestAccounts, wallet_requestPermissions)
async function handleConnectionRequest(origin) {
    const s = await getState();
    const activeAddress = activeAddressOf(s);
    if (!activeAddress) {
        return { error: { message: "No accounts available" } };
    }

    const hostname = extractHostname(origin);
    const allowed = s.allowedSites[activeAddress] || [];
    const denied = s.deniedSites[activeAddress] || [];

    // Check denied list
    if (denied.includes(hostname)) {
        return {
            error: {
                code: 4001,
                message: "User rejected the request.",
            },
        };
    }

    // Check allowed list or in-memory connected
    if (
        allowed.includes(hostname) ||
        connectedSites[origin + ":" + activeAddress]
    ) {
        return { result: [activeAddress] };
    }

    // Open approval popup
    const decision = await requestApproval(origin, hostname);

    if (decision.approved) {
        if (decision.remember) {
            await rememberSiteChoice("allowedSites", activeAddress, hostname);
        } else {
            connectedSites[origin + ":" + activeAddress] = true;
        }
        return { result: [activeAddress] };
    } else {
        if (decision.remember) {
            await rememberSiteChoice("deniedSites", activeAddress, hostname);
        }
        return {
            error: {
                code: 4001,
                message: "User rejected the request.",
            },
        };
    }
}

// Methods that are safe to proxy directly to the RPC node
const PROXY_METHODS = [
    "eth_blockNumber",
    "eth_call",
    "eth_chainId",
    "eth_estimateGas",
    "eth_gasPrice",
    "eth_getBalance",
    "eth_getBlockByHash",
    "eth_getBlockByNumber",
    "eth_getCode",
    "eth_getLogs",
    "eth_getStorageAt",
    "eth_getTransactionByHash",
    "eth_getTransactionCount",
    "eth_getTransactionReceipt",
    "eth_maxPriorityFeePerGas",
    "eth_sendRawTransaction",
    "net_version",
    "web3_clientVersion",
    "eth_feeHistory",
    "eth_getBlockTransactionCountByHash",
    "eth_getBlockTransactionCountByNumber",
];

async function handleRpc(method, params, origin) {
    // Connection requests — go through approval flow
    if (method === "eth_requestAccounts") {
        return handleConnectionRequest(origin);
    }

    if (method === "eth_accounts") {
        const s = await getState();
        const activeAddress = activeAddressOf(s);
        if (!activeAddress) return { result: [] };
        const hostname = extractHostname(origin);
        const allowed = s.allowedSites[activeAddress] || [];
        if (
            allowed.includes(hostname) ||
            connectedSites[origin + ":" + activeAddress]
        ) {
            return { result: [activeAddress] };
        }
        return { result: [] };
    }

    // Both used to be answered from currentNetwork(), which reads the
    // module-level state singleton, and nothing populates that at module
    // scope. A worker revived by the page's own message therefore held
    // DEFAULT_STATE and told a page it was on mainnet while the user was on
    // Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/317).
    if (method === "eth_chainId" || method === "net_version") {
        const s = await getState();
        const net = networkById(s.networkId);
        return {
            result: method === "eth_chainId" ? net.chainId : net.networkVersion,
        };
    }

    if (method === "wallet_switchEthereumChain") {
        // Gated exactly like the signing methods, and gated before the
        // same-chain early return. Switching the chain is wallet-wide: it
        // moves the network the popup shows and the endpoints every other
        // tab is served from, so a page the user never connected to must
        // not be able to do it. Ungated, any page could clear the
        // [TESTNET] banner under a user who believed they were on Sepolia.
        const s = await getState();
        const activeAddress = activeAddressOf(s);
        const hostname = extractHostname(origin);
        const allowed = s.allowedSites[activeAddress] || [];
        if (
            !allowed.includes(hostname) &&
            !connectedSites[origin + ":" + activeAddress]
        ) {
            return { error: { code: 4100, message: "Unauthorized" } };
        }

        // The chain in force is read from the snapshot above, not from the
        // singleton: this worker may have been started by this very message,
        // and the singleton would then be DEFAULT_STATE, so the same-chain
        // check compared against mainnet whatever the user was on
        // (https://git.eeqj.de/sneak/AutistMask/issues/316).
        const chainId = params?.[0]?.chainId;
        if (chainId === networkById(s.networkId).chainId) {
            return { result: null };
        }
        if (SUPPORTED_CHAIN_IDS.has(chainId)) {
            const target = networkByChainId(chainId);
            // Read-modify-write against storage. The old path went through
            // onChainSwitch(), which mutates the singleton and then persists
            // every field of it — on an unloaded worker that wrote empty
            // wallets, empty allowedSites and the default endpoints over the
            // user's stored profile, encrypted secrets included.
            await updateState((fresh) =>
                applyChainSwitchFields(fresh, target.id),
            );
            broadcastChainChanged(target.chainId);
            return { result: null };
        }
        return {
            error: {
                code: 4902,
                message:
                    "AutistMask supports Ethereum Mainnet and Sepolia Testnet only.",
            },
        };
    }

    if (method === "wallet_addEthereumChain") {
        const chainId = params?.[0]?.chainId;
        if (SUPPORTED_CHAIN_IDS.has(chainId)) {
            return { result: null };
        }
        return {
            error: {
                code: 4902,
                message:
                    "AutistMask supports Ethereum Mainnet and Sepolia Testnet only.",
            },
        };
    }

    if (method === "wallet_requestPermissions") {
        const connResult = await handleConnectionRequest(origin);
        if (connResult.error) return connResult;
        return {
            result: [
                {
                    parentCapability: "eth_accounts",
                    caveats: [
                        {
                            type: "restrictReturnedAccounts",
                            value: connResult.result,
                        },
                    ],
                },
            ],
        };
    }

    if (method === "wallet_getPermissions") {
        const s = await getState();
        const activeAddress = activeAddressOf(s);
        const hostname = extractHostname(origin);
        const allowed = s.allowedSites[activeAddress] || [];
        const isConnected =
            allowed.includes(hostname) ||
            connectedSites[origin + ":" + activeAddress];
        if (!isConnected || !activeAddress) {
            return { result: [] };
        }
        return {
            result: [
                {
                    parentCapability: "eth_accounts",
                    caveats: [
                        {
                            type: "restrictReturnedAccounts",
                            value: [activeAddress],
                        },
                    ],
                },
            ],
        };
    }

    if (method === "personal_sign" || method === "eth_sign") {
        const s = await getState();
        const activeAddress = activeAddressOf(s);
        if (!activeAddress)
            return { error: { message: "No accounts available" } };

        const hostname = extractHostname(origin);
        const allowed = s.allowedSites[activeAddress] || [];
        if (
            !allowed.includes(hostname) &&
            !connectedSites[origin + ":" + activeAddress]
        ) {
            return { error: { code: 4100, message: "Unauthorized" } };
        }

        // personal_sign: params[0]=message, params[1]=address
        // eth_sign:      params[0]=address, params[1]=message
        const signParams =
            method === "personal_sign"
                ? { method, message: params[0], from: params[1] }
                : { method, message: params[1], from: params[0] };

        if (namesAnotherAddress(signParams.from, activeAddress)) {
            return {
                error: {
                    code: 4100,
                    message:
                        "This site asked to sign as an address that is not the active one.",
                },
            };
        }

        if (method === "eth_sign") {
            signParams.dangerWarning =
                "\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
                "This can be used to sign transactions that drain your funds. " +
                "Only proceed if you fully understand what you are signing.";
        }

        const decision = await requestSignApproval(
            origin,
            hostname,
            signParams,
            activeAddress,
        );
        if (decision.error) return { error: decision.error };
        return { result: decision.signature };
    }

    if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") {
        const s = await getState();
        const activeAddress = activeAddressOf(s);
        if (!activeAddress)
            return { error: { message: "No accounts available" } };

        const hostname = extractHostname(origin);
        const allowed = s.allowedSites[activeAddress] || [];
        if (
            !allowed.includes(hostname) &&
            !connectedSites[origin + ":" + activeAddress]
        ) {
            return { error: { code: 4100, message: "Unauthorized" } };
        }

        const signParams = { method, typedData: params[1], from: params[0] };
        if (namesAnotherAddress(signParams.from, activeAddress)) {
            return {
                error: {
                    code: 4100,
                    message:
                        "This site asked to sign as an address that is not the active one.",
                },
            };
        }
        const decision = await requestSignApproval(
            origin,
            hostname,
            signParams,
            activeAddress,
        );
        if (decision.error) return { error: decision.error };
        return { result: decision.signature };
    }

    if (method === "eth_sendTransaction") {
        return await handleSendTransaction(params, origin);
    }

    // Proxy safe read-only methods to the RPC node
    if (PROXY_METHODS.includes(method)) {
        try {
            const result = await proxyRpc(method, params);
            return { result };
        } catch (e) {
            // A node that answered with an error is reported as itself. The
            // wallet being unable to read its own profile is not that, and it
            // must not be flattened into a message with no code: it goes back
            // to the dispatcher, which has the one answer for it.
            if (e instanceof StateUnusableError) throw e;
            return { error: { message: e.message } };
        }
    }

    return { error: { message: "Unsupported method: " + method } };
}

// The body of eth_sendTransaction, from the connection check through to the
// user's decision. It takes the single transaction-approval slot once it knows
// it is going to populate a transaction, and holds it until the requesting
// page has its answer.
async function handleSendTransaction(params, origin) {
    const s = await getState();
    const activeAddress = activeAddressOf(s);
    if (!activeAddress) return { error: { message: "No accounts available" } };

    const hostname = extractHostname(origin);
    const allowed = s.allowedSites[activeAddress] || [];
    if (
        !allowed.includes(hostname) &&
        !connectedSites[origin + ":" + activeAddress]
    ) {
        return { error: { code: 4100, message: "Unauthorized" } };
    }

    const txParams = params?.[0] || {};
    if (namesAnotherAddress(txParams.from, activeAddress)) {
        return {
            error: {
                code: 4100,
                message:
                    "This site asked to send from an address that is not the active one.",
            },
        };
    }

    // Everything above refuses without populating anything, so the slot is
    // taken here rather than at the top of the handler: a page the wallet was
    // never going to serve must not be able to hold the slot and make the
    // connected site's own transaction fail as "already in progress". The
    // reservation is atomic because nothing awaits between its test and its
    // set, not because of where it sits.
    const slot = reserveTxApprovalSlot();
    if (!slot) {
        return {
            error: {
                code: TX_APPROVAL_PENDING_CODE,
                message: TX_APPROVAL_PENDING_MESSAGE,
            },
        };
    }

    try {
        // Populate here, before any window opens, so that the transaction the
        // user is shown is a complete one and is the same object the signed
        // artifact is checked against. A failure raises no approval at all and
        // is reported to the requesting page; see approvalTx.js.
        //
        // The provider is built from ONE snapshot — the endpoint and the
        // network name both come from `s`. It used to be
        // getProvider(await getRpcUrl()) with no network name at all, so
        // getProvider fell back to the unpopulated singleton's mainnet: the
        // endpoint was the user's chain and the static hint was 0x1, ethers
        // fixed chainId at 0x1, and the wallet's own verifySignedTx then
        // refused every non-mainnet dApp send
        // (https://git.eeqj.de/sneak/AutistMask/issues/320).
        let approvedTx;
        try {
            approvedTx = await prepareApprovalTx(
                getProvider(s.rpcUrl, s.networkId),
                activeAddress,
                txParams,
            );
        } catch (e) {
            return { error: { message: e.message } };
        }

        // Population is a network round trip, and the user can switch address
        // during it. Raising the approval anyway would put an account on the
        // screen that the wallet is no longer on, and it could never be signed
        // — the signing handler refuses exactly that. Refuse it here instead,
        // while the page is still waiting and nothing has been displayed.
        if (!sameAddress(await getActiveAddress(), activeAddress)) {
            return {
                error: {
                    message:
                        "The active address changed while this transaction was being prepared, so it was not sent.",
                },
            };
        }

        const decision = await requestTxApproval(
            origin,
            hostname,
            approvedTx,
            activeAddress,
            slot,
        );
        if (decision.error) return { error: decision.error };
        return { result: decision.txHash };
    } finally {
        // Retiring the approval has normally freed the slot already, through
        // settleApproval(); this covers the paths that return before an
        // approval exists at all, and frees nothing if another request has
        // since taken the slot.
        releaseTxApprovalSlot(slot);
    }
}

// Broadcast chainChanged to all tabs when the network is switched.
//
// Never rejects: its caller is an RPC handler that must answer the page
// whatever the browser made of the broadcast.
async function broadcastChainChanged(chainId) {
    let tabs;
    try {
        tabs = await tabsQuery({});
    } catch {
        return;
    }
    for (const tab of tabs) {
        // A tab with no content script has no receiver, and that is the
        // ordinary case rather than a fault. The rejection it produces is the
        // promise-shaped form of the runtime.lastError this used to read.
        tabsSendMessage(tab.id, {
            type: "AUTISTMASK_EVENT",
            eventName: "chainChanged",
            data: chainId,
        }).catch(() => {});
    }
}

// Broadcast accountsChanged to all tabs, respecting per-address permissions
async function broadcastAccountsChanged() {
    // Clear non-remembered approvals on address switch
    for (const key of Object.keys(connectedSites)) {
        delete connectedSites[key];
    }
    // Reject and close any pending approval popups so they don't hang. An
    // approval an attempt has already claimed is left alone entirely: it is
    // being signed and broadcast right now, and neither rejecting it to the
    // page nor closing the window it is reporting into is survivable.
    for (const [id, approval] of Object.entries(pendingApprovals)) {
        const rejection = abandonedResult(
            approval,
            APPROVAL_REJECTED_CODE,
            APPROVAL_REJECTED_MESSAGE,
        );
        if (!settleApproval(id, rejection)) continue;
        if (approval.windowId) {
            // Rejects when the window has already gone, which is a race the
            // user wins routinely by closing it themselves.
            windowsRemove(approval.windowId).catch(() => {});
        }
    }
    resetPopupUrl();
    const s = await getState();
    const activeAddress = activeAddressOf(s);
    const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
    let tabs;
    try {
        tabs = await tabsQuery({});
    } catch {
        return;
    }
    for (const tab of tabs) {
        const origin = tab.url ? new URL(tab.url).origin : "";
        const hostname = extractHostname(origin);
        const hasPermission =
            activeAddress &&
            (allowed.includes(hostname) ||
                connectedSites[origin + ":" + activeAddress]);
        // Same as chainChanged above: a tab without our content script
        // rejects, and that is expected rather than a fault.
        tabsSendMessage(tab.id, {
            type: "AUTISTMASK_EVENT",
            eventName: "accountsChanged",
            data: hasPermission ? [activeAddress] : [],
        }).catch(() => {});
    }
}

// Background balance refresh: every 60 seconds when the popup isn't open.
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
// fresh, so this naturally skips.
//
// The alarm period alone sets the cadence; this guard only suppresses a
// refresh something else has just done, so it must stay strictly shorter than
// the period. Timed to the period it would veto every tick it gates —
// lastBalanceRefresh is stamped after the refresh runs, so a tick one period
// after the last one always lands inside a guard of equal length and the real
// cadence becomes two periods. Half the period keeps it comfortably above the
// popup's 10-second refresh, so an open popup still suppresses the background
// job, and comfortably below the alarm period, so the schedule always wins.
const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);

// The wallets this refresh works on are its OWN, and nothing else in the
// worker can reach them.
//
// refreshBalances() mutates address objects in place across a multi-second
// network round trip. It used to be handed the module-level singleton's
// wallets, which meant any concurrent handler that called loadState() replaced
// state.wallets underneath it: the refreshed balances landed on detached
// objects, and the save that followed persisted the PRE-refresh values while
// still stamping lastBalanceRefresh, suppressing the redo. Every point fix for
// the singleton added such a loadState(), so the next one would have done it
// again (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// So: read a snapshot, refresh a private copy of its wallets, then apply the
// balances that came back — by address, onto whatever storage holds NOW.
// Applying by address rather than writing the array back is what keeps a
// wallet or address added, renamed or deleted during the round trip.
async function backgroundRefresh() {
    const s = await getState();
    const now = Date.now();
    if (now - (s.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS) return;
    if (s.wallets.length === 0) return;

    const wallets = s.wallets;
    await refreshBalances(
        wallets,
        s.rpcUrl,
        s.blockscoutUrl,
        s.trackedTokens,
        s.networkId,
    );

    const refreshed = new Map();
    for (const wallet of wallets) {
        for (const addr of wallet.addresses || []) {
            refreshed.set(String(addr.address).toLowerCase(), addr);
        }
    }

    await updateState((fresh) => {
        for (const wallet of fresh.wallets) {
            for (const addr of wallet.addresses || []) {
                const got = refreshed.get(String(addr.address).toLowerCase());
                if (!got) continue;
                // Only fields the refresh actually produced. refreshBalances()
                // leaves a field untouched when its lookup failed, so an
                // undefined here means "no answer", not "the answer is empty",
                // and must not overwrite what is stored.
                for (const key of ["balance", "ensName", "tokenBalances"]) {
                    if (got[key] !== undefined) addr[key] = got[key];
                }
            }
        }
        fresh.lastBalanceRefresh = now;
    });
}

// The recurring job runs off an alarm, not a timer. On Chrome MV3 this file is
// a service worker that the browser terminates after about 30 seconds idle,
// so a setInterval would only ever survive until the first idle period and
// module-level state does not outlive it. Alarms are held by the browser and
// wake the worker to deliver them.
registerAlarmHandlers({
    // Caught here rather than left to the alarm dispatcher, which does not
    // await what it calls: a profile this build cannot read makes every
    // refresh throw, and an unhandled rejection per alarm tick says less than
    // one logged line per tick does.
    [BALANCE_REFRESH_ALARM]: () =>
        backgroundRefresh().catch((e) => {
            log.errorf("background balance refresh failed:", e);
        }),
});

// Everything the background context needs re-established on start. This runs
// on a fresh install, on browser startup, and on every revival of a
// terminated worker, so it must be idempotent: ensureRecurringAlarms() only
// creates alarms that are missing or carrying a stale period, and only clears
// retired ones that are still registered.
//
// On a fresh install the top-level call and the onInstalled listener both run,
// close enough together that both could see an alarm missing and create it.
// Sharing one in-flight run makes the "create only when missing" check
// race-free; the memo is dropped once it settles so a later onStartup runs
// again.
let backgroundJobsRun = null;

function startBackgroundJobs() {
    if (backgroundJobsRun) return backgroundJobsRun;
    backgroundJobsRun = ensureRecurringAlarms()
        .catch((err) => {
            // An alarm that failed to schedule means a recurring job silently
            // never runs again; it must not be an unhandled rejection.
            log.errorf("background job startup failed:", err);
        })
        .finally(() => {
            backgroundJobsRun = null;
        });
    return backgroundJobsRun;
}

if (runtime.onInstalled) {
    runtime.onInstalled.addListener(startBackgroundJobs);
}
if (runtime.onStartup) {
    runtime.onStartup.addListener(startBackgroundJobs);
}
startBackgroundJobs();

// When approval window is closed without a response, treat as rejection.
// "Without a response" is the operative part: the popup stays open across the
// verify and broadcast it is waiting on, so a user closing an apparently-hung
// window is an ordinary event with an attempt already in flight behind it.
// settleApproval() refuses those, which leaves the attempt to report its real
// outcome to the page — and the window is recorded as gone, so that an attempt
// which then fails retryably settles instead of waiting in a window that no
// longer exists.
//
// A site-connection approval whose popup connected its port is not decided
// here. That popup approves and closes in the same breath, and this event
// races the decision on a channel of its own — the same race the port exists
// to end. Its port disconnect says the same thing this event does, in an order
// that is defined, so the disconnect is left to say it. The window closing
// before any port connected is the one case with nothing else to speak for it,
// and is rejected here so the dApp is not left waiting on a window that is
// gone.
if (windowsNs && windowsNs.onRemoved) {
    windowsNs.onRemoved.addListener((windowId) => {
        for (const [id, approval] of Object.entries(pendingApprovals)) {
            if (approval.windowId !== windowId) continue;
            const isSite = approval.type !== "tx" && approval.type !== "sign";
            if (isSite && approval.portConnected) continue;
            const rejection = abandonedResult(
                approval,
                APPROVAL_REJECTED_CODE,
                APPROVAL_REJECTED_MESSAGE,
            );
            if (!settleApproval(id, rejection)) approval.windowClosed = true;
        }
    });
}

// Listen for messages from content scripts and popup
runtime.onMessage.addListener((msg, sender, sendResponse) => {
    if (msg.type === "AUTISTMASK_RPC") {
        // Derive origin from trusted sender info to prevent origin spoofing.
        // Chrome MV3 provides sender.origin; Firefox MV2 fallback uses sender.tab.url.
        let trustedOrigin = msg.origin; // fallback only if sender info unavailable
        if (sender.origin) {
            trustedOrigin = sender.origin;
        } else if (sender.tab && sender.tab.url) {
            try {
                trustedOrigin = new URL(sender.tab.url).origin;
            } catch {
                // keep fallback
            }
        }
        handleRpc(msg.method, msg.params, trustedOrigin)
            .then((response) => {
                sendResponse(response);
            })
            .catch((err) => {
                // Without this the page's window.ethereum.request() promise
                // stays pending forever: no response is sent, the content
                // script posts nothing back, and the dApp cannot tell the
                // failure from a slow wallet. handleRpc does real work —
                // state loads, provider calls, transaction population — so
                // "it does not throw today" is not a property anyone is
                // maintaining.
                log.errorf("RPC request failed:", msg.method, err);
                sendResponse({ error: failureError(err) });
            });
        return true;
    }

    // Validate that popup-only messages originate from the extension itself.
    // The site-connection decision is not here: it is a port message, and it
    // is checked the same way where the port is served.
    const POPUP_ONLY_TYPES = [
        "AUTISTMASK_GET_APPROVAL",
        "AUTISTMASK_TX_RESPONSE",
        "AUTISTMASK_SIGN_RESPONSE",
    ];
    if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
        sendResponse({ error: "Unauthorized sender" });
        return false;
    }

    if (msg.type === "AUTISTMASK_GET_APPROVAL") {
        const approval = pendingApprovals[msg.id];
        if (approval) {
            const resp = {
                hostname: approval.hostname,
                origin: approval.origin,
            };
            if (approval.type === "tx") {
                resp.type = "tx";
                // The populated transaction, and the address it was raised
                // for. The popup displays and signs exactly this and does not
                // populate or re-read anything itself.
                resp.approvedTx = approval.approvedTx;
                resp.approvedFrom = approval.approvedFrom;
            }
            if (approval.type === "sign") {
                resp.type = "sign";
                resp.signParams = approval.signParams;
                resp.approvedFrom = approval.approvedFrom;
            }
            // Flag if the requesting domain is on the phishing blocklist.
            resp.isPhishingDomain = isPhishingDomain(approval.hostname);
            sendResponse(resp);
        } else {
            sendResponse(null);
        }
        return false;
    }

    if (msg.type === "AUTISTMASK_TX_RESPONSE") {
        const approval = pendingApprovals[msg.id];
        if (!approval) return false;

        // A reject arriving while an attempt holds the approval is refused,
        // not honoured: the attempt is on its way to broadcasting the
        // transaction, and resolving 4001 here would tell the page the request
        // was rejected while it goes out.
        if (!msg.approved) {
            if (
                !settleApproval(msg.id, {
                    error: {
                        code: 4001,
                        message: "User rejected the request.",
                    },
                })
            ) {
                sendResponse({
                    error: "This transaction is already being sent.",
                    retryable: false,
                    stage: TX_STAGE_BROADCAST,
                });
                return false;
            }
            return true;
        }

        // The popup signs; it reports back here when it could not. Keep the
        // approval so the user can correct the problem and try again with the
        // transaction they already saw.
        if (msg.error) {
            const outcome = describeTxFailure(TX_STAGE_SIGN, msg.error);
            sendResponse({
                error: outcome.error,
                retryable: outcome.retryable,
                stage: outcome.stage,
            });
            return false;
        }

        // Exactly one broadcast per approval, whatever the popup sends.
        if (!claimApproval(approval)) {
            sendResponse({
                error: "This transaction is already being sent.",
                retryable: false,
                stage: TX_STAGE_BROADCAST,
            });
            return false;
        }

        // Which phase the last-resort .catch() below reports. Everything up to
        // the broadcastTransaction() call provably never reached the network,
        // so an escape from there must not tell the user it might have.
        let lastResortStage = TX_STAGE_VERIFY;
        (async () => {
            // The chain this attempt is on, read once — and the endpoint it
            // will be broadcast to comes from the SAME read.
            //
            // Verification below refuses an artifact signed for any other
            // chain, and the nonce record is both consulted and written under
            // this one, so a network switch part-way through cannot make the
            // check and the record disagree about which chain the nonce was
            // spent on. The endpoint used to be read separately, several
            // awaits later (`state.rpcUrl` off the singleton), so a chain
            // switch committed in that window moved the endpoint out from
            // under a transaction already verified against the old chain: the
            // artifact would be sent to the new chain's node, which is
            // precisely the "signed for a different network" case the
            // verification exists to prevent.
            let chainId;
            let rpcUrl;
            let networkId;
            try {
                const s = await getState();
                networkId = s.networkId;
                chainId = networkById(networkId).chainId;
                rpcUrl = s.rpcUrl;
                const activeAddress = activeAddressOf(s);
                // An address switch between approval and signing refuses. The
                // approval named one account; signing from whichever account
                // is active now would send funds from an account this screen
                // never showed. A switch normally rejects every pending
                // approval on its way through broadcastAccountsChanged(), so
                // this is the case where that did not reach the approval —
                // and it is a refusal, not a retry, because the transaction
                // the user saw is no longer the transaction that would go out.
                if (!sameAddress(activeAddress, approval.approvedFrom)) {
                    throw new ApprovalMismatchError(
                        "The active address changed after this transaction was approved, so it was not sent.",
                    );
                }
                // The popup holds the secret, but the background stays the
                // authority on what is broadcast: the raw transaction must be
                // the transaction that was displayed, signed by the address
                // the approval named, on the network that is selected.
                verifySignedTx(
                    msg.rawSignedTx,
                    approval.approvedTx,
                    approval.approvedFrom,
                    chainId,
                );
            } catch (e) {
                // A signed transaction that is not the approved one is not
                // retried against that approval; it is refused outright.
                // Anything else that failed before the check ran is the
                // user's to retry.
                const outcome = describeTxFailure(TX_STAGE_VERIFY, e);
                if (outcome.spendApproval) {
                    settleApproval(
                        msg.id,
                        { error: { message: outcome.error } },
                        { holdsClaim: true },
                    );
                } else {
                    releaseApproval(approval);
                }
                sendResponse({
                    error: outcome.error,
                    retryable: outcome.retryable,
                    stage: outcome.stage,
                });
                return;
            }

            // A nonce this worker has already broadcast for this address on
            // this chain. The node is not asked: it has answered once already,
            // and the wallet holding the receipt of that answer is what makes
            // this failure one the user can be told did not reach the network.
            // A nonce spent on another chain is not spent here — the chains
            // count separately, and refusing across them would block ordinary
            // use with a message that is not true.
            const nonce = approvedNonce(approval.approvedTx);
            const spent = broadcastNoncesFor(chainId, approval.approvedFrom);
            if (nonce !== null && spent.has(nonce)) {
                const outcome = describeTxFailure(TX_STAGE_NONCE, null);
                settleApproval(
                    msg.id,
                    { error: { message: outcome.error } },
                    { holdsClaim: true },
                );
                sendResponse({
                    error: outcome.error,
                    retryable: outcome.retryable,
                    stage: outcome.stage,
                });
                return;
            }

            try {
                const provider = getProvider(rpcUrl, networkId);
                lastResortStage = TX_STAGE_BROADCAST;
                const tx = await provider.broadcastTransaction(msg.rawSignedTx);
                if (nonce !== null) spent.add(nonce);
                settleApproval(
                    msg.id,
                    { txHash: tx.hash },
                    { holdsClaim: true },
                );
                sendResponse({ txHash: tx.hash });
            } catch (e) {
                // Terminal, never retried: the node may have accepted the
                // transaction and still failed to answer, so the wallet cannot
                // tell a transaction that never left from one already in the
                // mempool. The page has been given its outcome for this
                // request; a second attempt would report a second one.
                //
                // Unless the node blamed the nonce, which is the one answer
                // that says plainly it did not take the transaction:
                // describeTxFailure() reclassifies that, and the stage it
                // returns is the one reported.
                const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
                settleApproval(
                    msg.id,
                    { error: { message: outcome.error } },
                    { holdsClaim: true },
                );
                sendResponse({
                    error: outcome.error,
                    retryable: outcome.retryable,
                    stage: outcome.stage,
                });
            }
        })().catch((e) => {
            // Every statement above is inside a try, but a throw from one of
            // the catch blocks escapes as an unhandled rejection and neither
            // the popup nor the page is ever answered. Settle both, through
            // the same chokepoint as every other retirement.
            log.errorf("transaction approval response failed:", e);
            const failure = failureError(e);
            settleApproval(msg.id, { error: failure }, { holdsClaim: true });
            sendResponse({
                error: failure.message,
                retryable: false,
                stage: lastResortStage,
            });
        });
        return true;
    }

    if (msg.type === "AUTISTMASK_SIGN_RESPONSE") {
        const approval = pendingApprovals[msg.id];
        if (!approval) return false;

        // Same as the transaction path: a reject cannot retire an approval an
        // attempt already holds.
        if (!msg.approved) {
            if (
                !settleApproval(msg.id, {
                    error: {
                        code: 4001,
                        message: "User rejected the request.",
                    },
                })
            ) {
                sendResponse({
                    error: "This request is already being signed.",
                    retryable: false,
                    stage: TX_STAGE_INFLIGHT,
                });
                return false;
            }
            return true;
        }

        // The popup signs; it reports back here when it could not. Keep the
        // approval so the user can correct the problem and try again with the
        // message they already saw.
        if (msg.error) {
            sendResponse({ error: msg.error, retryable: true });
            return false;
        }

        // Exactly one signature handed back per approval.
        if (!claimApproval(approval)) {
            sendResponse({
                error: "This request is already being signed.",
                retryable: false,
                stage: TX_STAGE_INFLIGHT,
            });
            return false;
        }

        (async () => {
            try {
                const activeAddress = await getActiveAddress();
                // Same as the transaction path: the address the approval named
                // is the one that must have signed, and a switch since then is
                // a refusal rather than a signature from another account.
                if (!sameAddress(activeAddress, approval.approvedFrom)) {
                    throw new ApprovalMismatchError(
                        "The active address changed after this request was approved, so it was not signed.",
                    );
                }
                // The popup holds the secret, but the background stays the
                // authority on what is handed back to the page: the signature
                // must cover the approved payload and recover to the address
                // the approval named.
                const signature = msg.signature;
                verifySignature(
                    approval.signParams,
                    signature,
                    approval.approvedFrom,
                );
                settleApproval(msg.id, { signature }, { holdsClaim: true });
                sendResponse({ signature });
            } catch (e) {
                const errMsg = e.shortMessage || e.message;
                const retryable = failureIsRetryable(e);
                if (!retryable) {
                    settleApproval(
                        msg.id,
                        { error: { message: errMsg } },
                        { holdsClaim: true },
                    );
                } else {
                    releaseApproval(approval);
                }
                sendResponse({ error: errMsg, retryable });
            }
        })().catch((e) => {
            // Same shape as the transaction path: a throw out of the catch
            // block above would leave the popup and the page both waiting.
            log.errorf("sign approval response failed:", e);
            const failure = failureError(e);
            settleApproval(msg.id, { error: failure }, { holdsClaim: true });
            sendResponse({
                error: failure.message,
                retryable: false,
            });
        });
        return true;
    }

    if (msg.type === "AUTISTMASK_ACTIVE_CHANGED") {
        broadcastAccountsChanged();
        return false;
    }

    if (msg.type === "AUTISTMASK_REMOVE_SITE") {
        // Popup already saved state; nothing else needed
        return false;
    }
});
