Compare commits

..

3 Commits

Author SHA1 Message Date
9665ac448e fix: answer the page when a background handler throws (closes #280)
All checks were successful
check / check (push) Successful in 46s
handleRpc(...).then(sendResponse) had no .catch(), and sendResponse is the only
thing that settles the dApp's window.ethereum.request() promise. Any throw
inside handleRpc therefore sent nothing back: the content script posted nothing,
and the page's promise stayed pending forever with no error and no timeout,
indistinguishable from a slow wallet. handleRpc does real work -- state loads,
provider calls, transaction population, approval plumbing -- so "it does not
throw today" was not a property anyone was maintaining.

A rejected handleRpc now answers { code: -32603, message }. -32603 is the
JSON-RPC internal error EIP-1474 defines and EIP-1193 defers to for RPC-layer
failures; no EIP-1193 4xxx code describes "the wallet broke" and none was
invented for it. The cause is not put in the message: the page gets a stable
sentence, the background console gets the method and the throw, so the failure
is visible rather than swallowed.

The two async IIFEs behind AUTISTMASK_TX_RESPONSE and AUTISTMASK_SIGN_RESPONSE
are the same shape one level down. Every statement is inside a try, but a throw
from one of the catch blocks escapes as an unhandled rejection and neither the
popup nor the page is answered. Each gets a last-resort .catch() that settles
the approval through settleApproval() -- the existing chokepoint, with no new
delete or resolve -- and answers the popup. The transaction one reports the
broadcast stage, because it cannot tell whether the transaction reached the
network and that is the wording that does not invite a second send. Every other
message handler on the path is synchronous and cannot leave a promise pending.

Each of the three is driven by a real failure rather than a hook in the handler:
a rejecting extension-storage read, which getState() awaits unguarded, and a
failure classifier that throws while classifying a genuine verification failure.
All three were demonstrated failing against the unfixed code, the RPC one with
sendResponse at zero calls, which is precisely the page-side hang.
2026-08-14 04:06:17 +00:00
9dcd875dd4 fix: carry EIP-1193 error codes through to the page (closes #274)
All checks were successful
check / check (push) Successful in 28s
The provider rebuilt every rejection as a bare Error carrying only a message,
so a dApp checking err.code === 4001 saw undefined and could not tell a user's
deliberate refusal from a failure. Well-behaved sites therefore showed an error
or retried instead of accepting the refusal. The code was produced correctly
and did cross the extension boundary; it was lost in the last hop.

Rejections now reach the page as a ProviderRpcError carrying code, and data
where present. The code is passed through verbatim rather than matched against
a whitelist, so a code added upstream later needs no change here. An error that
genuinely has no code stays a plain Error with no code property at all, rather
than advertising code: undefined -- 'code' in err is what a careful dApp asks.

Messages are unchanged for every path, verified byte-for-byte against the
previous provider across every background error shape.

The end-to-end assertion that printed the observed code now requires it.
2026-08-12 13:47:33 +02:00
c755a5e944 fix: a shared ticker no longer hides one of its two real tokens (closes #276)
All checks were successful
check / check (push) Successful in 33s
Seven bundled tokens were filtered as spoofs at their own address, so a user
holding FRAX, TON, REUSD, EURE, MSUSD, MUSD or JPYC could not see or spend the
one the wallet happened not to pick.

The known-symbol table is derived from the bundled token list, first-wins in
market-cap order, so a symbol that appears twice silently condemned its second
contract. Both are real tokens from the same fetch and neither is stale --
three pairs are one issuer's old and new contract, four are unrelated issuers
sharing a ticker. Picking a winner would have been guessing, and dropping the
ambiguous symbols would have ended spoof filtering for those tickers entirely.

The table now maps a symbol to the set of addresses that legitimately bear it.
A contract outside the set is still a spoof, so the check is not weakened: a
third contract bearing any of the seven shared tickers is refused, and that is
tested. The filter decides what is fake, not what is worth holding, so a legacy
contract stays in the set -- it still holds real balances.

A test walks the whole bundled list asserting no token is filtered at its own
address, which is the guard whose absence let this ship.
2026-08-12 13:31:55 +02:00
16 changed files with 937 additions and 776 deletions

View File

@@ -638,21 +638,6 @@ ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
so the popup can never reopen onto one of them with no password prompt in front so the popup can never reopen onto one of them with no password prompt in front
of it. of it.
A reopened popup renders the wallet list and the one screen it restores onto,
and nothing else, so every screen on the stack behind that one is still the
blank template from `index.html`. "Back" therefore renders its target rather
than only unhiding it, through the same dispatch and data guards as the restore
(`src/popup/viewRouter.js`), and falls back to Home when the state the target
would render is gone.
It renders only a screen this page load has not rendered yet. Forward navigation
renders as it goes, and `viewRouter.js` records every screen that reaches
`showView()`, so "Back" onto a screen already on the page unhides it and nothing
more — rendering it a second time would re-fetch and overwrite what it holds,
such as an edit typed into Settings and not yet saved. Home is the one screen
"Back" always re-renders, so the wallet list reflects anything that changed
while the user was away from it.
Every screen that holds secret material in the page registers a cleanup with Every screen that holds secret material in the page registers a cleanup with
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every `onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
exit from that screen rather than only on its "Back" button, so nothing secret exit from that screen rather than only on its "Back" button, so nothing secret

64
TODO.md
View File

@@ -45,23 +45,53 @@ undefined identifiers, which is how
# Completed Steps # Completed Steps
- 2026-08-12: "Back" now renders the screen it lands on instead of only unhiding - 2026-08-14: A background message handler that throws now rejects the page
it. A reopened popup renders the wallet list and the one screen it restores instead of hanging it. `handleRpc(...).then(sendResponse)` had no `.catch()`,
onto, so every screen further down the stack was still the blank template from and `sendResponse` is the only thing that settles the dApp's
`index.html`, and Back walked straight onto it — an empty address, no `window.ethereum.request()` promise — so any throw inside `handleRpc` left
balances, no QR code. The Back path now goes through the same per-view that promise pending forever, with no error and no timeout, indistinguishable
dispatch and data guards as the restore (`src/popup/viewRouter.js`, shared from a slow wallet. It now answers `{ code: -32603, message }` (the JSON-RPC
with `restoreView()`), falling back to Home when the state the target would internal error EIP-1474 defines and EIP-1193 defers to; no EIP-1193 4xxx code
render is gone. It renders only a view this page load has not rendered yet: describes "the wallet broke" and none was invented) and logs the method and
`viewRouter.js` records every view that reaches `showView()`, which is where the throw to the background console rather than swallowing them. The two async
forward navigation and the restore both end, so Back onto a view already on IIFEs behind `AUTISTMASK_TX_RESPONSE` and `AUTISTMASK_SIGN_RESPONSE` were the
the page unhides it and nothing more. That is what keeps a second render from same shape one level down — every statement inside a `try`, but a throw out of
re-fetching and overwriting what the view holds — an unsaved edit in Settings, a `catch` block escaping unhandled — and each got a last-resort `.catch()`
a transaction list already loaded. Home is the exception and is always settling the approval through `settleApproval()` and answering the popup; the
re-rendered, as it was before. Covered by unit tests on the real `goBack()` transaction one reports the broadcast stage, because it cannot tell whether
and by three end-to-end cases against the real popup, each demonstrated the transaction reached the network. Every other handler on the path is
failing on the unfixed build synchronous. All three are driven by real failures — a rejecting storage read,
([#268](https://git.eeqj.de/sneak/AutistMask/issues/268)). and a failure classifier that throws while classifying a genuine verification
failure — and were demonstrated failing first, the RPC one with `sendResponse`
at zero calls ([#280](https://git.eeqj.de/sneak/AutistMask/issues/280)).
- 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js`
rebuilt every failure as `new Error(error.message)`, so the code the
background produced and the content script relayed intact was dropped in the
last hop and a dApp checking `err.code === 4001` saw `undefined` — a wallet
the user deliberately declined was indistinguishable from one that broke. The
provider now rejects with a `ProviderRpcError` carrying `code` and, where the
boundary sent one, `data`, passed through verbatim rather than matched against
a list, so 4001, 4100 and 4902 all arrive and a future code needs no edit
here. An error the background sent with no code stays a plain `Error` with no
`code` property, and `message` is unchanged in every case. All four request
entry points (`request`, `enable`, `send`, `sendAsync`) are covered by
`tests/inpageErrors.test.js`, and the e2e probe that printed the missing code
now requires it on the page's Error as well as on the wire, for all four
rejected flows ([#274](https://git.eeqj.de/sneak/AutistMask/issues/274)).
- 2026-08-12: `KNOWN_SYMBOLS` now maps a symbol to the set of contract addresses
that bear it, not to one of them. A ticker is not unique: seven of the 512
bundled tokens — `FRAX`, `REUSD`, `TON`, `EURE`, `MSUSD`, `MUSD` and `JPYC`
share a symbol with another bundled entry at a different real contract, and
the table, built from the list first-wins, kept only the earlier one. The
other seven were judged spoofs of their own symbol at their own address and
hidden from the balance list, the history and the send selector, so a holder
could not spend them. Both contracts of each pair come from the same CoinGecko
fetch of 2026-02-27, so neither was stale and neither was dropped.
`isSpoofedSymbol()` asks set membership instead of equality, which does not
loosen the rule — a contract outside the set is still a spoof — and a test now
walks `TOKENS` asserting no bundled token is filtered at its own address,
which is the walk the suite lacked
([#276](https://git.eeqj.de/sneak/AutistMask/issues/276)).
- 2026-08-12: The dApp approval round trips are driven end to end in the - 2026-08-12: The dApp approval round trips are driven end to end in the
browser. A test page served by the harness speaks EIP-1193 to the real inpage browser. A test page served by the harness speaks EIP-1193 to the real inpage
provider through the real content script, background worker and approval popup provider through the real content script, background worker and approval popup

View File

@@ -57,6 +57,16 @@ const connectedSites = {};
// Pending approval requests: { id: { origin, hostname, resolve } } // Pending approval requests: { id: { origin, hostname, resolve } }
const pendingApprovals = {}; const pendingApprovals = {};
// 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() { async function getState() {
const result = await storageApi.get("autistmask"); const result = await storageApi.get("autistmask");
return ( return (
@@ -865,8 +875,25 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// keep fallback // keep fallback
} }
} }
handleRpc(msg.method, msg.params, trustedOrigin).then((response) => { handleRpc(msg.method, msg.params, trustedOrigin)
.then((response) => {
sendResponse(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; return true;
} }
@@ -1051,7 +1078,31 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
stage: TX_STAGE_BROADCAST, stage: TX_STAGE_BROADCAST,
}); });
} }
})(); })().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. The stage is
// broadcast because this cannot tell whether the transaction
// reached the network, and that is the wording that does not
// invite a second send.
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: TX_STAGE_BROADCAST,
});
});
return true; return true;
} }
@@ -1135,7 +1186,25 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
} }
sendResponse({ error: errMsg, retryable }); 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; return true;
} }

View File

@@ -11,6 +11,39 @@
let nextId = 1; let nextId = 1;
const pending = {}; const pending = {};
// EIP-1193 ProviderRpcError: `code`, `message`, optional `data`. A class
// rather than properties bolted onto an Error because this object crosses
// no boundary after construction — it is built in the page's own realm and
// handed straight to the caller's catch — so the prototype survives and
// `error.name` is a stable thing for a dApp to see.
class ProviderRpcError extends Error {
constructor(code, message, data) {
super(message);
this.name = "ProviderRpcError";
this.code = code;
if (data !== undefined) this.data = data;
}
}
// Rebuild a boundary error as the error the page catches, carrying the
// code (and data) the extension reported. Without this a dApp cannot tell
// a user's refusal (4001) from a wallet that broke, and retries or shows
// an error instead of accepting the refusal.
//
// Whatever code arrived is passed through verbatim rather than being
// matched against a list: the extension emits 4001, 4100 and 4902 today,
// and a code this file has never heard of is still the truth about what
// happened. An error reported with no code at all stays a plain Error —
// a ProviderRpcError whose `code` is undefined would advertise a
// conformance it does not have. `message` is untouched in every case.
function toPageError(error) {
const message = (error && error.message) || "Request failed";
if (error && error.code !== undefined && error.code !== null) {
return new ProviderRpcError(error.code, message, error.data);
}
return new Error(message);
}
// Listen for responses from the content script // Listen for responses from the content script
window.addEventListener("message", function onUuid(event) { window.addEventListener("message", function onUuid(event) {
if (event.source !== window) return; if (event.source !== window) return;
@@ -20,7 +53,7 @@
if (!p) return; if (!p) return;
delete pending[id]; delete pending[id];
if (error) { if (error) {
p.reject(new Error(error.message || "Request failed")); p.reject(toPageError(error));
} else { } else {
p.resolve(result); p.resolve(result);
} }

View File

@@ -9,17 +9,16 @@ const {
$, $,
showView, showView,
updateDebugBanner, updateDebugBanner,
setBackRenderer, setRenderMain,
pushCurrentView, pushCurrentView,
goBack, goBack,
clearViewStack, clearViewStack,
} = require("./views/helpers"); } = require("./views/helpers");
const { applyTheme } = require("./theme"); const { applyTheme } = require("./theme");
// Renders a view the popup lands on without having navigated to it forward: // Views that can be fully re-rendered from persisted state. All others fall
// on restore here, and on Back. Only the views that can be fully re-rendered // back to the nearest restorable parent; see the module for why the
// from persisted state (RESTORABLE_VIEWS, src/popup/restorableViews.js) go // secret-bearing views are absent.
// through it; anything else falls back to the nearest restorable parent. const { RESTORABLE_VIEWS } = require("./restorableViews");
const { renderView, makeBackRenderer } = require("./viewRouter");
const home = require("./views/home"); const home = require("./views/home");
const welcome = require("./views/welcome"); const welcome = require("./views/welcome");
@@ -109,23 +108,92 @@ const ctx = {
}, },
}; };
// The view modules the router renders through, keyed as it expects them. function needsAddress(view) {
const viewModules = { return (
main: { show: () => fallbackView() }, view === "address" ||
addressDetail, view === "address-token" ||
addressToken, view === "receive" ||
receive, view === "transaction"
settings, );
settingsAddToken, }
confirmTx,
transactionDetail, function hasValidAddress() {
txStatus, return (
}; state.selectedWallet !== null &&
state.selectedAddress !== null &&
state.wallets[state.selectedWallet] &&
state.wallets[state.selectedWallet].addresses[state.selectedAddress]
);
}
function restoreView() { function restoreView() {
if (!renderView(state.currentView, state, viewModules)) { const view = state.currentView;
if (!view || !RESTORABLE_VIEWS.has(view)) {
return fallbackView();
}
if (needsAddress(view) && !hasValidAddress()) {
return fallbackView();
}
if (view === "address-token" && !state.selectedToken) {
return fallbackView();
}
switch (view) {
case "address":
addressDetail.show();
break;
case "address-token":
addressToken.show();
break;
case "receive":
receive.show();
break;
case "settings":
settings.show();
break;
case "settings-addtoken":
settingsAddToken.show();
break;
case "confirm-tx":
if (state.viewData && state.viewData.pendingTx) {
confirmTx.restore();
} else {
fallbackView(); fallbackView();
} }
break;
case "transaction":
if (state.viewData && state.viewData.tx) {
transactionDetail.render();
} else {
fallbackView();
}
break;
case "wait-tx":
// Resumes the receipt poll from the persisted broadcast time.
if (!txStatus.restoreWait()) {
fallbackView();
}
break;
case "success-tx":
if (state.viewData && state.viewData.hash) {
txStatus.renderSuccess();
} else {
fallbackView();
}
break;
case "error-tx":
if (state.viewData && state.viewData.message) {
txStatus.renderError();
} else {
fallbackView();
}
break;
default:
fallbackView();
break;
}
} }
function fallbackView() { function fallbackView() {
@@ -179,7 +247,7 @@ async function init() {
settings.show(); settings.show();
}); });
setBackRenderer(makeBackRenderer(state, viewModules)); setRenderMain(renderWalletList);
welcome.init(ctx); welcome.init(ctx);
addWallet.init(ctx); addWallet.init(ctx);

View File

@@ -1,167 +0,0 @@
// Rendering a view the popup lands on without having navigated to it
// forward: on restore, and on Back. In both cases the view may never have
// been rendered in this page load — a reopened popup renders only the
// wallet list and the view it restores onto, so every other view is still
// the blank static template from index.html — so unhiding it is not enough.
//
// Forward navigation renders as it goes and must NOT come through here:
// rendering a second time would re-fetch and clobber whatever the view has
// in flight.
//
// The view modules are injected and nothing here touches the DOM, so the
// dispatch and its data guards can be tested directly; src/popup/index.js
// cannot be required outside a browser.
const { RESTORABLE_VIEWS } = require("./restorableViews");
// The views this page load has rendered.
//
// The Back path cannot otherwise tell its two cases apart. A view the popup
// never rendered is still the blank template from index.html and has to be
// rendered; a view already on the page must NOT be rendered again, because
// a second render re-fetches and overwrites whatever the user has typed
// into it and not yet saved.
//
// Registration is showView() in views/helpers.js, which is the last thing
// every render path runs — restoreView()'s, the Back path's, and every
// forward show(). That is the point of putting it there rather than in the
// individual views: a view added later registers itself with no one having
// to remember it, so this cannot decay.
//
// Module scope is page-load scope: the popup loads this module once per
// page load, and a reopened popup gets a fresh, empty set — which is
// exactly the state that makes the Back path render.
const renderedViews = new Set();
function markViewRendered(view) {
if (view) renderedViews.add(view);
}
// Begin a fresh page-load scope. The popup gets one by being loaded; the
// unit tests, which simulate several page loads against one module
// instance, ask for one.
function resetRenderedViews() {
renderedViews.clear();
}
// Home is the exception: Back re-renders it every time, which is what the
// popup did before this router existed (index.js registered
// renderWalletList() as setRenderMain(), and goBack() called it on every
// Back onto "main"). It must stay that way — the wallet list has to reflect
// what changed while the user was away from it, such as a wallet renamed or
// an address removed in Settings — and Home holds no unsaved input to lose.
const ALWAYS_RENDER_ON_BACK = new Set(["main"]);
// Views that render an address the user picked and cannot be rendered
// without one.
const ADDRESS_VIEWS = new Set([
"address",
"address-token",
"receive",
"transaction",
]);
function needsAddress(view) {
return ADDRESS_VIEWS.has(view);
}
function hasValidAddress(state) {
return Boolean(
state.selectedWallet !== null &&
state.selectedAddress !== null &&
state.wallets[state.selectedWallet] &&
state.wallets[state.selectedWallet].addresses[state.selectedAddress],
);
}
// Render `view` from persisted state. Each view module shows itself, so a
// true return means the view is both rendered and on screen.
//
// Returns false when the view is not one the popup renders from state, or
// when the state it would render is gone — a token no longer selected, a
// transaction no longer persisted. The caller falls back rather than
// putting an empty template on screen.
function renderView(view, state, views) {
if (!view || !RESTORABLE_VIEWS.has(view)) return false;
if (needsAddress(view) && !hasValidAddress(state)) return false;
if (view === "address-token" && !state.selectedToken) return false;
const data = state.viewData || {};
switch (view) {
case "main":
views.main.show();
return true;
case "address":
views.addressDetail.show();
return true;
case "address-token":
views.addressToken.show();
return true;
case "receive":
views.receive.show();
return true;
case "settings":
views.settings.show();
return true;
case "settings-addtoken":
views.settingsAddToken.show();
return true;
case "confirm-tx":
if (!data.pendingTx) return false;
views.confirmTx.restore();
return true;
case "transaction":
if (!data.tx) return false;
views.transactionDetail.render();
return true;
case "wait-tx":
// Resumes the receipt poll from the persisted broadcast time,
// and answers false when there is nothing resumable left.
return Boolean(views.txStatus.restoreWait());
case "success-tx":
if (!data.hash) return false;
views.txStatus.renderSuccess();
return true;
case "error-tx":
if (!data.message) return false;
views.txStatus.renderError();
return true;
default:
return false;
}
}
// The Back-path renderer, registered with setBackRenderer() in
// views/helpers.js.
//
// Returns false — leaving goBack() to unhide the view, as it always did —
// in the two cases where the view is known to be on the page already:
//
// - It is not one the popup renders from persisted state. The restored
// stack is filtered against RESTORABLE_VIEWS, so such a view can only
// be on the stack from this page load, where forward navigation
// rendered it on the way in.
// - This page load has rendered it. Re-rendering would re-fetch and
// clobber what it holds; Home is rendered anyway, see above.
//
// What is left is the case the router exists for: a view on the stack that
// this page load has never rendered, whose template is still blank.
function makeBackRenderer(state, views) {
return function renderBack(view) {
if (!RESTORABLE_VIEWS.has(view)) return false;
if (renderedViews.has(view) && !ALWAYS_RENDER_ON_BACK.has(view)) {
return false;
}
if (!renderView(view, state, views)) {
views.main.show();
}
return true;
};
}
module.exports = {
renderView,
makeBackRenderer,
markViewRendered,
resetRenderedViews,
};

View File

@@ -7,7 +7,6 @@ const {
getAddressValueUsd, getAddressValueUsd,
} = require("../../shared/prices"); } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { markViewRendered } = require("../viewRouter");
// When views are added, removed, or transitions between them change, // When views are added, removed, or transitions between them change,
// update the view-navigation documentation in README.md to match. // update the view-navigation documentation in README.md to match.
@@ -77,10 +76,6 @@ function showView(name) {
} }
clearFlash(); clearFlash();
state.currentView = name; state.currentView = name;
// A view's show() ends here, so this is where the Back path learns the
// view is no longer the blank template from index.html and must not be
// rendered a second time. See viewRouter.js.
markViewRendered(name);
saveState(); saveState();
updateDebugBanner(name); updateDebugBanner(name);
} }
@@ -116,19 +111,12 @@ function updateDebugBanner(viewName) {
} }
} }
// Callback that renders a view being navigated BACK onto. Set once by // Callback to re-render the main/home view when navigating back to it.
// index.js via setBackRenderer(), which routes the view through the same // Set once by index.js via setRenderMain().
// per-view render and data guards restoreView() uses. let _renderMain = null;
//
// It answers true when it took the navigation — the view is rendered and
// shown, or its backing data was gone and it fell back — and false for a
// view the popup does not render from persisted state. Those can only be
// on the stack from this page load, because the stack is filtered on load,
// so they have already been rendered and only need unhiding.
let _renderBack = null;
function setBackRenderer(fn) { function setRenderMain(fn) {
_renderBack = fn; _renderMain = fn;
} }
// Push the current view onto the navigation stack so goBack() can // Push the current view onto the navigation stack so goBack() can
@@ -148,11 +136,9 @@ function goBack() {
} else { } else {
target = "main"; target = "main";
} }
// A popped view is landed on, not navigated to. If the popup has been if (target === "main" && _renderMain) {
// closed and reopened since the view was pushed, nothing has ever _renderMain();
// rendered it in this page load and its template is still blank, so it }
// has to be rendered here rather than merely unhidden.
if (_renderBack && _renderBack(target)) return;
showView(target); showView(target);
} }
@@ -484,7 +470,7 @@ module.exports = {
showView, showView,
onViewLeave, onViewLeave,
updateDebugBanner, updateDebugBanner,
setBackRenderer, setRenderMain,
pushCurrentView, pushCurrentView,
goBack, goBack,
clearViewStack, clearViewStack,

View File

@@ -8,12 +8,19 @@
// either verdict alone, because the balance list is where the user forms // either verdict alone, because the balance list is where the user forms
// their belief about what they own (issue #235). // their belief about what they own (issue #235).
// //
// KNOWN_SYMBOLS maps a symbol to the lowercased contract address that may // KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
// bear it, or to null. Null means the symbol belongs to the native asset, // that may bear it, or to null. Null means the symbol belongs to the native
// which has no contract at all, so no contract may bear it and every one // asset, which has no contract at all, so no contract may bear it and every
// that does is a spoof. "ETH" is the only such entry today; the rule is // one that does is a spoof. "ETH" is the only such entry today; the rule is
// written so that a second one needs no change here or at any call site. // written so that a second one needs no change here or at any call site.
// //
// The value is a set because a ticker is not unique: seven symbols in the
// bundled list belong to two real contracts each, and answering with one of
// them hid the other one's holders' money (issue #276). Membership, not
// equality, is therefore the question — but it is the same question, asked of
// a table that can now state the truth. Every address in a set is one the
// wallet ships as a real token; a contract outside the set is still a spoof.
//
// The symbol is attacker-controlled — it is whatever the ERC-20 contract // The symbol is attacker-controlled — it is whatever the ERC-20 contract
// returns — so the lookup is done on a normalized form (issue #260): the // returns — so the lookup is done on a normalized form (issue #260): the
// question is whether the symbol reaches the user's eye as a known one, // question is whether the symbol reaches the user's eye as a known one,
@@ -93,7 +100,7 @@ function isSpoofedSymbol(symbol, contractAddress) {
if (!KNOWN_SYMBOLS.has(sym)) return false; if (!KNOWN_SYMBOLS.has(sym)) return false;
const legit = KNOWN_SYMBOLS.get(sym); const legit = KNOWN_SYMBOLS.get(sym);
if (legit === null) return true; if (legit === null) return true;
return contract !== normalizeAddress(legit); return !legit.has(contract);
} }
module.exports = { module.exports = {

View File

@@ -3607,14 +3607,33 @@ for (const t of TOKENS) {
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t); TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
} }
// Build a map of symbol (uppercased) -> legitimate contract address (lowercased). // Build a map of symbol (uppercased) -> the set of contract addresses
// Used for spoofed-symbol detection. "ETH" maps to null (native token). // (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
// "ETH" maps to null: the native asset has no contract, so no contract may
// bear its symbol.
//
// The value is a set and not a single address because tickers are not unique
// and the list above proves it: seven of these 512 tokens share a symbol with
// another entry — FRAX, REUSD, TON, EURE, MSUSD, MUSD and JPYC — at two
// different real contracts each, all of them from the same source fetch. A
// one-address-per-symbol table can only answer that by picking a winner, and
// the loser is then a token in our own bundled list that the spoof filter
// hides from the balance list, the history and the send selector at its own
// address, so the user cannot spend it (issue #276). Naming every address
// that bears the symbol is the only shape that says what is true; it does not
// loosen the rule, because a contract outside the set is still a spoof.
const KNOWN_SYMBOLS = new Map(); const KNOWN_SYMBOLS = new Map();
KNOWN_SYMBOLS.set("ETH", null); KNOWN_SYMBOLS.set("ETH", null);
for (const t of TOKENS) { for (const t of TOKENS) {
const upper = t.symbol.toUpperCase(); const upper = t.symbol.toUpperCase();
if (!KNOWN_SYMBOLS.has(upper)) { if (!KNOWN_SYMBOLS.has(upper)) {
KNOWN_SYMBOLS.set(upper, t.address.toLowerCase()); KNOWN_SYMBOLS.set(upper, new Set());
}
const addresses = KNOWN_SYMBOLS.get(upper);
// A null entry is the native asset and stays null: an ERC-20 that reports
// the native symbol does not thereby become entitled to it.
if (addresses !== null) {
addresses.add(t.address.toLowerCase());
} }
} }

View File

@@ -1,329 +0,0 @@
// Back after reopening the popup (#268).
//
// A reopened popup renders the wallet list and the one view it restores
// onto; every other view is still the blank static template from
// index.html. goBack() used to only unhide its target, so Back landed on
// that blank template for any view the popup had not rendered in this page
// load. These tests drive the real goBack() with the real router wired to
// recording view modules, so what is asserted is which view render ran —
// the thing that was missing.
//
// The rendering itself is asserted against the real popup in a real
// browser by tests/e2e/run.js; here the DOM is a stub, because goBack()
// only needs showView() to work.
const els = new Map();
function fakeEl() {
return {
textContent: "",
innerHTML: "",
classList: {
toggle() {},
add() {},
remove() {},
contains: () => false,
},
remove() {},
};
}
globalThis.document = {
getElementById(id) {
if (!els.has(id)) els.set(id, fakeEl());
return els.get(id);
},
};
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const {
showView,
goBack,
setBackRenderer,
pushCurrentView,
} = require("../src/popup/views/helpers");
const {
makeBackRenderer,
markViewRendered,
resetRenderedViews,
} = require("../src/popup/viewRouter");
const { state } = require("../src/shared/state");
const ADDRESS = "0x1111111111111111111111111111111111111111";
const TOKEN = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
let calls;
// Stand-ins for the view modules. Each records itself and then shows its
// view, which is what every real view render ends with — so the assertions
// can tell "rendered and shown" apart from "merely unhidden".
function recorder(name, view) {
return () => {
calls.push(name);
showView(view);
};
}
function makeViews() {
return {
main: { show: recorder("main", "main") },
addressDetail: { show: recorder("addressDetail", "address") },
addressToken: { show: recorder("addressToken", "address-token") },
receive: { show: recorder("receive", "receive") },
settings: { show: recorder("settings", "settings") },
settingsAddToken: {
show: recorder("settingsAddToken", "settings-addtoken"),
},
confirmTx: { restore: recorder("confirmTx", "confirm-tx") },
transactionDetail: {
render: recorder("transactionDetail", "transaction"),
},
txStatus: {
restoreWait: () => {
calls.push("waitTx");
showView("wait-tx");
return true;
},
renderSuccess: recorder("successTx", "success-tx"),
renderError: recorder("errorTx", "error-tx"),
},
};
}
// The popup as it stands just after a reopen: one wallet with one address,
// the view the popup restored onto, and the stack behind it.
//
// A reopen is a fresh page load, so the record of what has been rendered
// starts empty — that emptiness is what makes the Back path render at all.
// Returns the view modules so a test can drive forward navigation through
// the same recorders the router renders through.
function reopenedOn(view, stack, extra) {
calls = [];
resetRenderedViews();
state.wallets = [
{
name: "Wallet 1",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = null;
state.viewData = null;
state.currentView = view;
state.viewStack = stack.slice();
Object.assign(state, extra || {});
// Restoring onto a view renders it, so the reopened popup has that one
// view on the page and nothing else.
markViewRendered(view);
const views = makeViews();
setBackRenderer(makeBackRenderer(state, views));
return views;
}
// The reproduction from the issue, step for step.
describe("Back onto a view the reopened popup never rendered", () => {
test("Back from settings renders the address detail underneath", () => {
reopenedOn("settings", ["main", "address"]);
goBack();
expect(calls).toEqual(["addressDetail"]);
expect(state.currentView).toBe("address");
expect(state.viewStack).toEqual(["main"]);
});
test("Back onto the token detail renders it", () => {
reopenedOn("settings", ["main", "address", "address-token"], {
selectedToken: TOKEN,
});
goBack();
expect(calls).toEqual(["addressToken"]);
expect(state.currentView).toBe("address-token");
});
test("Back onto Receive renders it", () => {
reopenedOn("settings", ["main", "address", "receive"]);
goBack();
expect(calls).toEqual(["receive"]);
expect(state.currentView).toBe("receive");
});
test("Back onto the transaction detail renders it", () => {
reopenedOn("settings", ["main", "transaction"], {
viewData: { tx: { hash: "0xdead" } },
});
goBack();
expect(calls).toEqual(["transactionDetail"]);
expect(state.currentView).toBe("transaction");
});
test("Back onto the transaction confirmation restores it", () => {
reopenedOn("settings", ["main", "confirm-tx"], {
viewData: { pendingTx: { to: ADDRESS, amount: "1" } },
});
goBack();
expect(calls).toEqual(["confirmTx"]);
expect(state.currentView).toBe("confirm-tx");
});
test("Back onto the success screen renders it", () => {
reopenedOn("settings", ["main", "success-tx"], {
viewData: { hash: "0xdead" },
});
goBack();
expect(calls).toEqual(["successTx"]);
expect(state.currentView).toBe("success-tx");
});
test("Back onto the failure screen renders it", () => {
reopenedOn("settings", ["main", "error-tx"], {
viewData: { message: "execution reverted" },
});
goBack();
expect(calls).toEqual(["errorTx"]);
expect(state.currentView).toBe("error-tx");
});
test("Back onto Home renders the wallet list", () => {
reopenedOn("settings", ["main"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("Back with an empty stack renders Home", () => {
reopenedOn("settings", []);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
});
// The guards are restoreView()'s, so a popped view whose backing data is
// gone lands on Home rather than on an empty template.
describe("Back onto a view whose backing data is gone", () => {
test("the token detail with no token selected falls back to Home", () => {
reopenedOn("settings", ["main", "address-token"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("the transaction detail with no transaction falls back to Home", () => {
reopenedOn("settings", ["main", "transaction"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("the confirmation with no pending transaction falls back to Home", () => {
reopenedOn("settings", ["main", "confirm-tx"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("an address view with no address selected falls back to Home", () => {
reopenedOn("settings", ["main", "receive"], {
selectedAddress: null,
});
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("the success screen with no transaction hash falls back to Home", () => {
reopenedOn("settings", ["main", "success-tx"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("the failure screen with no message falls back to Home", () => {
reopenedOn("settings", ["main", "error-tx"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("a wait that can no longer be resumed falls back to Home", () => {
reopenedOn("settings", ["main", "wait-tx"]);
const views = makeViews();
views.txStatus.restoreWait = () => false;
setBackRenderer(makeBackRenderer(state, views));
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
});
// Forward navigation renders as it goes, and a second render would re-fetch
// and clobber whatever the view holds — an unsaved edit, a request in
// flight. So the Back path renders only a view this page load has never
// rendered, and merely unhides every other one: the views it does not
// render from persisted state, and the views already on the page.
describe("what the Back path leaves alone", () => {
test("forward navigation renders nothing by itself", () => {
reopenedOn("address", ["main"]);
pushCurrentView();
showView("send");
expect(calls).toEqual([]);
expect(state.viewStack).toEqual(["main", "address"]);
});
test("Back onto a live-session view only unhides it", () => {
reopenedOn("confirm-tx", ["main", "address", "send"]);
goBack();
expect(calls).toEqual([]);
expect(state.currentView).toBe("send");
});
test("Back renders its target exactly once", () => {
reopenedOn("settings", ["main", "address"]);
goBack();
expect(calls.filter((c) => c === "addressDetail")).toHaveLength(1);
});
test("Back onto a view this page load already rendered only unhides it", () => {
const views = reopenedOn("main", []);
pushCurrentView();
views.addressDetail.show();
pushCurrentView();
views.settings.show();
calls = [];
goBack();
expect(calls).toEqual([]);
expect(state.currentView).toBe("address");
});
// The unit mirror of the regression the browser suite pins: Settings
// reassigns its fields from persisted state on every render, so a
// re-render on the way back discards an edit the user has not saved.
test("Back onto Settings visited earlier in this page load does not re-render it", () => {
const views = reopenedOn("main", []);
pushCurrentView();
views.settings.show();
pushCurrentView();
views.settingsAddToken.show();
calls = [];
goBack();
expect(calls).toEqual([]);
expect(state.currentView).toBe("settings");
});
// Home is the deliberate exception, unchanged from the popup's
// behaviour before the router existed: it re-renders on every Back so
// the wallet list reflects what changed while the user was away.
test("Back onto Home renders it again even when it is already on the page", () => {
const views = reopenedOn("main", []);
pushCurrentView();
views.addressDetail.show();
calls = [];
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
});

View File

@@ -133,6 +133,14 @@ function loadBackground(options) {
ensureRecurringAlarms: jest.fn(async () => {}), ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(), registerAlarmHandlers: jest.fn(),
})); }));
// The real verification module, except where a test replaces one export
// with a throw to drive the handler's own error handling into failing.
if (opts.approvalVerify) {
jest.doMock("../src/shared/approvalVerify", () => ({
...jest.requireActual("../src/shared/approvalVerify"),
...opts.approvalVerify,
}));
}
const persisted = { const persisted = {
wallets: [ wallets: [
@@ -152,7 +160,10 @@ function loadBackground(options) {
global.chrome = { global.chrome = {
storage: { storage: {
local: { local: {
get: jest.fn(async () => ({ autistmask: persisted })), get: jest.fn(
opts.storageGet ||
(async () => ({ autistmask: persisted })),
),
set: jest.fn(async () => {}), set: jest.fn(async () => {}),
}, },
}, },
@@ -280,6 +291,25 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve(); for (let i = 0; i < 50; i++) await Promise.resolve();
} }
// Node aborts the worker process on an unhandled rejection; an extension
// service worker does not — the promise is simply never settled, nothing is
// sent back, and the page's window.ethereum.request() waits forever. Recording
// them instead of dying on them keeps that difference visible: the assertion
// that the page WAS answered is what reports the failure, and the recording is
// asserted empty alongside it.
const unhandledRejections = [];
process.on("unhandledRejection", (reason) => {
unhandledRejections.push(reason);
});
// Node reports an unhandled rejection on the macrotask turn after the promise
// was left unhandled, which is past everything settle() waits for.
async function settleIncludingRejections() {
await settle();
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
}
afterEach(() => { afterEach(() => {
delete global.chrome; delete global.chrome;
jest.resetModules(); jest.resetModules();
@@ -1034,6 +1064,147 @@ describe("a claimed approval outlives every other retirement path", () => {
}); });
}); });
// A handler that throws must still answer. `sendResponse` is the only thing
// that settles the page's window.ethereum.request() promise, so a throw that
// escapes a handler leaves that promise pending forever — no error, no
// timeout, indistinguishable from a slow wallet. Each case below drives a real
// throw out of a handler rather than asserting the catch block exists.
describe("a handler that throws still settles the page", () => {
const INTERNAL_ERROR = {
code: -32603,
message:
"AutistMask could not complete this request because of an internal error.",
};
let errorLog;
beforeEach(() => {
errorLog = jest.spyOn(console, "error").mockImplementation(() => {});
unhandledRejections.length = 0;
});
afterEach(() => {
errorLog.mockRestore();
});
// getState() awaits extension storage unguarded, and every read path in
// handleRpc goes through it. A storage read that rejects is the whole
// failure — no hook in the handler itself.
test("a rejected handleRpc rejects the page instead of hanging it", async () => {
const bg = loadBackground({
storageGet: async () => {
throw new Error("storage unavailable");
},
});
const answer = bg.send(
{ type: "AUTISTMASK_RPC", method: "eth_accounts", params: [] },
{ origin: ORIGIN },
);
await settleIncludingRejections();
// The channel is held open for the async answer, and the answer
// arrives.
expect(answer.kept).toBe(true);
expect(answer.sendResponse).toHaveBeenCalledWith({
error: INTERNAL_ERROR,
});
expect(unhandledRejections).toEqual([]);
// Not swallowed: the throw is on the background console, which is how
// this class gets caught in future.
expect(errorLog).toHaveBeenCalledWith(
"[AutistMask]",
"RPC request failed:",
"eth_accounts",
expect.objectContaining({ message: "storage unavailable" }),
);
});
// The transaction response handler wraps every statement in a try, so what
// escapes it is a throw from inside one of its catch blocks. Here the
// failure classifier itself throws while classifying a real verification
// failure — the approval is left claimed, so nothing else can settle it.
test("a throw while handling a failed transaction settles both the page and the popup", async () => {
const bg = loadBackground({
approvalVerify: {
describeTxFailure: () => {
throw new Error("classifier broke");
},
},
});
const pending = bg.requestTx();
await settle();
const id = pending.id();
// A real verification failure: the artifact is signed at a nonce the
// approval never displayed.
const answer = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(NONCE + 1),
},
{ url: bg.fromPopup.url },
);
await settleIncludingRejections();
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
expect(answer.sendResponse).toHaveBeenCalledWith({
error: INTERNAL_ERROR.message,
retryable: false,
// The handler cannot tell whether the transaction reached the
// network, so the popup must not say "start again from the site".
stage: "broadcast",
});
expect(unhandledRejections).toEqual([]);
expect(errorLog).toHaveBeenCalledWith(
"[AutistMask]",
"transaction approval response failed:",
expect.objectContaining({ message: "classifier broke" }),
);
});
test("a throw while handling a failed signature settles both the page and the popup", async () => {
const bg = loadBackground({
approvalVerify: {
failureIsRetryable: () => {
throw new Error("classifier broke");
},
},
});
const pending = bg.requestSign();
await settle();
// A real verification failure: the active address moved after the
// approval was raised.
bg.setActiveAddress(other.address);
const answer = bg.send(
{
type: "AUTISTMASK_SIGN_RESPONSE",
id: pending.id(),
approved: true,
signature: await signer.signMessage(
Buffer.from(MESSAGE.slice(2), "hex"),
),
},
{ url: bg.fromPopup.url },
);
await settleIncludingRejections();
expect(pending.result()).toEqual({ error: INTERNAL_ERROR });
expect(answer.sendResponse).toHaveBeenCalledWith({
error: INTERNAL_ERROR.message,
retryable: false,
});
expect(unhandledRejections).toEqual([]);
expect(errorLog).toHaveBeenCalledWith(
"[AutistMask]",
"sign approval response failed:",
expect.objectContaining({ message: "classifier broke" }),
);
});
});
describe("popup-only messages", () => { describe("popup-only messages", () => {
test("a page sender cannot answer an approval", async () => { test("a page sender cannot answer an approval", async () => {
const bg = loadBackground(); const bg = loadBackground();

View File

@@ -86,9 +86,11 @@ const DAPP_URL = DAPP_ORIGIN + "/";
// never drive the popup that has to settle it; start() files the promise // never drive the popup that has to settle it; start() files the promise
// under a key and settle() collects it once the prompt has been dealt with. // under a key and settle() collects it once the prompt has been dealt with.
// //
// The rejection branch records `code` as it arrives. EIP-1193 says a user // The rejection branch records the whole observable shape of the error as it
// rejection is a ProviderRpcError carrying code 4001; what the page can // arrives — name, message, and whether a `code` is present at all as distinct
// actually see is recorded here rather than assumed, and asserted in run.js. // from its value. EIP-1193 says a user rejection is a ProviderRpcError
// carrying code 4001; what the page can actually see is recorded here rather
// than assumed, and asserted in run.js.
// //
// The message log is the page's half of the boundary observation: every // The message log is the page's half of the boundary observation: every
// AUTISTMASK_* message that crosses between this page and the content // AUTISTMASK_* message that crosses between this page and the content
@@ -120,6 +122,7 @@ const DAPP_HTML = [
" return {", " return {",
" settled: 'rejected',", " settled: 'rejected',",
" message: String((error && error.message) || error),", " message: String((error && error.message) || error),",
" name: error ? error.name : undefined,",
" hasCode: !!error && 'code' in Object(error),", " hasCode: !!error && 'code' in Object(error),",
" code: error ? error.code : undefined,", " code: error ? error.code : undefined,",
" };", " };",

View File

@@ -419,172 +419,6 @@ test("reopening the popup never lands on the phrase screen (#161)", async (env)
assertWiped(st, env.phrase, "after reopening the popup"); assertWiped(st, env.phrase, "after reopening the popup");
}); });
// ------------------------------- Back after reopening the popup (#268)
// A reopened popup renders the wallet list and the view it restores onto,
// and nothing else: every other screen is still the blank static template
// from index.html. Back used to only unhide its target, which is why these
// have to run against the real popup — the template is present and
// well-formed, so only its emptiness distinguishes the defect, and only a
// real reopen produces it.
// Everything the address screen must have on it, read out of the DOM.
function addressScreenState(page) {
return page.evaluate(() => {
const line = document.getElementById("address-line");
const balances = document.getElementById("address-balances");
return {
hidden: document
.getElementById("view-address")
.classList.contains("hidden"),
line: line ? line.innerText.trim() : "",
balances: balances ? balances.innerText.trim() : "",
};
});
}
// Close and reopen the page rather than reload it: that is what the toolbar
// popup does, and it is the only thing that produces the unrendered views.
async function reopenPopup(env, restoredView) {
await env.page.close();
env.page = await openPopup(env.ctx, env.popupUrl);
await visible(env.page, restoredView);
}
// The reproduction from the issue, step for step.
test("Back after reopening the popup renders the address screen (#268)", async (env) => {
await openAddressDetail(env.page);
const before = await addressScreenState(env.page);
assert(
before.line.length > 0,
"the address screen was blank to begin with",
);
await env.page.click("#btn-settings");
await visible(env.page, "#view-settings");
await reopenPopup(env, "#view-settings");
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-address");
const after = await addressScreenState(env.page);
assert(
after.line === before.line,
"the address line reads " +
JSON.stringify(after.line) +
", expected " +
JSON.stringify(before.line),
);
assert(
after.balances.includes("ETH"),
"the balances read " + JSON.stringify(after.balances),
);
});
// The same defect one screen further in. Receive holds the address twice
// over — as text and as the QR code the sender scans — and a blank one is
// worse than a missing screen.
// Everything the Receive screen must have on it. The QR code is read as
// pixels, not as an element: the blank template carries the canvas too, a
// default 300x150 one with nothing drawn on it and every pixel fully
// transparent. A drawn QR paints an opaque background across the whole
// canvas, so a single opaque pixel is the whole question.
function receiveScreenState(page) {
return page.evaluate(() => {
const block = document.getElementById("receive-address-block");
const canvas = document.getElementById("receive-qr");
const px = canvas
.getContext("2d")
.getImageData(0, 0, canvas.width, canvas.height).data;
let opaque = 0;
for (let i = 3; i < px.length; i += 4) {
if (px[i] > 0) opaque += 1;
}
return {
address: block.dataset.full || "",
text: block.innerText.trim(),
qrOpaquePixels: opaque,
};
});
}
test("Back after reopening the popup renders the Receive screen (#268)", async (env) => {
await openAddressDetail(env.page);
await env.page.click("#btn-receive");
await visible(env.page, "#view-receive");
const before = await receiveScreenState(env.page);
assert(
/^0x[0-9a-fA-F]{40}$/.test(before.address),
"Receive showed no address to begin with: " +
JSON.stringify(before.address),
);
await env.page.click("#btn-settings");
await visible(env.page, "#view-settings");
await reopenPopup(env, "#view-settings");
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-receive");
const shown = await receiveScreenState(env.page);
assert(
shown.address === before.address,
"Receive shows " +
JSON.stringify(shown.address) +
", expected " +
JSON.stringify(before.address),
);
assert(
shown.text.includes(before.address),
"the Receive address is not on screen: " + JSON.stringify(shown.text),
);
assert(shown.qrOpaquePixels > 0, "Receive shows an unpainted QR code");
// Leave the suite where it found it.
await env.page.click("#btn-receive-back");
await visible(env.page, "#view-address");
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
});
// The other half of the requirement: Back renders a screen this page load
// never rendered, and must NOT re-render one it already has on screen.
// settings.show() reassigns #settings-rpc from persisted state, so
// re-rendering Settings on the way back would silently revert whatever the
// user typed and had not saved yet — and they could then press Save and
// store the value they believed they had replaced. No reopen here: this is
// an ordinary in-session forward-and-back, which is exactly why the render
// must not happen.
test("Back onto Settings keeps unsaved input (#268)", async (env) => {
await visible(env.page, "#view-main");
await env.page.click("#btn-settings");
await visible(env.page, "#view-settings");
const typed = "https://rpc.example.invalid/unsaved";
await env.page.fill("#settings-rpc", typed);
await env.page.click("#btn-settings-add-token");
await visible(env.page, "#view-settings-addtoken");
await env.page.click("#btn-settings-addtoken-back");
await visible(env.page, "#view-settings");
const kept = await env.page.inputValue("#settings-rpc");
assert(
kept === typed,
"the unsaved RPC URL reads " +
JSON.stringify(kept) +
", expected " +
JSON.stringify(typed),
);
// Leave the suite where it found it. The typed value was never saved,
// and Settings reloads the field from state next time it renders.
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-main");
});
// -------------------------------------------- address removal (#162) // -------------------------------------------- address removal (#162)
// Number of address rows across every wallet in the list, counted in the DOM // Number of address rows across every wallet in the list, counted in the DOM
@@ -1757,15 +1591,14 @@ async function lastResponseError(page) {
} }
// A rejected prompt, asserted at both ends: the page's promise rejected // A rejected prompt, asserted at both ends: the page's promise rejected
// rather than hanging or resolving, and the response that crossed the // rather than hanging or resolving, and EIP-1193 code 4001 is present both
// boundary carried EIP-1193 code 4001. // on the wire and on the Error the calling page catches.
// //
// The code is asserted on the wire because that is the only place it // Both ends matter because they used to disagree. The code crossed the
// survives. src/content/inpage.js rebuilds the rejection as `new // boundary correctly and src/content/inpage.js then threw it away, rebuilding
// Error(error.message)`, so the Error the calling page catches carries the // every rejection as `new Error(error.message)` so a dApp branching on
// message and no code. That is reported rather than asserted either way — // `err.code === 4001` saw undefined and could not tell a refusal from a
// locking in the current behaviour would make the gap permanent, and // failure (#274). Asserting only the wire would leave that gap invisible.
// asserting the code on the Error would fail today.
async function assertUserRejection(page, key, label) { async function assertUserRejection(page, key, label) {
const outcome = await settleRequest(page, key); const outcome = await settleRequest(page, key);
assert( assert(
@@ -1787,15 +1620,32 @@ async function assertUserRejection(page, key, label) {
" did not carry EIP-1193 code 4001 across the boundary: " + " did not carry EIP-1193 code 4001 across the boundary: " +
JSON.stringify(error), JSON.stringify(error),
); );
assert(
outcome.hasCode,
label +
" reached the page as an error with no code property at all, so a " +
"dApp cannot tell the user's refusal from a failure: " +
JSON.stringify(outcome),
);
assert(
outcome.code === 4001,
label +
" reached the page with code " +
JSON.stringify(outcome.code) +
" rather than EIP-1193 4001",
);
assert(
outcome.name === "ProviderRpcError",
label +
" reached the page as " +
JSON.stringify(outcome.name) +
" rather than an EIP-1193 ProviderRpcError",
);
console.log( console.log(
"# " + "# " +
label + label +
": boundary code=" + ": code 4001 on the wire and on the page's " +
error.code + outcome.name,
" page Error.code=" +
JSON.stringify(outcome.code) +
" page Error carries a code=" +
outcome.hasCode,
); );
return outcome; return outcome;
} }

310
tests/inpageErrors.test.js Normal file
View File

@@ -0,0 +1,310 @@
// The EIP-1193 error the page actually catches (src/content/inpage.js).
//
// The bug this pins down (issue #274): the provider rebuilt every failure as
// `new Error(error.message)`, so the `code` the background produced and the
// content script relayed intact was thrown away in the last hop. A dApp
// checking `err.code === 4001` — the standard way to tell "the user said no"
// from "the wallet broke" — saw undefined, and well-behaved sites showed an
// error or retried instead of accepting the refusal.
//
// inpage.js is a bare IIFE injected into the page's JS context, not a module:
// it takes no import and exports nothing, and reaches for `window` at load.
// So it is evaluated here the way the browser evaluates it, against a stub
// window, and the provider is collected from `window.ethereum`. The globals it
// touches are passed in as function parameters rather than assigned to
// globalThis: nothing leaks between tests, and the source is compiled in this
// realm, so the errors it constructs are comparable against this file's own
// `Error` — which a second realm's intrinsics would silently defeat.
//
// There is no jsdom in this repo; see tests/txStatus.test.js.
const fs = require("fs");
const path = require("path");
const { webcrypto } = require("crypto");
const SOURCE = fs.readFileSync(
path.join(__dirname, "..", "src", "content", "inpage.js"),
"utf8",
);
const loadInto = new Function(
"window",
"self",
"crypto",
"Event",
"CustomEvent",
SOURCE,
);
class StubEvent {
constructor(type) {
this.type = type;
}
}
class StubCustomEvent extends StubEvent {
constructor(type, init) {
super(type);
this.detail = init && init.detail;
}
}
// Every code the background emits on the RPC path today, read out of
// src/background/index.js. The provider must not know this list — it passes
// through whatever arrived — but the cases below are the real ones.
const REJECTED = 4001; // user rejected the request
const UNAUTHORIZED = 4100; // site not connected / wrong address
const UNRECOGNIZED_CHAIN = 4902; // switch/add to an unsupported chain
// A stub window with the four things inpage.js touches: message listeners,
// postMessage out to the content script, window.ethereum, and dispatchEvent
// for the EIP-6963 announcement.
function loadProvider() {
const messageListeners = [];
const posted = [];
const win = {
addEventListener(type, fn) {
if (type === "message") messageListeners.push(fn);
},
removeEventListener(type, fn) {
const i = messageListeners.indexOf(fn);
if (type === "message" && i !== -1) messageListeners.splice(i, 1);
},
postMessage(data) {
posted.push(data);
},
dispatchEvent() {
return true;
},
};
win.window = win;
loadInto(win, win, webcrypto, StubEvent, StubCustomEvent);
// Deliver the content script's answer to an outstanding request. The id is
// read back off the wire rather than assumed: inpage.js issues its own
// eth_chainId at load, so the first id a test sees is not 1.
function respond(response) {
const request = posted
.filter((m) => m.type === "AUTISTMASK_REQUEST")
.pop();
expect(request).toBeDefined();
const event = {
source: win,
data: { type: "AUTISTMASK_RESPONSE", id: request.id, ...response },
};
for (const fn of messageListeners.slice()) fn(event);
}
return { provider: win.ethereum, posted, respond };
}
// Start a request, answer it with `response`, and hand back the rejection.
// Fails the test if the call resolves instead.
async function rejectionFrom(start, response) {
const { provider, respond } = loadProvider();
const settled = start(provider).then(
(result) => ({ resolved: result }),
(error) => ({ error }),
);
// The provider posts synchronously, so the request is already on the wire.
respond(response);
const outcome = await settled;
expect(outcome).not.toHaveProperty("resolved");
return outcome.error;
}
describe("an EIP-1193 code reaches the page", () => {
test("a user rejection arrives as code 4001", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_requestAccounts" }),
{
error: {
code: REJECTED,
message: "User rejected the request.",
},
},
);
expect(err.code).toBe(REJECTED);
expect(err.message).toBe("User rejected the request.");
});
test("it is a ProviderRpcError, and an Error", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_requestAccounts" }),
{
error: {
code: REJECTED,
message: "User rejected the request.",
},
},
);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("ProviderRpcError");
});
test("4100 unauthorized arrives intact", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "personal_sign", params: ["0x00"] }),
{ error: { code: UNAUTHORIZED, message: "Unauthorized" } },
);
expect(err.code).toBe(UNAUTHORIZED);
expect(err.message).toBe("Unauthorized");
});
test("4902 unrecognized chain arrives intact", async () => {
const message =
"AutistMask supports Ethereum Mainnet and Sepolia Testnet only.";
const err = await rejectionFrom(
(p) => p.request({ method: "wallet_switchEthereumChain" }),
{ error: { code: UNRECOGNIZED_CHAIN, message } },
);
expect(err.code).toBe(UNRECOGNIZED_CHAIN);
expect(err.message).toBe(message);
});
// The provider is not allowed to know the list above: a code added to the
// background later must reach the page without this file being edited.
test("a code the provider has never heard of is passed through", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_accounts" }),
{ error: { code: 4900, message: "Disconnected" } },
);
expect(err.code).toBe(4900);
});
test("data is carried when the boundary sent it", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_call" }),
{
error: {
code: -32000,
message: "execution reverted",
data: "0x08c379a0",
},
},
);
expect(err.code).toBe(-32000);
expect(err.data).toBe("0x08c379a0");
});
test("no data property is invented when the boundary sent none", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_requestAccounts" }),
{
error: {
code: REJECTED,
message: "User rejected the request.",
},
},
);
expect("data" in err).toBe(false);
});
});
describe("the message is untouched", () => {
test("a coded error keeps the message byte for byte", async () => {
const message =
"This site asked to sign as an address that is not " +
"the active one.";
const err = await rejectionFrom(
(p) => p.request({ method: "personal_sign" }),
{ error: { code: UNAUTHORIZED, message } },
);
expect(err.message).toBe(message);
});
test("an error the background sent with no code keeps its message", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_sendTransaction" }),
{ error: { message: "No accounts available" } },
);
expect(err.message).toBe("No accounts available");
});
// A ProviderRpcError whose code is undefined would claim a conformance it
// does not have, and `'code' in err` is exactly what a careful dApp asks.
test("an error with no code gets no code property at all", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_sendTransaction" }),
{ error: { message: "No accounts available" } },
);
expect(err).toBeInstanceOf(Error);
expect("code" in err).toBe(false);
});
test("an error with no message keeps the generic fallback", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_sendTransaction" }),
{ error: { code: REJECTED } },
);
expect(err.message).toBe("Request failed");
expect(err.code).toBe(REJECTED);
});
});
// Every entry point the provider exposes, not just eth_requestAccounts. They
// all funnel through the same response listener, and this is what says so.
describe("every request path carries the code", () => {
const rejection = {
error: { code: REJECTED, message: "User rejected the request." },
};
test("request()", async () => {
const err = await rejectionFrom(
(p) => p.request({ method: "eth_requestAccounts" }),
rejection,
);
expect(err.code).toBe(REJECTED);
});
test("enable()", async () => {
const err = await rejectionFrom((p) => p.enable(), rejection);
expect(err.code).toBe(REJECTED);
});
test("send(method, params)", async () => {
const err = await rejectionFrom(
(p) => p.send("eth_requestAccounts", []),
rejection,
);
expect(err.code).toBe(REJECTED);
});
test("send({ method, params })", async () => {
const err = await rejectionFrom(
(p) => p.send({ method: "personal_sign", params: ["0x00"] }),
rejection,
);
expect(err.code).toBe(REJECTED);
});
test("sendAsync() hands the code to its callback", async () => {
const { provider, respond } = loadProvider();
const called = new Promise((resolve) => {
provider.sendAsync({ id: 1, method: "eth_requestAccounts" }, (e) =>
resolve(e),
);
});
respond(rejection);
const err = await called;
expect(err.name).toBe("ProviderRpcError");
expect(err.code).toBe(REJECTED);
expect(err.message).toBe("User rejected the request.");
});
});
describe("the success path is unchanged", () => {
test("a result still resolves", async () => {
const { provider, respond } = loadProvider();
const settled = provider.request({ method: "eth_requestAccounts" });
respond({ result: ["0xb61264DEFB0c4B8afb3D73724be15310036743a5"] });
await expect(settled).resolves.toEqual([
"0xb61264DEFB0c4B8afb3D73724be15310036743a5",
]);
expect(provider.selectedAddress).toBe(
"0xb61264DEFB0c4B8afb3D73724be15310036743a5",
);
});
});

View File

@@ -56,7 +56,7 @@ global.chrome = {
}; };
const { isSpoofedSymbol } = require("../src/shared/symbolSpoof"); const { isSpoofedSymbol } = require("../src/shared/symbolSpoof");
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList"); const { TOKENS, KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { filterTransactions } = require("../src/shared/transactions"); const { filterTransactions } = require("../src/shared/transactions");
const { const {
fetchTokenBalances, fetchTokenBalances,
@@ -284,12 +284,138 @@ describe("the shared rule: symbols that render as a known symbol", () => {
// asserts the claim it stands for — `[ -~]` would admit an interior // asserts the claim it stands for — `[ -~]` would admit an interior
// space and let a whitespace-bearing entry through the guard. // space and let a whitespace-bearing entry through the guard.
test("no bundled symbol is touched by the normalization", () => { test("no bundled symbol is touched by the normalization", () => {
for (const [symbol, address] of KNOWN_SYMBOLS) { for (const [symbol, addresses] of KNOWN_SYMBOLS) {
expect(symbol).toBe(symbol.trim()); expect(symbol).toBe(symbol.trim());
expect(symbol).toMatch(/^[!-~]+$/); expect(symbol).toMatch(/^[!-~]+$/);
if (address === null) continue; if (addresses === null) continue;
for (const address of addresses) {
expect(isSpoofedSymbol(symbol, address)).toBe(false); expect(isSpoofedSymbol(symbol, address)).toBe(false);
} }
}
});
});
// Issue #276: the guard that was missing. The suite walked KNOWN_SYMBOLS,
// which is built from TOKENS, so it could only ever assert that the table
// agrees with itself. Seven symbols appear twice in the bundled list at two
// different real contracts, and the table kept whichever came first, so the
// other seven contracts — tokens in our own shipped list, at their own
// addresses — were judged spoofs and hidden from the balance list, the
// history and the send selector. That is the over-filtering direction: it
// hides a holding the user cannot then spend.
//
// This walk is over TOKENS, the data the wallet actually ships, so it fails
// whenever a bundled token would be filtered at its own address no matter
// which side of the table the mistake is on.
describe("the shipped token list", () => {
test("no bundled token is filtered at its own address", () => {
const filtered = TOKENS.filter((t) =>
isSpoofedSymbol(t.symbol, t.address),
).map((t) => t.symbol + " @ " + t.address);
expect(filtered).toEqual([]);
});
// The third failure mode the issue asks about: a symbol whose table entry
// names an address that is in neither the table nor the list would be a
// contract we vouch for and do not ship. There is none, and the table is
// built from the list, so this asserts the derivation has not acquired a
// hand-written entry.
test("every address the table vouches for is a bundled token", () => {
const bundled = new Set(TOKENS.map((t) => t.address.toLowerCase()));
for (const [symbol, addresses] of KNOWN_SYMBOLS) {
if (addresses === null) continue;
expect(addresses.size).toBeGreaterThan(0);
for (const address of addresses) {
expect(address).toBe(address.toLowerCase());
expect(bundled.has(address)).toBe(true);
// And it is the token that actually reports that symbol.
const token = TOKENS.find(
(t) => t.address.toLowerCase() === address,
);
expect(token.symbol.toUpperCase()).toBe(symbol);
}
}
});
// Both contracts behind a shared ticker must pass, from either side: a
// rule that admits only the one the table happens to visit first is the
// bug, not the fix.
test("both contracts behind a shared ticker are admitted", () => {
const bySymbol = new Map();
for (const t of TOKENS) {
const upper = t.symbol.toUpperCase();
if (!bySymbol.has(upper)) bySymbol.set(upper, []);
bySymbol.get(upper).push(t);
}
const shared = [...bySymbol].filter(([, list]) => list.length > 1);
// The shared tickers are a fact about the shipped data; if a future
// list has none, this test would silently assert nothing.
expect(shared.length).toBeGreaterThan(0);
for (const [, list] of shared) {
for (const t of list) {
expect(isSpoofedSymbol(t.symbol, t.address)).toBe(false);
}
}
});
// The seven from issue #276, named so that the reconciliation is a fact
// in the suite: each is two real contracts from the same source fetch,
// and the table now holds both rather than the one that came first.
test("the seven shared tickers each name both bundled contracts", () => {
const expected = {
TON: [
"0x582d872a1b094fc48f5de31d3b73f2d9be47def1", // Toncoin
"0x2be5e8c109e2197d077d13a82daead6a9b3433c5", // Tokamak Network
],
FRAX: [
"0x853d955acef822db058eb8505911ed77f175b99e", // Legacy Frax Dollar
"0x3432b6a60d23ca0dfca7761b7ab56459d9c964d0", // Frax (prev. FXS)
],
REUSD: [
"0x5086bf358635b81d8c47c66d1c8b9e567db70c72", // Re Protocol reUSD
"0x57ab1e0003f623289cd798b1824be09a793e4bec", // Resupply USD
],
EURE: [
"0x39b8b6385416f4ca36a20319f70d28621895279d", // Monerium EUR emoney
"0x3231cb76718cdef2155fc47b5286d82e6eda273f", // Monerium EUR emoney [OLD]
],
MSUSD: [
"0x4ba01f22827018b4772cd326c7627fb4956a7c00", // Main Street USD
"0xab5eb14c09d416f0ac63661e57edb7aecdb9befa", // Metronome Synth USD
],
MUSD: [
"0xaca92e438df0b2401ff60da7e4337b687a2435da", // MetaMask USD
"0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186", // Mezo USD
],
JPYC: [
"0x431d5dff03120afa4bdf332c61a6e1766ef37bdb", // JPY Coin
"0x2370f9d504c7a6e775bf6e14b3f12846b594cd53", // JPY Coin v1
],
};
for (const [symbol, addresses] of Object.entries(expected)) {
expect([...KNOWN_SYMBOLS.get(symbol)].sort()).toEqual(
[...addresses].sort(),
);
for (const address of addresses) {
expect(isSpoofedSymbol(symbol, address)).toBe(false);
}
}
});
// The other direction, on the same symbols: widening the table to hold
// every bundled address for a ticker must not turn it into a pass for
// any other contract.
test("a shared ticker from a third contract is still a spoof", () => {
const bySymbol = new Map();
for (const t of TOKENS) {
const upper = t.symbol.toUpperCase();
if (!bySymbol.has(upper)) bySymbol.set(upper, []);
bySymbol.get(upper).push(t);
}
for (const [symbol, list] of bySymbol) {
if (list.length < 2) continue;
expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT)).toBe(true);
}
}); });
}); });

View File

@@ -207,8 +207,8 @@ describe("token list assumptions the fixtures rely on", () => {
}); });
test("USDC and WETH map to their genuine lowercased contracts", () => { test("USDC and WETH map to their genuine lowercased contracts", () => {
expect(KNOWN_SYMBOLS.get("USDC")).toBe(USDC_CONTRACT); expect([...KNOWN_SYMBOLS.get("USDC")]).toEqual([USDC_CONTRACT]);
expect(KNOWN_SYMBOLS.get("WETH")).toBe(WETH_CONTRACT); expect([...KNOWN_SYMBOLS.get("WETH")]).toEqual([WETH_CONTRACT]);
}); });
test("the spam fixture symbol is not in the known token list", () => { test("the spam fixture symbol is not in the known token list", () => {