// 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 { DEFAULT_RPC_URL } = require("../shared/constants"); const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks"); const { onChainSwitch } = require("../shared/chainSwitch"); const { state, loadState, saveState, currentNetwork, } = require("../shared/state"); 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, storageGet, 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."; async function getState() { const result = await storageGet("autistmask"); return ( result.autistmask || { wallets: [], rpcUrl: DEFAULT_RPC_URL, activeAddress: null, allowedSites: {}, deniedSites: {}, } ); } async function getActiveAddress() { const s = await getState(); 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; } // 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); } async function getRpcUrl() { const s = await getState(); return s.rpcUrl || DEFAULT_RPC_URL; } 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); }); } // Detect when an approval popup (browser-action) closes without a response. // TX and sign approvals now use windows.create() and are handled by the // windows.onRemoved listener below, but we still handle site-connection // approval disconnects here. runtime.onConnect.addListener((port) => { if (port.name.startsWith("approval:")) { const id = port.name.split(":")[1]; 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 }); } resetPopupUrl(); }); } }); // Handle connection requests (eth_requestAccounts, wallet_requestPermissions) async function handleConnectionRequest(origin) { const s = await getState(); const activeAddress = await getActiveAddress(); 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) { // Reload state to get latest, add to allowed, persist await loadState(); if (!state.allowedSites[activeAddress]) { state.allowedSites[activeAddress] = []; } if (!state.allowedSites[activeAddress].includes(hostname)) { state.allowedSites[activeAddress].push(hostname); } await saveState(); } else { connectedSites[origin + ":" + activeAddress] = true; } return { result: [activeAddress] }; } else { if (decision.remember) { await loadState(); if (!state.deniedSites[activeAddress]) { state.deniedSites[activeAddress] = []; } if (!state.deniedSites[activeAddress].includes(hostname)) { state.deniedSites[activeAddress].push(hostname); } await saveState(); } 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 = await getActiveAddress(); 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: [] }; } if (method === "eth_chainId") { return { result: currentNetwork().chainId }; } if (method === "net_version") { return { result: currentNetwork().networkVersion }; } if (method === "wallet_switchEthereumChain") { const chainId = params?.[0]?.chainId; if (chainId === currentNetwork().chainId) { return { result: null }; } if (SUPPORTED_CHAIN_IDS.has(chainId)) { const target = networkByChainId(chainId); await onChainSwitch(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 = await getActiveAddress(); 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 = await getActiveAddress(); 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 = await getActiveAddress(); 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) { 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 = await getActiveAddress(); 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. let approvedTx; try { approvedTx = await prepareApprovalTx( getProvider(await getRpcUrl()), 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 = await getActiveAddress(); 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); async function backgroundRefresh() { await loadState(); const now = Date.now(); if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS) return; if (state.wallets.length === 0) return; await refreshBalances( state.wallets, state.rpcUrl, state.blockscoutUrl, state.trackedTokens, ); state.lastBalanceRefresh = now; await saveState(); } // 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({ [BALANCE_REFRESH_ALARM]: backgroundRefresh, }); // 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. if (windowsNs && windowsNs.onRemoved) { windowsNs.onRemoved.addListener((windowId) => { for (const [id, approval] of Object.entries(pendingApprovals)) { if (approval.windowId !== windowId) 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: { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE, }, }); }); return true; } // Validate that popup-only messages originate from the extension itself. const POPUP_ONLY_TYPES = [ "AUTISTMASK_GET_APPROVAL", "AUTISTMASK_APPROVAL_RESPONSE", "AUTISTMASK_TX_RESPONSE", "AUTISTMASK_SIGN_RESPONSE", ]; if (POPUP_ONLY_TYPES.includes(msg.type)) { const extUrl = runtime.getURL(""); if (!sender.url || !sender.url.startsWith(extUrl)) { 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_APPROVAL_RESPONSE") { settleApproval(msg.id, { approved: msg.approved, remember: msg.remember, }); resetPopupUrl(); 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. 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. let chainId; try { await loadState(); chainId = currentNetwork().chainId; const activeAddress = await getActiveAddress(); // 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(state.rpcUrl); 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); settleApproval( msg.id, { error: { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE, }, }, { holdsClaim: true }, ); sendResponse({ error: INTERNAL_ERROR_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); settleApproval( msg.id, { error: { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE, }, }, { holdsClaim: true }, ); sendResponse({ error: INTERNAL_ERROR_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; } });