Compare commits
1 Commits
84c58a97ab
...
21b158b3b6
| Author | SHA1 | Date | |
|---|---|---|---|
| 21b158b3b6 |
40
README.md
40
README.md
@@ -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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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.
|
||||
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
|
||||
clobber whatever it has in flight.
|
||||
|
||||
Every screen that holds secret material in the page registers a cleanup with
|
||||
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
|
||||
|
||||
41
TODO.md
41
TODO.md
@@ -52,41 +52,12 @@ undefined identifiers, which is how
|
||||
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
|
||||
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:
|
||||
`viewRouter.js` records every view that reaches `showView()`, which is where
|
||||
forward navigation and the restore both end, so Back onto a view already on
|
||||
the page unhides it and nothing more. That is what keeps a second render from
|
||||
re-fetching and overwriting what the view holds — an unsaved edit in Settings,
|
||||
a transaction list already loaded. Home is the exception and is always
|
||||
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: 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 `ETH` 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)).
|
||||
render is gone, and declining any view the popup does not render from
|
||||
persisted state, which can only be on the stack from the current page load and
|
||||
was rendered on the way in. Forward navigation is untouched, so nothing
|
||||
renders twice. Covered by unit tests on the real `goBack()` and by two
|
||||
end-to-end cases against the real popup, both demonstrated failing on the
|
||||
unfixed build ([#268](https://git.eeqj.de/sneak/AutistMask/issues/268)).
|
||||
- 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
|
||||
build installed as a temporary add-on. Zero npm dependencies — a WebDriver
|
||||
|
||||
@@ -14,44 +14,6 @@
|
||||
|
||||
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([
|
||||
@@ -132,26 +94,13 @@ function renderView(view, state, views) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
// filtered against RESTORABLE_VIEWS, so such a view can only be on the
|
||||
// stack from this page load, where forward navigation already rendered it.
|
||||
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();
|
||||
}
|
||||
@@ -162,6 +111,6 @@ function makeBackRenderer(state, views) {
|
||||
module.exports = {
|
||||
renderView,
|
||||
makeBackRenderer,
|
||||
markViewRendered,
|
||||
resetRenderedViews,
|
||||
needsAddress,
|
||||
hasValidAddress,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ const {
|
||||
getAddressValueUsd,
|
||||
} = require("../../shared/prices");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { markViewRendered } = require("../viewRouter");
|
||||
|
||||
// When views are added, removed, or transitions between them change,
|
||||
// update the view-navigation documentation in README.md to match.
|
||||
@@ -77,10 +76,6 @@ function showView(name) {
|
||||
}
|
||||
clearFlash();
|
||||
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();
|
||||
updateDebugBanner(name);
|
||||
}
|
||||
|
||||
@@ -66,12 +66,7 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
|
||||
const balances = [];
|
||||
for (const item of items) {
|
||||
// Case-insensitive: the token type is an explorer's label, not a
|
||||
// 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;
|
||||
if (item.token?.type !== "ERC-20") continue;
|
||||
const decimals = parseInt(item.token.decimals || "18", 10);
|
||||
const bal = formatTokenBalance(item.value || "0", decimals);
|
||||
if (bal === "0.0") continue;
|
||||
|
||||
@@ -13,11 +13,6 @@
|
||||
// which has no contract at all, so no contract may bear it and every 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.
|
||||
//
|
||||
// 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");
|
||||
|
||||
@@ -27,59 +22,6 @@ function normalizeAddress(addr) {
|
||||
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
|
||||
// impersonating a known symbol.
|
||||
//
|
||||
@@ -89,7 +31,7 @@ function normalizeSymbol(symbol) {
|
||||
function isSpoofedSymbol(symbol, contractAddress) {
|
||||
const contract = normalizeAddress(contractAddress);
|
||||
if (!contract) return false;
|
||||
const sym = normalizeSymbol(symbol);
|
||||
const sym = (symbol || "").toUpperCase();
|
||||
if (!KNOWN_SYMBOLS.has(sym)) return false;
|
||||
const legit = KNOWN_SYMBOLS.get(sym);
|
||||
if (legit === null) return true;
|
||||
|
||||
@@ -46,11 +46,7 @@ const {
|
||||
setBackRenderer,
|
||||
pushCurrentView,
|
||||
} = require("../src/popup/views/helpers");
|
||||
const {
|
||||
makeBackRenderer,
|
||||
markViewRendered,
|
||||
resetRenderedViews,
|
||||
} = require("../src/popup/viewRouter");
|
||||
const { makeBackRenderer } = require("../src/popup/viewRouter");
|
||||
const { state } = require("../src/shared/state");
|
||||
|
||||
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
||||
@@ -96,14 +92,8 @@ function makeViews() {
|
||||
|
||||
// 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",
|
||||
@@ -117,12 +107,7 @@ function reopenedOn(view, stack, extra) {
|
||||
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;
|
||||
setBackRenderer(makeBackRenderer(state, makeViews()));
|
||||
}
|
||||
|
||||
// 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");
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -235,20 +202,6 @@ describe("Back onto a view whose backing data is gone", () => {
|
||||
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();
|
||||
@@ -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
|
||||
// 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.
|
||||
// Forward navigation renders as it goes. Re-rendering a second time would
|
||||
// re-fetch and clobber whatever the view has in flight, so the Back path is
|
||||
// the only place this happens — and it declines any view the popup does not
|
||||
// render from persisted state, since the restored stack is filtered against
|
||||
// that same set and such a view can only have been rendered in this page
|
||||
// load.
|
||||
describe("what the Back path leaves alone", () => {
|
||||
test("forward navigation renders nothing by itself", () => {
|
||||
reopenedOn("address", ["main"]);
|
||||
@@ -286,44 +240,4 @@ describe("what the Back path leaves alone", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
|
||||
"use strict";
|
||||
|
||||
const { Transaction } = require("ethers");
|
||||
|
||||
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
|
||||
// must not collide with any entry in src/shared/tokenList.js, or
|
||||
// isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a
|
||||
@@ -64,84 +62,6 @@ function word(value) {
|
||||
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 `code` as it arrives. 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),",
|
||||
" 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
|
||||
//
|
||||
// The confirmation screen carries two different numbers for the same
|
||||
@@ -181,11 +101,6 @@ const RPC_RESULTS = {
|
||||
eth_estimateGas: hex(GAS_LIMIT),
|
||||
eth_getTransactionCount: "0x0",
|
||||
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
|
||||
@@ -349,31 +264,6 @@ function rpcReply(req, opts, report) {
|
||||
if (req.method === "eth_getBlockByNumber") {
|
||||
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) {
|
||||
// A refusal the node itself would produce, not a transport error:
|
||||
// this is the shape the confirmation screen has to turn into
|
||||
@@ -485,8 +375,6 @@ function traceEnabled(raw) {
|
||||
* node-side refusal.
|
||||
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
|
||||
* 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) =>
|
||||
* Promise<string|null>}>}
|
||||
*/
|
||||
@@ -532,18 +420,6 @@ async function installNetworkStubs(ctx, opts) {
|
||||
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
|
||||
if (p.includes("/api/v2/")) {
|
||||
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
|
||||
@@ -632,8 +508,6 @@ async function installNetworkStubs(ctx, opts) {
|
||||
|
||||
module.exports = {
|
||||
installNetworkStubs,
|
||||
DAPP_ORIGIN,
|
||||
DAPP_URL,
|
||||
FEE_ESTIMATE_WEI,
|
||||
FEE_RESERVE_WEI,
|
||||
STUB_COUNTERPARTY,
|
||||
|
||||
1022
tests/e2e/run.js
1022
tests/e2e/run.js
File diff suppressed because it is too large
Load Diff
@@ -124,175 +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, address] of KNOWN_SYMBOLS) {
|
||||
expect(symbol).toBe(symbol.trim());
|
||||
expect(symbol).toMatch(/^[!-~]+$/);
|
||||
if (address === null) continue;
|
||||
expect(isSpoofedSymbol(symbol, address)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("surface 1: the transaction history", () => {
|
||||
function fakeEthTransfer() {
|
||||
return {
|
||||
@@ -316,22 +147,6 @@ describe("surface 1: the transaction history", () => {
|
||||
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", () => {
|
||||
const native = {
|
||||
hash: "0x" + "2".repeat(64),
|
||||
@@ -386,36 +201,6 @@ describe("surface 2: the Send token selector", () => {
|
||||
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", () => {
|
||||
render([]);
|
||||
expect(select.innerHTML).toBe('<option value="ETH">ETH</option>');
|
||||
@@ -466,35 +251,6 @@ describe("surface 3: the balance list", () => {
|
||||
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 () => {
|
||||
respondWith([
|
||||
fakeEthItem({
|
||||
@@ -518,33 +274,6 @@ describe("surface 3: the balance list", () => {
|
||||
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
|
||||
// ETH ERC-20. The fake is gone from the list of tokens; the real balance
|
||||
// is exactly what the node reported.
|
||||
|
||||
Reference in New Issue
Block a user