Compare commits

..

1 Commits

Author SHA1 Message Date
21b158b3b6 fix: render the view "Back" lands on after the popup is reopened (closes #268)
All checks were successful
check / check (push) Successful in 44s
goBack() only unhid its target. A reopened popup renders the wallet list
and the one view restoreView() lands on, so every other view is still the
blank static template from index.html: pressing Back from Settings onto an
address showed an empty address line and no balances, and the same held for
address-token, receive, confirm-tx and transaction.

The per-view dispatch and its data guards move out of restoreView() into
src/popup/viewRouter.js, and goBack() now routes a popped view through the
same code by way of a renderer index.js registers with setBackRenderer().
A view whose backing state is gone falls back to Home the way the restore
does, rather than showing an empty template. The renderer declines any view
outside RESTORABLE_VIEWS, so goBack() unhides it as before: the restored
stack is filtered against that set, so such a view can only be on the stack
from the current page load, where forward navigation rendered it on the way
in. Forward navigation is untouched and nothing renders twice.

tests/backNavigation.test.js drives the real goBack() over the reproduction
and over each of address-token, receive, confirm-tx and transaction, with
and without their backing state, and pins that a live-session view is still
only unhidden. tests/e2e/run.js adds two cases against the real popup in a
real browser — a real close and reopen, then Back onto the address screen
and onto Receive — because make check cannot see a blank view. Both were
demonstrated failing against the unmodified sources: the address line read
"" where it should have read the address.
2026-08-12 10:41:26 +00:00
14 changed files with 46 additions and 2603 deletions

View File

@@ -169,34 +169,6 @@ reserve while sitting on the same side of the estimate, so swapping the two in
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only. previously correct by reading only.
It also covers the **dApp approval round trips** — the one place where the
content script, the inpage provider, the background worker and the approval
popup all have to work together. A local test page is served by the route
handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
`MAIN`-world content script like any other page, and drives
`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
`eth_sendTransaction` through the real prompts. Every signature is recovered in
the runner and compared against the active address, the transaction assertions
run against the raw signed transaction captured at `eth_sendRawTransaction`
rather than against anything the extension reported, rejecting each prompt is
required to return a rejection to the page rather than hang or resolve, and the
password is required to be absent from every message the approval window sends
to the background — with the message that would carry it required to be present,
so that check cannot pass by observing nothing. That last one is the standing
floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
Three limits of that coverage, none of them papered over. The RPC is stubbed
throughout, so this is **not** a real dApp against a real network with real
funds; that remains a human pass before 1.0.0. The site-connection prompt is
raised through `chrome.action.openPopup()`, and headless Chromium's
browser-action popup is not a page Playwright can see or click, so that one
prompt is driven at the URL the extension itself puts on the action — the same
page and the same approval id, but whether a real toolbar click shows it is not
observable here. And the EIP-1193 error code does not survive the last hop: the
rejection that crosses the boundary carries code 4001 and is asserted to, but
`src/content/inpage.js` rebuilds it as `new Error(message)`, so the calling page
catches an error with no `code` property.
Any test that drives a failure path on purpose declares the `console.error` it Any test that drives a failure path on purpose declares the `console.error` it
is about to provoke, via `errors.expect()`. That is not a mute: the declaration is about to provoke, via `errors.expect()`. That is not a mute: the declaration
consumes exactly one matching record, and a declaration nothing matched fails consumes exactly one matching record, and a declaration nothing matched fails
@@ -643,15 +615,9 @@ 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 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 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 (`src/popup/viewRouter.js`), and falls back to Home when the state the target
would render is gone. would render is gone. Forward navigation renders as it goes and does not go
through that dispatch: rendering a screen a second time would re-fetch and
It renders only a screen this page load has not rendered yet. Forward navigation clobber whatever it has in flight.
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

85
TODO.md
View File

@@ -45,36 +45,6 @@ undefined identifiers, which is how
# Completed Steps # Completed Steps
- 2026-08-14: The parts of the
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) definition of done the
e2e suite did not cover are asserted. It had only shown that the two screens
open without throwing. Now: the Add Token round trip leaves the navigation
stack exactly as it found it, read out of extension storage rather than
inferred from which screen is up, so an orphaned entry — the second-order
damage of #150 — is caught where it happens rather than one Back press later;
a common-token quick-pick puts its contract address in the field; the native
ETH detail path renders with its own type, value and raw quantity and with the
token contract row still hidden, against a new `seedNativeTransfer` fixture,
since the normal-transactions endpoint answered `[]` unconditionally and there
was no non-ERC-20 row to open; and tapping the token contract address puts it
on the real clipboard, read back after a sentinel write. Each of the four was
demonstrated failing against a deliberately broken build
([#188](https://git.eeqj.de/sneak/AutistMask/issues/188)).
- 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: "Back" now renders the screen it lands on instead of only unhiding - 2026-08-12: "Back" now renders the screen it lands on instead of only unhiding
it. A reopened popup renders the wallet list and the one screen it restores it. A reopened popup renders the wallet list and the one screen it restores
onto, so every screen further down the stack was still the blank template from onto, so every screen further down the stack was still the blank template from
@@ -82,55 +52,12 @@ undefined identifiers, which is how
balances, no QR code. The Back path now goes through the same per-view balances, no QR code. The Back path now goes through the same per-view
dispatch and data guards as the restore (`src/popup/viewRouter.js`, shared dispatch and data guards as the restore (`src/popup/viewRouter.js`, shared
with `restoreView()`), falling back to Home when the state the target would with `restoreView()`), falling back to Home when the state the target would
render is gone. It renders only a view this page load has not rendered yet: render is gone, and declining any view the popup does not render from
`viewRouter.js` records every view that reaches `showView()`, which is where persisted state, which can only be on the stack from the current page load and
forward navigation and the restore both end, so Back onto a view already on was rendered on the way in. Forward navigation is untouched, so nothing
the page unhides it and nothing more. That is what keeps a second render from renders twice. Covered by unit tests on the real `goBack()` and by two
re-fetching and overwriting what the view holds — an unsaved edit in Settings, end-to-end cases against the real popup, both demonstrated failing on the
a transaction list already loaded. Home is the exception and is always unfixed build ([#268](https://git.eeqj.de/sneak/AutistMask/issues/268)).
re-rendered, as it was before. Covered by unit tests on the real `goBack()`
and by three end-to-end cases against the real popup, each demonstrated
failing on the unfixed build
([#268](https://git.eeqj.de/sneak/AutistMask/issues/268)).
- 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
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
for `eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
`eth_sendTransaction`. Every signature is recovered and compared against the
active address, the transaction is checked against the bytes handed to the
stubbed RPC, each rejection must reach the page as a rejection, and the
password must appear in no message the approval window sends — the assertion
that gives [#157](https://git.eeqj.de/sneak/AutistMask/issues/157) a permanent
floor. This does not discharge a real dApp with real funds against mainnet
([#183](https://git.eeqj.de/sneak/AutistMask/issues/183)).
- 2026-08-12: The known-symbol spoof rule now judges the symbol a user actually
sees. `isSpoofedSymbol()` normalizes before the lookup — NFKC, then every
character that paints nothing removed (the format and default-ignorable
characters, plus U+007F), then trimmed — so `" ETH "`, a no-break space, a
zero-width space, a Hangul filler, a variation selector, a DELETE and a
fullwidth `` are all caught on the balance list, the history and the
send selector at once. Confusables that are distinct letters (Cyrillic `Е`),
bidi reordering and the visible C0/C1 controls — which measure 48.00px, a box,
in the pinned e2e Chromium where an invisible prefix measures 32.00px — stay
knowingly open and are asserted as open in the suite. No bundled symbol
contains whitespace or a non-ASCII character, so nothing legitimate is newly
filtered; the balance list's token-type gate also became case-insensitive,
which no longer drops a real holding if an explorer writes `erc-20`
([#260](https://git.eeqj.de/sneak/AutistMask/issues/260)).
- 2026-08-12: A containerized Firefox end-to-end harness - 2026-08-12: A containerized Firefox end-to-end harness
(`make test-e2e-firefox`) drives the real popup in a real Firefox with the MV2 (`make test-e2e-firefox`) drives the real popup in a real Firefox with the MV2
build installed as a temporary add-on. Zero npm dependencies — a WebDriver build installed as a temporary add-on. Zero npm dependencies — a WebDriver

View File

@@ -11,39 +11,6 @@
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;
@@ -53,7 +20,7 @@
if (!p) return; if (!p) return;
delete pending[id]; delete pending[id];
if (error) { if (error) {
p.reject(toPageError(error)); p.reject(new Error(error.message || "Request failed"));
} else { } else {
p.resolve(result); p.resolve(result);
} }

View File

@@ -14,44 +14,6 @@
const { RESTORABLE_VIEWS } = require("./restorableViews"); 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 // Views that render an address the user picked and cannot be rendered
// without one. // without one.
const ADDRESS_VIEWS = new Set([ const ADDRESS_VIEWS = new Set([
@@ -132,26 +94,13 @@ function renderView(view, state, views) {
} }
// The Back-path renderer, registered with setBackRenderer() in // The Back-path renderer, registered with setBackRenderer() in
// views/helpers.js. // views/helpers.js. Returns false for a view the popup does not render from
// // persisted state, leaving goBack() to unhide it: the restored stack is
// Returns false — leaving goBack() to unhide the view, as it always did — // filtered against RESTORABLE_VIEWS, so such a view can only be on the
// in the two cases where the view is known to be on the page already: // stack from this page load, where forward navigation already rendered it.
//
// - 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) { function makeBackRenderer(state, views) {
return function renderBack(view) { return function renderBack(view) {
if (!RESTORABLE_VIEWS.has(view)) return false; if (!RESTORABLE_VIEWS.has(view)) return false;
if (renderedViews.has(view) && !ALWAYS_RENDER_ON_BACK.has(view)) {
return false;
}
if (!renderView(view, state, views)) { if (!renderView(view, state, views)) {
views.main.show(); views.main.show();
} }
@@ -162,6 +111,6 @@ function makeBackRenderer(state, views) {
module.exports = { module.exports = {
renderView, renderView,
makeBackRenderer, makeBackRenderer,
markViewRendered, needsAddress,
resetRenderedViews, hasValidAddress,
}; };

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);
} }

View File

@@ -66,12 +66,7 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
const balances = []; const balances = [];
for (const item of items) { for (const item of items) {
// Case-insensitive: the token type is an explorer's label, not a if (item.token?.type !== "ERC-20") continue;
// protocol value, and an exact comparison silently drops a real
// holding if one ever writes "erc-20". Which types are admitted
// is unchanged.
const type = String(item.token?.type || "").toUpperCase();
if (type !== "ERC-20") continue;
const decimals = parseInt(item.token.decimals || "18", 10); const decimals = parseInt(item.token.decimals || "18", 10);
const bal = formatTokenBalance(item.value || "0", decimals); const bal = formatTokenBalance(item.value || "0", decimals);
if (bal === "0.0") continue; if (bal === "0.0") continue;

View File

@@ -8,23 +8,11 @@
// 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 set of lowercased contract addresses // KNOWN_SYMBOLS maps a symbol to the lowercased contract address that may
// that may bear it, or to null. Null means the symbol belongs to the native // bear it, or to null. Null means the symbol belongs to the native asset,
// asset, which has no contract at all, so no contract may bear it and every // which has no contract at all, so no contract may bear it and every one
// one that does is a spoof. "ETH" is the only such entry today; the rule is // 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
// 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,
// since that is what the user acts on.
const { KNOWN_SYMBOLS } = require("./tokenList"); const { KNOWN_SYMBOLS } = require("./tokenList");
@@ -34,59 +22,6 @@ function normalizeAddress(addr) {
return (addr || "").toLowerCase(); return (addr || "").toLowerCase();
} }
// Fold a symbol onto what a user actually sees, and no further:
//
// NFKC collapses compatibility variants that render as the ASCII
// letters they imitate — fullwidth ETH, styled mathematical
// letters — and maps the non-ASCII spaces onto U+0020.
// strip drops what paints nothing: \p{Cf} plus
// \p{Default_Ignorable_Code_Point} plus U+007F. That covers
// the format characters (zero-width space, joiner and
// non-joiner, word joiner, soft hyphen, byte-order mark, bidi
// marks and overrides), the variation selectors, the Hangul
// fillers, and DELETE. Removed everywhere, not merely at the
// ends.
// trim removes surrounding whitespace, which HTML collapses:
// `" ETH "` is painted next to the user's real ETH as `ETH`.
// toUpperCase makes the comparison case-insensitive, as before.
//
// The rule is "strip what paints nothing". The Unicode classes are how
// that is spelled, not what it means, which is why U+007F is named on its
// own: it is a control rather than a default-ignorable character, so no
// class here reaches it, yet it paints nothing all the same. Measured in
// the repo's pinned e2e Chromium (16px sans-serif, plain `ETH` = 32.00px,
// so an invisible prefix leaves 32.00px):
//
// U+007F, U+3164, U+115F, U+FE0F, U+FE00 32.00px — invisible
// U+FFA0 40.00px — a box
// U+1160 48.00px — a box
// U+0001, U+0085, U+0090 48.00px — a box
//
// U+1160 and U+FFA0 are `Default_Ignorable_Code_Point` members that font
// fallback nonetheless draws, and they are stripped anyway: erring toward
// hiding a token that does not look like `ETH` is the harmless direction of
// the two. The other controls are left alone for the same reason read the
// other way — a symbol carrying a visible box does not reach the eye as
// `ETH`, so filtering it would hide a token the user could not have
// confused with the native asset.
//
// Deliberately not folded, and asserted as open in tests/symbolSpoof.test.js:
// interior whitespace (`E T H` renders as `E T H`, so folding it would filter
// a token nobody could confuse with the native asset), confusables that are
// distinct letters rather than compatibility variants (Cyrillic capital Ie,
// U+0415; Greek capital Epsilon, U+0395), bidi reordering, which needs the
// bidi algorithm rather than a character filter, and the visible controls.
//
// This decides only how the question is asked. Nothing here changes what a
// surface displays; a token still shows the symbol it reports.
function normalizeSymbol(symbol) {
return String(symbol || "")
.normalize("NFKC")
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
.trim()
.toUpperCase();
}
// True when a token bearing `symbol` from contract `contractAddress` is // True when a token bearing `symbol` from contract `contractAddress` is
// impersonating a known symbol. // impersonating a known symbol.
// //
@@ -96,11 +31,11 @@ function normalizeSymbol(symbol) {
function isSpoofedSymbol(symbol, contractAddress) { function isSpoofedSymbol(symbol, contractAddress) {
const contract = normalizeAddress(contractAddress); const contract = normalizeAddress(contractAddress);
if (!contract) return false; if (!contract) return false;
const sym = normalizeSymbol(symbol); const sym = (symbol || "").toUpperCase();
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 !legit.has(contract); return contract !== normalizeAddress(legit);
} }
module.exports = { module.exports = {

View File

@@ -3607,33 +3607,14 @@ 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) -> the set of contract addresses // Build a map of symbol (uppercased) -> legitimate contract address (lowercased).
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection. // Used for spoofed-symbol detection. "ETH" maps to null (native token).
// "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, new Set()); KNOWN_SYMBOLS.set(upper, t.address.toLowerCase());
}
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

@@ -46,11 +46,7 @@ const {
setBackRenderer, setBackRenderer,
pushCurrentView, pushCurrentView,
} = require("../src/popup/views/helpers"); } = require("../src/popup/views/helpers");
const { const { makeBackRenderer } = require("../src/popup/viewRouter");
makeBackRenderer,
markViewRendered,
resetRenderedViews,
} = require("../src/popup/viewRouter");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const ADDRESS = "0x1111111111111111111111111111111111111111"; const ADDRESS = "0x1111111111111111111111111111111111111111";
@@ -96,14 +92,8 @@ function makeViews() {
// The popup as it stands just after a reopen: one wallet with one address, // 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. // 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) { function reopenedOn(view, stack, extra) {
calls = []; calls = [];
resetRenderedViews();
state.wallets = [ state.wallets = [
{ {
name: "Wallet 1", name: "Wallet 1",
@@ -117,12 +107,7 @@ function reopenedOn(view, stack, extra) {
state.currentView = view; state.currentView = view;
state.viewStack = stack.slice(); state.viewStack = stack.slice();
Object.assign(state, extra || {}); Object.assign(state, extra || {});
// Restoring onto a view renders it, so the reopened popup has that one setBackRenderer(makeBackRenderer(state, makeViews()));
// 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. // The reproduction from the issue, step for step.
@@ -169,24 +154,6 @@ describe("Back onto a view the reopened popup never rendered", () => {
expect(state.currentView).toBe("confirm-tx"); 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", () => { test("Back onto Home renders the wallet list", () => {
reopenedOn("settings", ["main"]); reopenedOn("settings", ["main"]);
goBack(); goBack();
@@ -235,20 +202,6 @@ describe("Back onto a view whose backing data is gone", () => {
expect(state.currentView).toBe("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", () => { test("a wait that can no longer be resumed falls back to Home", () => {
reopenedOn("settings", ["main", "wait-tx"]); reopenedOn("settings", ["main", "wait-tx"]);
const views = makeViews(); const views = makeViews();
@@ -260,11 +213,12 @@ describe("Back onto a view whose backing data is gone", () => {
}); });
}); });
// Forward navigation renders as it goes, and a second render would re-fetch // Forward navigation renders as it goes. Re-rendering a second time would
// and clobber whatever the view holds — an unsaved edit, a request in // re-fetch and clobber whatever the view has in flight, so the Back path is
// flight. So the Back path renders only a view this page load has never // the only place this happens — and it declines any view the popup does not
// rendered, and merely unhides every other one: the views it does not // render from persisted state, since the restored stack is filtered against
// render from persisted state, and the views already on the page. // that same set and such a view can only have been rendered in this page
// load.
describe("what the Back path leaves alone", () => { describe("what the Back path leaves alone", () => {
test("forward navigation renders nothing by itself", () => { test("forward navigation renders nothing by itself", () => {
reopenedOn("address", ["main"]); reopenedOn("address", ["main"]);
@@ -286,44 +240,4 @@ describe("what the Back path leaves alone", () => {
goBack(); goBack();
expect(calls.filter((c) => c === "addressDetail")).toHaveLength(1); 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

@@ -23,8 +23,6 @@
"use strict"; "use strict";
const { Transaction } = require("ethers");
// Fictional ERC-20 used to seed the transaction-detail test. The symbol // Fictional ERC-20 used to seed the transaction-detail test. The symbol
// must not collide with any entry in src/shared/tokenList.js, or // must not collide with any entry in src/shared/tokenList.js, or
// isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a // isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a
@@ -46,24 +44,9 @@ const STUB_TX_HASH =
const STUB_BLOCK_NUMBER = 21000000; const STUB_BLOCK_NUMBER = 21000000;
// The native ETH transfer, seeded by opts.seedNativeTransfer. Its own hash
// and an older block, so it is a second row rather than a leg of the token
// transfer: mergeTransactions() consolidates a native entry and a token
// transfer that share a hash into one row, which would leave nothing native
// to open. 0.25 ETH clears the 100000 gwei dust threshold the default
// filters apply, so the row is not silently dropped.
const STUB_NATIVE_TX_HASH =
"0xe7e0000000000000000000000000000000000000000000000000000000000e7e";
const STUB_NATIVE_BLOCK_NUMBER = STUB_BLOCK_NUMBER - 1;
const STUB_NATIVE_VALUE_WEI = "250000000000000000";
// Fixed instant so timeAgo() output is stable across runs. // Fixed instant so timeAgo() output is stable across runs.
const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z"; const STUB_TX_TIMESTAMP = "2026-01-02T03:04:05.000000Z";
const STUB_NATIVE_TX_TIMESTAMP = "2026-01-02T02:03:04.000000Z";
// A 32-byte zero word. Returned for every eth_call, which is what makes // A 32-byte zero word. Returned for every eth_call, which is what makes
// ethers' ENS reverse lookup resolve to "no resolver set" and return null // ethers' ENS reverse lookup resolve to "no resolver set" and return null
// instead of throwing. A throw would be logged by src/shared/ens.js via // instead of throwing. A throw would be logged by src/shared/ens.js via
@@ -79,87 +62,6 @@ function word(value) {
return "0x" + BigInt(value).toString(16).padStart(64, "0"); return "0x" + BigInt(value).toString(16).padStart(64, "0");
} }
// -------------------------------------------------------- dApp fixture
//
// The origin the EIP-1193 test page is served from, and the page itself.
//
// It is a fixture like every other one in this file: the route handler
// fulfils the navigation from the string below, so the page never comes
// from a remote origin and nothing about the dApp round trips leaves the
// container. `.test` is reserved by RFC 6761 and has no owner to reach in
// the first place; the launch arguments map every host to NOTFOUND anyway.
//
// What the page deliberately does NOT do is load a provider. window.ethereum
// is put there by the shipped manifest's MAIN-world content script, exactly
// as it is on any http(s) page a user visits, so what these tests speak to
// is the real inpage provider and not a copy the harness wired up.
const DAPP_ORIGIN = "https://dapp.e2e.test";
const DAPP_URL = DAPP_ORIGIN + "/";
// Requests are parked rather than awaited. An approval prompt only exists
// while its call is in flight, so a test that awaited the promise could
// 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.
//
// The rejection branch records the whole observable shape of the error as it
// arrives — name, message, and whether a `code` is present at all as distinct
// 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
// AUTISTMASK_* message that crosses between this page and the content
// script, in both directions, verbatim.
const DAPP_HTML = [
"<!doctype html>",
'<html lang="en">',
"<head>",
'<meta charset="utf-8">',
"<title>AutistMask e2e dApp</title>",
// Inline and empty: without it Chromium asks for /favicon.ico, which
// the unstubbed-request guard would report as escaping traffic.
'<link rel="icon" href="data:,">',
"</head>",
"<body>",
"<h1>AutistMask e2e dApp</h1>",
"<script>",
"window.__dapp = {",
" messages: [],",
" calls: {},",
" start: function (key, method, params) {",
" window.__dapp.calls[key] = window.ethereum",
" .request({ method: method, params: params })",
" .then(",
" function (result) {",
" return { settled: 'resolved', result: result };",
" },",
" function (error) {",
" return {",
" settled: 'rejected',",
" message: String((error && error.message) || error),",
" name: error ? error.name : undefined,",
" hasCode: !!error && 'code' in Object(error),",
" code: error ? error.code : undefined,",
" };",
" },",
" );",
" },",
" settle: function (key) {",
" return window.__dapp.calls[key];",
" },",
"};",
"window.addEventListener('message', function (event) {",
" if (event.source !== window) return;",
" var d = event.data;",
" if (!d || typeof d.type !== 'string') return;",
" if (d.type.indexOf('AUTISTMASK') !== 0) return;",
" window.__dapp.messages.push(d);",
"});",
"</script>",
"</body>",
"</html>",
].join("\n");
// ------------------------------------------------------------ fee fixture // ------------------------------------------------------------ fee fixture
// //
// The confirmation screen carries two different numbers for the same // The confirmation screen carries two different numbers for the same
@@ -199,11 +101,6 @@ const RPC_RESULTS = {
eth_estimateGas: hex(GAS_LIMIT), eth_estimateGas: hex(GAS_LIMIT),
eth_getTransactionCount: "0x0", eth_getTransactionCount: "0x0",
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI), eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
// "not mined yet", which is what a node answers for a transaction it has
// only just accepted. The wait screen the dApp transaction approval hands
// off to polls this every 10 seconds; leaving it unstubbed would report
// the poll as escaping traffic the moment a test outlived one tick.
eth_getTransactionReceipt: null,
}; };
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from // The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
@@ -273,25 +170,6 @@ function tokenTransferItems(address) {
]; ];
} }
// One received native ETH transfer, in the shape src/shared/transactions.js
// parses. to.is_contract is false and there is no method, so parseTx() keeps
// it a plain transfer rather than a contract call — which is what makes the
// detail screen classify it "Native ETH Transfer" and leave the token
// contract row hidden.
function nativeTransactionItems(address) {
return [
{
hash: STUB_NATIVE_TX_HASH,
block_number: STUB_NATIVE_BLOCK_NUMBER,
timestamp: STUB_NATIVE_TX_TIMESTAMP,
from: { hash: STUB_COUNTERPARTY },
to: { hash: address, is_contract: false },
value: STUB_NATIVE_VALUE_WEI,
status: "ok",
},
];
}
// A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving // A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving
// this is what puts an ERC-20 in the send screen's token dropdown, which is // this is what puts an ERC-20 in the send screen's token dropdown, which is
// the only way the confirmation screen's ERC-20 path can be reached. // the only way the confirmation screen's ERC-20 path can be reached.
@@ -304,17 +182,12 @@ function tokenBalanceItems() {
]; ];
} }
// Full details for either seeded transaction — the detail screen fetches // Full details for STUB_TX_HASH. raw_input is "0x" so the calldata
// them for whichever row was opened, and an unstubbed hash would be // decoder short-circuits; the on-chain detail fields still populate.
// reported as escaping traffic. raw_input is "0x" so the calldata decoder function transactionDetails() {
// short-circuits; the on-chain detail fields still populate.
function transactionDetails(hash) {
return { return {
hash: hash, hash: STUB_TX_HASH,
block_number: block_number: STUB_BLOCK_NUMBER,
hash === STUB_NATIVE_TX_HASH
? STUB_NATIVE_BLOCK_NUMBER
: STUB_BLOCK_NUMBER,
nonce: 7, nonce: 7,
gas_used: "51000", gas_used: "51000",
gas_price: "1000000000", gas_price: "1000000000",
@@ -391,31 +264,6 @@ function rpcReply(req, opts, report) {
if (req.method === "eth_getBlockByNumber") { if (req.method === "eth_getBlockByNumber") {
return Object.assign(envelope, { result: latestBlock() }); return Object.assign(envelope, { result: latestBlock() });
} }
// The end of the dApp transaction round trip: the raw signed transaction
// the background hands to the node. It is recorded verbatim so a test can
// recover the signer from the exact bytes that were broadcast, rather than
// from anything the extension reported about them.
//
// The reply must be the transaction's real hash. ethers compares the hash
// the node returns against the one it computes itself and throws on a
// mismatch, so a constant here would fail the broadcast for a reason that
// has nothing to do with what is being tested.
if (req.method === "eth_sendRawTransaction") {
const raw = Array.isArray(req.params) ? req.params[0] : null;
let parsed;
try {
parsed = Transaction.from(raw);
} catch {
report("eth_sendRawTransaction with an undecodable transaction");
return Object.assign(envelope, {
error: { code: -32000, message: "undecodable transaction" },
});
}
if (Array.isArray(opts.broadcastTransactions)) {
opts.broadcastTransactions.push(raw);
}
return Object.assign(envelope, { result: parsed.hash });
}
if (req.method === "eth_estimateGas" && opts.failGasEstimate) { if (req.method === "eth_estimateGas" && opts.failGasEstimate) {
// A refusal the node itself would produce, not a transport error: // A refusal the node itself would produce, not a transport error:
// this is the shape the confirmation screen has to turn into // this is the shape the confirmation screen has to turn into
@@ -518,10 +366,6 @@ function traceEnabled(raw) {
* @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20 * @param {boolean} [opts.seedTokenTransfer] serve the stubbed ERC-20
* transfer. Read at request time, so a test can flip it on the same * transfer. Read at request time, so a test can flip it on the same
* options object without re-registering the route. * options object without re-registering the route.
* @param {boolean} [opts.seedNativeTransfer] serve the stubbed native ETH
* transfer, read at request time like seedTokenTransfer. Without it the
* normal-transactions endpoint answers with an empty list, so there is no
* non-ERC-20 row to open.
* @param {boolean} [opts.seedTokenBalance] serve the stubbed ERC-20 * @param {boolean} [opts.seedTokenBalance] serve the stubbed ERC-20
* holding, which is what makes the token reachable from the send screen. * holding, which is what makes the token reachable from the send screen.
* @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance; * @param {string} [opts.ethBalanceWei] hex wei answered to eth_getBalance;
@@ -531,8 +375,6 @@ function traceEnabled(raw) {
* node-side refusal. * node-side refusal.
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an * @param {boolean} [opts.holdGasEstimate] hold every batch containing an
* eth_estimateGas until this is cleared again. * eth_estimateGas until this is cleared again.
* @param {string[]} [opts.broadcastTransactions] every raw signed
* transaction handed to eth_sendRawTransaction, appended in order.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>} * Promise<string|null>}>}
*/ */
@@ -578,28 +420,10 @@ async function installNetworkStubs(ctx, opts) {
return handleRpc(route, req.postData(), opts, report); return handleRpc(route, req.postData(), opts, report);
} }
// The local EIP-1193 test page. Served from here so the dApp round
// trips run against a real http(s) origin — which is what makes the
// shipped content scripts inject at all — without any remote origin
// being involved.
if (url.origin === DAPP_ORIGIN && p === "/") {
return route.fulfill({
status: 200,
contentType: "text/html; charset=utf-8",
body: DAPP_HTML,
});
}
// Blockscout v2 // Blockscout v2
if (p.includes("/api/v2/")) { if (p.includes("/api/v2/")) {
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
const addr = blockscoutAddress(p); return jsonResponse(route, { items: [] });
return jsonResponse(route, {
items:
opts.seedNativeTransfer && addr
? nativeTransactionItems(addr)
: [],
});
} }
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-transfers$/.test(p)) {
const addr = blockscoutAddress(p); const addr = blockscoutAddress(p);
@@ -616,10 +440,8 @@ async function installNetworkStubs(ctx, opts) {
opts.seedTokenBalance ? tokenBalanceItems() : [], opts.seedTokenBalance ? tokenBalanceItems() : [],
); );
} }
for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) { if (p.endsWith("/transactions/" + STUB_TX_HASH)) {
if (p.endsWith("/transactions/" + hash)) { return jsonResponse(route, transactionDetails());
return jsonResponse(route, transactionDetails(hash));
}
} }
} }
@@ -686,13 +508,9 @@ async function installNetworkStubs(ctx, opts) {
module.exports = { module.exports = {
installNetworkStubs, installNetworkStubs,
DAPP_ORIGIN,
DAPP_URL,
FEE_ESTIMATE_WEI, FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI, FEE_RESERVE_WEI,
STUB_COUNTERPARTY, STUB_COUNTERPARTY,
STUB_NATIVE_TX_HASH,
STUB_NATIVE_VALUE_WEI,
STUB_TOKEN, STUB_TOKEN,
STUB_TX_HASH, STUB_TX_HASH,
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -1,310 +0,0 @@
// 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 { TOKENS, KNOWN_SYMBOLS } = require("../src/shared/tokenList"); const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { filterTransactions } = require("../src/shared/transactions"); const { filterTransactions } = require("../src/shared/transactions");
const { const {
fetchTokenBalances, fetchTokenBalances,
@@ -124,301 +124,6 @@ describe("the shared rule", () => {
}); });
}); });
// Issue #260: the symbol is whatever the ERC-20 contract returns, and HTML
// collapses leading and trailing whitespace, so a token calling itself
// `" ETH "` reaches the user's eye as `ETH` while missing a raw
// KNOWN_SYMBOLS lookup. Normalizing inside the shared rule fixes all three
// surfaces at once, which is what consolidating the rule bought.
//
// Every character under test here is built from its code point rather than
// pasted in: most of them are invisible, and an invisible character in a
// test file is unreviewable.
const cp = (...codes) => String.fromCodePoint(...codes);
const NBSP = cp(0x00a0); // no-break space
const FIGURE_SPACE = cp(0x2007);
const IDEOGRAPHIC_SPACE = cp(0x3000);
const ZWSP = cp(0x200b); // zero-width space
const BOM = cp(0xfeff); // zero-width no-break space
const WORD_JOINER = cp(0x2060);
const SOFT_HYPHEN = cp(0x00ad);
const LRM = cp(0x200e); // left-to-right mark
const RLO = cp(0x202e); // right-to-left override
const HANGUL_FILLER = cp(0x3164);
const CHOSEONG_FILLER = cp(0x115f);
const VS16 = cp(0xfe0f); // variation selector-16
const VS1 = cp(0xfe00); // variation selector-1
const NEL = cp(0x0085); // next line, a C1 control
const DEL = cp(0x007f);
const FULLWIDTH_ETH = cp(0xff25, 0xff34, 0xff28);
const FULLWIDTH_USDC = cp(0xff55, 0xff53, 0xff44, 0xff43); // lowercase
const CYRILLIC_CAPITAL_IE = cp(0x0415);
describe("the shared rule: symbols that render as a known symbol", () => {
test("ASCII padding does not buy a pass", () => {
expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("\tETH\n", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT)).toBe(true);
});
test("non-breaking and other Unicode spaces do not either", () => {
expect(isSpoofedSymbol(NBSP + "ETH" + NBSP, FAKE_ETH_CONTRACT)).toBe(
true,
);
expect(
isSpoofedSymbol(
FIGURE_SPACE + "ETH" + IDEOGRAPHIC_SPACE,
FAKE_ETH_CONTRACT,
),
).toBe(true);
});
// These render as nothing at all, in any position, so they are removed
// wherever they sit rather than only at the ends.
test("zero-width characters are stripped wherever they sit", () => {
expect(isSpoofedSymbol("E" + ZWSP + "TH", FAKE_ETH_CONTRACT)).toBe(
true,
);
expect(isSpoofedSymbol(BOM + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
expect(
isSpoofedSymbol("ET" + WORD_JOINER + "H", FAKE_ETH_CONTRACT),
).toBe(true);
expect(
isSpoofedSymbol("E" + SOFT_HYPHEN + "TH", FAKE_ETH_CONTRACT),
).toBe(true);
});
// An LRM is invisible and, in all-Latin text, moves nothing: dropping it
// leaves exactly the string the user saw.
test("an invisible bidi mark does not hide a known symbol", () => {
expect(isSpoofedSymbol(LRM + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
});
// Invisibility is not confined to \p{Cf}. A Hangul filler is Lo and a
// variation selector is Mn, yet each of these four measures 32.00px in
// the repo's pinned e2e Chromium at 16px sans-serif — exactly the width
// of a plain `ETH` — so each reaches the user's eye as `ETH`. They are
// caught by \p{Default_Ignorable_Code_Point}, not by \p{Cf}.
test("invisible non-format characters are stripped too", () => {
expect(isSpoofedSymbol(HANGUL_FILLER + "ETH", FAKE_ETH_CONTRACT)).toBe(
true,
);
expect(
isSpoofedSymbol(CHOSEONG_FILLER + "ETH", FAKE_ETH_CONTRACT),
).toBe(true);
expect(isSpoofedSymbol("ETH" + VS16, FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("E" + VS1 + "TH", FAKE_ETH_CONTRACT)).toBe(true);
});
// Nor is it confined to the Unicode classes. U+007F is a control (Cc)
// and is not default-ignorable, so neither class reaches it, but it
// measures 32.00px in the same browser — it paints nothing, so a
// symbol carrying it reaches the eye as `ETH`. It is named on its own
// in the strip for exactly that reason.
test("U+007F paints nothing and is stripped", () => {
expect(isSpoofedSymbol(DEL + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
});
// The other side of the boundary, which is not the class boundary but
// the visibility one: the remaining C0 and C1 controls render as a
// visible 48.00px box in the same browser, so a symbol carrying one
// does not look like `ETH` and must not be judged a spoof. Widening
// the strip to \p{Cc} — the obvious over-correction once U+007F is in
// it — fails this test.
test("visible control characters do not make a symbol a spoof", () => {
expect(isSpoofedSymbol(NEL + "ETH", FAKE_ETH_CONTRACT)).toBe(false);
expect(isSpoofedSymbol(cp(0x0001) + "ETH", FAKE_ETH_CONTRACT)).toBe(
false,
);
expect(isSpoofedSymbol(cp(0x0090) + "ETH", FAKE_ETH_CONTRACT)).toBe(
false,
);
});
test("compatibility forms fold onto the symbol they imitate", () => {
expect(isSpoofedSymbol(FULLWIDTH_ETH, FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT)).toBe(true);
});
// The two knowingly open classes, asserted here so that the boundary is
// a fact in the suite and not a claim in a PR body. A Cyrillic capital
// Ie is a distinct letter rather than a compatibility variant, so NFKC
// leaves it alone; and a right-to-left override reverses the rendering
// of what follows it, which dropping the control character does not
// undo. Closing either needs a confusables table or a bidi resolver,
// and both are a separate change from this one.
test("a Cyrillic homoglyph is knowingly still not caught", () => {
expect(
isSpoofedSymbol(CYRILLIC_CAPITAL_IE + "TH", FAKE_ETH_CONTRACT),
).toBe(false);
});
test("a bidi-reordered symbol is knowingly still not caught", () => {
expect(isSpoofedSymbol(RLO + "HTE", FAKE_ETH_CONTRACT)).toBe(false);
});
// Normalization does not reach the native-asset exemption, which turns
// on the absence of a contract address and never on the symbol.
test("a padded symbol with no contract is still not a spoof", () => {
expect(isSpoofedSymbol(" ETH ", null)).toBe(false);
expect(isSpoofedSymbol(NBSP + "ETH", "")).toBe(false);
});
test("a genuine contract still bears its own padded symbol", () => {
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT)).toBe(false);
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT)).toBe(false);
});
// Normalization must not invent a match. Interior ASCII whitespace is
// left alone: `E T H` renders as `E T H`, not as `ETH`, so folding it
// would filter a token no user could confuse with the native asset.
test("a symbol that renders differently is not judged a spoof", () => {
expect(isSpoofedSymbol("E T H", FAKE_ETH_CONTRACT)).toBe(false);
expect(isSpoofedSymbol("ETH2", FAKE_ETH_CONTRACT)).toBe(false);
expect(isSpoofedSymbol("MY ETH", FAKE_ETH_CONTRACT)).toBe(false);
});
// The false-positive question, answered against the shipped data rather
// than by assertion: no bundled symbol carries whitespace or a
// non-ASCII character, so the normalization cannot newly filter one.
// The character class starts at `!` rather than at the space so that it
// asserts the claim it stands for — `[ -~]` would admit an interior
// space and let a whitespace-bearing entry through the guard.
test("no bundled symbol is touched by the normalization", () => {
for (const [symbol, addresses] of KNOWN_SYMBOLS) {
expect(symbol).toBe(symbol.trim());
expect(symbol).toMatch(/^[!-~]+$/);
if (addresses === null) continue;
for (const address of addresses) {
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);
}
});
});
describe("surface 1: the transaction history", () => { describe("surface 1: the transaction history", () => {
function fakeEthTransfer() { function fakeEthTransfer() {
return { return {
@@ -442,22 +147,6 @@ describe("surface 1: the transaction history", () => {
expect(result.transactions).toEqual([]); expect(result.transactions).toEqual([]);
}); });
// Issue #260 on this surface: the same transfer with a padded symbol.
test("a padded fake ETH token transfer is filtered too", () => {
const padded = { ...fakeEthTransfer(), symbol: " ETH " };
const result = filterTransactions([padded], {
hideSpoofedSymbols: true,
hideFraudContracts: true,
hideLowHolderTokens: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
});
expect(result.transactions).toEqual([]);
// The contract is learned as fraudulent, exactly as for the
// unpadded symbol: the padding must not cost the blocklist entry.
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
});
test("a real native ETH transfer survives", () => { test("a real native ETH transfer survives", () => {
const native = { const native = {
hash: "0x" + "2".repeat(64), hash: "0x" + "2".repeat(64),
@@ -512,36 +201,6 @@ describe("surface 2: the Send token selector", () => {
expect(select.children).toEqual([]); expect(select.children).toEqual([]);
}); });
// Issue #260 on this surface: the option text is rendered into HTML,
// which collapses the padding, so an unfiltered padded token would sit
// in the selector reading exactly `ETH`.
test("a padded fake ETH token is not selectable either", () => {
render([
{
address: FAKE_ETH_CONTRACT,
symbol: " ETH ",
decimals: 18,
balance: "0.005",
holders: 900000,
},
]);
expect(select.children).toEqual([]);
});
test("a genuine token with a padded symbol stays selectable", () => {
render([
{
address: USDC_CONTRACT,
symbol: " USDC ",
decimals: 6,
balance: "12.5",
holders: 900000,
},
]);
expect(select.children).toHaveLength(1);
expect(select.children[0].value).toBe(USDC_CONTRACT);
});
test("native ETH remains the always-present option", () => { test("native ETH remains the always-present option", () => {
render([]); render([]);
expect(select.innerHTML).toBe('<option value="ETH">ETH</option>'); expect(select.innerHTML).toBe('<option value="ETH">ETH</option>');
@@ -592,35 +251,6 @@ describe("surface 3: the balance list", () => {
expect(balances).toEqual([]); expect(balances).toEqual([]);
}); });
// Issue #260 on this surface: the balance list is where the user forms
// their belief about what they own, and it renders the symbol into HTML.
test("a padded fake ETH token is filtered too", async () => {
respondWith([fakeEthItem({ symbol: " ETH " })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
test("a fake ETH token padded with a no-break space is filtered", async () => {
respondWith([fakeEthItem({ symbol: NBSP + "ETH" + NBSP })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
// The false-positive direction on the surface that matters most: a real
// holding whose symbol happens to carry padding is still listed, and the
// list still shows the symbol the token actually reports.
test("a genuine token with a padded symbol is not newly filtered", async () => {
respondWith([
fakeEthItem({
address_hash: USDC_CONTRACT,
symbol: " USDC ",
name: "USD Coin",
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe(" USDC ");
});
test("a genuine token keeps its place in the list", async () => { test("a genuine token keeps its place in the list", async () => {
respondWith([ respondWith([
fakeEthItem({ fakeEthItem({
@@ -644,33 +274,6 @@ describe("surface 3: the balance list", () => {
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]); expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
}); });
// The adjacent finding from the same review as issue #260: the type gate
// compared exactly, so an explorer that ever varied the casing would
// silently drop a real holding before any filter ran. The comparison is
// now case-insensitive, which changes nothing about which types are
// admitted.
test("a differently-cased ERC-20 type still lists a real holding", async () => {
respondWith([
fakeEthItem({
type: "erc-20",
address_hash: USDC_CONTRACT,
symbol: "USDC",
name: "USD Coin",
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC");
});
test("case insensitivity does not admit another token type", async () => {
respondWith([fakeEthItem({ type: "erc-721" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
respondWith([fakeEthItem({ type: "ERC-20-EXTRA" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
// The money test: the user holds real ETH and has been airdropped a fake // The money test: the user holds real ETH and has been airdropped a fake
// ETH ERC-20. The fake is gone from the list of tokens; the real balance // ETH ERC-20. The fake is gone from the list of tokens; the real balance
// is exactly what the node reported. // is exactly what the node reported.

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")]).toEqual([USDC_CONTRACT]); expect(KNOWN_SYMBOLS.get("USDC")).toBe(USDC_CONTRACT);
expect([...KNOWN_SYMBOLS.get("WETH")]).toEqual([WETH_CONTRACT]); expect(KNOWN_SYMBOLS.get("WETH")).toBe(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", () => {