The blocklist URL in shipped code named a competitor and pointed at a moving ref, and the extension re-fetched from it every 24 hours, which also meant a third party decided what this wallet warns about. All of that is gone. script/vendor-blocklist fetches upstream at a pinned commit, verifies the sha256 of the bytes that commit serves, and writes src/shared/phishingBlocklist.json. It is build-time tooling, never shipped, and the one place in the repo that names the upstream project; a source reference nobody can verify is not a source reference. The artifact stores truncated sha256 digests rather than domain names. That is what censors it: the previous file contained the competitor's name 6,475 times, as phishing domains impersonating them, and not one of those domains is dropped. It also makes lookups a binary search over a fixed-width string, so nothing is built at module load — which matters on MV3, where the worker re-evaluates the module on every wake — and takes the file from 8.7 MB to 1.7 MB. script/check-censored enforces the rest: it reads the name out of the vendoring script rather than repeating it, and fails on any occurrence in the working tree or under dist/ that is not one of the two literals shipped code cannot avoid. It runs in make check, which inspects dist/ when there is one and says loudly when there is not, and again with --require-dist at the end of every make build. Removing the runtime fetch retires the delta, the extension-storage persistence and the 24-hour alarm from #158. A retired alarm is now cleared rather than left waking the worker forever on installs that already have it. The e2e suite drives the warning end to end from a real blocklisted origin served as a real http(s) site, with a control asserting the banner stays hidden for one that is not listed. Its service-worker interception canary needed a new anchor, since the startup fetch it used to watch for no longer happens: it now wakes the worker with a message and asks it for one throwaway fetch. LICENSE no longer cites a repository that returns 404. eslint.config.js gains one block: script/lib/ holds node programs the shell entrypoints call, and without it they lint with no globals at all.
1474 lines
56 KiB
JavaScript
1474 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 } = 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;
|
|
}
|
|
});
|