// 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, TX_STAGE_SIGN, TX_STAGE_VERIFY, TX_STAGE_BROADCAST, TX_STAGE_INFLIGHT, } = require("../shared/approvalVerify"); const { isPhishingDomain, refreshPhishingListOnSchedule, initPhishingList, } = require("../shared/phishingDomains"); const { BALANCE_REFRESH_ALARM, PHISHING_REFRESH_ALARM, BALANCE_REFRESH_PERIOD_MINUTES, ensureRecurringAlarms, registerAlarmHandlers, } = require("../shared/alarms"); const storageApi = typeof browser !== "undefined" ? browser.storage.local : chrome.storage.local; const runtime = typeof browser !== "undefined" ? browser.runtime : chrome.runtime; const windowsApi = typeof browser !== "undefined" ? browser.windows : chrome.windows; const tabsApi = typeof browser !== "undefined" ? browser.tabs : chrome.tabs; const actionApi = typeof browser !== "undefined" ? browser.browserAction : chrome.action; // Connected sites (in-memory, non-persisted): { "origin:address": true } const connectedSites = {}; // Pending approval requests: { id: { origin, hostname, resolve } } const pendingApprovals = {}; async function getState() { const result = await storageApi.get("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; } 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 (actionApi && typeof actionApi.setPopup === "function") { actionApi.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]; approval.resolve(result); resetPopupUrl(); return true; } // 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. function releaseApproval(approval) { approval.attemptInFlight = false; } // 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. function openApprovalWindow(id) { const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id); const popupWidth = 360; const popupHeight = 600; windowsApi.getLastFocused((currentWin) => { 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, ); } windowsApi.create(opts, (win) => { if (win) { pendingApprovals[id].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] = { origin, hostname, resolve }; if (actionApi && typeof actionApi.openPopup === "function") { actionApi.setPopup({ popup: "src/popup/index.html?approval=" + id, }); try { const result = actionApi.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. function requestTxApproval(origin, hostname, txParams) { return new Promise((resolve) => { const id = crypto.randomUUID(); pendingApprovals[id] = { origin, hostname, txParams, resolve, type: "tx", }; 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) { return new Promise((resolve) => { const id = crypto.randomUUID(); pendingApprovals[id] = { origin, hostname, signParams, 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 // windowsApi.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 (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, ); 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] }; const decision = await requestSignApproval( origin, hostname, signParams, ); if (decision.error) return { error: decision.error }; return { result: decision.signature }; } if (method === "eth_sendTransaction") { 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] || {}; const decision = await requestTxApproval(origin, hostname, txParams); if (decision.error) return { error: decision.error }; return { result: decision.txHash }; } // 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 } }; } // Broadcast chainChanged to all tabs when the network is switched. function broadcastChainChanged(chainId) { tabsApi.query({}, (tabs) => { for (const tab of tabs) { tabsApi.sendMessage( tab.id, { type: "AUTISTMASK_EVENT", eventName: "chainChanged", data: chainId, }, () => { if (runtime.lastError) { // expected for tabs without our content script } }, ); } }); } // 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 = approval.type === "tx" || approval.type === "sign" ? { error: { code: 4001, message: "User rejected the request.", }, } : { approved: false, remember: false }; if (!settleApproval(id, rejection)) continue; if (approval.windowId) { windowsApi.remove(approval.windowId, () => { if (runtime.lastError) { // window already closed } }); } } resetPopupUrl(); const s = await getState(); const activeAddress = await getActiveAddress(); const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : []; tabsApi.query({}, (tabs) => { 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]); tabsApi.sendMessage( tab.id, { type: "AUTISTMASK_EVENT", eventName: "accountsChanged", data: hasPermission ? [activeAddress] : [], }, () => { // Ignore errors for tabs without content script if (runtime.lastError) { // expected for tabs without our content script } }, ); } }); } // 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(); } // Both recurring jobs run off alarms, not timers. 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, // The scheduled refresh, which restores persisted state on a freshly // revived worker and then fetches unconditionally. The freshness guards // belong to the startup path; applying them here would make the tick skip // itself. [PHISHING_REFRESH_ALARM]: refreshPhishingListOnSchedule, }); // 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 // initPhishingList() fetches only when the persisted timestamps say the list // is stale. // // 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 = Promise.all([ ensureRecurringAlarms(), initPhishingList(), ]) .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. if (windowsApi && windowsApi.onRemoved) { windowsApi.onRemoved.addListener((windowId) => { for (const [id, approval] of Object.entries(pendingApprovals)) { if (approval.windowId !== windowId) continue; const rejection = approval.type === "tx" || approval.type === "sign" ? { error: { code: 4001, message: "User rejected the request.", }, } : { approved: false, remember: false }; settleApproval(id, rejection); } }); } // 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); }); 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"; resp.txParams = approval.txParams; } if (approval.type === "sign") { resp.type = "sign"; resp.signParams = approval.signParams; } // 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: TX_STAGE_SIGN, }); 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; } (async () => { try { await loadState(); const activeAddress = await getActiveAddress(); // The popup holds the secret, but the background stays the // authority on what is broadcast: the raw transaction must be // the approved one, signed by the approved address, on the // network that is selected. verifySignedTx( msg.rawSignedTx, approval.txParams, activeAddress, currentNetwork().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: TX_STAGE_VERIFY, }); return; } try { const provider = getProvider(state.rpcUrl); const tx = await provider.broadcastTransaction(msg.rawSignedTx); 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, and the popup's // retry re-signs at a freshly fetched nonce rather than // re-broadcasting these bytes. Retrying would send the // approved transfer a second time. const outcome = describeTxFailure(TX_STAGE_BROADCAST, e); settleApproval( msg.id, { error: { message: outcome.error } }, { holdsClaim: true }, ); sendResponse({ error: outcome.error, retryable: outcome.retryable, stage: TX_STAGE_BROADCAST, }); } })(); 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(); // 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 approved // address. const signature = msg.signature; verifySignature(approval.signParams, signature, activeAddress); 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 }); } })(); 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; } });