Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.
So the reachability goes rather than a sixth call site.
The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile.
- Every handler takes one snapshot and answers from it, including the address
it names: activeAddressOf(s) replaced a second, later storage read that
could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
chainSwitch.js, which keeps the singleton path for the popup) inside
updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
balances that came back by address, so it never publishes an object other
in-flight work holds, and a wallet added or deleted during the round trip
survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
snapshot. They used to come from different moments, so a chain switch
committed in between moved the endpoint under an artifact already verified
against the old chain.
getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.
The prohibition is enforced mechanically, not by review: a custom ESLint rule
walks the CommonJS require graph from every src/background/ file and fails the
lint when src/shared/state.js is reachable, naming the chain. A re-export from
any shared module cannot put the singleton back in the bundle unnoticed.
The rule's matcher covers every specifier syntax esbuild resolves statically —
quoted require, backtick require, dynamic import(), and a static import/export
`from` clause — because a narrower match is not a matter of tidiness but a sixth
site the build cannot see: each of those shapes was measured to put state.js in
dist/chrome/src/background/index.js while the lint stayed clean.
tests/backgroundStateLintRule.test.js pins all of them, plus the two-hop
re-export, against a real fixture tree. A computed specifier
(require("../shared/" + "state")) is deliberately not matched: esbuild cannot
resolve it either, so it never reaches the bundle.
Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.
Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.
closes #320
1612 lines
64 KiB
JavaScript
1612 lines
64 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 {
|
|
SUPPORTED_CHAIN_IDS,
|
|
networkById,
|
|
networkByChainId,
|
|
} = require("../shared/networks");
|
|
const { applyChainSwitchFields } = require("../shared/chainSwitchFields");
|
|
// The background's own storage layer. src/shared/state.js — the module-level
|
|
// `state` singleton, loadState() and saveState() — is deliberately NOT
|
|
// imported here and must never be: see the header of src/background/state.js,
|
|
// and the lint rule that enforces it in eslint.config.js.
|
|
const { getState, updateState } = require("./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,
|
|
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.";
|
|
|
|
// The active address of a profile snapshot. Pure, and taking the snapshot as
|
|
// an argument rather than reading storage itself: a handler that has already
|
|
// read state must not answer "which account is this" from a SECOND, later read
|
|
// — the two can disagree, and the checks that compare them would then be
|
|
// comparing two different moments.
|
|
function activeAddressOf(s) {
|
|
if (s.activeAddress) return s.activeAddress;
|
|
// Fall back to first address
|
|
if (s.wallets.length > 0 && s.wallets[0].addresses.length > 0) {
|
|
return s.wallets[0].addresses[0].address;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// For the few call sites that need only the address and hold no snapshot.
|
|
async function getActiveAddress() {
|
|
return activeAddressOf(await getState());
|
|
}
|
|
|
|
// Whether a request names a signing address other than the active one. Such a
|
|
// request is refused rather than quietly signed as whichever address happens
|
|
// to be active: the page asked for account A and would otherwise be handed
|
|
// something from account B.
|
|
function namesAnotherAddress(requested, activeAddress) {
|
|
return !!requested && !sameAddress(requested, activeAddress);
|
|
}
|
|
|
|
// The endpoint alone, for the one caller that needs nothing else. Anything
|
|
// that also needs the network the endpoint belongs to must take both from ONE
|
|
// snapshot — see handleSendTransaction() — because a chain switch moves them
|
|
// together and a provider built from two different reads can end up pointed at
|
|
// one chain and told it is on another
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
|
|
async function getRpcUrl() {
|
|
return (await getState()).rpcUrl;
|
|
}
|
|
|
|
function extractHostname(origin) {
|
|
try {
|
|
return new URL(origin).hostname;
|
|
} catch {
|
|
return origin;
|
|
}
|
|
}
|
|
|
|
// Proxy an RPC call to the Ethereum node
|
|
async function proxyRpc(method, params) {
|
|
const rpcUrl = await getRpcUrl();
|
|
const resp = await debugFetch(rpcUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method,
|
|
params,
|
|
}),
|
|
});
|
|
const json = await resp.json();
|
|
if (json.error) {
|
|
throw new Error(json.error.message || "RPC error");
|
|
}
|
|
return json.result;
|
|
}
|
|
|
|
function resetPopupUrl() {
|
|
if (actionNs && typeof actionNs.setPopup === "function") {
|
|
actionNs.setPopup({ popup: "src/popup/index.html" });
|
|
}
|
|
}
|
|
|
|
// Settle a pending approval: hand `result` to the promise the requesting page
|
|
// is waiting on and retire the approval. This is the ONLY place an approval is
|
|
// resolved or removed — the popup closing, an active-address switch, a reject
|
|
// from the popup and the attempt that signs and broadcasts all come through
|
|
// here — because a settlement that bypasses the claim below is a fund-loss bug
|
|
// and enumerating the call sites has repeatedly missed one.
|
|
//
|
|
// A claimed approval belongs to the attempt holding the claim, and only that
|
|
// attempt may settle it. Anything else settling first would leave the attempt
|
|
// running to completion against an already-settled promise: the transaction
|
|
// reaches the chain while the page is told "User rejected the request", and the
|
|
// user's natural response is to send it again at a fresh nonce.
|
|
//
|
|
// Returns false when the approval is gone or claimed by someone else, so the
|
|
// caller can refuse instead of assuming it settled.
|
|
function settleApproval(id, result, options) {
|
|
const approval = pendingApprovals[id];
|
|
if (!approval) return false;
|
|
const holdsClaim = !!(options && options.holdsClaim);
|
|
if (approval.attemptInFlight && !holdsClaim) return false;
|
|
delete pendingApprovals[id];
|
|
// The transaction-approval slot is held for exactly as long as the
|
|
// approval it was taken for is alive, and this is the one place an
|
|
// approval stops being alive.
|
|
releaseTxApprovalSlotFor(id);
|
|
approval.resolve(result);
|
|
resetPopupUrl();
|
|
return true;
|
|
}
|
|
|
|
// What a pending approval resolves to when it is given up on rather than
|
|
// answered: the window was closed, or could not be opened at all. A tx or sign
|
|
// approval answers the requesting page in EIP-1193 shape; a site-connection
|
|
// approval answers the connection handler in its own.
|
|
function abandonedResult(approval, code, message) {
|
|
if (approval.type === "tx" || approval.type === "sign") {
|
|
return { error: { code, message } };
|
|
}
|
|
return { approved: false, remember: false };
|
|
}
|
|
|
|
// A window the user closed without answering is a refusal by the user, which
|
|
// is 4001 and the wording every other rejection path already uses.
|
|
const APPROVAL_REJECTED_CODE = 4001;
|
|
const APPROVAL_REJECTED_MESSAGE = "User rejected the request.";
|
|
|
|
// The window could not be opened, so the user was never asked. This is the
|
|
// wallet failing, not the user refusing, so it does not claim to be a
|
|
// rejection: -32603 is the JSON-RPC code for the wallet's own internal
|
|
// failure, and the page is told plainly that nothing was shown.
|
|
const APPROVAL_WINDOW_FAILED_CODE = -32603;
|
|
|
|
const APPROVAL_WINDOW_FAILED_MESSAGE =
|
|
"AutistMask could not open its approval window, so this request was not" +
|
|
" shown to you and nothing was sent.";
|
|
|
|
// Take exclusive hold of a pending approval for one attempt, or refuse.
|
|
//
|
|
// An approval that failed retryably has to stay in pendingApprovals, so its
|
|
// presence cannot be the interlock against a second attempt; this flag is. It
|
|
// is set synchronously, before the handler's first await, so a second response
|
|
// carrying the same id — a reloaded approval window re-rendering a live
|
|
// Approve button, a popup that emits the message twice — finds the attempt
|
|
// already running instead of starting an independent verify and broadcast.
|
|
// Without it one approval can put two transactions on the chain: with the
|
|
// ordinary dApp approval shape the page fixes no nonce, so two artifacts
|
|
// signed at different nonces both verify.
|
|
function claimApproval(approval) {
|
|
if (approval.attemptInFlight) return false;
|
|
approval.attemptInFlight = true;
|
|
return true;
|
|
}
|
|
|
|
// Release an approval whose attempt failed in a way the user can retry.
|
|
// Nothing was broadcast, so the next attempt may claim it.
|
|
//
|
|
// Unless the window it would be retried in is already gone. The user closed it
|
|
// while the attempt was running and settleApproval() declined then, correctly,
|
|
// because the attempt still owned the approval; the attempt has now failed, so
|
|
// nothing owns it and nothing can reach it. Left standing it would hold the
|
|
// requesting page's promise open forever and, with it, the transaction
|
|
// approval slot. It is settled here as the rejection the closed window
|
|
// already meant.
|
|
function releaseApproval(approval) {
|
|
approval.attemptInFlight = false;
|
|
if (approval.windowClosed) {
|
|
settleApproval(
|
|
approval.id,
|
|
abandonedResult(
|
|
approval,
|
|
APPROVAL_REJECTED_CODE,
|
|
APPROVAL_REJECTED_MESSAGE,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Open approval in a separate popup window.
|
|
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
|
|
// not from a user gesture) and the fallback for site-connection approvals.
|
|
// Never rejects. Its callers raise it from inside a Promise executor and drop
|
|
// the result on the floor, so a rejection here would be unhandled.
|
|
async function openApprovalWindow(id) {
|
|
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
|
|
const popupWidth = 360;
|
|
const popupHeight = 600;
|
|
|
|
let currentWin = null;
|
|
try {
|
|
currentWin = await windowsGetLastFocused();
|
|
} catch {
|
|
// Nothing focused to centre on. The window still opens, at whatever
|
|
// position the browser picks.
|
|
}
|
|
|
|
const opts = {
|
|
url: popupUrl,
|
|
type: "popup",
|
|
width: popupWidth,
|
|
height: popupHeight,
|
|
};
|
|
if (currentWin) {
|
|
opts.left = Math.round(
|
|
currentWin.left + (currentWin.width - popupWidth) / 2,
|
|
);
|
|
opts.top = Math.round(
|
|
currentWin.top + (currentWin.height - popupHeight) / 2,
|
|
);
|
|
}
|
|
|
|
let win = null;
|
|
try {
|
|
win = await windowsCreate(opts);
|
|
} catch (e) {
|
|
// The promise namespace reports the failure by rejecting where the
|
|
// callback namespace reported it by handing back no window; both land
|
|
// on the !win branch below, which settles the approval.
|
|
log.errorf("could not open the approval window:", e);
|
|
}
|
|
|
|
const approval = pendingApprovals[id];
|
|
if (!approval) {
|
|
// Settled while the window was opening — an address switch, say.
|
|
// Nothing is waiting on it, and a window showing an approval that no
|
|
// longer exists is not left on screen. The await above makes this a
|
|
// real race: writing the id back would resurrect a bare entry that
|
|
// nothing would ever resolve.
|
|
if (win) windowsRemove(win.id).catch(() => {});
|
|
return;
|
|
}
|
|
if (!win) {
|
|
// No window means no way to ever answer this approval, and an
|
|
// approval nothing can answer holds the requesting page's promise
|
|
// open forever. Settle it now instead.
|
|
settleApproval(
|
|
id,
|
|
abandonedResult(
|
|
approval,
|
|
APPROVAL_WINDOW_FAILED_CODE,
|
|
APPROVAL_WINDOW_FAILED_MESSAGE,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
approval.windowId = win.id;
|
|
}
|
|
|
|
// Open an approval popup and return a promise that resolves with the user decision.
|
|
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
|
|
function requestApproval(origin, hostname) {
|
|
return new Promise((resolve) => {
|
|
const id = crypto.randomUUID();
|
|
pendingApprovals[id] = { id, origin, hostname, resolve };
|
|
|
|
if (actionNs && typeof actionNs.openPopup === "function") {
|
|
actionNs.setPopup({
|
|
popup: "src/popup/index.html?approval=" + id,
|
|
});
|
|
try {
|
|
const result = actionNs.openPopup();
|
|
if (result && typeof result.catch === "function") {
|
|
result.catch(() => openApprovalWindow(id));
|
|
}
|
|
} catch {
|
|
openApprovalWindow(id);
|
|
}
|
|
} else {
|
|
openApprovalWindow(id);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Open a tx-approval popup and return a promise that resolves with txHash or error.
|
|
// Uses windows.create() directly because tx approvals are triggered programmatically
|
|
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
|
// unreliable in this context.
|
|
//
|
|
// `approvedTx` is the fully populated transaction (see approvalTx.js): the
|
|
// object the popup displays, the object it signs, and the object the artifact
|
|
// is verified against. `approvedFrom` is the address that is active now, and
|
|
// it is pinned here rather than read again at signing time — an address switch
|
|
// between approval and signing must refuse, not sign from an account this
|
|
// screen never named.
|
|
// `slot` is the transaction-approval slot its caller holds. Handing the
|
|
// approval's id to it is what makes retiring the approval free the slot.
|
|
function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) {
|
|
return new Promise((resolve) => {
|
|
const id = crypto.randomUUID();
|
|
pendingApprovals[id] = {
|
|
id,
|
|
origin,
|
|
hostname,
|
|
approvedTx,
|
|
approvedFrom,
|
|
resolve,
|
|
type: "tx",
|
|
};
|
|
if (slot) slot.approvalId = id;
|
|
|
|
openApprovalWindow(id);
|
|
});
|
|
}
|
|
|
|
// Open a sign-approval popup and return a promise that resolves with { signature } or { error }.
|
|
// Uses windows.create() directly because sign approvals are triggered programmatically
|
|
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
|
// unreliable in this context.
|
|
function requestSignApproval(origin, hostname, signParams, approvedFrom) {
|
|
return new Promise((resolve) => {
|
|
const id = crypto.randomUUID();
|
|
pendingApprovals[id] = {
|
|
id,
|
|
origin,
|
|
hostname,
|
|
signParams,
|
|
approvedFrom,
|
|
resolve,
|
|
type: "sign",
|
|
};
|
|
|
|
openApprovalWindow(id);
|
|
});
|
|
}
|
|
|
|
// Anything only the extension's own pages may say. A content script speaks
|
|
// with the page's URL, so this is what separates the popup from the site the
|
|
// popup is being asked about.
|
|
function isExtensionSender(sender) {
|
|
const extUrl = runtime.getURL("");
|
|
return !!(sender && sender.url && sender.url.startsWith(extUrl));
|
|
}
|
|
|
|
// The approval popup's port: it carries the user's decision on a
|
|
// site-connection approval, and its disconnect is how that approval learns the
|
|
// popup closed without one.
|
|
//
|
|
// The decision travels this port rather than a one-off runtime.sendMessage()
|
|
// for exactly one reason: the port is also what the popup's window.close()
|
|
// disconnects. A message posted on a port is delivered before that port's
|
|
// disconnect, so approve-then-close settles as an approval no matter how fast
|
|
// the teardown is. Sent as a one-off message the two crossed on independent
|
|
// channels with nothing ordering them, and the teardown won every time when
|
|
// the prompt was driven in a tab: the user approved and the dApp was told they
|
|
// had refused.
|
|
//
|
|
// TX and sign approvals do not decide here. They stay pending across a
|
|
// disconnect — the user can reopen the toolbar popup — and are rejected by the
|
|
// windows.onRemoved listener below.
|
|
runtime.onConnect.addListener((port) => {
|
|
if (port.name.startsWith("approval:")) {
|
|
const id = port.name.split(":")[1];
|
|
if (pendingApprovals[id] && isExtensionSender(port.sender)) {
|
|
// The extension's own popup is on the other end, so its disconnect
|
|
// is a trustworthy "closed" and onRemoved below stands down. The
|
|
// sender check is what keeps that from being an off switch: a
|
|
// content script that guessed the id and held its port open would
|
|
// otherwise disable the only settlement path a prompt whose popup
|
|
// never connected has left, and the dApp would wait forever.
|
|
pendingApprovals[id].portConnected = true;
|
|
}
|
|
port.onMessage.addListener((msg) => {
|
|
if (!msg || msg.type !== "AUTISTMASK_APPROVAL_DECISION") return;
|
|
if (!isExtensionSender(port.sender)) return;
|
|
const approval = pendingApprovals[id];
|
|
if (!approval || approval.type === "tx" || approval.type === "sign")
|
|
return;
|
|
settleApproval(id, {
|
|
approved: !!msg.approved,
|
|
remember: !!msg.remember,
|
|
});
|
|
});
|
|
port.onDisconnect.addListener(() => {
|
|
const approval = pendingApprovals[id];
|
|
if (approval) {
|
|
if (approval.type === "tx" || approval.type === "sign") {
|
|
// Keep pending — user can reopen the toolbar popup
|
|
return;
|
|
}
|
|
settleApproval(id, { approved: false, remember: false });
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Record a remembered site decision under one address.
|
|
//
|
|
// A read-modify-write against storage, not a load-mutate-save of a shared
|
|
// singleton: the user takes seconds to answer the prompt, and everything else
|
|
// in the worker — a balance refresh in flight, another site's approval — has
|
|
// gone on running the whole time. Loading here used to replace the very
|
|
// objects that work was holding.
|
|
async function rememberSiteChoice(field, address, hostname) {
|
|
await updateState((s) => {
|
|
if (!s[field][address]) s[field][address] = [];
|
|
if (!s[field][address].includes(hostname)) {
|
|
s[field][address].push(hostname);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Handle connection requests (eth_requestAccounts, wallet_requestPermissions)
|
|
async function handleConnectionRequest(origin) {
|
|
const s = await getState();
|
|
const activeAddress = activeAddressOf(s);
|
|
if (!activeAddress) {
|
|
return { error: { message: "No accounts available" } };
|
|
}
|
|
|
|
const hostname = extractHostname(origin);
|
|
const allowed = s.allowedSites[activeAddress] || [];
|
|
const denied = s.deniedSites[activeAddress] || [];
|
|
|
|
// Check denied list
|
|
if (denied.includes(hostname)) {
|
|
return {
|
|
error: {
|
|
code: 4001,
|
|
message: "User rejected the request.",
|
|
},
|
|
};
|
|
}
|
|
|
|
// Check allowed list or in-memory connected
|
|
if (
|
|
allowed.includes(hostname) ||
|
|
connectedSites[origin + ":" + activeAddress]
|
|
) {
|
|
return { result: [activeAddress] };
|
|
}
|
|
|
|
// Open approval popup
|
|
const decision = await requestApproval(origin, hostname);
|
|
|
|
if (decision.approved) {
|
|
if (decision.remember) {
|
|
await rememberSiteChoice("allowedSites", activeAddress, hostname);
|
|
} else {
|
|
connectedSites[origin + ":" + activeAddress] = true;
|
|
}
|
|
return { result: [activeAddress] };
|
|
} else {
|
|
if (decision.remember) {
|
|
await rememberSiteChoice("deniedSites", activeAddress, hostname);
|
|
}
|
|
return {
|
|
error: {
|
|
code: 4001,
|
|
message: "User rejected the request.",
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
// Methods that are safe to proxy directly to the RPC node
|
|
const PROXY_METHODS = [
|
|
"eth_blockNumber",
|
|
"eth_call",
|
|
"eth_chainId",
|
|
"eth_estimateGas",
|
|
"eth_gasPrice",
|
|
"eth_getBalance",
|
|
"eth_getBlockByHash",
|
|
"eth_getBlockByNumber",
|
|
"eth_getCode",
|
|
"eth_getLogs",
|
|
"eth_getStorageAt",
|
|
"eth_getTransactionByHash",
|
|
"eth_getTransactionCount",
|
|
"eth_getTransactionReceipt",
|
|
"eth_maxPriorityFeePerGas",
|
|
"eth_sendRawTransaction",
|
|
"net_version",
|
|
"web3_clientVersion",
|
|
"eth_feeHistory",
|
|
"eth_getBlockTransactionCountByHash",
|
|
"eth_getBlockTransactionCountByNumber",
|
|
];
|
|
|
|
async function handleRpc(method, params, origin) {
|
|
// Connection requests — go through approval flow
|
|
if (method === "eth_requestAccounts") {
|
|
return handleConnectionRequest(origin);
|
|
}
|
|
|
|
if (method === "eth_accounts") {
|
|
const s = await getState();
|
|
const activeAddress = activeAddressOf(s);
|
|
if (!activeAddress) return { result: [] };
|
|
const hostname = extractHostname(origin);
|
|
const allowed = s.allowedSites[activeAddress] || [];
|
|
if (
|
|
allowed.includes(hostname) ||
|
|
connectedSites[origin + ":" + activeAddress]
|
|
) {
|
|
return { result: [activeAddress] };
|
|
}
|
|
return { result: [] };
|
|
}
|
|
|
|
// Both used to be answered from currentNetwork(), which reads the
|
|
// module-level state singleton, and nothing populates that at module
|
|
// scope. A worker revived by the page's own message therefore held
|
|
// DEFAULT_STATE and told a page it was on mainnet while the user was on
|
|
// Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/317).
|
|
if (method === "eth_chainId" || method === "net_version") {
|
|
const s = await getState();
|
|
const net = networkById(s.networkId);
|
|
return {
|
|
result: method === "eth_chainId" ? net.chainId : net.networkVersion,
|
|
};
|
|
}
|
|
|
|
if (method === "wallet_switchEthereumChain") {
|
|
// Gated exactly like the signing methods, and gated before the
|
|
// same-chain early return. Switching the chain is wallet-wide: it
|
|
// moves the network the popup shows and the endpoints every other
|
|
// tab is served from, so a page the user never connected to must
|
|
// not be able to do it. Ungated, any page could clear the
|
|
// [TESTNET] banner under a user who believed they were on Sepolia.
|
|
const s = await getState();
|
|
const activeAddress = activeAddressOf(s);
|
|
const hostname = extractHostname(origin);
|
|
const allowed = s.allowedSites[activeAddress] || [];
|
|
if (
|
|
!allowed.includes(hostname) &&
|
|
!connectedSites[origin + ":" + activeAddress]
|
|
) {
|
|
return { error: { code: 4100, message: "Unauthorized" } };
|
|
}
|
|
|
|
// The chain in force is read from the snapshot above, not from the
|
|
// singleton: this worker may have been started by this very message,
|
|
// and the singleton would then be DEFAULT_STATE, so the same-chain
|
|
// check compared against mainnet whatever the user was on
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/316).
|
|
const chainId = params?.[0]?.chainId;
|
|
if (chainId === networkById(s.networkId).chainId) {
|
|
return { result: null };
|
|
}
|
|
if (SUPPORTED_CHAIN_IDS.has(chainId)) {
|
|
const target = networkByChainId(chainId);
|
|
// Read-modify-write against storage. The old path went through
|
|
// onChainSwitch(), which mutates the singleton and then persists
|
|
// every field of it — on an unloaded worker that wrote empty
|
|
// wallets, empty allowedSites and the default endpoints over the
|
|
// user's stored profile, encrypted secrets included.
|
|
await updateState((fresh) =>
|
|
applyChainSwitchFields(fresh, target.id),
|
|
);
|
|
broadcastChainChanged(target.chainId);
|
|
return { result: null };
|
|
}
|
|
return {
|
|
error: {
|
|
code: 4902,
|
|
message:
|
|
"AutistMask supports Ethereum Mainnet and Sepolia Testnet only.",
|
|
},
|
|
};
|
|
}
|
|
|
|
if (method === "wallet_addEthereumChain") {
|
|
const chainId = params?.[0]?.chainId;
|
|
if (SUPPORTED_CHAIN_IDS.has(chainId)) {
|
|
return { result: null };
|
|
}
|
|
return {
|
|
error: {
|
|
code: 4902,
|
|
message:
|
|
"AutistMask supports Ethereum Mainnet and Sepolia Testnet only.",
|
|
},
|
|
};
|
|
}
|
|
|
|
if (method === "wallet_requestPermissions") {
|
|
const connResult = await handleConnectionRequest(origin);
|
|
if (connResult.error) return connResult;
|
|
return {
|
|
result: [
|
|
{
|
|
parentCapability: "eth_accounts",
|
|
caveats: [
|
|
{
|
|
type: "restrictReturnedAccounts",
|
|
value: connResult.result,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (method === "wallet_getPermissions") {
|
|
const s = await getState();
|
|
const activeAddress = activeAddressOf(s);
|
|
const hostname = extractHostname(origin);
|
|
const allowed = s.allowedSites[activeAddress] || [];
|
|
const isConnected =
|
|
allowed.includes(hostname) ||
|
|
connectedSites[origin + ":" + activeAddress];
|
|
if (!isConnected || !activeAddress) {
|
|
return { result: [] };
|
|
}
|
|
return {
|
|
result: [
|
|
{
|
|
parentCapability: "eth_accounts",
|
|
caveats: [
|
|
{
|
|
type: "restrictReturnedAccounts",
|
|
value: [activeAddress],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
if (method === "personal_sign" || method === "eth_sign") {
|
|
const s = await getState();
|
|
const activeAddress = activeAddressOf(s);
|
|
if (!activeAddress)
|
|
return { error: { message: "No accounts available" } };
|
|
|
|
const hostname = extractHostname(origin);
|
|
const allowed = s.allowedSites[activeAddress] || [];
|
|
if (
|
|
!allowed.includes(hostname) &&
|
|
!connectedSites[origin + ":" + activeAddress]
|
|
) {
|
|
return { error: { code: 4100, message: "Unauthorized" } };
|
|
}
|
|
|
|
// personal_sign: params[0]=message, params[1]=address
|
|
// eth_sign: params[0]=address, params[1]=message
|
|
const signParams =
|
|
method === "personal_sign"
|
|
? { method, message: params[0], from: params[1] }
|
|
: { method, message: params[1], from: params[0] };
|
|
|
|
if (namesAnotherAddress(signParams.from, activeAddress)) {
|
|
return {
|
|
error: {
|
|
code: 4100,
|
|
message:
|
|
"This site asked to sign as an address that is not the active one.",
|
|
},
|
|
};
|
|
}
|
|
|
|
if (method === "eth_sign") {
|
|
signParams.dangerWarning =
|
|
"\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
|
|
"This can be used to sign transactions that drain your funds. " +
|
|
"Only proceed if you fully understand what you are signing.";
|
|
}
|
|
|
|
const decision = await requestSignApproval(
|
|
origin,
|
|
hostname,
|
|
signParams,
|
|
activeAddress,
|
|
);
|
|
if (decision.error) return { error: decision.error };
|
|
return { result: decision.signature };
|
|
}
|
|
|
|
if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") {
|
|
const s = await getState();
|
|
const activeAddress = activeAddressOf(s);
|
|
if (!activeAddress)
|
|
return { error: { message: "No accounts available" } };
|
|
|
|
const hostname = extractHostname(origin);
|
|
const allowed = s.allowedSites[activeAddress] || [];
|
|
if (
|
|
!allowed.includes(hostname) &&
|
|
!connectedSites[origin + ":" + activeAddress]
|
|
) {
|
|
return { error: { code: 4100, message: "Unauthorized" } };
|
|
}
|
|
|
|
const signParams = { method, typedData: params[1], from: params[0] };
|
|
if (namesAnotherAddress(signParams.from, activeAddress)) {
|
|
return {
|
|
error: {
|
|
code: 4100,
|
|
message:
|
|
"This site asked to sign as an address that is not the active one.",
|
|
},
|
|
};
|
|
}
|
|
const decision = await requestSignApproval(
|
|
origin,
|
|
hostname,
|
|
signParams,
|
|
activeAddress,
|
|
);
|
|
if (decision.error) return { error: decision.error };
|
|
return { result: decision.signature };
|
|
}
|
|
|
|
if (method === "eth_sendTransaction") {
|
|
return await handleSendTransaction(params, origin);
|
|
}
|
|
|
|
// Proxy safe read-only methods to the RPC node
|
|
if (PROXY_METHODS.includes(method)) {
|
|
try {
|
|
const result = await proxyRpc(method, params);
|
|
return { result };
|
|
} catch (e) {
|
|
return { error: { message: e.message } };
|
|
}
|
|
}
|
|
|
|
return { error: { message: "Unsupported method: " + method } };
|
|
}
|
|
|
|
// The body of eth_sendTransaction, from the connection check through to the
|
|
// user's decision. It takes the single transaction-approval slot once it knows
|
|
// it is going to populate a transaction, and holds it until the requesting
|
|
// page has its answer.
|
|
async function handleSendTransaction(params, origin) {
|
|
const s = await getState();
|
|
const activeAddress = activeAddressOf(s);
|
|
if (!activeAddress) return { error: { message: "No accounts available" } };
|
|
|
|
const hostname = extractHostname(origin);
|
|
const allowed = s.allowedSites[activeAddress] || [];
|
|
if (
|
|
!allowed.includes(hostname) &&
|
|
!connectedSites[origin + ":" + activeAddress]
|
|
) {
|
|
return { error: { code: 4100, message: "Unauthorized" } };
|
|
}
|
|
|
|
const txParams = params?.[0] || {};
|
|
if (namesAnotherAddress(txParams.from, activeAddress)) {
|
|
return {
|
|
error: {
|
|
code: 4100,
|
|
message:
|
|
"This site asked to send from an address that is not the active one.",
|
|
},
|
|
};
|
|
}
|
|
|
|
// Everything above refuses without populating anything, so the slot is
|
|
// taken here rather than at the top of the handler: a page the wallet was
|
|
// never going to serve must not be able to hold the slot and make the
|
|
// connected site's own transaction fail as "already in progress". The
|
|
// reservation is atomic because nothing awaits between its test and its
|
|
// set, not because of where it sits.
|
|
const slot = reserveTxApprovalSlot();
|
|
if (!slot) {
|
|
return {
|
|
error: {
|
|
code: TX_APPROVAL_PENDING_CODE,
|
|
message: TX_APPROVAL_PENDING_MESSAGE,
|
|
},
|
|
};
|
|
}
|
|
|
|
try {
|
|
// Populate here, before any window opens, so that the transaction the
|
|
// user is shown is a complete one and is the same object the signed
|
|
// artifact is checked against. A failure raises no approval at all and
|
|
// is reported to the requesting page; see approvalTx.js.
|
|
//
|
|
// The provider is built from ONE snapshot — the endpoint and the
|
|
// network name both come from `s`. It used to be
|
|
// getProvider(await getRpcUrl()) with no network name at all, so
|
|
// getProvider fell back to the unpopulated singleton's mainnet: the
|
|
// endpoint was the user's chain and the static hint was 0x1, ethers
|
|
// fixed chainId at 0x1, and the wallet's own verifySignedTx then
|
|
// refused every non-mainnet dApp send
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
|
|
let approvedTx;
|
|
try {
|
|
approvedTx = await prepareApprovalTx(
|
|
getProvider(s.rpcUrl, s.networkId),
|
|
activeAddress,
|
|
txParams,
|
|
);
|
|
} catch (e) {
|
|
return { error: { message: e.message } };
|
|
}
|
|
|
|
// Population is a network round trip, and the user can switch address
|
|
// during it. Raising the approval anyway would put an account on the
|
|
// screen that the wallet is no longer on, and it could never be signed
|
|
// — the signing handler refuses exactly that. Refuse it here instead,
|
|
// while the page is still waiting and nothing has been displayed.
|
|
if (!sameAddress(await getActiveAddress(), activeAddress)) {
|
|
return {
|
|
error: {
|
|
message:
|
|
"The active address changed while this transaction was being prepared, so it was not sent.",
|
|
},
|
|
};
|
|
}
|
|
|
|
const decision = await requestTxApproval(
|
|
origin,
|
|
hostname,
|
|
approvedTx,
|
|
activeAddress,
|
|
slot,
|
|
);
|
|
if (decision.error) return { error: decision.error };
|
|
return { result: decision.txHash };
|
|
} finally {
|
|
// Retiring the approval has normally freed the slot already, through
|
|
// settleApproval(); this covers the paths that return before an
|
|
// approval exists at all, and frees nothing if another request has
|
|
// since taken the slot.
|
|
releaseTxApprovalSlot(slot);
|
|
}
|
|
}
|
|
|
|
// Broadcast chainChanged to all tabs when the network is switched.
|
|
//
|
|
// Never rejects: its caller is an RPC handler that must answer the page
|
|
// whatever the browser made of the broadcast.
|
|
async function broadcastChainChanged(chainId) {
|
|
let tabs;
|
|
try {
|
|
tabs = await tabsQuery({});
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const tab of tabs) {
|
|
// A tab with no content script has no receiver, and that is the
|
|
// ordinary case rather than a fault. The rejection it produces is the
|
|
// promise-shaped form of the runtime.lastError this used to read.
|
|
tabsSendMessage(tab.id, {
|
|
type: "AUTISTMASK_EVENT",
|
|
eventName: "chainChanged",
|
|
data: chainId,
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// Broadcast accountsChanged to all tabs, respecting per-address permissions
|
|
async function broadcastAccountsChanged() {
|
|
// Clear non-remembered approvals on address switch
|
|
for (const key of Object.keys(connectedSites)) {
|
|
delete connectedSites[key];
|
|
}
|
|
// Reject and close any pending approval popups so they don't hang. An
|
|
// approval an attempt has already claimed is left alone entirely: it is
|
|
// being signed and broadcast right now, and neither rejecting it to the
|
|
// page nor closing the window it is reporting into is survivable.
|
|
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
|
const rejection = abandonedResult(
|
|
approval,
|
|
APPROVAL_REJECTED_CODE,
|
|
APPROVAL_REJECTED_MESSAGE,
|
|
);
|
|
if (!settleApproval(id, rejection)) continue;
|
|
if (approval.windowId) {
|
|
// Rejects when the window has already gone, which is a race the
|
|
// user wins routinely by closing it themselves.
|
|
windowsRemove(approval.windowId).catch(() => {});
|
|
}
|
|
}
|
|
resetPopupUrl();
|
|
const s = await getState();
|
|
const activeAddress = activeAddressOf(s);
|
|
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
|
|
let tabs;
|
|
try {
|
|
tabs = await tabsQuery({});
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const tab of tabs) {
|
|
const origin = tab.url ? new URL(tab.url).origin : "";
|
|
const hostname = extractHostname(origin);
|
|
const hasPermission =
|
|
activeAddress &&
|
|
(allowed.includes(hostname) ||
|
|
connectedSites[origin + ":" + activeAddress]);
|
|
// Same as chainChanged above: a tab without our content script
|
|
// rejects, and that is expected rather than a fault.
|
|
tabsSendMessage(tab.id, {
|
|
type: "AUTISTMASK_EVENT",
|
|
eventName: "accountsChanged",
|
|
data: hasPermission ? [activeAddress] : [],
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// Background balance refresh: every 60 seconds when the popup isn't open.
|
|
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
|
|
// fresh, so this naturally skips.
|
|
//
|
|
// The alarm period alone sets the cadence; this guard only suppresses a
|
|
// refresh something else has just done, so it must stay strictly shorter than
|
|
// the period. Timed to the period it would veto every tick it gates —
|
|
// lastBalanceRefresh is stamped after the refresh runs, so a tick one period
|
|
// after the last one always lands inside a guard of equal length and the real
|
|
// cadence becomes two periods. Half the period keeps it comfortably above the
|
|
// popup's 10-second refresh, so an open popup still suppresses the background
|
|
// job, and comfortably below the alarm period, so the schedule always wins.
|
|
const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
|
|
const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
|
|
|
|
// The wallets this refresh works on are its OWN, and nothing else in the
|
|
// worker can reach them.
|
|
//
|
|
// refreshBalances() mutates address objects in place across a multi-second
|
|
// network round trip. It used to be handed the module-level singleton's
|
|
// wallets, which meant any concurrent handler that called loadState() replaced
|
|
// state.wallets underneath it: the refreshed balances landed on detached
|
|
// objects, and the save that followed persisted the PRE-refresh values while
|
|
// still stamping lastBalanceRefresh, suppressing the redo. Every point fix for
|
|
// the singleton added such a loadState(), so the next one would have done it
|
|
// again (https://git.eeqj.de/sneak/AutistMask/issues/324).
|
|
//
|
|
// So: read a snapshot, refresh a private copy of its wallets, then apply the
|
|
// balances that came back — by address, onto whatever storage holds NOW.
|
|
// Applying by address rather than writing the array back is what keeps a
|
|
// wallet or address added, renamed or deleted during the round trip.
|
|
async function backgroundRefresh() {
|
|
const s = await getState();
|
|
const now = Date.now();
|
|
if (now - (s.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS) return;
|
|
if (s.wallets.length === 0) return;
|
|
|
|
const wallets = s.wallets;
|
|
await refreshBalances(
|
|
wallets,
|
|
s.rpcUrl,
|
|
s.blockscoutUrl,
|
|
s.trackedTokens,
|
|
s.networkId,
|
|
);
|
|
|
|
const refreshed = new Map();
|
|
for (const wallet of wallets) {
|
|
for (const addr of wallet.addresses || []) {
|
|
refreshed.set(String(addr.address).toLowerCase(), addr);
|
|
}
|
|
}
|
|
|
|
await updateState((fresh) => {
|
|
for (const wallet of fresh.wallets) {
|
|
for (const addr of wallet.addresses || []) {
|
|
const got = refreshed.get(String(addr.address).toLowerCase());
|
|
if (!got) continue;
|
|
// Only fields the refresh actually produced. refreshBalances()
|
|
// leaves a field untouched when its lookup failed, so an
|
|
// undefined here means "no answer", not "the answer is empty",
|
|
// and must not overwrite what is stored.
|
|
for (const key of ["balance", "ensName", "tokenBalances"]) {
|
|
if (got[key] !== undefined) addr[key] = got[key];
|
|
}
|
|
}
|
|
}
|
|
fresh.lastBalanceRefresh = now;
|
|
});
|
|
}
|
|
|
|
// The recurring job runs off an alarm, not a timer. On Chrome MV3 this file is
|
|
// a service worker that the browser terminates after about 30 seconds idle,
|
|
// so a setInterval would only ever survive until the first idle period and
|
|
// module-level state does not outlive it. Alarms are held by the browser and
|
|
// wake the worker to deliver them.
|
|
registerAlarmHandlers({
|
|
[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.
|
|
//
|
|
// A site-connection approval whose popup connected its port is not decided
|
|
// here. That popup approves and closes in the same breath, and this event
|
|
// races the decision on a channel of its own — the same race the port exists
|
|
// to end. Its port disconnect says the same thing this event does, in an order
|
|
// that is defined, so the disconnect is left to say it. The window closing
|
|
// before any port connected is the one case with nothing else to speak for it,
|
|
// and is rejected here so the dApp is not left waiting on a window that is
|
|
// gone.
|
|
if (windowsNs && windowsNs.onRemoved) {
|
|
windowsNs.onRemoved.addListener((windowId) => {
|
|
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
|
if (approval.windowId !== windowId) continue;
|
|
const isSite = approval.type !== "tx" && approval.type !== "sign";
|
|
if (isSite && approval.portConnected) continue;
|
|
const rejection = abandonedResult(
|
|
approval,
|
|
APPROVAL_REJECTED_CODE,
|
|
APPROVAL_REJECTED_MESSAGE,
|
|
);
|
|
if (!settleApproval(id, rejection)) approval.windowClosed = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Listen for messages from content scripts and popup
|
|
runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
if (msg.type === "AUTISTMASK_RPC") {
|
|
// Derive origin from trusted sender info to prevent origin spoofing.
|
|
// Chrome MV3 provides sender.origin; Firefox MV2 fallback uses sender.tab.url.
|
|
let trustedOrigin = msg.origin; // fallback only if sender info unavailable
|
|
if (sender.origin) {
|
|
trustedOrigin = sender.origin;
|
|
} else if (sender.tab && sender.tab.url) {
|
|
try {
|
|
trustedOrigin = new URL(sender.tab.url).origin;
|
|
} catch {
|
|
// keep fallback
|
|
}
|
|
}
|
|
handleRpc(msg.method, msg.params, trustedOrigin)
|
|
.then((response) => {
|
|
sendResponse(response);
|
|
})
|
|
.catch((err) => {
|
|
// Without this the page's window.ethereum.request() promise
|
|
// stays pending forever: no response is sent, the content
|
|
// script posts nothing back, and the dApp cannot tell the
|
|
// failure from a slow wallet. handleRpc does real work —
|
|
// state loads, provider calls, transaction population — so
|
|
// "it does not throw today" is not a property anyone is
|
|
// maintaining.
|
|
log.errorf("RPC request failed:", msg.method, err);
|
|
sendResponse({
|
|
error: {
|
|
code: INTERNAL_ERROR_CODE,
|
|
message: INTERNAL_ERROR_MESSAGE,
|
|
},
|
|
});
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// Validate that popup-only messages originate from the extension itself.
|
|
// The site-connection decision is not here: it is a port message, and it
|
|
// is checked the same way where the port is served.
|
|
const POPUP_ONLY_TYPES = [
|
|
"AUTISTMASK_GET_APPROVAL",
|
|
"AUTISTMASK_TX_RESPONSE",
|
|
"AUTISTMASK_SIGN_RESPONSE",
|
|
];
|
|
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
|
|
sendResponse({ error: "Unauthorized sender" });
|
|
return false;
|
|
}
|
|
|
|
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
|
const approval = pendingApprovals[msg.id];
|
|
if (approval) {
|
|
const resp = {
|
|
hostname: approval.hostname,
|
|
origin: approval.origin,
|
|
};
|
|
if (approval.type === "tx") {
|
|
resp.type = "tx";
|
|
// The populated transaction, and the address it was raised
|
|
// for. The popup displays and signs exactly this and does not
|
|
// populate or re-read anything itself.
|
|
resp.approvedTx = approval.approvedTx;
|
|
resp.approvedFrom = approval.approvedFrom;
|
|
}
|
|
if (approval.type === "sign") {
|
|
resp.type = "sign";
|
|
resp.signParams = approval.signParams;
|
|
resp.approvedFrom = approval.approvedFrom;
|
|
}
|
|
// Flag if the requesting domain is on the phishing blocklist.
|
|
resp.isPhishingDomain = isPhishingDomain(approval.hostname);
|
|
sendResponse(resp);
|
|
} else {
|
|
sendResponse(null);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (msg.type === "AUTISTMASK_TX_RESPONSE") {
|
|
const approval = pendingApprovals[msg.id];
|
|
if (!approval) return false;
|
|
|
|
// A reject arriving while an attempt holds the approval is refused,
|
|
// not honoured: the attempt is on its way to broadcasting the
|
|
// transaction, and resolving 4001 here would tell the page the request
|
|
// was rejected while it goes out.
|
|
if (!msg.approved) {
|
|
if (
|
|
!settleApproval(msg.id, {
|
|
error: {
|
|
code: 4001,
|
|
message: "User rejected the request.",
|
|
},
|
|
})
|
|
) {
|
|
sendResponse({
|
|
error: "This transaction is already being sent.",
|
|
retryable: false,
|
|
stage: TX_STAGE_BROADCAST,
|
|
});
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// The popup signs; it reports back here when it could not. Keep the
|
|
// approval so the user can correct the problem and try again with the
|
|
// transaction they already saw.
|
|
if (msg.error) {
|
|
const outcome = describeTxFailure(TX_STAGE_SIGN, msg.error);
|
|
sendResponse({
|
|
error: outcome.error,
|
|
retryable: outcome.retryable,
|
|
stage: outcome.stage,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
// Exactly one broadcast per approval, whatever the popup sends.
|
|
if (!claimApproval(approval)) {
|
|
sendResponse({
|
|
error: "This transaction is already being sent.",
|
|
retryable: false,
|
|
stage: TX_STAGE_BROADCAST,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
// Which phase the last-resort .catch() below reports. Everything up to
|
|
// the broadcastTransaction() call provably never reached the network,
|
|
// so an escape from there must not tell the user it might have.
|
|
let lastResortStage = TX_STAGE_VERIFY;
|
|
(async () => {
|
|
// The chain this attempt is on, read once — and the endpoint it
|
|
// will be broadcast to comes from the SAME read.
|
|
//
|
|
// Verification below refuses an artifact signed for any other
|
|
// chain, and the nonce record is both consulted and written under
|
|
// this one, so a network switch part-way through cannot make the
|
|
// check and the record disagree about which chain the nonce was
|
|
// spent on. The endpoint used to be read separately, several
|
|
// awaits later (`state.rpcUrl` off the singleton), so a chain
|
|
// switch committed in that window moved the endpoint out from
|
|
// under a transaction already verified against the old chain: the
|
|
// artifact would be sent to the new chain's node, which is
|
|
// precisely the "signed for a different network" case the
|
|
// verification exists to prevent.
|
|
let chainId;
|
|
let rpcUrl;
|
|
let networkId;
|
|
try {
|
|
const s = await getState();
|
|
networkId = s.networkId;
|
|
chainId = networkById(networkId).chainId;
|
|
rpcUrl = s.rpcUrl;
|
|
const activeAddress = activeAddressOf(s);
|
|
// An address switch between approval and signing refuses. The
|
|
// approval named one account; signing from whichever account
|
|
// is active now would send funds from an account this screen
|
|
// never showed. A switch normally rejects every pending
|
|
// approval on its way through broadcastAccountsChanged(), so
|
|
// this is the case where that did not reach the approval —
|
|
// and it is a refusal, not a retry, because the transaction
|
|
// the user saw is no longer the transaction that would go out.
|
|
if (!sameAddress(activeAddress, approval.approvedFrom)) {
|
|
throw new ApprovalMismatchError(
|
|
"The active address changed after this transaction was approved, so it was not sent.",
|
|
);
|
|
}
|
|
// The popup holds the secret, but the background stays the
|
|
// authority on what is broadcast: the raw transaction must be
|
|
// the transaction that was displayed, signed by the address
|
|
// the approval named, on the network that is selected.
|
|
verifySignedTx(
|
|
msg.rawSignedTx,
|
|
approval.approvedTx,
|
|
approval.approvedFrom,
|
|
chainId,
|
|
);
|
|
} catch (e) {
|
|
// A signed transaction that is not the approved one is not
|
|
// retried against that approval; it is refused outright.
|
|
// Anything else that failed before the check ran is the
|
|
// user's to retry.
|
|
const outcome = describeTxFailure(TX_STAGE_VERIFY, e);
|
|
if (outcome.spendApproval) {
|
|
settleApproval(
|
|
msg.id,
|
|
{ error: { message: outcome.error } },
|
|
{ holdsClaim: true },
|
|
);
|
|
} else {
|
|
releaseApproval(approval);
|
|
}
|
|
sendResponse({
|
|
error: outcome.error,
|
|
retryable: outcome.retryable,
|
|
stage: outcome.stage,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// A nonce this worker has already broadcast for this address on
|
|
// this chain. The node is not asked: it has answered once already,
|
|
// and the wallet holding the receipt of that answer is what makes
|
|
// this failure one the user can be told did not reach the network.
|
|
// A nonce spent on another chain is not spent here — the chains
|
|
// count separately, and refusing across them would block ordinary
|
|
// use with a message that is not true.
|
|
const nonce = approvedNonce(approval.approvedTx);
|
|
const spent = broadcastNoncesFor(chainId, approval.approvedFrom);
|
|
if (nonce !== null && spent.has(nonce)) {
|
|
const outcome = describeTxFailure(TX_STAGE_NONCE, null);
|
|
settleApproval(
|
|
msg.id,
|
|
{ error: { message: outcome.error } },
|
|
{ holdsClaim: true },
|
|
);
|
|
sendResponse({
|
|
error: outcome.error,
|
|
retryable: outcome.retryable,
|
|
stage: outcome.stage,
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const provider = getProvider(rpcUrl, networkId);
|
|
lastResortStage = TX_STAGE_BROADCAST;
|
|
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
|
|
if (nonce !== null) spent.add(nonce);
|
|
settleApproval(
|
|
msg.id,
|
|
{ txHash: tx.hash },
|
|
{ holdsClaim: true },
|
|
);
|
|
sendResponse({ txHash: tx.hash });
|
|
} catch (e) {
|
|
// Terminal, never retried: the node may have accepted the
|
|
// transaction and still failed to answer, so the wallet cannot
|
|
// tell a transaction that never left from one already in the
|
|
// mempool. The page has been given its outcome for this
|
|
// request; a second attempt would report a second one.
|
|
//
|
|
// Unless the node blamed the nonce, which is the one answer
|
|
// that says plainly it did not take the transaction:
|
|
// describeTxFailure() reclassifies that, and the stage it
|
|
// returns is the one reported.
|
|
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
|
|
settleApproval(
|
|
msg.id,
|
|
{ error: { message: outcome.error } },
|
|
{ holdsClaim: true },
|
|
);
|
|
sendResponse({
|
|
error: outcome.error,
|
|
retryable: outcome.retryable,
|
|
stage: outcome.stage,
|
|
});
|
|
}
|
|
})().catch((e) => {
|
|
// Every statement above is inside a try, but a throw from one of
|
|
// the catch blocks escapes as an unhandled rejection and neither
|
|
// the popup nor the page is ever answered. Settle both, through
|
|
// the same chokepoint as every other retirement.
|
|
log.errorf("transaction approval response failed:", e);
|
|
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;
|
|
}
|
|
});
|