handleRpc(...).then(sendResponse) had no .catch(), and sendResponse is the only
thing that settles the dApp's window.ethereum.request() promise. Any throw
inside handleRpc therefore sent nothing back: the content script posted nothing,
and the page's promise stayed pending forever with no error and no timeout,
indistinguishable from a slow wallet. handleRpc does real work -- state loads,
provider calls, transaction population, approval plumbing -- so "it does not
throw today" was not a property anyone was maintaining.
A rejected handleRpc now answers { code: -32603, message }. -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 none was
invented for it. Nothing is stripped or overwritten by this: every deliberately
coded rejection the wallet emits (4001, 4100, 4902) is a returned value, not a
throw, so it travels the resolved path and never reaches this catch. The cause
is not put in the message: the page gets a stable sentence, the background
console gets the method and the throw, so the failure is visible rather than
swallowed.
The two async IIFEs behind AUTISTMASK_TX_RESPONSE and AUTISTMASK_SIGN_RESPONSE
are the same shape one level down. Every statement 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 answered. Each gets a last-resort .catch() that settles
the approval through settleApproval() -- the existing chokepoint, with no new
delete or resolve -- and answers the popup. The transaction one tracks the phase
it is in and reports that: an escape from the verify catch runs before
broadcastTransaction() is ever called, so it says the request is gone rather
than that it may still have reached the network, and only an escape from the
broadcast catch keeps the warning about a second send. Every other message
handler on the path is synchronous and cannot leave a promise pending.
Each of the four tests is driven by a real failure rather than a hook in the
handler: a rejecting extension-storage read, which getState() awaits unguarded,
and a failure classifier that throws while classifying a genuine verification or
broadcast failure. All four were demonstrated failing against the unfixed code,
the RPC one with sendResponse at zero calls, which is precisely the page-side
hang. The two transaction cases also assert the sentence describeSigningFailure
builds for each stage, which is the copy the user reads.
1488 lines
56 KiB
JavaScript
1488 lines
56 KiB
JavaScript
// 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,
|
|
refreshPhishingListOnSchedule,
|
|
initPhishingList,
|
|
} = require("../shared/phishingDomains");
|
|
const {
|
|
BALANCE_REFRESH_ALARM,
|
|
PHISHING_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();
|
|
}
|
|
|
|
// 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 — 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;
|
|
}
|
|
});
|