Compare commits

..

1 Commits

Author SHA1 Message Date
681f2bf83c fix: judge the symbol a user sees, not the bytes a contract returns (closes #260)
All checks were successful
check / check (push) Successful in 35s
A token whose symbol is " ETH " missed KNOWN_SYMBOLS on all three surfaces
while HTML collapsed the padding and painted it as ETH next to the user's
real ETH. isSpoofedSymbol() now normalizes before the lookup: NFKC, then
every character that paints nothing removed wherever it sits, then trimmed,
then uppercased. All three surfaces inherit it unchanged.

The strip is "renders as nothing", spelled as \p{Cf} plus
\p{Default_Ignorable_Code_Point} plus U+007F. The classes are the spelling
and not the rule, which is why U+007F is named on its own: it is a control
rather than a default-ignorable character, so no class reaches it, yet it
measures 32.00px in the repo's pinned e2e Chromium at 16px sans-serif --
exactly a plain ETH -- so it paints nothing and would otherwise be a live
bypass. The remaining C0/C1 controls measure 48.00px, a visible box, and are
left alone; both directions are pinned by tests, and widening the strip to
\p{Cc} fails the visible-controls test.

Covered: ASCII and Unicode whitespace padding, zero-width and other
invisible characters, and compatibility variants such as fullwidth letters.
Knowingly left open, and asserted as open in the suite: confusables that are
distinct letters (Cyrillic capital Ie), bidi reordering, the visible
controls, and interior whitespace, which renders differently and so is not
the confusion.

No symbol in KNOWN_SYMBOLS or the bundled token list contains whitespace or
a non-ASCII character, so nothing legitimate is newly filtered; a test walks
the whole table and asserts it.

The balance list's token-type gate is now case-insensitive. It compared
exactly, so an explorer writing "erc-20" would silently drop a real holding
before any filter ran. Which types are admitted is unchanged.
2026-08-12 10:22:45 +00:00
40 changed files with 197 additions and 3092 deletions

View File

@@ -1,13 +1,8 @@
# node:22-slim (22.x LTS), 2026-02-24
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS base
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36
WORKDIR /app
# Marks "already inside the lint container" for script/lint, which otherwise
# shells out to docker to build the lint stage below. Nothing outside this
# image sets it.
ENV AUTISTMASK_LINT_NATIVE=1
# script/bootstrap installs all prerequisites (make via apt here; node
# is already in the base image, yarn comes via corepack) and runs
# yarn install --frozen-lockfile. Dependency manifests are copied first
@@ -18,17 +13,5 @@ RUN script/bootstrap
COPY . .
# Lint stage — fail fast on static analysis and formatting, before the tests
# and the build. This is also the stage script/lint builds from a host, which
# is how linting stays on the pinned ESLint rather than the host's.
FROM base AS lint
RUN make lint
# Full check and build. The COPY --from is a no-op file copy whose only job is
# to make BuildKit finish the lint stage before this one starts; without it the
# stages run in parallel and a lint failure would not fail the build early.
FROM base AS check
COPY --from=lint /app/package.json /dev/null
RUN make check
RUN make build

View File

@@ -88,13 +88,7 @@ provide:
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (docker
required; builds its own pinned image, see
[End-to-End Tests](#end-to-end-tests))
- `script/lint` — run ESLint (`eslint.config.js`) and then `prettier --check`,
failing on either. It never writes: `--fix` is not in this path, so
`make check` stays non-mutating. Linting runs in the container — the script
builds the Dockerfile's `lint` stage — because an ESLint result that depends
on whichever ESLint the host happens to have is not a result. Docker is
therefore required to lint; inside that image `AUTISTMASK_LINT_NATIVE=1` makes
the same script lint in place instead of recursing.
- `script/lint` — run the linter
- `script/fmt` — format all files (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, test-verify-build, lint, and fmt-check
@@ -175,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
@@ -241,12 +207,9 @@ being intercepted, run with `E2E_TRACE_NETWORK=1` and every routed request is
printed, tagged `[sw]` or `[page]`.
**Any uncaught page error or `console.error` fails the run.** That is the point:
this suite exists because a `ReferenceError` from a used-but-not-imported
identifier shipped twice, fatal in a browser and invisible to a `make check`
that was `prettier --check` only. ESLint's `no-undef` now catches that exact
class before a browser is involved, so this suite is no longer the only thing
standing between it and a release — but a static rule only sees identifiers, and
the runtime errors this suite catches are broader than one rule.
a `ReferenceError` from a used-but-not-imported identifier is invisible to
`make check` (`script/lint` is only `prettier --check`) but fatal in a browser,
and this suite exists because exactly that class of bug shipped twice.
### Firefox (`make test-e2e-firefox`)
@@ -647,21 +610,6 @@ ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
so the popup can never reopen onto one of them with no password prompt in front
of it.
A reopened popup renders the wallet list and the one screen it restores onto,
and nothing else, so every screen on the stack behind that one is still the
blank template from `index.html`. "Back" therefore renders its target rather
than only unhiding it, through the same dispatch and data guards as the restore
(`src/popup/viewRouter.js`), and falls back to Home when the state the target
would render is gone.
It renders only a screen this page load has not rendered yet. Forward navigation
renders as it goes, and `viewRouter.js` records every screen that reaches
`showView()`, so "Back" onto a screen already on the page unhides it and nothing
more — rendering it a second time would re-fetch and overwrite what it holds,
such as an edit typed into Settings and not yet saved. Home is the one screen
"Back" always re-renders, so the wallet list reflects anything that changed
while the user was away from it.
Every screen that holds secret material in the page registers a cleanup with
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
exit from that screen rather than only on its "Back" button, so nothing secret

84
TODO.md
View File

@@ -32,86 +32,19 @@ The backlog lives on the
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
authoritative; this file does not duplicate it. Full policy file set present.
Real-browser end-to-end suites (`make test-e2e` for Chrome,
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which now does
static analysis as well as formatting.
`make test-e2e-firefox` for Firefox) now sit alongside `make check`, which
cannot see a runtime `ReferenceError` in a popup view.
# Next Step
Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
input validation) before any 1.0rc tag. Individual filed issues are parts of it,
but the review is broader than any of them.
Land [#152](https://git.eeqj.de/sneak/AutistMask/issues/152): add ESLint to
`script/lint`. `make check` is `prettier --check` only today and cannot catch
undefined identifiers, which is how
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) shipped.
# Completed Steps
- 2026-08-14: `make check` does static analysis. `script/lint` ran
`prettier --check .`, byte-identical to `script/fmt-check`, so a wallet with
two shipped used-but-not-imported crashes behind it was green. ESLint is now
pinned in `package.json` with `@eslint/js` recommended as the base, flat
config in `eslint.config.js`, `no-undef` and `no-unused-vars` error-level, and
globals declared per tree — browser for the popup and content scripts, service
worker for `src/background/` and `src/shared/`, jest for `tests/`, node for
`build.js`. It found 41 unused bindings and 53 undefined identifiers; all are
fixed, and dropping a call to an unimported `foo()` into any `src/` file fails
`make lint`. Linting is also containerized now: `script/lint` builds the
Dockerfile's new `lint` stage, so the ESLint that decides whether this repo is
green is the pinned one and not the host's
([#152](https://git.eeqj.de/sneak/AutistMask/issues/152)).
- 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
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
`index.html`, and Back walked straight onto it — an empty address, no
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: `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
@@ -376,6 +309,9 @@ but the review is broader than any of them.
Only work that has no issue of its own belongs here; everything else is on the
tracker.
- Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
input validation) before any 1.0rc tag. Individual filed issues are parts of
it, but the review is broader than any of them.
- Decide whether docker-in-docker makes `make test-e2e` and
`make test-e2e-firefox` runnable in the Gitea workflow. Extending the Chrome
suite itself is tracked as

View File

@@ -63,7 +63,7 @@ function getBuildInfo() {
commitHash = execSync("git rev-parse --short HEAD", {
encoding: "utf8",
}).trim();
} catch {
} catch (_) {
// not a git repo or git not available
}
let commitHashFull = "unknown";
@@ -71,7 +71,7 @@ function getBuildInfo() {
commitHashFull = execSync("git rev-parse HEAD", {
encoding: "utf8",
}).trim();
} catch {
} catch (_) {
// not a git repo or git not available
}
return {

View File

@@ -1,144 +0,0 @@
// ESLint flat config. Static analysis for make check; formatting stays with
// prettier (script/fmt-check), so nothing here touches style.
//
// The sources are CommonJS and are bundled per entrypoint by build.js, so the
// globals differ by tree and are declared per tree below. Getting that wrong in
// either direction defeats the point: too few globals buries a real no-undef in
// false positives, too many hides the next unimported identifier.
const js = require("@eslint/js");
const globals = require("globals");
// The extension APIs. MV3 Chrome exposes `chrome`; Firefox exposes both, and
// the code feature-detects between them.
const extensionGlobals = {
chrome: "readonly",
browser: "readonly",
};
const commonjs = {
ecmaVersion: 2024,
sourceType: "commonjs",
};
module.exports = [
{
ignores: [
"dist/",
"node_modules/",
// Emitted by build.js, not authored here.
"src/popup/styles/",
],
},
js.configs.recommended,
{
rules: {
// The two rules this config exists for. Both are already
// error-level in the recommended set; restated so a future
// recommended-set change cannot silently downgrade them.
"no-undef": "error",
// `_`-prefixed arguments are the deliberate "present for the
// interface, unused here" marker: the popup views share one
// init(ctx) signature and three of the eight do not read ctx.
// An unused catch binding is written `catch {`, which the repo
// already does, so caught errors stay checked.
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
// Off: it flags `password = null` and `decryptedSecret = null` in
// approval.js and confirmTx.js, which are the best-effort wipes of
// decrypted key material after use. The assignments are dead by
// construction — that is the point of them — and satisfying the
// rule would mean deleting the wipes.
"no-useless-assignment": "off",
// Off: it requires every rethrow to carry `{ cause }`. That is a
// change to what the wallet's error paths actually throw, which is
// not this config's business; adopting it is its own decision.
"preserve-caught-error": "off",
},
},
// Popup and content scripts: page/window context.
{
files: ["src/popup/**/*.js", "src/content/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.browser, ...extensionGlobals },
},
},
// MV3 background: a service worker, with no window and no document.
{
files: ["src/background/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.serviceworker, ...extensionGlobals },
},
},
// src/shared is bundled into both, so it may only use what both provide:
// the service worker globals are the intersection, plus the extension APIs.
{
files: ["src/shared/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.serviceworker, ...extensionGlobals },
},
},
// src/shared/ens.js is the documented exception to the line above: its own
// header says POPUP ONLY, it caches in localStorage, and only popup views
// require it. Linting it as a service worker would be wrong about the file.
{
files: ["src/shared/ens.js"],
languageOptions: {
...commonjs,
globals: { ...globals.browser, ...extensionGlobals },
},
},
// Unit tests: jest on node.
{
files: ["tests/**/*.test.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node, ...globals.jest },
},
},
// The build script is a plain node program.
{
files: ["build.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node },
},
},
// The e2e harnesses are node programs that also carry, inline, the
// callbacks they ship into the browser via page.evaluate — so both
// contexts really are present in the same file and both sets of globals
// are in scope somewhere in it.
{
files: ["tests/e2e/**/*.js"],
languageOptions: {
...commonjs,
globals: {
...globals.node,
...globals.browser,
...extensionGlobals,
},
},
},
// This config file itself.
{
files: ["eslint.config.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node },
},
},
];

View File

@@ -9,16 +9,13 @@
"test": "jest --forceExit",
"test:verbose": "jest --forceExit --verbose",
"build": "node build.js",
"lint": "eslint . && prettier --check .",
"lint": "prettier --check .",
"fmt": "prettier --write .",
"fmt-check": "prettier --check ."
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@tailwindcss/cli": "^4.2.1",
"esbuild": "^0.27.3",
"eslint": "10.8.1",
"globals": "17.11.0",
"jest": "^30.2.0",
"playwright-core": "1.56.0",
"prettier": "^3.8.1",

View File

@@ -1,41 +1,13 @@
#!/bin/sh
# script/lint: run the linter (eslint, then prettier --check).
#
# Linting is containerized. ESLint results depend on the ESLint version, and
# the pinned one is the one in the image; a host's own install must not be
# able to decide whether this repo is green. From a host this therefore builds
# the Dockerfile's `lint` stage, which runs this same script inside the image.
#
# AUTISTMASK_LINT_NATIVE is set only in that image (see the Dockerfile) and is
# what stops the recursion, so `make check` inside the CI build lints in place
# instead of trying to reach a docker daemon it does not have.
# script/lint: run the linter.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
if [ "${AUTISTMASK_LINT_NATIVE:-}" = "1" ]; then
echo "Linting..."
yarn run lint 2>&1
return 0
fi
if ! command -v docker >/dev/null 2>&1; then
echo "lint: docker is required; linting does not run on the host" >&2
exit 1
fi
echo "Linting in the pinned container..."
# --progress=plain: the default progress renderer collapses the lint
# output on success, and a lint run whose output cannot be seen is not
# evidence that it ran.
#
# --output=type=cacheonly: the exit status is the whole result; exporting
# an image afterwards costs about ten times the lint itself.
docker build --progress=plain --target lint \
--output=type=cacheonly . 2>&1
echo "Linting..."
yarn run lint 2>&1
}
main "$@"

View File

@@ -5,9 +5,8 @@
#
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md
# caps make test at 20 seconds and a browser suite does not fit. Run it
# yourself before touching popup views. ESLint's no-undef now catches a
# used-but-not-imported identifier in make check, but only this suite sees
# what a view actually does when it runs.
# yourself before touching popup views; it is the only check that can see
# a used-but-not-imported identifier blow up at runtime.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"

View File

@@ -11,39 +11,6 @@
let nextId = 1;
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
window.addEventListener("message", function onUuid(event) {
if (event.source !== window) return;
@@ -53,7 +20,7 @@
if (!p) return;
delete pending[id];
if (error) {
p.reject(toPageError(error));
p.reject(new Error(error.message || "Request failed"));
} else {
p.resolve(result);
}
@@ -79,7 +46,7 @@
for (const cb of cbs) {
try {
cb(data);
} catch {
} catch (e) {
// ignore listener errors
}
}

View File

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

View File

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

View File

@@ -194,7 +194,7 @@ async function importPrivateKey(ctx) {
let addr;
try {
addr = addressFromPrivateKey(key);
} catch {
} catch (e) {
showFlash("Invalid private key.");
return;
}
@@ -246,7 +246,7 @@ async function importXprvKey(ctx) {
let result;
try {
result = hdWalletFromXprv(xprv);
} catch {
} catch (e) {
showFlash(
"That extended private key is not valid. Please check it and try again.",
);

View File

@@ -12,7 +12,7 @@ const {
goBack,
pushCurrentView,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { state, currentAddress, saveState } = require("../../shared/state");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
const {
fetchRecentTransactions,
@@ -44,6 +44,7 @@ function show() {
state.selectedToken = null;
const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress];
const wi = state.selectedWallet;
const ai = state.selectedAddress;
$("address-title").textContent =
wallet.name + " \u2014 Address " + (ai + 1);
@@ -245,6 +246,7 @@ function renderTransactions(txs) {
row.addEventListener("click", () => {
const idx = parseInt(row.dataset.tx, 10);
const tx = loadedTxs[idx];
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
tx.fromEns = ensNameMap.get(tx.from) || null;
tx.toEns = ensNameMap.get(tx.to) || null;
ctx.showTransactionDetail(tx);

View File

@@ -16,9 +16,13 @@ const {
goBack,
pushCurrentView,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { state, currentAddress, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { formatUsd, getPrice } = require("../../shared/prices");
const {
formatUsd,
getPrice,
getAddressValueUsd,
} = require("../../shared/prices");
const {
fetchRecentTransactions,
filterTransactions,

View File

@@ -9,7 +9,7 @@ const {
attachCopyHandlers,
onViewLeave,
} = require("./helpers");
const { state, saveState } = require("../../shared/state");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { networkByChainId } = require("../../shared/networks");
const {
formatEther,
@@ -537,7 +537,7 @@ function clearSignPassword() {
hideError("approve-sign-error");
}
function init(_ctx) {
function init(ctx) {
onViewLeave("approve-tx", clearTxPassword);
onViewLeave("approve-sign", clearSignPassword);

View File

@@ -2,12 +2,20 @@
// Shows transaction details, warnings, errors. On Sign & Send,
// reads inline password, decrypts secret, signs and broadcasts.
const { parseEther, parseUnits, formatEther, Contract } = require("ethers");
const {
parseEther,
parseUnits,
formatEther,
formatUnits,
Contract,
} = require("ethers");
const {
$,
showError,
hideError,
showView,
showFlash,
flashCopyFeedback,
addressTitle,
escapeHtml,
renderAddressHtml,
@@ -15,7 +23,7 @@ const {
goBack,
onViewLeave,
} = require("./helpers");
const { state } = require("../../shared/state");
const { state, currentNetwork } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices");
@@ -391,7 +399,7 @@ function clearPassword() {
hideError("confirm-tx-password-error");
}
function init(_ctx) {
function init(ctx) {
onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => {
@@ -413,7 +421,7 @@ function init(_ctx) {
wallet.encryptedSecret,
password,
);
} catch {
} catch (e) {
showError(
"confirm-tx-password-error",
"That password is incorrect. Please try again.",

View File

@@ -73,7 +73,7 @@ function init(_ctx) {
// Verify password against the wallet's encrypted data
try {
await decryptWithPassword(wallet.encryptedSecret, pw);
} catch {
} catch (_e) {
$("delete-wallet-flash").textContent =
"That password is incorrect. Please try again.";
$("delete-wallet-flash").style.visibility = "visible";

View File

@@ -1,9 +1,12 @@
// Shared DOM helpers used by all views.
const { isDebug } = require("../../shared/log");
const { formatUsd, getPrice } = require("../../shared/prices");
const {
formatUsd,
getPrice,
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.
@@ -73,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);
}
@@ -112,19 +111,12 @@ function updateDebugBanner(viewName) {
}
}
// Callback that renders a view being navigated BACK onto. Set once by
// index.js via setBackRenderer(), which routes the view through the same
// per-view render and data guards restoreView() uses.
//
// It answers true when it took the navigation — the view is rendered and
// shown, or its backing data was gone and it fell back — and false for a
// view the popup does not render from persisted state. Those can only be
// on the stack from this page load, because the stack is filtered on load,
// so they have already been rendered and only need unhiding.
let _renderBack = null;
// Callback to re-render the main/home view when navigating back to it.
// Set once by index.js via setRenderMain().
let _renderMain = null;
function setBackRenderer(fn) {
_renderBack = fn;
function setRenderMain(fn) {
_renderMain = fn;
}
// Push the current view onto the navigation stack so goBack() can
@@ -144,11 +136,9 @@ function goBack() {
} else {
target = "main";
}
// A popped view is landed on, not navigated to. If the popup has been
// closed and reopened since the view was pushed, nothing has ever
// rendered it in this page load and its template is still blank, so it
// has to be rendered here rather than merely unhidden.
if (_renderBack && _renderBack(target)) return;
if (target === "main" && _renderMain) {
_renderMain();
}
showView(target);
}
@@ -480,7 +470,7 @@ module.exports = {
showView,
onViewLeave,
updateDebugBanner,
setBackRenderer,
setRenderMain,
pushCurrentView,
goBack,
clearViewStack,

View File

@@ -2,6 +2,7 @@ const {
$,
showView,
showFlash,
flashCopyFeedback,
balanceLinesForAddress,
isoDate,
timeAgo,

View File

@@ -57,7 +57,7 @@ function show() {
attachCopyHandlers("view-receive");
}
function init(_ctx) {
function init(ctx) {
$("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address-block").dataset.full;
if (addr) {

View File

@@ -4,6 +4,7 @@ const {
$,
showFlash,
addressTitle,
escapeHtml,
renderAddressHtml,
attachCopyHandlers,
goBack,
@@ -209,7 +210,7 @@ function init(_ctx) {
}
resolvedTo = resolved;
ensName = to;
} catch {
} catch (e) {
showFlash("Failed to resolve ENS name.");
return;
}

View File

@@ -14,6 +14,7 @@ const {
parseDustThresholdGwei,
} = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
const { onChainSwitch } = require("../../shared/chainSwitch");
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
const deleteWallet = require("./deleteWallet");

View File

@@ -23,6 +23,8 @@ const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval");
let ctx;
/**
* Determine a human-readable transaction type string from tx fields.
*/
@@ -164,7 +166,7 @@ function render() {
if (el) el.classList.add("hidden");
}
loadFullTxDetails(tx.hash, tx.to);
loadFullTxDetails(tx.hash, tx.to, tx.isContractCall);
const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML =
@@ -272,7 +274,7 @@ function populateOnChainDetails(txData) {
}
}
async function loadFullTxDetails(txHash, toAddress) {
async function loadFullTxDetails(txHash, toAddress, isContractCall) {
const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
@@ -347,9 +349,8 @@ async function loadFullTxDetails(txHash, toAddress) {
}
}
// The ctx this view is initialized with is unused: this module is the leaf of
// the navigation, and the other views reach it through their own ctx.
function init(_ctx) {
ctx = _ctx;
$("btn-tx-back").addEventListener("click", () => {
goBack();
});

View File

@@ -12,7 +12,7 @@ const {
clearViewStack,
} = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state, currentNetwork } = require("../../shared/state");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log");
@@ -229,6 +229,10 @@ function tokenLabel(address) {
return t ? t.symbol : null;
}
function etherscanTokenLink(address) {
return `${currentNetwork().explorerUrl}/token/${address}`;
}
function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`;

View File

@@ -21,7 +21,7 @@ async function refreshPrices() {
const fetched = await getTopTokenPrices(25);
Object.assign(prices, fetched);
lastFetchedAt = now;
} catch {
} catch (e) {
// prices stay stale on error
}
}

View File

@@ -8,19 +8,12 @@
// either verdict alone, because the balance list is where the user forms
// their belief about what they own (issue #235).
//
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
// that may bear it, or to null. Null means the symbol belongs to the native
// asset, 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
// KNOWN_SYMBOLS maps a symbol to the lowercased contract address that may
// bear it, or to null. Null means the symbol belongs to the native asset,
// 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 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,
@@ -100,7 +93,7 @@ function isSpoofedSymbol(symbol, contractAddress) {
if (!KNOWN_SYMBOLS.has(sym)) return false;
const legit = KNOWN_SYMBOLS.get(sym);
if (legit === null) return true;
return !legit.has(contract);
return contract !== normalizeAddress(legit);
}
module.exports = {

View File

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

View File

@@ -82,7 +82,7 @@ function toFixedPoint(value) {
if (text === "") return null;
try {
return parseUnits(text, SCALE_DECIMALS);
} catch {
} catch (e) {
return null;
}
}

View File

@@ -102,11 +102,6 @@ function decodeV2SwapExactIn(input) {
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
// address[] path, bool payerIsUser)
//
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
// its command name and no token or amount detail. Kept for the fix, which is
// https://git.eeqj.de/sneak/AutistMask/issues/283.
// eslint-disable-next-line no-unused-vars
function decodeV2SwapExactOut(input) {
try {
const d = coder.decode(

View File

@@ -57,7 +57,7 @@ async function cryptoBackend() {
try {
await WebAssembly.compile(EMPTY_WASM_MODULE);
return "wasm";
} catch {
} catch (_) {
return "asmjs";
}
}

View File

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

View File

@@ -88,7 +88,7 @@ class Driver {
let parsed;
try {
parsed = JSON.parse(text);
} catch {
} catch (_) {
throw new Error(
method + " " + path + ": non-JSON response: " + text,
);
@@ -420,7 +420,7 @@ async function waitForDriverReady(base, timeoutMs) {
const body = await res.json();
if (body && body.value && body.value.ready !== false) return;
}
} catch {
} catch (_) {
// not listening yet
}
if (Date.now() >= deadline) {

View File

@@ -287,7 +287,7 @@ async function pageCompilesWasm(page) {
try {
await WebAssembly.compile(new Uint8Array(bytes));
return true;
} catch {
} catch (_) {
return false;
}
}, EMPTY_WASM_MODULE);

View File

@@ -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,87 +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 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
//
// The confirmation screen carries two different numbers for the same
@@ -184,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
@@ -352,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
@@ -488,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>}>}
*/
@@ -535,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)) {
@@ -635,8 +508,6 @@ async function installNetworkStubs(ctx, opts) {
module.exports = {
installNetworkStubs,
DAPP_ORIGIN,
DAPP_URL,
FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI,
STUB_COUNTERPARTY,

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 { TOKENS, KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { filterTransactions } = require("../src/shared/transactions");
const {
fetchTokenBalances,
@@ -284,137 +284,11 @@ describe("the shared rule: symbols that render as a known symbol", () => {
// 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) {
for (const [symbol, address] 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);
if (address === null) continue;
expect(isSpoofedSymbol(symbol, address)).toBe(false);
}
});
});

View File

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

View File

@@ -1,4 +1,4 @@
const { AbiCoder, Interface, solidityPacked } = require("ethers");
const { AbiCoder, Interface, solidityPacked, getBytes } = require("ethers");
const uniswap = require("../src/shared/uniswap");
const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";

380
yarn.lock
View File

@@ -427,90 +427,6 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17"
integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==
"@eslint-community/eslint-utils@^4.8.0":
version "4.10.1"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6"
integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==
dependencies:
eslint-visitor-keys "^3.4.3"
"@eslint-community/regexpp@^4.12.2":
version "4.12.2"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
"@eslint/config-array@^0.23.5":
version "0.23.5"
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95"
integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==
dependencies:
"@eslint/object-schema" "^3.0.5"
debug "^4.3.1"
minimatch "^10.2.4"
"@eslint/config-helpers@^0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377"
integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==
dependencies:
"@eslint/core" "^1.2.1"
"@eslint/core@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce"
integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==
dependencies:
"@types/json-schema" "^7.0.15"
"@eslint/js@10.0.1":
version "10.0.1"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583"
integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==
"@eslint/object-schema@^3.0.5":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091"
integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==
"@eslint/plugin-kit@^0.7.2":
version "0.7.2"
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz#4b0962f3f2c7ce8bc98b3ecfe34525c09d2cb729"
integrity sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==
dependencies:
"@eslint/core" "^1.2.1"
levn "^0.4.1"
"@humanfs/core@^0.19.2":
version "0.19.2"
resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60"
integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==
dependencies:
"@humanfs/types" "^0.15.0"
"@humanfs/node@^0.16.6":
version "0.16.8"
resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed"
integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==
dependencies:
"@humanfs/core" "^0.19.2"
"@humanfs/types" "^0.15.0"
"@humanwhocodes/retry" "^0.4.0"
"@humanfs/types@^0.15.0":
version "0.15.0"
resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090"
integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==
"@humanwhocodes/module-importer@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2":
version "0.4.3"
resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba"
integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==
"@isaacs/cliui@^8.0.2":
version "8.0.2"
resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"
@@ -1092,16 +1008,6 @@
dependencies:
"@babel/types" "^7.28.2"
"@types/esrecurse@^4.3.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec"
integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==
"@types/estree@^1.0.6", "@types/estree@^1.0.8":
version "1.0.9"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24"
integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6":
version "2.0.6"
resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz"
@@ -1121,11 +1027,6 @@
dependencies:
"@types/istanbul-lib-report" "*"
"@types/json-schema@^7.0.15":
version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
"@types/node@*", "@types/node@22.7.5":
version "22.7.5"
resolved "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz"
@@ -1252,31 +1153,11 @@
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777"
integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==
acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn@^8.16.0:
version "8.18.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940"
integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==
aes-js@4.0.0-beta.5:
version "4.0.0-beta.5"
resolved "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz"
integrity sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==
ajv@^6.14.0:
version "6.15.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492"
integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==
dependencies:
fast-deep-equal "^3.1.1"
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
ansi-escapes@^4.3.2:
version "4.3.2"
resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz"
@@ -1416,13 +1297,6 @@ brace-expansion@^5.0.2:
dependencies:
balanced-match "^4.0.2"
brace-expansion@^5.0.8:
version "5.0.9"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf"
integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==
dependencies:
balanced-match "^4.0.2"
braces@^3.0.3:
version "3.0.3"
resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz"
@@ -1555,7 +1429,7 @@ cross-spawn@^7.0.3, cross-spawn@^7.0.6:
shebang-command "^2.0.0"
which "^2.0.1"
debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2:
debug@^4.1.0, debug@^4.1.1, debug@^4.3.1:
version "4.4.3"
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -1572,11 +1446,6 @@ dedent@^1.6.0:
resolved "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz"
integrity sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@^4.3.1:
version "4.3.1"
resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz"
@@ -1679,105 +1548,11 @@ escape-string-regexp@^2.0.0:
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz"
integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==
escape-string-regexp@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
eslint-scope@^9.1.2:
version "9.1.2"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802"
integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==
dependencies:
"@types/esrecurse" "^4.3.1"
"@types/estree" "^1.0.8"
esrecurse "^4.3.0"
estraverse "^5.2.0"
eslint-visitor-keys@^3.4.3:
version "3.4.3"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
eslint-visitor-keys@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
eslint@10.8.1:
version "10.8.1"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.8.1.tgz#fb37d514c19b6dd5b2d6b70169fd26fddfa97967"
integrity sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.2"
"@eslint/config-array" "^0.23.5"
"@eslint/config-helpers" "^0.7.0"
"@eslint/core" "^1.2.1"
"@eslint/plugin-kit" "^0.7.2"
"@humanfs/node" "^0.16.6"
"@humanwhocodes/module-importer" "^1.0.1"
"@humanwhocodes/retry" "^0.4.2"
"@types/estree" "^1.0.6"
ajv "^6.14.0"
cross-spawn "^7.0.6"
debug "^4.3.2"
escape-string-regexp "^4.0.0"
eslint-scope "^9.1.2"
eslint-visitor-keys "^5.0.1"
espree "^11.2.0"
esquery "^1.7.0"
esutils "^2.0.2"
fast-deep-equal "^3.1.3"
file-entry-cache "^8.0.0"
find-up "^5.0.0"
glob-parent "^6.0.2"
ignore "^5.2.0"
imurmurhash "^0.1.4"
is-glob "^4.0.0"
json-stable-stringify-without-jsonify "^1.0.1"
minimatch "^10.2.5"
natural-compare "^1.4.0"
optionator "^0.9.3"
espree@^11.2.0:
version "11.2.0"
resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5"
integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==
dependencies:
acorn "^8.16.0"
acorn-jsx "^5.3.2"
eslint-visitor-keys "^5.0.1"
esprima@^4.0.0:
version "4.0.1"
resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz"
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
esquery@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d"
integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==
dependencies:
estraverse "^5.1.0"
esrecurse@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
dependencies:
estraverse "^5.2.0"
estraverse@^5.1.0, estraverse@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
esutils@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
ethereum-blockies-base64@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/ethereum-blockies-base64/-/ethereum-blockies-base64-1.0.2.tgz"
@@ -1830,21 +1605,11 @@ expect@30.2.0:
jest-mock "30.2.0"
jest-util "30.2.0"
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0:
fast-json-stable-stringify@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
fast-levenshtein@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
fb-watchman@^2.0.2:
version "2.0.2"
resolved "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz"
@@ -1852,13 +1617,6 @@ fb-watchman@^2.0.2:
dependencies:
bser "2.1.1"
file-entry-cache@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f"
integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==
dependencies:
flat-cache "^4.0.0"
fill-range@^7.1.1:
version "7.1.1"
resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz"
@@ -1874,27 +1632,6 @@ find-up@^4.0.0, find-up@^4.1.0:
locate-path "^5.0.0"
path-exists "^4.0.0"
find-up@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
dependencies:
locate-path "^6.0.0"
path-exists "^4.0.0"
flat-cache@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c"
integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==
dependencies:
flatted "^3.2.9"
keyv "^4.5.4"
flatted@^3.2.9:
version "3.4.4"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6"
integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==
foreground-child@^3.1.0:
version "3.3.1"
resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz"
@@ -1933,13 +1670,6 @@ get-stream@^6.0.0:
resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz"
integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
glob-parent@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
dependencies:
is-glob "^4.0.3"
glob@^10.3.10:
version "10.5.0"
resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz"
@@ -1964,11 +1694,6 @@ glob@^7.1.4:
once "^1.3.0"
path-is-absolute "^1.0.0"
globals@17.11.0:
version "17.11.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
graceful-fs@^4.2.11, graceful-fs@^4.2.4:
version "4.2.11"
resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz"
@@ -1989,11 +1714,6 @@ human-signals@^2.1.0:
resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz"
integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
ignore@^5.2.0:
version "5.3.2"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
import-local@^3.2.0:
version "3.2.0"
resolved "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz"
@@ -2040,7 +1760,7 @@ is-generator-fn@^2.1.0:
resolved "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz"
integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==
is-glob@^4.0.0, is-glob@^4.0.3:
is-glob@^4.0.3:
version "4.0.3"
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
@@ -2492,51 +2212,21 @@ jsesc@^3.0.2:
resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz"
integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==
json-buffer@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
json-parse-even-better-errors@^2.3.0:
version "2.3.1"
resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz"
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
json-schema-traverse@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
json-stable-stringify-without-jsonify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
json5@^2.2.3:
version "2.2.3"
resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
keyv@^4.5.4:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==
dependencies:
json-buffer "3.0.1"
leven@^3.1.0:
version "3.1.0"
resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz"
integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==
levn@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
dependencies:
prelude-ls "^1.2.1"
type-check "~0.4.0"
libsodium-sumo@^0.8.0:
version "0.8.2"
resolved "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz"
@@ -2635,13 +2325,6 @@ locate-path@^5.0.0:
dependencies:
p-locate "^4.1.0"
locate-path@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
dependencies:
p-locate "^5.0.0"
lru-cache@^10.2.0:
version "10.4.3"
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
@@ -2693,13 +2376,6 @@ mimic-fn@^2.1.0:
resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
minimatch@^10.2.4, minimatch@^10.2.5:
version "10.2.6"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef"
integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==
dependencies:
brace-expansion "^5.0.8"
minimatch@^3.0.4, minimatch@^3.1.1:
version "3.1.3"
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz"
@@ -2780,18 +2456,6 @@ onetime@^5.1.2:
dependencies:
mimic-fn "^2.1.0"
optionator@^0.9.3:
version "0.9.4"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==
dependencies:
deep-is "^0.1.3"
fast-levenshtein "^2.0.6"
levn "^0.4.1"
prelude-ls "^1.2.1"
type-check "^0.4.0"
word-wrap "^1.2.5"
p-limit@^2.2.0:
version "2.3.0"
resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"
@@ -2799,7 +2463,7 @@ p-limit@^2.2.0:
dependencies:
p-try "^2.0.0"
p-limit@^3.0.2, p-limit@^3.1.0:
p-limit@^3.1.0:
version "3.1.0"
resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz"
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
@@ -2813,13 +2477,6 @@ p-locate@^4.1.0:
dependencies:
p-limit "^2.2.0"
p-locate@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
dependencies:
p-limit "^3.0.2"
p-try@^2.0.0:
version "2.2.0"
resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz"
@@ -2905,11 +2562,6 @@ pnglib@0.0.1:
resolved "https://registry.npmjs.org/pnglib/-/pnglib-0.0.1.tgz"
integrity sha512-95ChzOoYLOPIyVmL+Y6X+abKGXUJlvOVLkB1QQkyXl7Uczc6FElUy/x01NS7r2GX6GRezloO/ecCX9h4U9KadA==
prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@^3.8.1:
version "3.8.1"
resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz"
@@ -2924,11 +2576,6 @@ pretty-format@30.2.0:
ansi-styles "^5.2.0"
react-is "^18.3.1"
punycode@^2.1.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
pure-rand@^7.0.0:
version "7.0.1"
resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz"
@@ -3175,13 +2822,6 @@ tslib@^2.8.1:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
dependencies:
prelude-ls "^1.2.1"
type-detect@4.0.8:
version "4.0.8"
resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz"
@@ -3232,13 +2872,6 @@ update-browserslist-db@^1.2.0:
escalade "^3.2.0"
picocolors "^1.1.1"
uri-js@^4.2.2:
version "4.4.1"
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
dependencies:
punycode "^2.1.0"
v8-to-istanbul@^9.0.1:
version "9.3.0"
resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz"
@@ -3267,11 +2900,6 @@ which@^2.0.1:
dependencies:
isexe "^2.0.0"
word-wrap@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"