Author SHA1 Message Date
clawbot 894d972caa fix: the swap decoder shows the ETH of a WRAP_ETH that does not set the input side (closes #721)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A later WRAP_ETH of a stated amount now counts as a take from the sender.
When it pays anyone but the router, it gets its own Moved from the sender
lines, with the recipient on Moved to. When it pays the router, it joins
the Amount line if Token In is ETH, and otherwise gets those lines too.
When the router may already hold ETH, the amount reads Up to.

A WRAP_ETH whose input this decoder cannot read gets a line saying the
amount could not be decoded.

A WRAP_ETH of CONTRACT_BALANCE states no amount and reads as before, so
Uniswap's own routes do not change.

Model: opus-5-5
2026-11-03 04:34:24 +00:00
clawbot 076726b13d docs: rewrite README.md sentence by sentence against the prose rules (closes #672)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Rewrites README.md from scratch for
#672: what the program is, how
to use it, every setting, the design, then development and tests. Every
statement was checked against the code on next. Where the old text and
the code disagreed, the code decides; the PR body lists each
disagreement and judgement call.

Model: fable-5-1
Co-authored-by: clawbot <clawbot@noreply.example.org>
2026-11-03 04:49:03 +01:00
clawbot c90efa988b docs: rewrite the user guide sentence by sentence against the prose rules
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Rewrites docs/README.md from scratch for
#672. Every statement was
checked against the code on next. The two false statements of the old
guide are gone: history rows show the other party's address in full
(the only shortened address is the label of a token with no known
symbol), and the balance list hides a token that claims ETH from the
wrong contract. Each technical word is defined in a short clause where
it first appears: block, receipt, network fee, gas, swap, token
approval, Permit, Permit2, Uniswap, contract, origin, host name, port,
nonce, hash, node.

Model: fable-5-1
2026-11-03 04:08:10 +01:00
clawbot 754cf9686f fix: the bundled token list names tokens on Ethereum Mainnet only (closes #632)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
bundledToken(address, chainId) in tokenList.js answers only for Ethereum
Mainnet's chain id. Every reader of TOKEN_BY_ADDRESS now goes through it,
with the chain id of the transaction, balance or history entry it draws.
The token sources the screens build carry that chain id. The Uniswap
decoder sets the transaction's chain id on them. fetchTokenBalances()
takes the chain id of the network it asks about.

isSpoofedSymbol() takes the chain id of the token's network. It checks the
bundled list's symbols only on Ethereum Mainnet. The native asset's labels
stay reserved on every network.

Twenty existing tests and four shared test helpers read the bundled
list without naming a network. Each now names Ethereum Mainnet.

Model: opus-5-5
2026-11-03 03:41:15 +01:00
clawbot 430ea0166e docs: rewrite docs/RELEASE.md sentence by sentence against the prose rules
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Rewrite docs/RELEASE.md from scratch for
#672: a terms list, what is
needed, and six numbered steps, each with its check. Every statement is
checked against the Makefile, script/, build.js, the two workflows and the
tests. The step that described distribution routes not built is cut. The
reproducibility claim names the build date that build.js compiles in, so it
holds only within one UTC day.

The commit carries the findings of the three reviews, the last two in the
third review's wording: the self-contained check is described with the
browser's directory as its subject, and Node and yarn must be on the PATH,
because script/bootstrap leaves an nvm install off it.

Model: fable-5-1
2026-11-03 03:40:28 +01:00
clawbot ae0b86a8ac fix: the swap decoder names where a WRAP_ETH pays and shows a V4 step's ETH as a maximum when the router may hold ETH (closes #665)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A `WRAP_ETH` that sets the input side and pays its WETH to anyone but
the router names that recipient on its `Moved to` line.

V4 pays a `SETTLE` of ETH from the router's own ETH, whichever payer it
names. When an earlier step may leave ETH in the router, the ETH that
the user pays a later V4 step gets lines of its own. Their
`Moved from the sender` line reads `Up to <amount>`, or
`Unknown (could not be decoded)` when the amount is not known. The
decoder never subtracts the router's ETH from that amount. Without such
an earlier step, the lines read as before.

Model: opus-5-5
2026-11-03 03:02:10 +01:00
clawbot d89099d90a fix: the address screen shows a token just added at its fetched balance, not 0.0000 (closes #516)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A balance refresh that returns now draws the balances on AddressDetail or
AddressToken again when that screen is up, not only the wallet list. Their
transaction lists are left alone.

Every add path on AddToken and SettingsAddToken tracks a token through
trackToken(), which gives the tracked record `balanceNotFetched: true`. While
the field is set, the token's line reads `quantity unknown` instead of 0.0000,
and Send reads its balance as unknown. refreshBalances() now resolves with the
tracked tokens whose balance it read at every address. The popup's refresh
deletes the field from those tokens in the stored record and leaves the rest of
the stored list as it is. Removing the token removes the field, and each network
keeps its own records.

Model: opus-5-5
2026-11-03 02:25:25 +01:00
clawbot 664629a1b6 fix: a split swap's Min. received is the sum of its steps' minimums (closes #647)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The step that sets the output side gives the minimum. When that step
and earlier swap steps each pay the output token to the sender, the
minimum is now the sum of their minimums. There is no figure when the
decoder reads none from one of them, or when one of them pays part of
that token elsewhere.

These steps drop earlier steps from the sum:

- a V2 or V3 swap step that spends the output token;
- a V4 step with a swap action that spends that token, or with a SETTLE
  or SETTLE_ALL of that token;
- a PERMIT2_TRANSFER_FROM of that token.

A step after the step that sets the output side drops nothing from the
sum.

Model: opus-5-5
2026-11-03 00:21:40 +01:00
clawbot 26a2a51387 fix: two pages switching networks no longer lose tracked tokens (closes #701)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A page that had not yet taken another page's network switch could lose
tracked tokens.

- Its save wrote its network's list as the new network's list. saveState()
  now keeps that list and merges the change into its own network's list in
  networkTrackedTokens.
- Its own switch wrote its old per-network lists over the stored ones.
  onChainSwitch() now first takes the stored switch with takeStoredNetwork().
- takeStoredNetwork() dropped the page's unsaved change. It now keeps it in
  the list of the page's network. This also fixes
  #706.
- A page already on the stored network now takes the stored
  networkTrackedTokens before it switches, because another page's save may
  have changed a list this page holds.

Model: opus-5-5
2026-11-03 00:04:20 +01:00
clawbot b5737f915c fix: a transaction history that lands after a network switch is dropped (closes #657)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Home, AddressDetail and AddressToken remember which network is active
before they ask Blockscout for the transaction history. Each screen drops
the answer if another network is active on the page or in storage when the
answer lands. AddressDetail and AddressToken also drop an answer that lands
once the screen shows another address. AddressToken also drops an answer
that lands once the screen shows another token.

A dropped answer draws no rows and saves no fraud contracts. A failed
request goes through the same checks. A dropped failure shows no message.
Holding the lock, the save adds new fraud contracts only if the page and
the stored record still name the request's network.

storedNetworkIs() reads the stored network, in src/shared/storedSettings.js.

Model: opus-5-5
2026-11-02 23:41:24 +01:00
clawbot 016acbcda5 fix: Settings ignores an endpoint check once the same "Save" is tapped again (closes #698)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Each RPC and Blockscout "Save" in Settings now counts its taps. A check
that answers after a newer tap of the same "Save" stores nothing and
leaves the flash message alone. Before this change, the older check's
error or refusal replaced the newer tap's "Saved.".

The save also checks for a newer tap at two points. A tap made while the
save waits for the lock means nothing is stored. A tap made during the
write means no flash message is shown.

Model: opus-5-5
2026-11-02 22:59:23 +01:00
clawbot 7ababcf69e fix: the balance list and the add-token screens apply the spoof rule to tracked tokens (closes #705)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A tracked token that the spoof rule refuses still got a zero row in the balance list while "Show tracked tokens with zero balance" was on. balanceLinesForAddress() now asks isSpoofedSymbol() before it adds that row. AddToken and SettingsAddToken refuse such a token after the contract lookup, with the flash "Not added: this token fakes a known symbol." The bundled-list buttons on SettingsAddToken need no check: the rule never refuses a bundled token. README.md and docs/README.md now say the balance list drops such a token whatever the settings.

Model: opus-5-5
2026-11-02 06:04:07 +01:00
45 changed files with 6511 additions and 4189 deletions
+1723 -3495
View File
File diff suppressed because it is too large Load Diff
+644 -385
View File
File diff suppressed because it is too large Load Diff
+213 -75
View File
@@ -1,87 +1,225 @@
# Releasing AutistMask
This is the procedure that turns a green `main` into a tagged, packaged release.
It gathers into one place what is otherwise spread across the `Makefile` and
three `README.md` sections, so the person cutting a release does not have to
reconstruct the order from them.
A release is a tagged commit on the `main` branch and the two archives that
`make package` writes from that commit. This file is the procedure, in order.
There is one version, declared in three files (`package.json`,
`manifest/chrome.json`, `manifest/firefox.json`), and `make package` builds and
packages but publishes nothing. `make build` and `make package` can be run by
anyone; tagging, signing, packing a CRX and any upload need credentials only the
owner ([@sneak](https://sneak.berlin)) holds and are marked **owner-only**
below. Releases are tagged from `main` (see the Workflow section of `TODO.md`),
so the "release commit" throughout is the `main` commit the milestone PR merged.
Only the owner ([@sneak](https://sneak.berlin)):
- decides whether and when a release is tagged;
- creates, moves or deletes a tag;
- decides where the archives are published.
Anyone can run steps 1 to 4. No script in this repository uploads or publishes
the archives. None signs them, and none packs a CRX (Chrome's packaged extension
file).
## Terms
- The tracker is the repository's Gitea site (Gitea is the software that hosts
the repository): https://git.eeqj.de/sneak/AutistMask. The tracker's Actions
page, https://git.eeqj.de/sneak/AutistMask/actions, lists the workflow runs.
- A workflow is a file under `.gitea/workflows/` that lists jobs. The tracker
runs every workflow on each push. Each job runs one script on the tracker's
runner (the machine that runs jobs).
- The release commit is a commit on `main`. Releases are tagged from `main` only
(`TODO.md`, Workflow).
- A clean checkout is a working tree (the checked-out files) at the release
commit where `git status --porcelain` prints nothing. The build stamps the
commit hash (the commit's id) into the extension and appends `-dirty` when the
tree has local changes.
- A tag is a name that git attaches to one commit. Release tags read `vX.Y.Z`,
such as `v0.1.0`.
- A digest is the sha256 hash of a file (a fixed-length fingerprint of its
bytes), written as 64 hexadecimal characters.
## What you need
- A clean checkout of the release commit.
- `make`.
- Docker (a tool that runs programs in containers), for `script/cibuild` in
step 1. `make package` needs no Docker.
- Node and yarn, on the `PATH`. `make package` runs both directly.
`script/bootstrap` can install both through nvm (a Node version manager), but
it leaves them off the `PATH`.
## Procedure
1. **Confirm `main` is green in CI.** The `check` workflow
(`.gitea/workflows/check.yml`) runs `script/cibuild`, which runs
`script/check` and then builds the image uncached, so a green `check` run is
a green `make check`. Find the run for the exact release commit on the
tracker's Actions view. _Check:_ that commit's `check` run succeeded; running
`make check` on a clean checkout of the commit reproduces it and exits 0.
### 1. Confirm the release commit is green
2. **Confirm the version matches the intended tag.** `package.json`,
`manifest/chrome.json` and `manifest/firefox.json` must all declare the same
`X.Y.Z`. `make build` fails when they disagree, but nothing checks that they
equal the tag you mean to create — that is this manual step. _Check:_ all
three files read the same `X.Y.Z`, and it is the version you intend to tag
`vX.Y.Z`.
The `check` workflow (`.gitea/workflows/check.yml`) runs `script/cibuild` on
every push. `script/cibuild` runs:
3. **Build and package from a clean checkout of that commit.** From a fresh
clone, or a working tree with no local modifications (`git status` clean),
checked out at the release commit: run `make setup`, then `make package`.
`make package` runs `make build` first, so the archives can only be made from
a `dist/` verified against that build's own receipt as a release (not debug)
build. It writes three files into `release/`:
`autistmask-chrome-<version>.zip`, `autistmask-firefox-<version>.xpi`, and
`SHA256SUMS`. _Check:_ those three files exist and `<version>` in the archive
names is the version confirmed in step 2. The Firefox `.xpi` is **unsigned**
(see step 6 and "Installing on Firefox" in `README.md`).
- `script/bootstrap`, which installs the dependencies;
- `script/check`, which is what `make check` runs;
- the Docker image build (an image is Docker's packaged form of a program).
4. **Verify `SHA256SUMS`.** The archives are deterministic — sorted entries,
fixed timestamps, fixed compression — so a second `make package` from another
clean checkout of the same commit produces byte-identical files. Verify the
recorded digests against the files with `sha256sum -c SHA256SUMS`, run from
`release/`. To confirm reproducibility, run `make package` again on a
separate clean checkout and compare the digests. _Check:_ `sha256sum -c`
reports `OK` for every file, and an independent build's digests match.
`script/check` runs:
5. **Tag the release commit.** _(owner-only)_ Create an annotated tag `vX.Y.Z`
on the release commit and push it: `git tag -a vX.Y.Z` (with a message), then
`git push origin vX.Y.Z`. _Check:_ `git tag` lists `vX.Y.Z`, and
`git rev-parse vX.Y.Z^{commit}` resolves to the release commit.
- `script/test`: the tests, in Docker;
- `script/lint`, which runs three checks in Docker:
- ESLint, a code checker;
- `prettier --check .`, the formatting check;
- `script/check-censored`, which fails when the name that `RULES.md` bars
appears outside its allowed places;
- `script/fmt-check`: `prettier --check .` again, on the host.
6. **Distribute per browser.** _(owner-only; pending the owner's choice on
https://git.eeqj.de/sneak/AutistMask/issues/386)_ How 1.0.0 is distributed on
each browser is not yet decided; it is the open question on that issue, and
the concrete steps cannot be written until the owner records a choice there.
These steps need credentials only the owner holds. The options under
consideration are:
- **Firefox** — the packaged `.xpi` is unsigned, and release Firefox and ESR
refuse an unsigned add-on:
- (a) AMO self-distribution signing (unlisted): submit the `.xpi` to AMO
with the owner's credentials; AMO returns a signed `.xpi` installable
on every Firefox, with nothing listed publicly.
- (b) AMO listed: as (a), plus a public AMO listing and review.
- (c) Ship the unsigned `.xpi` and state that Firefox support means
Developer Edition, Nightly, or an Unbranded build with
`xpinstall.signatures.required` set to `false`.
- **Chrome** — the repo packs no CRX and publishes nothing; the extension id
is fixed by the `key` in `manifest/chrome.json`:
- (a) Chrome Web Store (unlisted): upload the `.zip` with the owner's
developer account; the store delivers installs and updates.
- (b) Self-hosted CRX signed with the private key the owner holds
(`chrome --pack-extension=dist/chrome --pack-extension-key=<path to the .pem>`),
installable only via enterprise policy on Windows and macOS, so
realistically Linux-only.
- (c) "Load unpacked" from `dist/chrome/` only, as today.
The `e2e` workflow (`.gitea/workflows/e2e.yml`) has two jobs. One runs
`script/test-e2e`, the browser tests in Chrome. The other runs
`script/test-e2e-firefox`, the browser tests in Firefox. A red `e2e` job does
not fail the `check` run.
Once the owner decides, the chosen steps — including which credentials they
need and who holds them — are written into this section and `README.md`'s
installation sections are updated to match, which is part of the definition
of done of https://git.eeqj.de/sneak/AutistMask/issues/386. _Check:_ for a
store or AMO route, the artifact installs from the store or AMO on a clean
browser profile; for the CRX or unpacked route, the documented load succeeds
and Chrome reports the extension id `gipbhkogfopeahplcjhipkgpcimdpkip`.
Run `script/cibuild` on the clean checkout. `script/cibuild` runs the same steps
as the `check` run, and must exit 0.
Check: on the Actions page, the `check` run for the release commit succeeded,
and both `e2e` jobs for that commit succeeded. `script/cibuild` exited 0 on the
clean checkout.
### 2. Confirm the version
Three files declare the version:
- `package.json`;
- `manifest/chrome.json`;
- `manifest/firefox.json`.
`script/lib/version.js` fails the build when the three disagree.
`tests/version.test.js` fails when the version is not three numbers separated by
dots. Nothing compares the version with a tag. That comparison is this step.
Check: all three files read the same `X.Y.Z`, and `vX.Y.Z` is the tag that the
owner creates in step 5.
### 3. Build and package
On the clean checkout, run:
```bash
make setup
make package
```
`make setup` installs two things:
- the dependencies, from the lockfile `yarn.lock`, which pins each one's
version;
- the git pre-commit hook, a script that git runs before each commit. Here the
hook runs `script/check`.
`make package` runs `make build`, then `script/lib/package.js`.
`make build`:
- runs `build.js`. `build.js` writes `dist/chrome/`, `dist/firefox/` and a build
receipt. The receipt lists every file that `build.js` wrote, with each file's
digest. The receipt lives outside the repository and is deleted when the build
ends;
- runs `script/verify-build --expect release` against the receipt. The check
fails when:
- `dist/` differs from the receipt;
- a bundle (one JavaScript file built from many source files) was compiled
with the build-time constant `DEBUG` on;
- any file carries the test recovery phrase, which a debug build gives every
new wallet;
- runs `script/check-censored --require-dist` against the built files;
- removes `dist/` when any of those steps fails.
`script/lib/package.js`:
- empties `release/`;
- refuses a symbolic link (a file that points at another file) under `dist/`;
- checks that the browser's directory (`dist/chrome/` or `dist/firefox/`) holds
every file named in `manifest.json` or in the popup's HTML page.
`manifest.json` is the file that describes the extension to the browser. The
popup is the window that the toolbar button opens;
- checks that the `version` in each `manifest.json` is the version from step 2;
- writes one archive per browser;
- reads each archive back;
- compares every member (a file inside the archive) with `dist/` byte for byte;
- writes `SHA256SUMS`, with one line per archive:
- the digest;
- two spaces;
- the file name.
`make package` writes three files:
```
release/autistmask-chrome-<version>.zip
release/autistmask-firefox-<version>.xpi
release/SHA256SUMS
```
The `.xpi` is Firefox's add-on package file (an add-on is Firefox's word for an
extension). The `.xpi` is not signed.
`make package` prints a line that starts `Not shipped` and names `styles.css`.
This is expected: each archive holds its own copy of the stylesheet.
Check: `release/` holds those three files, and `<version>` in their names is the
version from step 2.
### 4. Verify the digests
From `release/`, run `sha256sum -c SHA256SUMS`.
The archives are deterministic for a given `dist/`: the same `dist/` always
gives the same bytes. `script/lib/zip.js`:
- sorts the members;
- sets every member's timestamp (its recorded date) to 1980-01-01;
- compresses each member the same way every time.
`dist/` itself carries the build date. `build.js` compiles the build date, a UTC
(universal time) day, into the extension. So two builds of the same commit on
the same UTC day give byte-identical archives. Builds on different days differ.
Check: `sha256sum -c` prints `OK` for both archives. On the same UTC day, a
second `make package` from another clean checkout of the same commit gives the
same `SHA256SUMS`.
### 5. Tag the release commit
Owner only. Only the owner decides whether and when a release is tagged. Only
the owner creates, moves or deletes a tag. On the release commit, the owner
runs:
```bash
git tag -a vX.Y.Z -m "AutistMask X.Y.Z"
git push origin vX.Y.Z
```
`-a` makes an annotated tag: a tag that carries a message and records who made
the tag and when. `v0.1.0` is one.
Once the tag exists, `git describe --tags` on the release commit prints
`vX.Y.Z`. `script/cibuild` and `script/docker` pass that value to the Docker
image build as `VERSION`.
Check: `git rev-parse vX.Y.Z^{commit}` prints the release commit's hash, and the
tag is listed at https://git.eeqj.de/sneak/AutistMask/tags.
### 6. Publish the archives
Owner only. The owner decides where the archives are published. No script in
this repository uploads or publishes them.
Whoever receives an archive installs it as follows.
Chrome loads the extension from a directory:
- unzip the archive into a new, empty directory, for example
`unzip autistmask-chrome-0.1.0.zip -d autistmask-chrome`;
- open `chrome://extensions/` and turn on "Developer mode";
- click "Load unpacked" and pick that directory.
Chrome then reports the extension id `gipbhkogfopeahplcjhipkgpcimdpkip`. The id
is the fixed string that identifies the extension to Chrome; the `key` in
`manifest/chrome.json` sets the id.
Release builds of Firefox refuse to install an unsigned add-on. Desktop Firefox
115 or newer, in any edition, loads the `.xpi` as a temporary add-on from the
`about:debugging` page. Firefox unloads a temporary add-on when Firefox exits.
The section "Installing on Firefox" in `README.md` has the details.
Check: each archive installs on a fresh browser profile (a new, empty set of
browser data) by the route above.
+16 -1
View File
@@ -6,6 +6,7 @@ const {
saveState,
onSaveFailure,
takeStoredNetwork,
clearBalanceNotFetched,
loadState,
} = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema");
@@ -95,6 +96,17 @@ let refreshTimer = null;
// removed meanwhile is left as it is. Blockscout may have answered before the
// user turned "Use Blockscout" off. So the save drops every token the user has
// not tracked if the stored setting is off by then.
//
// The save also deletes `balanceNotFetched` from each tracked token whose
// balance this refresh read at every address. trackToken() in
// src/popup/views/helpers.js sets that field. The save takes the field off the
// matching tokens of the stored record (clearBalanceNotFetched() in
// src/shared/state.js). The rest of the stored tracked token list stays as it
// is.
//
// Once the save is done, the wallet list is drawn again. So are the balances
// on AddressDetail or AddressToken when that screen is up. No other screen is
// drawn again, DeleteAddress and its balance lines included.
async function doRefreshAndRender() {
if (refreshInFlight) {
refreshAgain = true;
@@ -105,7 +117,7 @@ async function doRefreshAndRender() {
await followStoredSettings();
const networkId = state.networkId;
const wallets = structuredClone(state.wallets);
await Promise.all([
const [, readTokens] = await Promise.all([
refreshPrices(pageClosed.signal),
refreshBalances(
wallets,
@@ -137,10 +149,13 @@ async function doRefreshAndRender() {
if (!stored.useBlockscout) {
dropUntrackedTokens(state.wallets, stored.trackedTokens);
}
clearBalanceNotFetched(stored, readTokens);
state.lastBalanceRefresh = Date.now();
return true;
});
renderWalletList();
if (state.currentView === "address") addressDetail.renderBalances();
if (state.currentView === "address-token") addressToken.renderBalance();
} catch (e) {
// No caller waits for this function, so it must never reject. An
// unhandled rejection reports nothing. The save inside it reports its
+19 -2
View File
@@ -1,8 +1,16 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const {
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { log } = require("../../shared/log");
// Counts the times AddToken has opened. A contract lookup started before
@@ -77,8 +85,17 @@ function init(ctx) {
);
return;
}
// The balance list never shows a token that the spoof rule
// refuses, so such a token is not tracked.
if (isSpoofedSymbol(info.symbol, contractAddr, chainId)) {
showFlash("Not added: this token fakes a known symbol.");
if (addTokenScreenOpenings !== opening) return;
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
return;
}
log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({
trackToken({
address: contractAddr,
symbol: info.symbol,
decimals: info.decimals,
+66 -24
View File
@@ -22,6 +22,7 @@ const {
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const {
updateSendBalance,
renderSendTokenSelect,
@@ -66,11 +67,27 @@ function show() {
});
$("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden");
renderBalances();
// Only Blockscout reports the tokens the user has not tracked.
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
}
// The USD total and the balance list. Drawn again by each balance refresh
// that returns while this screen is up (doRefreshAndRender() in
// src/popup/index.js), which leaves the rest of the screen as it is.
function renderBalances() {
const addr =
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
$("address-balances").innerHTML = balanceLinesForAddress(
addr,
state.trackedTokens,
@@ -84,13 +101,6 @@ function show() {
ctx.showAddressToken();
});
});
// Only Blockscout reports the tokens the user has not tracked.
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
}
let loadedTxs = [];
@@ -98,6 +108,7 @@ let loadedTxs = [];
let ensNameMap = new Map();
async function loadTransactions(address) {
const networkId = state.networkId;
try {
const rawTxs = await fetchRecentTransactions(
address,
@@ -114,43 +125,74 @@ async function loadTransactions(address) {
});
const txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
loadedTxs = txs;
// Collect ALL unique addresses (from + to) for ENS resolution so
// that reverse lookups work for every displayed address, not just
// the ones that were originally entered as ENS names.
const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
];
let names = new Map();
if (counterparties.length > 0) {
try {
ensNameMap = await resolveEnsNames(
names = await resolveEnsNames(
counterparties,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
} catch {
ensNameMap = new Map();
// The rows then show no ENS names.
}
}
// If the network changed during the lookups above, or the screen now
// shows another address, nothing is saved or drawn. See
// src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-line").dataset.full !== address
) {
return;
}
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (result.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
loadedTxs = txs;
ensNameMap = names;
renderTransactions(txs);
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message);
// If the network changed, or the screen now shows another address,
// the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-line").dataset.full !== address
) {
return;
}
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
}
@@ -287,4 +329,4 @@ function init(_ctx) {
exportPrivkey.init();
}
module.exports = { init, show };
module.exports = { init, show, renderBalances };
+105 -48
View File
@@ -22,13 +22,14 @@ const {
timeAgo,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { bundledToken, resolveSymbol } = require("../../shared/tokenList");
const { formatUsd, getPrice } = require("../../shared/prices");
const {
fetchRecentTransactions,
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const {
updateSendBalance,
renderSendTokenSelect,
@@ -49,37 +50,24 @@ function show() {
const addr = wallet.addresses[state.selectedAddress];
const ai = state.selectedAddress;
const tokenId = state.selectedToken;
// Determine token symbol and balance
let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") {
symbol = nativeCurrency();
amount = parseFloat(addr.balance || "0");
price = getPrice("ETH");
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
symbol = resolveSymbol(
tokenId,
addr.tokenBalances,
state.trackedTokens,
);
// null when the scale is unknown: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000.
amount = tb ? unknownableAmount(tb.balance) : 0;
price = getPrice(tokenId);
}
currentSymbol = symbol;
const chainId = currentNetwork().chainId;
const knownToken = bundledToken(tokenId, chainId);
currentSymbol =
tokenId === "ETH"
? nativeCurrency()
: resolveSymbol(
tokenId,
addr.tokenBalances,
state.trackedTokens,
chainId,
);
$("address-token-title").textContent =
wallet.name +
" \u2014 Address " +
(ai + 1) +
" \u2014 " +
displaySymbol(symbol);
displaySymbol(currentSymbol);
// Blockie
const blockieEl = $("address-token-jazzicon");
@@ -101,13 +89,7 @@ function show() {
$("address-token-line").dataset.full = addr.address;
attachCopyHandlers($("address-token-line"));
// USD total for this token only
const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;";
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
renderBalance();
// Token contract details (ERC-20 only)
const contractInfo = $("address-token-contract-info");
@@ -169,7 +151,49 @@ function show() {
loadTransactions(addr.address, tokenId);
}
// The USD total and the balance line of the selected token. Drawn again by
// each balance refresh that returns while this screen is up
// (doRefreshAndRender() in src/popup/index.js), which leaves the rest of the
// screen as it is.
function renderBalance() {
const addr =
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
const tokenId = state.selectedToken;
let amount, price;
if (tokenId === "ETH") {
amount = parseFloat(addr.balance || "0");
price = getPrice("ETH");
} else {
const id = tokenId.toLowerCase();
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === id,
);
const tracked = state.trackedTokens.find(
(t) => t.address.toLowerCase() === id,
);
// null when the scale is unknown, or when no refresh has read the
// balance of a token just added: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000.
if (tb) amount = unknownableAmount(tb.balance);
else amount = tracked && tracked.balanceNotFetched ? null : 0;
price = getPrice(tokenId);
}
// USD total for this token only
const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;";
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(
currentSymbol,
amount,
price,
);
}
async function loadTransactions(address, tokenId) {
const networkId = state.networkId;
try {
const rawTxs = await fetchRecentTransactions(
address,
@@ -186,16 +210,6 @@ async function loadTransactions(address, tokenId) {
});
let txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
// Filter to this token only
if (tokenId === "ETH") {
txs = txs.filter((tx) => tx.contractAddress === null);
@@ -207,32 +221,75 @@ async function loadTransactions(address, tokenId) {
);
}
loadedTxs = txs;
// Collect ALL unique addresses for ENS resolution so reverse
// lookups work for every displayed address.
const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
];
let names = new Map();
if (counterparties.length > 0) {
try {
ensNameMap = await resolveEnsNames(
names = await resolveEnsNames(
counterparties,
state.rpcUrl,
state.networkId,
ctx.pageClosed,
);
} catch {
ensNameMap = new Map();
// The rows then show no ENS names.
}
}
// If the network changed during the lookups above, or the screen now
// shows another address or token, nothing is saved or drawn. See
// src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
return;
}
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (result.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
loadedTxs = txs;
ensNameMap = names;
renderTransactions(txs);
} catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message);
// If the network changed, or the screen now shows another address or
// token, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
return;
}
$("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
}
@@ -348,4 +405,4 @@ function init(_ctx) {
});
}
module.exports = { init, show };
module.exports = { init, show, renderBalance };
+24 -3
View File
@@ -33,6 +33,7 @@ const { networkByChainId } = require("../../shared/networks");
const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { markViewRendered } = require("../viewRouter");
@@ -313,14 +314,17 @@ function nativeCurrency() {
// The wallets' balances hold what Blockscout reported for each token. So the
// wallets are returned only while "Use Blockscout" is on.
function tokenSourcesByChainId(chainId) {
const net = chainId ? networkByChainId(chainId) : currentNetwork();
const chainIdOrActive = chainId || currentNetwork().chainId;
const net = networkByChainId(chainIdOrActive);
if (net && net.id === state.networkId) {
return {
chainId: chainIdOrActive,
trackedTokens: state.trackedTokens,
wallets: state.useBlockscout ? state.wallets : [],
};
}
return {
chainId: chainIdOrActive,
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
wallets: [],
};
@@ -351,7 +355,7 @@ function blockscoutOffTokenNoteHtml(token, chainId) {
// The symbol shown for a token line, from these sources in order:
//
// - the bundled list
// - the bundled list, for a token on Ethereum Mainnet
// - the tokens the user tracks
// - the explorer's report, while "Use Blockscout" is on
//
@@ -417,6 +421,15 @@ function balanceLine(symbol, amount, price, tokenId) {
);
}
// Tracks `token` on the active network, as every add path on AddToken and
// SettingsAddToken does. The tracked record carries `balanceNotFetched: true`
// until a balance refresh has read the token's balance at every address
// (doRefreshAndRender() in src/popup/index.js). Until then, an address with no
// balance row for the token shows `quantity unknown` for it, not 0.0000.
function trackToken(token) {
state.trackedTokens.push({ ...token, balanceNotFetched: true });
}
function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine(
nativeCurrency(),
@@ -447,9 +460,16 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
if (showZero && trackedTokens) {
for (const t of trackedTokens) {
if (seen.has(t.address.toLowerCase())) continue;
// A token that the spoof rule refuses has no stored balance, and
// gets no zero row either.
if (
isSpoofedSymbol(t.symbol, t.address, currentNetwork().chainId)
) {
continue;
}
html += balanceLine(
t.symbol,
0,
t.balanceNotFetched ? null : 0,
getPrice(t.address),
t.address.toLowerCase(),
);
@@ -829,6 +849,7 @@ module.exports = {
flashCopyFeedback,
balanceLine,
balanceLinesForAddress,
trackToken,
addressHoldsFunds,
keyMaterialName,
unknownableAmount,
+34 -6
View File
@@ -44,6 +44,7 @@ const {
fetchRecentTransactions,
filterTransactions,
} = require("../../shared/transactions");
const { storedNetworkIs } = require("../../shared/storedSettings");
const { log } = require("../../shared/log");
function findActiveAddr() {
@@ -183,6 +184,7 @@ async function loadHomeTxs(ctx) {
}
if (allAddresses.length === 0) return;
const networkId = state.networkId;
const filters = {
hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens,
@@ -202,6 +204,15 @@ async function loadHomeTxs(ctx) {
);
const results = await Promise.all(fetches);
// If the network changed during the lookups, nothing is saved or
// drawn. See src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
) {
return;
}
// Merge, deduplicate by hash, filter, sort, take 25
const seen = new Set();
let merged = [];
@@ -215,14 +226,24 @@ async function loadHomeTxs(ctx) {
const filtered = filterTransactions(merged, filters);
// Persist any newly discovered fraud contracts
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (filtered.newFraudContracts.length > 0) {
for (const addr of filtered.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of filtered.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
}
await saveState();
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
merged = filtered.transactions;
@@ -234,6 +255,13 @@ async function loadHomeTxs(ctx) {
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
log.errorf("loadHomeTxs failed:", e.message);
// If the network changed, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
) {
return;
}
const list = $("home-tx-list");
if (list) {
list.innerHTML =
+16 -4
View File
@@ -12,7 +12,7 @@ const {
goBack,
tokenSourcesByChainId,
} = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
const { state, currentAddress, currentNetwork } = require("../../shared/state");
let ctx;
const { getProvider } = require("../../shared/balances");
const { resolveTokenDecimals } = require("../../shared/approvalAmount");
@@ -154,7 +154,9 @@ function renderSendTokenSelect(addr) {
// out. Its token's own screen can still send it: there
// state.selectedToken picks the token, not this list.
if (isBelowOneMillionth(t.balance)) continue;
if (isSpoofedSymbol(t.symbol, t.address)) continue;
if (isSpoofedSymbol(t.symbol, t.address, currentNetwork().chainId)) {
continue;
}
if (fraudSet.has(t.address.toLowerCase())) continue;
// An unknown holder count does not withhold a token the user holds:
// only a count the explorer actually reported as below the threshold
@@ -198,13 +200,21 @@ function renderSendTokenSelect(addr) {
// null rather than "0": this screen states it as unknown, as the confirmation
// screen does, and Max fills nothing in for it. Only a stored quantity is
// withdrawn: the "0" for a token that has no row at all is an absence of
// holdings, which is true at every scale.
// holdings, which is true at every scale. A token with no row whose tracked
// record carries `balanceNotFetched` gets null instead, because no refresh has
// read its balance.
function tokenBalanceAndDecimals(addr, token) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const tokenDecimals = resolveTokenDecimals(token, tokenSourcesByChainId());
if (!tb) return { tokenBalance: "0", tokenDecimals };
if (!tb) {
const tracked = state.trackedTokens.find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const notRead = tracked && tracked.balanceNotFetched;
return { tokenBalance: notRead ? null : "0", tokenDecimals };
}
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
return { tokenBalance: tb.balance ?? null, tokenDecimals };
}
@@ -230,6 +240,7 @@ function updateSendBalance() {
token,
addr.tokenBalances,
state.trackedTokens,
currentNetwork().chainId,
);
// A null balance is a holding whose scale is unknown. Saying a figure
// for it would be a claim about the amount, so it reads as the
@@ -385,6 +396,7 @@ function init(_ctx) {
token,
addr.tokenBalances,
state.trackedTokens,
currentNetwork().chainId,
);
({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
}
+30 -3
View File
@@ -50,6 +50,11 @@ let versionClickTimer = null;
// not the one in `state`.
let shownNetwork = null;
// Each endpoint "Save" counts its taps here, under the field that it stores.
// Once the user taps the same "Save" again, the earlier tap shows no flash
// message. The earlier tap stores its URL only if its write had already begun.
const saveTaps = { rpcUrl: 0, blockscoutUrl: 0 };
// One row per site origin, however many addresses it appears under, each with
// an [x] that hands it to onRemove.
function renderSiteList(containerId, origins, onRemove) {
@@ -347,10 +352,16 @@ function showNewSettings() {
// shows `url`, nothing is saved. The flash message then says that the URL was
// changed during the check. The save compares the input and sets `url` in
// `state` in one step. So a redraw after the comparison shows `url`.
async function saveEndpoint(field, input, url, networkId) {
//
// `tap` is the number of the "Save" tap that checked `url`. If the user taps
// the same "Save" again before this save holds the lock, nothing is stored. If
// the user taps the same "Save" again before the write ends, no flash message
// is shown.
async function saveEndpoint(field, input, url, networkId, tap) {
let refused = false;
let inputChanged = false;
await saveState((stored) => {
if (saveTaps[field] !== tap) return false;
refused =
state.networkId !== networkId || stored.networkId !== networkId;
inputChanged = $(input).value.trim() !== url;
@@ -358,6 +369,7 @@ async function saveEndpoint(field, input, url, networkId) {
state[field] = url;
stored[field] = url;
});
if (saveTaps[field] !== tap) return;
if (refused) {
showFlash("The URL was not saved because the network changed.");
} else if (inputChanged) {
@@ -375,6 +387,8 @@ function init(_ctx) {
showPhrase.init();
$("btn-save-rpc").addEventListener("click", async () => {
saveTaps.rpcUrl++;
const tap = saveTaps.rpcUrl;
const url = $("settings-rpc").value.trim();
if (!url) {
showFlash("Please enter an RPC URL.");
@@ -395,6 +409,8 @@ function init(_ctx) {
}),
});
const json = await resp.json();
// The user has tapped this "Save" again since the check began.
if (tap !== saveTaps.rpcUrl) return;
if (json.error) {
log.errorf("RPC validation error:", json.error);
showFlash("Endpoint returned an error.");
@@ -408,6 +424,7 @@ function init(_ctx) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
if (tap !== saveTaps.rpcUrl) return;
// Not the error's message: fetch puts the whole URL, password and
// key included, in the message of the error it throws for a URL
// with a user name and password or one it cannot parse.
@@ -415,10 +432,12 @@ function init(_ctx) {
showFlash("Could not reach endpoint.");
return;
}
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id);
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id, tap);
});
$("btn-save-blockscout").addEventListener("click", async () => {
saveTaps.blockscoutUrl++;
const tap = saveTaps.blockscoutUrl;
const url = $("settings-blockscout").value.trim();
if (!url) {
showFlash("Please enter a Blockscout API URL.");
@@ -428,18 +447,26 @@ function init(_ctx) {
showFlash("Testing endpoint...");
try {
const resp = await debugFetch(url + "/stats");
if (tap !== saveTaps.blockscoutUrl) return;
if (!resp.ok) {
showFlash("Endpoint returned HTTP " + resp.status + ".");
return;
}
} catch {
if (ctx.pageClosed.aborted) return;
if (tap !== saveTaps.blockscoutUrl) return;
// Not the error's message, as for the RPC check above.
log.errorf("Blockscout validation failed:", urlOrigin(url));
showFlash("Could not reach endpoint.");
return;
}
await saveEndpoint("blockscoutUrl", "settings-blockscout", url, net.id);
await saveEndpoint(
"blockscoutUrl",
"settings-blockscout",
url,
net.id,
tap,
);
});
// Each change is made inside the save, holding the lock. So
+22 -4
View File
@@ -1,8 +1,16 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const {
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { log } = require("../../shared/log");
let ctx;
@@ -43,7 +51,7 @@ function renderTop10() {
decimals: parseInt(btn.dataset.decimals, 10),
name: btn.dataset.name || btn.dataset.symbol,
};
state.trackedTokens.push(token);
trackToken(token);
await saveState();
showFlash("Added " + token.symbol);
renderTop10();
@@ -110,7 +118,7 @@ function init(_ctx) {
decimals: parseInt(opt.dataset.decimals, 10),
name: opt.dataset.name || opt.dataset.symbol,
};
state.trackedTokens.push(token);
trackToken(token);
await saveState();
showFlash("Added " + token.symbol);
renderTop10();
@@ -155,8 +163,18 @@ function init(_ctx) {
);
return;
}
// The balance list never shows a token that the spoof rule
// refuses, so such a token is not tracked. The rule never refuses
// a bundled token, so the "Top tokens:" buttons and the top 100
// dropdown need no such check.
if (isSpoofedSymbol(info.symbol, addr, chainId)) {
showFlash("Not added: this token fakes a known symbol.");
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
return;
}
log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({
trackToken({
address: addr,
symbol: info.symbol,
decimals: info.decimals,
+13 -10
View File
@@ -29,7 +29,7 @@
// string, and a token the user added by hand carries whatever lookupTokenInfo()
// got back, so the accepted types are enumerated rather than coerced.
const { toDecimals } = require("./transferAmount");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { bundledToken } = require("./tokenList");
const { isSpoofedSymbol } = require("./symbolSpoof");
// Every decimals the explorer reported for this contract, across all the
@@ -53,12 +53,14 @@ function explorerDecimals(lower, wallets) {
}
// The decimals to render a token amount with, or null when nothing knows.
// `sources` is { trackedTokens, wallets }, both shaped as they are on `state`.
// `sources` is { chainId, trackedTokens, wallets }. `chainId` is the chain id
// of the token's network. The bundled list is read only when that network is
// Ethereum Mainnet. `trackedTokens` and `wallets` are shaped as on `state`.
function resolveTokenDecimals(tokenAddress, sources) {
const lower = (tokenAddress || "").toLowerCase();
if (!lower) return null;
const bundled = TOKEN_BY_ADDRESS.get(lower);
const bundled = bundledToken(lower, sources && sources.chainId);
if (bundled) {
const d = toDecimals(bundled.decimals);
if (d !== null) return d;
@@ -100,18 +102,19 @@ function explorerSymbol(lower, wallets) {
// explorer reported: the same sources and the same precedence
// resolveTokenDecimals() uses, so a token's name and its scale are drawn from
// the same place and the two can no longer disagree about which sources they
// trust. `sources` is { trackedTokens, wallets }, shaped as on `state`.
// trust. `sources` is as for resolveTokenDecimals().
//
// A tracked or explorer-reported symbol is attacker-influenced text, so it is
// held to the spoof rule (symbolSpoof.js): a candidate that wears a bundled or
// native ticker from a contract not entitled to it is refused and the next
// source tried, so resolving a symbol never becomes a new way to claim a known
// ticker. The bundled list is the wallet's own data and is trusted as it is.
// held to the spoof rule (symbolSpoof.js): a candidate that the rule calls a
// spoof is refused and the next source tried, so resolving a symbol never
// becomes a new way to claim a known ticker. The bundled list is the wallet's
// own data and is trusted as it is.
function resolveTokenSymbol(tokenAddress, sources) {
const lower = (tokenAddress || "").toLowerCase();
if (!lower) return null;
const bundled = TOKEN_BY_ADDRESS.get(lower);
const chainId = sources && sources.chainId;
const bundled = bundledToken(lower, chainId);
if (bundled && bundled.symbol) return bundled.symbol;
const tracked = ((sources && sources.trackedTokens) || []).find(
@@ -123,7 +126,7 @@ function resolveTokenSymbol(tokenAddress, sources) {
if (reported) candidates.push(reported);
for (const symbol of candidates) {
if (!isSpoofedSymbol(symbol, tokenAddress)) return symbol;
if (!isSpoofedSymbol(symbol, tokenAddress, chainId)) return symbol;
}
return null;
}
+40 -11
View File
@@ -17,7 +17,7 @@ const { ERC20_ABI } = require("./constants");
const { NETWORKS } = require("./networks");
const { log, debugFetch, urlOrigin } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { bundledToken } = require("./tokenList");
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
const { toDecimals } = require("./transferAmount");
@@ -106,6 +106,9 @@ function rawUnits(value) {
// balance is read through the RPC endpoint instead, and its row here gives that
// entry its holder count and `decimals` (fetchTrackedTokenBalances()).
//
// `chainId` is the chain id of the network `blockscoutUrl` serves. The known
// token list is used only when that network is Ethereum Mainnet.
//
// `decimals` and `balance` are each null when the answer is unknown, the same
// way `holders` already is. Absence is never filled in here: this is the
// upstream of every screen that displays a token amount, so a value invented
@@ -117,6 +120,7 @@ async function fetchTokenBalances(
address,
blockscoutUrl,
trackedTokens,
chainId,
signal,
) {
if (!(await storedSettingIsOn("useBlockscout"))) return [];
@@ -175,7 +179,10 @@ async function fetchTokenBalances(
// either way: copying another source into it would make
// explorerDecimals()'s disagreement check compare something other
// than explorer values.
const known = resolveTokenDecimals(tokenAddr, { trackedTokens });
const known = resolveTokenDecimals(tokenAddr, {
chainId,
trackedTokens,
});
const scale = known !== null ? known : decimals;
// null is a holding of an amount that cannot be stated, which is
// not the same as a holding of zero, and must never render as one.
@@ -191,7 +198,7 @@ async function fetchTokenBalances(
// through to the views, where the two low-holder filters treat
// an unknown count as "do not judge" rather than as zero.
const holders = parseHoldersCount(item.token.holders_count);
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
const isKnown = bundledToken(tokenAddr, chainId) !== null;
const isTracked = trackedSet.has(tokenAddr);
const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD;
@@ -202,7 +209,9 @@ async function fetchTokenBalances(
// Every row here is an ERC-20 the explorer reported, so it has a
// contract address; the native ETH balance is fetched over RPC in
// refreshBalances and never passes through this loop.
if (isSpoofedSymbol(item.token.symbol, tokenAddr)) continue;
if (isSpoofedSymbol(item.token.symbol, tokenAddr, chainId)) {
continue;
}
balances.push({
address: item.token.address_hash,
@@ -247,19 +256,24 @@ async function fetchTokenBalances(
// it has none. The failure is logged. A balance of zero gets no entry, as a
// zero Blockscout reports gets none: the balance lists take a tracked token's
// zero from the tracked list. A token whose symbol the spoof rule refuses gets
// no entry either, as in fetchTokenBalances().
// no entry either, as in fetchTokenBalances(), and its balance is not read.
//
// `signal` is as for fetchTokenBalances().
// Resolves with `rows`, the entries, and `unread`, the tracked tokens whose
// balance was not read, as lowercased contract addresses. A token is unread
// when the spoof rule refuses it or its balanceOf fails.
//
// `chainId` and `signal` are as for fetchTokenBalances().
async function fetchTrackedTokenBalances(
provider,
address,
trackedTokens,
previous,
explorerRequest,
chainId,
signal,
) {
const listed = trackedTokens.filter(
(token) => !isSpoofedSymbol(token.symbol, token.address),
(token) => !isSpoofedSymbol(token.symbol, token.address, chainId),
);
// Each token's balanceOf answer, or null where it failed.
const balances = await Promise.all(
@@ -288,12 +302,16 @@ async function fetchTrackedTokenBalances(
const reported = explorerRows ?? previous;
const rows = [];
const unread = trackedTokens
.filter((token) => !listed.includes(token))
.map((token) => token.address.toLowerCase());
listed.forEach((token, i) => {
const lower = token.address.toLowerCase();
const entryIn = (list) =>
list.find((row) => String(row.address).toLowerCase() === lower);
const raw = balances[i];
if (raw === null) {
unread.push(lower);
const kept = entryIn(previous) || entryIn(explorerRows || []);
if (kept) rows.push(kept);
return;
@@ -301,7 +319,7 @@ async function fetchTrackedTokenBalances(
if (raw === 0n) return;
const entry = entryIn(reported);
const decimals = entry ? entry.decimals : null;
const known = resolveTokenDecimals(lower, { trackedTokens });
const known = resolveTokenDecimals(lower, { chainId, trackedTokens });
const scale = known !== null ? known : decimals;
rows.push({
address: token.address,
@@ -314,12 +332,15 @@ async function fetchTrackedTokenBalances(
holders: entry ? entry.holders : null,
});
});
return rows;
return { rows, unread };
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// `trackedTokens` are the tokens tracked on the active network. `signal` is as
// for fetchTokenBalances().
//
// Resolves with the lowercased contract addresses of the tracked tokens whose
// balance it read at every address.
async function refreshBalances(
wallets,
rpcUrl,
@@ -330,8 +351,11 @@ async function refreshBalances(
) {
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
const provider = getProvider(rpcUrl, networkId);
const { chainId } = requireNetworkId(networkId);
const tracked = trackedTokens || [];
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
// The tracked tokens whose balance was not read at some address.
const unread = new Set();
const updates = [];
for (const wallet of wallets) {
@@ -393,6 +417,7 @@ async function refreshBalances(
addr.address,
blockscoutUrl,
tracked,
chainId,
signal,
);
updates.push(
@@ -403,15 +428,18 @@ async function refreshBalances(
tracked,
previous,
explorerRequest,
chainId,
signal,
),
explorerRequest,
]).then(([trackedRows, explorerRows]) => {
]).then(([trackedResult, explorerRows]) => {
for (const lower of trackedResult.unread) unread.add(lower);
const untrackedRows = (explorerRows ?? previous).filter(
(row) =>
!trackedSet.has(String(row.address).toLowerCase()),
);
addr.tokenBalances = trackedRows.concat(untrackedRows);
addr.tokenBalances =
trackedResult.rows.concat(untrackedRows);
log.debugf(
"Token balances",
addr.address,
@@ -425,6 +453,7 @@ async function refreshBalances(
await Promise.all(updates);
log.debugf("refreshBalances done");
return [...trackedSet].filter((lower) => !unread.has(lower));
}
// Drops the balance of each token not in `trackedTokens` from every address.
+26 -6
View File
@@ -61,20 +61,40 @@ function clearPopupCaches() {
// (https://git.eeqj.de/sneak/AutistMask/issues/653,
// https://git.eeqj.de/sneak/AutistMask/issues/692).
//
// When the stored record is on another network, a switch saved elsewhere has
// not reached this page yet. This page first takes that switch with
// takeStoredNetwork() (state.js), as followStoredSettings() does. The switch to
// `newNetworkId` is then made from the stored record's network. So every
// network's list of tracked tokens in the stored record is kept
// (https://git.eeqj.de/sneak/AutistMask/issues/701). When the stored record is
// already on `newNetworkId`, the switch made from there only resets the
// balances and caches.
//
// When the stored record is on this page's network, this page first takes the
// stored `networkTrackedTokens` (takeStoredNetworkTrackedTokens() in state.js),
// because another page's save may have changed a list this page holds.
//
// Before the switch, `state` takes the tracked tokens that another page stored
// since this page's last save (takeStoredTrackedTokens() in state.js). The
// switch then puts them away under the previous network, the network they were
// added on. When the stored record is on another network, a switch saved
// elsewhere has not reached this page yet. The stored record's tokens are then
// that network's, and none are taken.
// added on.
async function onChainSwitch(newNetworkId) {
const { state, saveState, takeStoredTrackedTokens } = require("./state");
const {
state,
saveState,
takeStoredNetwork,
takeStoredTrackedTokens,
takeStoredNetworkTrackedTokens,
} = require("./state");
let net = null;
await saveState((stored) => {
if (state.networkId === newNetworkId) return false;
if (stored.networkId === state.networkId) {
takeStoredTrackedTokens(stored);
if (stored.networkId !== state.networkId) {
takeStoredNetwork(stored);
} else {
takeStoredNetworkTrackedTokens(stored);
}
takeStoredTrackedTokens(stored);
net = applyChainSwitchFields(state, newNetworkId);
stored.lastBalanceRefresh = 0;
stored.fraudContracts = [];
+94 -8
View File
@@ -127,10 +127,12 @@ function currentNetwork() {
// `baseline` holds the persisted fields as this page held them when its last
// loadState() finished, or when its last successful saveState() began.
// takeStoredNetwork() and takeStoredTrackedTokens() also write into it what
// this page has taken from the stored record since then. saveState() diffs the
// live state against `baseline` to find only the fields THIS page changed
// since.
// takeStoredNetwork(), takeStoredTrackedTokens() and
// takeStoredNetworkTrackedTokens() also write into it what this page has taken
// from the stored record since then. `baseline` also lacks any
// `balanceNotFetched` field that a balance refresh has deleted since
// (clearBalanceNotFetched()). saveState() diffs the live state against
// `baseline` to find only the fields THIS page changed since.
//
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -402,6 +404,23 @@ function mergeNetworkEndpoints(base, ours, theirs) {
return mergeMapByKey(base, ours, theirs, mergeEndpointEntry);
}
// Returns a copy of `stored.networkTrackedTokens` for when this page has not yet
// taken the switch in `stored`. `page` is this page's state. A token that this
// page tracked or removed since its last save is tracked or removed in the list
// of `page.networkId`, by contract address.
function networkTrackedTokensWithChange(stored, page) {
return {
...stored.networkTrackedTokens,
[page.networkId]: mergeListByIdentity(
baseline.trackedTokens,
page.trackedTokens,
stored.networkTrackedTokens[page.networkId],
(token) => token.address.toLowerCase(),
(base, ours) => ours,
),
};
}
// Read-modify-write, merged per field, rather than one full-blob write.
//
// Every extension page (the toolbar popup, a dApp approval window) holds its
@@ -455,6 +474,11 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// own UI scratch space, not data another page has any reason to share
// membership of.
//
// One exception: when the stored record is on another network than this
// page's, a save keeps the stored `trackedTokens`. It merges this page's change
// into the list in `networkTrackedTokens` for this page's network, by contract
// address.
//
// This does not make two pages that both change the SAME leaf concurrently
// safe: last write wins on that one leaf, same as before. What it removes
// is the cross-field (and now cross-membership-vs-leaf) clobber — a page
@@ -527,6 +551,20 @@ async function saveStateOnce(beforeWrite) {
merged[key] = current[key];
}
}
// When the merged record names a network other than this page's, this page
// has not yet taken a switch saved elsewhere. This page's `trackedTokens`
// is then the list of this page's network. The stored record keeps the
// list of this page's network in `networkTrackedTokens`. So a token that
// this page added or removed since its last save is added to or removed
// from that stored list. The merged record keeps the stored `trackedTokens`
// (https://git.eeqj.de/sneak/AutistMask/issues/701).
if (merged.networkId !== current.networkId) {
merged.trackedTokens = fresh.trackedTokens;
merged.networkTrackedTokens = networkTrackedTokensWithChange(
fresh,
current,
);
}
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
// Stamped on every write, never merged or diffed: the record that goes to
// storage is in THIS build's shape whatever shape it was read in, which is
@@ -607,15 +645,27 @@ function saveState(beforeWrite) {
return turn;
}
// Give this page the network switch held by `stored`, a record a save of this
// page has just read (followStoredSettings() in src/popup/index.js calls this
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does
// with a whole record. The switch is then not this page's own change, even when
// Give this page the network switch held by `stored`, in `state` and in
// `baseline` alike, as loadState() does with a whole record. `stored` is the
// record that a save of this page has just read. followStoredSettings() in
// src/popup/index.js and onChainSwitch() in src/shared/chainSwitch.js call this
// from `beforeWrite`. The switch is then not this page's own change, even when
// that save fails to write: a later switch back to the previous network still
// differs from `baseline`, and is stored.
//
// A token that this page tracked or removed since its last save was tracked or
// removed on this page's network before the switch. `state` keeps that change
// in the list in `networkTrackedTokens` for that network, merged with the
// stored list by contract address. `baseline` takes the stored list, so the
// save stores the change (https://git.eeqj.de/sneak/AutistMask/issues/706).
function takeStoredNetwork(stored) {
const networkTrackedTokens = networkTrackedTokensWithChange(
stored,
rawState,
);
copyChainSwitchFields(rawState, stored);
copyChainSwitchFields(baseline, structuredClone(stored));
rawState.networkTrackedTokens = networkTrackedTokens;
}
// Give this page the tracked tokens of `stored`, a record on this page's
@@ -640,6 +690,40 @@ function takeStoredTrackedTokens(stored) {
baseline.trackedTokens = structuredClone(stored.trackedTokens);
}
// Give `state` and `baseline` each their own copy of the stored
// `networkTrackedTokens`. `stored` is a record on this page's network that a
// save of this page has just read. onChainSwitch() in
// src/shared/chainSwitch.js calls this from `beforeWrite`.
function takeStoredNetworkTrackedTokens(stored) {
rawState.networkTrackedTokens = structuredClone(
stored.networkTrackedTokens,
);
baseline.networkTrackedTokens = structuredClone(
stored.networkTrackedTokens,
);
}
// Deletes `balanceNotFetched` from each tracked token whose lowercased
// contract address is in `addresses`. The field goes from `stored`, from
// `state` and from `baseline` alike. `stored` is the record a save of this
// page has just read (doRefreshAndRender() in src/popup/index.js calls this
// from `beforeWrite`). So the deletion is not a change this page made to its
// tracked tokens. Unless this page has changed its tracked tokens itself, the
// save then writes the stored list with only the field gone.
function clearBalanceNotFetched(stored, addresses) {
for (const list of [
stored.trackedTokens,
rawState.trackedTokens,
baseline.trackedTokens,
]) {
for (const token of list) {
if (addresses.includes(token.address.toLowerCase())) {
delete token.balanceNotFetched;
}
}
}
}
// Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first
@@ -687,6 +771,8 @@ module.exports = {
onSaveFailure,
takeStoredNetwork,
takeStoredTrackedTokens,
takeStoredNetworkTrackedTokens,
clearBalanceNotFetched,
loadState,
currentAddress,
currentNetwork,
+19 -3
View File
@@ -1,5 +1,5 @@
// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
// profile.
// Reads "Use Blockscout", "Check recipients on Etherscan" or the active network
// from the stored profile.
//
// Before each request that sends the user's data to Blockscout or Etherscan, the
// wallet reads that service's setting from storage. The wallet reads the setting
@@ -7,6 +7,11 @@
// made, and an answer that lands after the setting went off is dropped. A page's
// own copy of the setting is not used, because another page may have just
// changed it.
//
// Before asking Blockscout for the transaction history, a screen remembers
// which network is active. If another network is active on the page or in
// storage when the answer lands, the screen drops the answer. A switch saved
// elsewhere reaches storage first and the page later.
const { storageGet } = require("./browserApi");
const { normalizePersisted } = require("./persistedState");
@@ -24,4 +29,15 @@ async function storedSettingIsOn(name) {
}
}
module.exports = { storedSettingIsOn };
// Whether `networkId` is the active network in the stored profile. A failed
// read counts as no.
async function storedNetworkIs(networkId) {
try {
const { autistmask } = await storageGet("autistmask");
return normalizePersisted(autistmask).networkId === networkId;
} catch {
return false;
}
}
module.exports = { storedSettingIsOn, storedNetworkIs };
+11 -3
View File
@@ -19,7 +19,8 @@
// 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.
// wallet ships as a real token on Ethereum Mainnet. On mainnet, 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
@@ -27,6 +28,7 @@
// since that is what the user acts on.
const { KNOWN_SYMBOLS } = require("./tokenList");
const { NETWORKS } = require("./networks");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity.
@@ -93,18 +95,24 @@ function normalizeSymbol(symbol) {
}
// True when a token bearing `symbol` from contract `contractAddress` is
// impersonating a known symbol.
// impersonating a known symbol. `chainId` is the chain id of the token's
// network.
//
// The native asset's labels are known symbols on every network. The bundled
// list's symbols are known symbols only on Ethereum Mainnet, because the list
// holds mainnet contracts.
//
// An empty contract address is the native asset, which is never a spoof:
// this is what keeps the user's real ETH out of the rule, and it holds for
// any symbol that becomes null-mapped later, not just for ETH.
function isSpoofedSymbol(symbol, contractAddress) {
function isSpoofedSymbol(symbol, contractAddress, chainId) {
const contract = normalizeAddress(contractAddress);
if (!contract) return false;
const sym = normalizeSymbol(symbol);
if (!KNOWN_SYMBOLS.has(sym)) return false;
const legit = KNOWN_SYMBOLS.get(sym);
if (legit === null) return true;
if (chainId !== NETWORKS.mainnet.chainId) return false;
return !legit.has(contract);
}
+16 -4
View File
@@ -3953,6 +3953,15 @@ for (const t of TOKENS) {
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
}
// The bundled list's entry for the contract at `address` on the network with
// chain id `chainId`, or null. The list holds Ethereum Mainnet contracts only.
// A contract at the same address on another network is another token, so any
// other chain id gets null.
function bundledToken(address, chainId) {
if (chainId !== NETWORKS.mainnet.chainId) return null;
return TOKEN_BY_ADDRESS.get((address || "").toLowerCase()) || null;
}
// 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
@@ -3969,7 +3978,8 @@ for (const t of TOKENS) {
// 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.
// loosen the rule. On Ethereum Mainnet, a contract outside the set is still a
// spoof.
const KNOWN_SYMBOLS = new Map();
KNOWN_SYMBOLS.set("ETH", null);
for (const network of Object.values(NETWORKS)) {
@@ -3988,14 +3998,15 @@ for (const t of TOKENS) {
}
}
// Resolve a token symbol from multiple sources, never returning "?".
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
// Resolve a token symbol from multiple sources, never returning "?". The token
// is a contract on the network with chain id `chainId`.
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens, chainId) {
const lower = (tokenAddress || "").toLowerCase();
const tb = (tokenBalances || []).find(
(t) => t.address.toLowerCase() === lower,
);
if (tb && tb.symbol) return tb.symbol;
const known = TOKEN_BY_ADDRESS.get(lower);
const known = bundledToken(lower, chainId);
if (known && known.symbol) return known.symbol;
const tracked = (trackedTokens || []).find(
(t) => t.address.toLowerCase() === lower,
@@ -4012,5 +4023,6 @@ module.exports = {
USD_PRICE_FEEDS,
PRICE_FEED_BY_ADDRESS,
getTopTokens,
bundledToken,
resolveSymbol,
};
+6 -3
View File
@@ -8,7 +8,7 @@
const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { bundledToken } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
const { nativeCurrencyByChainId } = require("./networks");
@@ -47,7 +47,7 @@ function parseTx(tx, addrLower, chainId) {
let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
let directionLabel = direction === "sent" ? "Sent" : "Received";
if (toIsContract && method && method !== "transfer") {
const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
const token = bundledToken(to, chainId);
if (token) {
symbol = token.symbol;
}
@@ -317,7 +317,10 @@ function filterTransactions(txs, filters = {}) {
const contract = normalizeAddress(tx.contractAddress);
// Filter spoofed known symbols and record the fraud contract
if (hideSpoofed && isSpoofedSymbol(tx.symbol, tx.contractAddress)) {
if (
hideSpoofed &&
isSpoofedSymbol(tx.symbol, tx.contractAddress, tx.chainId)
) {
if (contract && !fraudSet.has(contract)) {
fraudSet.add(contract);
newFraud.push(contract);
+221 -47
View File
@@ -452,8 +452,8 @@ const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
// `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
// 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are
// on `state`; resolveTokenDecimals() reads the bundled list, then those.
// 0.000000000001. `sources` is as for resolveTokenDecimals(), which reads the
// bundled list on Ethereum Mainnet, then the tracked tokens and the wallets.
//
// A null `address` means UNDETERMINED — the calldata named no currency for
// that side — and is refused rather than named. It is not native ETH: Uniswap
@@ -988,6 +988,9 @@ function totalAmount(amounts) {
// pays it of Token In as the step's amount in place of the rules' Amount, or
// no amount when this decoder cannot tell what that is.
//
// `spent` lists the currency that each swap action spends, for each swap
// action that this decoder could read.
//
// `takes` is every TAKE, TAKE_ALL and TAKE_PORTION, as the currency it takes
// and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes
// a share of the currency, so it does not name the output side as TAKE does.
@@ -1163,6 +1166,8 @@ function decodeV4Swap(input) {
}
}
const spent = swaps.map((s) => s.tokenIn);
// Rule 3.
if (swapUndecoded) {
return {
@@ -1170,6 +1175,7 @@ function decodeV4Swap(input) {
tokenOut: takeToken,
amountIn: null,
amountOutMin: null,
spent,
takes,
settledFromRouter,
paidByUser,
@@ -1180,7 +1186,6 @@ function decodeV4Swap(input) {
};
}
const spent = swaps.map((s) => s.tokenIn);
const bought = swaps.map((s) => s.tokenOut);
// Rules 1 and 2 for Token In.
@@ -1216,6 +1221,7 @@ function decodeV4Swap(input) {
tokenOut,
amountIn,
amountOutMin,
spent,
takes,
settledFromRouter,
paidByUser,
@@ -1240,6 +1246,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const release = routerRelease(toAddress, chainId);
if (release === null) return null;
const { commandTypeMask, commands, allowRevert } = release;
// The swap's tokens are contracts on the network with `chainId`, so they
// are named and scaled for that network.
const tokenSources = { ...sources, chainId };
try {
const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null;
@@ -1258,6 +1267,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// ERC-20 token from the router takes none of that token from the
// sender.
let inputPaidByUser = false;
// The recipient of a WRAP_ETH that set the input side, as written,
// when it is not the router; otherwise null. That WRAP_ETH pays its
// WETH there, not to a swap step (see the Moved to lines).
let wrapEthRecipient = null;
// The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop).
let permit = null;
@@ -1307,8 +1320,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come
// from the same hop, or from hops that all spend that token. A figure
// is never rendered against a token that did not supply it.
// from the same hop, or from hops that all spend that token or all buy
// it. A figure is never rendered against a token that did not supply
// it.
//
// Both sides are therefore set as a PAIR — never field by field, and
// never on truthiness. An address is never falsy once set but an
@@ -1354,18 +1368,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// After the loop each joins the Amount line or gets lines of its own
// (see permitStands). A take with `atMost` set may take less than
// `amount`: it never joins the Amount line, and its line reads
// "Up to" and the amount.
// "Up to" and the amount. A take with a `recipient`, the ETH of a
// later WRAP_ETH that pays anyone but the router, never joins the
// Amount line either: its Moved to line names that recipient.
const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender. A V2 exact-in of ALREADY_PAID takes
// nothing.
const addUserPaid = (token, amount) => {
if (amount !== ALREADY_PAID) userPaid.push({ token, amount });
// of `token` from the sender, or at most that when `atMost` is set. A
// V2 exact-in of ALREADY_PAID takes nothing.
const addUserPaid = (token, amount, atMost = false) => {
if (amount !== ALREADY_PAID) {
userPaid.push({ token, amount, atMost });
}
};
// A step may take from the sender what this decoder did not read: a
// V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it
// cannot read; a V2 or V3 swap step or PERMIT2_TRANSFER_FROM whose
// input it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; an
// cannot read; a V2 or V3 swap step, PERMIT2_TRANSFER_FROM or WRAP_ETH
// whose input it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; an
// EXECUTE_SUB_PLAN; and a step it fails on. It is recorded with a null
// token and amount: one line says so, there is no Amount line, whose
// figure may then be too low, and the amount of a permit after it
@@ -1423,6 +1441,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const r = recipient.toLowerCase();
return r === ADDRESS_THIS || r === toAddress.toLowerCase();
};
const isSender = (recipient) => {
const r = recipient.toLowerCase();
return r === MSG_SENDER || r === sender;
};
// A swap step or V4 SETTLE pays `amount` of `token` from the router's
// own balance. CONTRACT_BALANCE pays all of it. Any other quantity
// counts against the amount a WRAP_ETH stated, and the steps pay all
@@ -1455,17 +1477,73 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// A step pays `recipient`. `output` is the token a swap step or a V4
// TAKE or TAKE_PORTION pays; other steps pass none.
const addPayee = (recipient, output) => {
const r = recipient.toLowerCase();
if (isRouter(recipient)) {
if (present(output)) {
heldByRouter.set(output.toLowerCase(), null);
}
} else if (r === MSG_SENDER || r === sender) {
} else if (isSender(recipient)) {
paidToSender = true;
} else {
addPaidElsewhere(recipient);
}
};
// Each swap step that paid the sender some of the token it bought,
// as { step, token, amount, onlyToSender }:
// - `step` is the step's index;
// - `token` is that token;
// - `amount` is the step's stated minimum;
// - `onlyToSender` is whether the step paid all of that token to the
// sender.
// A later step that spends that token takes the step off this list
// (see dropFromSum()).
let swapsPayingSender = [];
// The step being read spends `token`, so it may spend what an earlier
// swap step paid the sender. Each step on swapsPayingSender that
// bought `token` leaves the list. A step spends a token as follows:
// - a V2 or V3 swap step spends its input token;
// - a V4 step spends each token that one of its swap actions spends,
// and each token that one of its SETTLEs or SETTLE_ALLs pays;
// - a PERMIT2_TRANSFER_FROM spends the token it moves.
const dropFromSum = (token) => {
swapsPayingSender = swapsPayingSender.filter(
(s) => !sameAddress(s.token, token),
);
};
// The minimum the output side shows for the swap step at `step`. The
// step buys `token` with the stated minimum `amount`, and pays
// `token` to `recipients`. The minimum is the step's own, unless this
// step and an earlier one on swapsPayingSender both pay `token` to
// the sender, as a route split across steps does. The minimum is then
// the total of all such steps' minimums. There is none when this
// decoder reads no minimum from one of them, or when one of them pays
// some of `token` elsewhere. There is also none when this decoder did
// not read one of them, or a step between them, in full (see
// stepsUnread).
const swapMinimum = (step, token, amount, recipients) => {
const toSender = recipients.filter(
(r) => present(r) && isSender(r),
);
if (toSender.length === 0) return amount;
swapsPayingSender.push({
step,
token,
amount,
onlyToSender: toSender.length === recipients.length,
});
const split = swapsPayingSender.filter((s) =>
sameAddress(s.token, token),
);
if (split.length < 2) return amount;
if (
split.some(
(s) => !s.onlyToSender || typeof s.amount !== "bigint",
) ||
stepsUnread.some((unread) => unread >= split[0].step)
) {
return null;
}
return split.reduce((sum, s) => sum + s.amount, 0n);
};
// A step that pays `token` to `recipient` is a hop, not a payout,
// when the very next step is a V2 exact-in whose input is already
// paid, whose path starts at `token`, and whose pair (see v2PairFor())
@@ -1607,6 +1685,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
: null,
});
}
if (p) dropFromSum(p.token);
} else if (command === "BALANCE_CHECK_ERC20") {
const b = decodeBalanceCheck(inputs[i]);
if (!b) {
@@ -1649,10 +1728,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
dropFromSum(s.tokenIn);
// Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token
// and minimum received amount.
setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
// the last swap step determines the final output
// token. swapMinimum() gives the minimum.
setOutput(
i,
s.tokenOut,
swapMinimum(i, s.tokenOut, s.amountOutMin, [
s.recipient,
]),
[s.recipient],
);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
@@ -1673,7 +1760,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
dropFromSum(s.tokenIn);
setOutput(
i,
s.tokenOut,
swapMinimum(i, s.tokenOut, s.amountOutMin, [
s.recipient,
]),
[s.recipient],
);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
@@ -1705,7 +1800,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax);
}
setOutput(i, s.tokenOut, s.amountOut, [s.recipient]);
dropFromSum(s.tokenIn);
setOutput(
i,
s.tokenOut,
swapMinimum(i, s.tokenOut, s.amountOut, [
s.recipient,
]),
[s.recipient],
);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
@@ -1803,6 +1906,28 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount,
);
inputPaidByUser = true;
if (!isRouter(w.recipient)) {
wrapEthRecipient = w.recipient;
}
} else if (
w.amount > 0n &&
w.amount !== CONTRACT_BALANCE
) {
// The ETH of a stated amount comes from the
// transaction's value. ETH an earlier step left in
// the router may pay part of it, and the sender
// then pays at most that amount. CONTRACT_BALANCE
// states no amount to take.
userPaid.push({
token: "0x0000000000000000000000000000000000000000",
amount: w.amount,
atMost: heldByRouter.has(
"0x0000000000000000000000000000000000000000",
),
recipient: isRouter(w.recipient)
? null
: w.recipient,
});
}
addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH.
@@ -1827,6 +1952,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else {
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
}
} else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]);
@@ -1877,6 +2003,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
addPayee(t.recipient, t.currency);
}
}
for (const token of v4.spent) dropFromSum(token);
for (const s of v4.paidByUser) dropFromSum(s.currency);
for (const s of v4.settledFromRouter) {
dropFromSum(s.currency);
}
}
if (!v4 || v4.recipientUnread) {
payeeUnread = true;
@@ -1925,7 +2056,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
setInput(v4.tokenIn, amountIn);
}
outputToken = v4.tokenOut;
minOutput = v4.amountOutMin;
minOutput = swapMinimum(
i,
v4.tokenOut,
v4.amountOutMin,
takeRecipients,
);
outputEstablished = true;
outputUnwrapped = false;
unwrapIntoRouterStep = null;
@@ -1953,9 +2089,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router still runs it.
const setInputHere = inputEstablished && !inputSetBefore;
if (setInputHere) inputPaidByUser = paidIn !== undefined;
// ETH an earlier step left in the router may pay part of
// the ETH that the user pays this step. The sender then
// pays at most that amount.
for (const p of paidByUser) {
if (setInputHere && p === paidIn) continue;
addUserPaid(p.currency, p.amount);
addUserPaid(
p.currency,
p.amount,
routerHeldEth &&
p.currency ===
"0x0000000000000000000000000000000000000000",
);
}
// A SETTLE of ETH from the router that `paidByUser` leaves
// out is a take of its own, even on the step that sets
@@ -2080,8 +2225,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// added to it while both are quantities. A take that cannot be added,
// being of another token, coming after an amount that is not a
// quantity (a V4 open delta, ALREADY_PAID, or none stated), or having
// `atMost` set, gets lines of its own. One whose amount is not known
// also leaves the total unknown, with no figure.
// `atMost` or a `recipient` set, gets lines of its own. One whose
// amount is not known also leaves the total unknown, with no figure.
//
// A PERMIT2_PERMIT names Token In only when its amount stands as the
// Amount figure in place of the total, which it does only in the
@@ -2119,15 +2264,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
permit.beforeEveryTake &&
!stepsUnread.some((step) => step > permit.step);
// What the steps take that the Amount line does not give, as
// { token, amount }. A null token is a step this decoder did not read
// (see spentUnread()), or a token it does not know.
// { token, amount, atMost, recipient }. A null token is a step this
// decoder did not read (see spentUnread()), or a token it does not
// know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH that
// pays elsewhere has a recipient (see wrapEthRecipient and userPaid).
const spentFromSender = [];
let total = null;
// The takes from the sender that `total` adds up, each a quantity, as
// { token, amount }. When there is no Amount line, each gets lines of
// its own (see below). The amount of the step that set the input side
// counts in `total` whoever pays that step. That amount is among them
// only when the user pays the step (see inputPaidByUser).
// The takes from the sender that `total` adds up, each a quantity, in
// the shape of `spentFromSender`. When there is no Amount line, each
// gets lines of its own (see below). The amount of the step that set
// the input side counts in `total` whoever pays that step. That amount
// is among them only when the user pays the step (see inputPaidByUser).
const inTotal = [];
// Whether the step that set the input side counts as a take from the
// sender, which may get lines of its own. The amount it stated counts
@@ -2141,7 +2288,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount;
if (typeof inputAmount === "bigint" && inputPaidByUser) {
inTotal.push({ token: inputToken, amount: inputAmount });
inTotal.push({
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
}
if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null };
@@ -2149,7 +2300,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (hasFirstTake) {
// The step that set the input side spent a token other than the
// permit's, or one not known, so its amount gets lines of its own.
spentFromSender.push({ token: inputToken, amount: inputAmount });
spentFromSender.push({
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
}
if (permitStands) inputToken = permit.token;
for (const t of userPaid) {
@@ -2159,7 +2314,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
ofTokenIn &&
typeof total === "bigint" &&
typeof t.amount === "bigint" &&
!t.atMost
!t.atMost &&
!present(t.recipient)
) {
total += t.amount;
inTotal.push(t);
@@ -2208,11 +2364,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// Any other output keeps its own token and figure: a swap that pays
// its WETH to the user and then unwraps receives WETH, and one that
// buys USDC and then unwraps the WETH it did not spend receives USDC.
const inInfo = tokenInfo(inputToken, sources);
const inInfo = tokenInfo(inputToken, tokenSources);
const outInfo =
outputUnwrapped || (hasUnwrapWeth && !outputEstablished)
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
: tokenInfo(outputToken, tokenSources);
const inSymbol = inInfo.symbol;
const outSymbol = outInfo.symbol;
@@ -2332,30 +2488,48 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What steps take from the sender beyond the Amount line, in the shape
// of the lines below for a PERMIT2_TRANSFER_FROM. An amount this
// decoder does not know is said so, and a step it did not read gets
// that one line alone.
// that one line alone. A take with no recipient goes to the swap step
// that spends it. A take with a recipient goes to that recipient;
// `MSG_SENDER` means the sender.
for (const s of spentFromSender) {
details.push({
label: "Moved from the sender",
value: present(s.amount)
? spentText(s.amount, tokenInfo(s.token, sources), s.atMost)
.display
? spentText(
s.amount,
tokenInfo(s.token, tokenSources),
s.atMost,
).display
: UNDECODED_CURRENCY,
});
if (present(s.token)) {
details.push(
tokenMovedLine("Token moved", tokenInfo(s.token, sources)),
tokenMovedLine(
"Token moved",
tokenInfo(s.token, tokenSources),
),
);
details.push({
label: "Moved to",
value: "The swap step that spends it",
});
if (!present(s.recipient)) {
details.push({
label: "Moved to",
value: "The swap step that spends it",
});
} else if (s.recipient.toLowerCase() === MSG_SENDER) {
details.push({ label: "Moved to", value: "The sender" });
} else {
details.push({
label: "Moved to",
value: s.recipient,
address: s.recipient,
});
}
}
}
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own.
for (const m of movedFromSender) {
const info = permit2TokenInfo(m.token, sources);
const info = permit2TokenInfo(m.token, tokenSources);
details.push({
label: "Moved from the sender",
value: amountText(m.amount, info).display,
@@ -2390,7 +2564,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
continue;
}
const info = permit2TokenInfo(p.token, sources);
const info = permit2TokenInfo(p.token, tokenSources);
details.push({
label: "Permit2 allowance",
value:
@@ -2456,7 +2630,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
}
for (const m of movedIntoRouter) {
const info = permit2TokenInfo(m.token, sources);
const info = permit2TokenInfo(m.token, tokenSources);
paidTo.push({
label: "Paid to",
value:
@@ -2481,7 +2655,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push(
tokenMovedLine(
"Token moved into the router",
tokenInfo(getAddress(token), sources),
tokenInfo(getAddress(token), tokenSources),
),
);
}
+1
View File
@@ -16,6 +16,7 @@ const TOKEN = {
symbol: "TST",
decimals: 18,
name: "Test Token",
balanceNotFetched: true,
};
// A second contract address, typed after AddToken was opened again.
+458
View File
@@ -0,0 +1,458 @@
// After "Add" on AddToken, AddressDetail lists the new token. Its line reads
// `quantity unknown`, not 0.0000, until a balance refresh has read the token's
// balance at every address, even if the popup closes and opens again
// meanwhile. A token added on SettingsAddToken reads the same wherever it is
// listed. A refresh that returns draws the balances on AddressDetail or
// AddressToken again, so the fetched balance shows without the screen being
// reopened (https://git.eeqj.de/sneak/AutistMask/issues/516).
//
// The tracked token's record carries `balanceNotFetched` until a refresh has
// read the token's balance. Removing the token removes that record. A network
// switch keeps each network's records apart. A refresh takes the field off the
// stored record's tokens and keeps another page's changes to the stored list.
//
// Driven through the real popup over a stored profile on Ethereum Mainnet,
// with "Show tracked tokens with zero balance" on, as it is by default.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
// Tracked by nobody until a test adds it.
const NEW_TOKEN = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
// A holding of a million NEW_TOKEN, as refreshBalances() stores it.
const MILLION = {
address: NEW_TOKEN,
name: "New Token",
symbol: "NEW",
decimals: 18,
balance: "1000000.0",
holders: 3,
};
// NEW_TOKEN as "Add" on AddToken tracks it, once a refresh has read its
// balance.
const NEW_TRACKED = {
address: NEW_TOKEN,
symbol: "NEW",
name: "New Token",
decimals: 18,
};
// A token that another popup page tracks.
const OTHER_TOKEN = {
address: "0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
symbol: "OTH",
name: "Other Token",
decimals: 18,
};
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressDetail for the one address.
function bootOnAddress(extra) {
return bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
...extra,
}),
);
}
// Holds the next balance refresh open. The returned function lets it finish
// with the address's ETH balance at 2 and `tokenBalances` as its token
// entries, having read the balance of every token tracked when it started.
// Given null, it finishes having read no token's balance, as when every
// balanceOf fails, and leaves the token entries as they were.
function holdNextRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let release;
refreshBalances.mockImplementationOnce(
async (wallets, rpcUrl, explorerUrl, trackedTokens) => {
const read = trackedTokens.map((t) => t.address.toLowerCase());
const tokenBalances = await new Promise((resolve) => {
release = resolve;
});
wallets[0].addresses[0].balance = "2.0";
if (tokenBalances === null) return [];
wallets[0].addresses[0].tokenBalances = tokenBalances;
return read;
},
);
return async (tokenBalances) => {
release(tokenBalances);
await page.settle();
};
}
// Runs the ten-second refresh to the end, finishing it as holdNextRefresh()
// does with `tokenBalances`.
async function refreshOnce(page, tokenBalances) {
const finish = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await finish(tokenBalances);
await ticking;
}
// "+ Token", NEW_TOKEN typed in, and "Add", with the contract lookup
// answering at once.
async function addNewToken(page) {
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
await page.click("btn-add-token");
page.node("add-token-address").value = NEW_TOKEN;
await page.click("btn-add-token-confirm");
}
// Settings' "+ Add token", NEW_TOKEN typed in, and "Add", with the contract
// lookup answering at once. It ends on SettingsAddToken.
async function addOnSettingsAddToken(page) {
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
await page.click("btn-settings-add-token");
page.node("settings-addtoken-address").value = NEW_TOKEN;
await page.click("btn-settings-addtoken-manual");
}
// The "[x]" button Settings draws beside the first tracked token. The page
// stub does not turn the markup Settings draws into elements. So the list
// hands Settings a stand-in for that button each time it is drawn, and
// `press()` presses the last one drawn. Call it before Settings opens.
function firstRemoveButton(page) {
const button = { press: null };
page.node("settings-tracked-tokens").querySelectorAll = () => [
{
dataset: { idx: "0" },
addEventListener: (name, fn) => {
button.press = fn;
},
},
];
return button;
}
// Picks `networkId` in the Settings network selector, as the user does.
async function chooseNetwork(page, networkId) {
const select = page.node("settings-network");
select.value = networkId;
for (const fn of select.listeners.change) await fn();
await page.settle();
}
// Another popup page saves `trackedTokens` as the tracked token list. This
// page is not told of the write.
function anotherPageTracks(page, trackedTokens) {
page.storage.write("autistmask", {
...page.storage.read("autistmask"),
trackedTokens,
});
}
// NEW_TOKEN's row in the balance list in element `id`, AddressDetail's unless
// another is named, as markup.
function newTokenRow(page, id = "address-balances") {
return page
.node(id)
.innerHTML.split("<div ")
.find((row) =>
row.includes('data-token="' + NEW_TOKEN.toLowerCase() + '"'),
);
}
// AddressDetail for the one address opened from Home, as its `[info]` button
// does.
async function openAddress(page) {
const { state } = require("../src/shared/state");
state.selectedWallet = 0;
state.selectedAddress = 0;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressDetail").show();
await page.settle();
}
// AddressToken for `token` opened from AddressDetail, as tapping the token's
// row there does.
async function openToken(page, token) {
const { state } = require("../src/shared/state");
state.selectedToken = token;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
}
test("until the refresh Add started returns, the new token's line reads quantity unknown", async () => {
const page = await bootOnAddress();
expect(page.pageErrors).toEqual([]);
const finish = holdNextRefresh(page);
await addNewToken(page);
expect(page.visibleViews()).toEqual(["address"]);
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
await finish([MILLION]);
});
test("once that refresh returns, AddressDetail shows the fetched balance without being reopened", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
// Stands for the transactions on the screen, which only show() replaces.
page.node("tx-list").innerHTML = "transactions on the screen";
await finish([MILLION]);
expect(page.visibleViews()).toEqual(["address"]);
expect(newTokenRow(page)).toContain("1000000.0000");
// Only the balances are drawn again, not the transaction list.
expect(page.node("tx-list").innerHTML).toBe("transactions on the screen");
});
test("a refresh already running when the token is added does not count as reading its balance", async () => {
const page = await bootOnAddress();
const finishRunning = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await addNewToken(page);
await finishRunning([]);
await ticking;
expect(newTokenRow(page)).toContain("quantity unknown");
// The next one reads the token's balance, which is zero.
await refreshOnce(page, []);
expect(newTokenRow(page)).toContain("0.0000");
});
test("closed and opened again before any refresh has read its balance, the new token's line still reads quantity unknown", async () => {
const closed = await bootOnAddress();
holdNextRefresh(closed);
await addNewToken(closed);
cleanupPopup();
// The refresh this boot starts reads no token's balance: the boot's stub
// of refreshBalances() reads none.
const page = await bootPopup(undefined, { storage: closed.storage });
expect(page.visibleViews()).toEqual(["address"]);
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
await refreshOnce(page, [MILLION]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh that could not read the new token's balance leaves its line quantity unknown", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
await finish(null);
// Drawn again: the ETH balance it fetched is shown.
expect(page.node("address-balances").innerHTML).toContain("2.0000");
expect(newTokenRow(page)).toContain("quantity unknown");
});
test("AddressToken for the new token reads quantity unknown until the refresh returns, then the fetched balance", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
await openToken(page, NEW_TOKEN.toLowerCase());
expect(page.visibleViews()).toEqual(["address-token"]);
const before = page.node("address-token-balance").innerHTML;
expect(before).toContain("quantity unknown");
expect(before).not.toContain("0.0000");
await finish([MILLION]);
expect(page.node("address-token-balance").innerHTML).toContain(
"1000000.0000",
);
});
test("Send for the new token reads its balance as unknown before any refresh has read it, and Max says so", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
await openToken(page, NEW_TOKEN.toLowerCase());
await page.click("btn-address-token-send");
expect(page.visibleViews()).toEqual(["send"]);
expect(page.text("send-balance")).toBe("Current balance: unknown (NEW)");
await page.click("btn-send-max");
expect(page.text("flash-msg")).toBe("This token's balance is unknown.");
await finish([MILLION]);
});
test("a token added by contract address on SettingsAddToken reads quantity unknown until the refresh returns", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.visibleViews()).toEqual(["main"]);
const finish = holdNextRefresh(page);
await page.click("btn-settings");
await addOnSettingsAddToken(page);
await page.click("btn-settings-addtoken-back");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["main"]);
const onHome = newTokenRow(page, "wallet-list");
expect(onHome).toContain("quantity unknown");
expect(onHome).not.toContain("0.0000");
await openAddress(page);
const onAddress = newTokenRow(page);
expect(onAddress).toContain("quantity unknown");
expect(onAddress).not.toContain("0.0000");
await finish([MILLION]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh that returns while AddressToken is up draws its balance again", async () => {
const page = await bootPopup(
unversionedValidProfile({
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: "ETH",
}),
);
expect(page.visibleViews()).toEqual(["address-token"]);
expect(page.node("address-token-balance").innerHTML).toContain("1.5000");
await refreshOnce(page, null);
expect(page.node("address-token-balance").innerHTML).toContain("2.0000");
});
test("a token removed before any refresh has read its balance leaves nothing stored, and adding it again tracks it once", async () => {
const page = await bootPopup(unversionedValidProfile());
const removeButton = firstRemoveButton(page);
const stored = () => page.storage.read("autistmask");
await page.click("btn-settings");
await addOnSettingsAddToken(page);
// Settings opened again from Home draws its list with the token in it.
await page.click("btn-settings-addtoken-back");
await page.click("btn-settings-back");
await page.click("btn-settings");
await removeButton.press();
await page.settle();
expect(stored().trackedTokens).toEqual([]);
// Nothing in the stored record names the token any more.
expect(JSON.stringify(stored()).toLowerCase()).not.toContain(
NEW_TOKEN.toLowerCase(),
);
await addOnSettingsAddToken(page);
// A second add of the same token is refused.
page.node("settings-addtoken-address").value = NEW_TOKEN;
await page.click("btn-settings-addtoken-manual");
const added = { address: NEW_TOKEN, symbol: "NEW", decimals: 18 };
expect(stored().trackedTokens).toEqual([
{ ...added, name: "New Token", balanceNotFetched: true },
]);
await refreshOnce(page, []);
expect(stored().trackedTokens).toEqual([{ ...added, name: "New Token" }]);
});
test("a refresh on another network leaves the new token reading quantity unknown on its own", async () => {
// Sepolia tracks a token at the same contract address, also just added.
const sepoliaToken = {
address: NEW_TOKEN,
symbol: "SEP",
name: "Sepolia Token",
decimals: 18,
};
const page = await bootOnAddress({
networkTrackedTokens: {
sepolia: [{ ...sepoliaToken, balanceNotFetched: true }],
},
});
const finishMainnet = holdNextRefresh(page);
await addNewToken(page);
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
// The refresh Add started returns after the switch and writes nothing.
await finishMainnet([MILLION]);
// Sepolia's refresh reads the balance of its own token.
await refreshOnce(page, []);
const stored = page.storage.read("autistmask");
expect(stored.trackedTokens).toEqual([sepoliaToken]);
expect(stored.networkTrackedTokens.mainnet[0].balanceNotFetched).toBe(true);
await chooseNetwork(page, "mainnet");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
expect(newTokenRow(page)).toContain("quantity unknown");
await refreshOnce(page, [MILLION]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh keeps a token that another page added after this page loaded", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
const otherAdded = { ...OTHER_TOKEN, balanceNotFetched: true };
anotherPageTracks(page, [
{ ...NEW_TRACKED, balanceNotFetched: true },
otherAdded,
]);
await finish([MILLION]);
expect(page.storage.read("autistmask").trackedTokens).toEqual([
NEW_TRACKED,
otherAdded,
]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh does not bring back a token that another page removed after this page loaded", async () => {
const page = await bootOnAddress({ trackedTokens: [OTHER_TOKEN] });
const finish = holdNextRefresh(page);
await addNewToken(page);
anotherPageTracks(page, [{ ...NEW_TRACKED, balanceNotFetched: true }]);
await finish([MILLION]);
expect(page.storage.read("autistmask").trackedTokens).toEqual([
NEW_TRACKED,
]);
});
+5 -2
View File
@@ -90,9 +90,11 @@ beforeEach(() => {
});
describe("resolveTokenDecimals", () => {
// The bundled list is read only for a token on Ethereum Mainnet.
test("prefers the bundled list", () => {
state.trackedTokens = [{ address: USDC, symbol: "USDC", decimals: 2 }];
expect(resolveTokenDecimals(USDC, state)).toBe(6);
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
expect(resolveTokenDecimals(USDC, sources)).toBe(6);
});
test("reads a token the user tracks", () => {
@@ -124,7 +126,8 @@ describe("resolveTokenDecimals", () => {
// answers 0 is used rather than fallen past like the unusable entry above.
test("uses a bundled scale of zero", () => {
state.trackedTokens = [{ address: SLP, symbol: "SLP", decimals: 18 }];
expect(resolveTokenDecimals(SLP, state)).toBe(0);
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
expect(resolveTokenDecimals(SLP, sources)).toBe(0);
});
test("uses a tracked scale of zero", () => {
+169
View File
@@ -0,0 +1,169 @@
// The approval screen for a site's Sepolia transfer of the contract at USDC's
// mainnet address, while Sepolia is active.
//
// The bundled token list holds Ethereum Mainnet contracts. The contract at the
// same address on Sepolia is another token, but the screen named it USDC and
// scaled its amount at USDC's decimals
// (https://git.eeqj.de/sneak/AutistMask/issues/632). The bundled list now names
// a token only on Ethereum Mainnet. The same screen for a mainnet transfer of
// USDC, while Sepolia is active, still names USDC.
//
// Driven through the real approval screen, against the minimal DOM stub
// tests/approvalTokenNetwork.test.js uses.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const approval = require("../src/popup/views/approval");
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// USDC's address on Ethereum Mainnet, where the bundled list names it.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// FROM sends 1234500000 base units to RECIPIENT.
const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// One line of the decoded details: its label and what is drawn under it.
function decodedLine(label) {
return node("approve-tx-decoded-details")
.innerHTML.split('<div class="mb-2">')
.find((line) =>
line.startsWith(`<div class="text-muted">${label}</div>`),
);
}
// Open the approval screen, with Sepolia active, for the site's transaction
// sending TRANSFER_DATA to the contract at USDC's address on the network with
// chain id `chainId`.
async function showTransfer(chainId) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId,
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: USDC,
value: "0x0",
data: TRANSFER_DATA,
accessList: [],
},
});
},
};
state.networkId = "sepolia";
state.trackedTokens = [];
state.networkTrackedTokens = {};
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances: [] },
],
},
];
state.currentView = null;
approval.init({});
await approval.show(1);
}
test("names no token, and states the amount in base units", async () => {
await showTransfer(NETWORKS.sepolia.chainId);
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-to").innerHTML).not.toContain("USDC");
expect(decodedLine("Token")).toContain(
'<div class="font-bold">Unknown token</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
);
});
// A transaction is named for its own network, not the active one.
test("a mainnet transfer of USDC is still named and scaled as USDC", async () => {
await showTransfer(NETWORKS.mainnet.chainId);
expect(state.currentView).toBe("approve-tx");
expect(decodedLine("Token")).toContain('<div class="font-bold">USDC</div>');
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 USDC</div>',
);
});
@@ -45,6 +45,7 @@ async function holdRefresh(page) {
holders: null,
},
];
return [];
});
const ticking = page.tick();
await page.settle();
@@ -720,6 +720,7 @@ describe("the popup", () => {
tokenRow(TRACKED, "TRK"),
tokenRow(UNTRACKED, "TST"),
];
return [];
});
const ticking = page.tick();
await page.settle();
@@ -764,6 +765,7 @@ describe("the popup", () => {
addr.tokenBalances = after
? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")]
: [tokenRow(TRACKED, "TRK")];
return [];
});
anotherPageSaves(page, { useBlockscout: after });
+81
View File
@@ -0,0 +1,81 @@
// The token screen, the Send screen and the confirmation screen for the
// contract at USDC's mainnet address. No address holds it and the user does
// not track it, so only the bundled token list can name it.
//
// The bundled token list holds Ethereum Mainnet contracts, so these screens
// name the token from it on Ethereum Mainnet only
// (https://git.eeqj.de/sneak/AutistMask/issues/632). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressToken for USDC's address, on `networkId`.
function bootOnToken(networkId) {
return bootPopup(
unversionedValidProfile({
networkId,
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: USDC,
}),
);
}
// AddressToken's Send, then Review for 1 token to RECIPIENT.
async function reviewSend(boot) {
await boot.click("btn-address-token-send");
expect(boot.visibleViews()).toEqual(["send"]);
boot.node("send-to").value = RECIPIENT;
boot.node("send-amount").value = "1";
await boot.click("btn-send-review");
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
}
describe("on Ethereum Mainnet, the bundled list names the token", () => {
test("on the token screen", async () => {
const boot = await bootOnToken("mainnet");
expect(boot.visibleViews()).toEqual(["address-token"]);
expect(boot.text("address-token-title")).toContain("USDC");
const info = boot.node("address-token-contract-info").innerHTML;
expect(info).toContain(
'<span class="text-muted">Decimals:</span> 6</div>',
);
});
test("on the Send screen and the confirmation screen", async () => {
const boot = await bootOnToken("mainnet");
await reviewSend(boot);
expect(boot.text("send-balance")).toContain("USDC");
expect(boot.text("confirm-amount")).toContain("USDC");
});
});
describe("with Sepolia active, the bundled list does not name the token", () => {
test("on the token screen", async () => {
const boot = await bootOnToken("sepolia");
expect(boot.visibleViews()).toEqual(["address-token"]);
expect(boot.text("address-token-title")).not.toContain("USDC");
const info = boot.node("address-token-contract-info").innerHTML;
expect(info).not.toContain("Decimals:");
});
test("on the Send screen and the confirmation screen", async () => {
const boot = await bootOnToken("sepolia");
await reviewSend(boot);
expect(boot.text("send-balance")).not.toContain("USDC");
expect(boot.text("confirm-amount")).not.toContain("USDC");
});
});
+2 -1
View File
@@ -969,7 +969,8 @@ test("a token added by its contract address is listed on the address screen (#15
await visible(env.page, "#view-address");
// No wait: the confirm renders the balance list before it shows the
// screen, and nothing renders the list again while the screen is up.
// screen. The refresh it starts draws the list again, with the row still
// in it.
const row = env.page.locator(
'#address-balances [data-token="' + STUB_TOKEN.address + '"]',
{ hasText: STUB_TOKEN.symbol },
+3 -1
View File
@@ -111,13 +111,15 @@ function respondWith(items) {
}
// Fetch and place the result exactly where refreshBalances() places it, so the
// approval screens read what a real refresh would have left on state.
// approval screens read what a real refresh on Ethereum Mainnet would have
// left on state.
async function fetchOnto(items, trackedTokens = []) {
respondWith(items);
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
trackedTokens,
"0x1",
);
state.trackedTokens = trackedTokens;
state.wallets = [
+1
View File
@@ -48,6 +48,7 @@ async function boot(stored) {
for (const wallet of wallets) {
for (const addr of wallet.addresses) addr.balance = chainBalance;
}
return [];
});
return { page, refreshBalances };
}
+1
View File
@@ -88,6 +88,7 @@ describe.each([
showFlash: (msg) => flashes.push(msg),
escapeHtml: (s) => s,
goBack: () => {},
trackToken: () => {},
}));
require("../src/popup/views/" + view).init({
+500
View File
@@ -0,0 +1,500 @@
// Home, AddressDetail and AddressToken drop a transaction history answer that
// lands after a network switch. A dropped answer draws no rows and saves no
// fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657).
//
// Most tests hold the history request open, switch the network or what the
// screen shows, and then let the answer land. The views run against a minimal
// DOM stub, as in tests/transactionListCancelled.test.js, and over
// tests/support/storageStub.js.
const { makeStorageStub } = require("./support/storageStub");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: jest.fn(async () => []),
}));
jest.mock("../src/shared/ens", () => ({
resolveEnsNames: jest.fn(async () => new Map()),
}));
const { fetchRecentTransactions } = require("../src/shared/transactions");
const { resolveEnsNames } = require("../src/shared/ens");
const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
const { storedNetworkIs } = require("../src/shared/storedSettings");
const home = require("../src/popup/views/home");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const ADDRESS = "0x1111111111111111111111111111111111111111";
const OTHER_ADDRESS = "0x2222222222222222222222222222222222222222";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// A contract that sends a token claiming USDT's symbol. Spotting the spoof
// adds this contract to the fraud contracts.
const SPOOF_CONTRACT = "0x3333333333333333333333333333333333333333";
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
// A profile on Ethereum Mainnet with two addresses, "Use Blockscout" on and no
// fraud contracts.
function profile() {
return {
schemaVersion: STATE_SCHEMA_VERSION,
hasWallet: true,
wallets: [
{
type: "hd",
name: "Main",
xpub: "xpub-main",
encryptedSecret: "encrypted-secret",
nextIndex: 2,
addresses: [
{ address: ADDRESS, balance: "1.0", tokenBalances: [] },
{
address: OTHER_ADDRESS,
balance: "0.0",
tokenBalances: [],
},
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
useBlockscout: true,
fraudContracts: [],
};
}
// What Blockscout answers for ADDRESS on Ethereum Mainnet:
//
// - a transfer of ETH from ADDRESS;
// - a transfer of the fake USDT from SPOOF_CONTRACT.
function history() {
return [
{
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
blockNumber: 2,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
rawAmount: "1000000000000000000",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
chainId: "0x1",
},
{
hash: "0x0d6a6e0a0d1b7d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c",
blockNumber: 1,
from: SPOOF_CONTRACT,
to: ADDRESS,
value: "100.0000",
exactValue: "100.0",
rawAmount: "100000000",
rawUnit: "base units",
symbol: "USDT",
timestamp: 1790000000,
isError: false,
directionLabel: "Received",
direction: "received",
contractAddress: SPOOF_CONTRACT,
chainId: "0x1",
},
];
}
// The next history request waits until the returned function is called, and
// then answers with `answer`.
function holdHistory(answer = history()) {
let land;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve) => {
land = () => resolve(answer);
}),
);
return () => land();
}
// The next history request waits until the returned function is called, and
// then fails.
function holdFailure() {
let fail;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve, reject) => {
fail = () => reject(new Error("Blockscout is down"));
}),
);
return () => fail();
}
// Another write holds the lock until the returned function is called. Every
// save waits until then.
function holdLock() {
let release;
const held = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
release = resolve;
}),
);
return async () => {
release();
await held;
};
}
function makeElement(id) {
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: () => {},
remove: () => {},
contains: () => false,
toggle: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Lets every promise that is not held open settle.
function settle() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
let storage;
beforeEach(async () => {
storage = makeStorageStub({ autistmask: profile() });
globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged },
};
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
await loadState();
state.viewStack = [];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
const ctx = { pageClosed: new AbortController().signal };
addressDetail.init(ctx);
addressToken.init(ctx);
});
afterEach(() => {
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
});
const HOME = {
name: "Home",
listId: "home-tx-list",
open: () => home.render({ pageClosed: new AbortController().signal }),
};
const ADDRESS_DETAIL = {
name: "AddressDetail",
listId: "tx-list",
open: () => addressDetail.show(),
};
const ADDRESS_TOKEN = {
name: "AddressToken",
listId: "address-token-tx-list",
open: () => addressToken.show(),
};
// Three ways a test switches the network:
//
// - made in Settings: the page and storage have both switched;
// - saved elsewhere: storage has switched and the page has not;
// - not yet stored: the page has switched and storage has not.
const SWITCHES = [
{
how: "made in Settings",
to: (networkId) => onChainSwitch(networkId),
},
{
// Another page or the background stored the switch, and this page has
// not followed it yet.
how: "saved elsewhere",
to: async (networkId) => {
const record = storage.read("autistmask");
applyChainSwitchFields(record, networkId);
storage.write("autistmask", record);
},
},
{
// The switch's own save has changed the page and has not yet written
// storage.
how: "not yet stored",
to: async (networkId) => {
applyChainSwitchFields(state, networkId);
},
},
];
// The list still shows `before`, and neither the page nor storage holds a
// fraud contract.
function expectNothingAdded(screen, before) {
expect(node(screen.listId).innerHTML).toBe(before);
expect(state.fraudContracts).toEqual([]);
expect(storage.read("autistmask").fraudContracts).toEqual([]);
}
describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands on the same network is drawn and saved", async () => {
const land = holdHistory();
screen.open();
land();
await settle();
expect(node(screen.listId).innerHTML).toContain("tx-row");
expect(state.fraudContracts).toEqual([SPOOF_CONTRACT]);
expect(storage.read("autistmask").fraudContracts).toEqual([
SPOOF_CONTRACT,
]);
});
test.each(SWITCHES)(
"an answer that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
// The answer is the transfer of ETH alone, which adds no fraud contract.
// Nothing is saved, so only the check before the save can drop the answer.
test.each(SWITCHES)(
"an answer without a fraud contract that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory(history().slice(0, 1));
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
test.each(SWITCHES)(
"a request that fails after a switch $how shows no message",
async ({ to }) => {
const fail = holdFailure();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
},
);
// The user picks Sepolia in Settings while another write holds the lock.
// The answer lands, and its save waits behind the switch.
test("an answer whose save waits behind a switch made in Settings adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
const switching = onChainSwitch("sepolia");
land();
await settle();
await releaseLock();
await switching;
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. Another page or the
// background then stores a switch.
test("an answer whose save waits while a switch is saved elsewhere adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. The page then switches,
// and storage has not.
test("an answer whose save waits while the page switches adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
applyChainSwitchFields(state, "sepolia");
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails after a switch leaves the new network's rows", async () => {
const fail = holdFailure();
screen.open();
await onChainSwitch("sepolia");
fetchRecentTransactions.mockImplementationOnce(async () => history());
screen.open();
await settle();
const drawn = node(screen.listId).innerHTML;
fail();
await settle();
expect(drawn).toContain("tx-row");
expect(node(screen.listId).innerHTML).toBe(drawn);
});
});
describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands once the screen shows another address adds nothing", async () => {
const land = holdHistory();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
land();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails once the screen shows another address shows no message", async () => {
const fail = holdFailure();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
});
// The answer is the transfer of ETH alone, which adds no fraud contract.
// Nothing is saved, so only the check after the name lookup can drop the
// answer.
test("an answer whose name lookup lands after a switch adds nothing", async () => {
let landNames;
resolveEnsNames.mockImplementationOnce(
() =>
new Promise((resolve) => {
landNames = () => resolve(new Map());
}),
);
fetchRecentTransactions.mockImplementationOnce(async () =>
history().slice(0, 1),
);
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await onChainSwitch("sepolia");
landNames();
await settle();
expectNothingAdded(screen, before);
});
});
test("AddressToken: an answer that lands once the screen shows another token adds nothing", async () => {
const land = holdHistory();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
land();
await settle();
expectNothingAdded(ADDRESS_TOKEN, before);
});
test("AddressToken: a request that fails once the screen shows another token shows no message", async () => {
const fail = holdFailure();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
fail();
await settle();
expect(node(ADDRESS_TOKEN.listId).innerHTML).toBe(before);
});
test("storedNetworkIs(): a failed read counts as no", async () => {
globalThis.chrome.storage.local = {
get: async () => {
throw new Error("storage is down");
},
};
await expect(storedNetworkIs("mainnet")).resolves.toBe(false);
});
+7 -1
View File
@@ -192,7 +192,13 @@ describe("the balance-list spam gate", () => {
},
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
// On Ethereum Mainnet, the only network the known-token list covers.
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
[],
"0x1",
);
expect(balances).toHaveLength(1);
expect(balances[0].holders).toBeNull();
});
+17 -3
View File
@@ -67,13 +67,15 @@ beforeEach(() => {
});
describe("resolveTokenSymbol", () => {
// The bundled list is read only for a token on Ethereum Mainnet.
test("reads the bundled list", () => {
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
expect(resolveTokenSymbol(USDC, { chainId: "0x1" })).toBe("USDC");
});
test("prefers the bundled list over a tracked entry", () => {
state.trackedTokens = [{ address: USDC, symbol: "NOTUSDC" }];
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
expect(resolveTokenSymbol(USDC, sources)).toBe("USDC");
});
test("reads a token the user tracks", () => {
@@ -104,7 +106,19 @@ describe("resolveTokenSymbol", () => {
test("rejects a tracked entry claiming a bundled ticker it is not", () => {
// NOVEL_TOKEN is not the real USDC contract, so it may not wear USDC.
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }];
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
expect(resolveTokenSymbol(NOVEL_TOKEN, sources)).toBeNull();
});
// The bundled list holds mainnet contracts, so on Sepolia any contract may
// carry one of its symbols.
test("on Sepolia, reads a tracked entry carrying a bundled ticker", () => {
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }];
const sources = {
chainId: "0xaa36a7",
trackedTokens: state.trackedTokens,
};
expect(resolveTokenSymbol(NOVEL_TOKEN, sources)).toBe("USDC");
});
test("rejects an explorer entry claiming the native ETH ticker", () => {
+269
View File
@@ -167,3 +167,272 @@ test.each([
);
},
);
// This answer passes both endpoint checks on Ethereum Mainnet.
const PASSING_ANSWER = {
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
};
// Both endpoint checks read this answer as an error. The RPC check reads the
// answer's `error`. The Blockscout check reads the answer's `ok`.
const ERROR_ANSWER = {
ok: false,
status: 500,
json: async () => ({ error: { code: -32603, message: "Internal error" } }),
};
// The user types a URL in Settings and taps "Save". Before the endpoint answers,
// the user types a second URL and taps "Save" again. The second check passes
// first. The second URL is stored, and the flash message reads "Saved.". The
// first check then ends in one of three ways:
//
// - It passes.
// - It fails, because fetch cannot reach the endpoint.
// - It gets an answer that holds an error.
//
// None of the three changes the stored URL or the flash message
// (https://git.eeqj.de/sneak/AutistMask/issues/698).
test.each([
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "passes",
},
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "fails",
},
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "answers with an error",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "passes",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "fails",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "answers with an error",
},
])(
"$button keeps the second tap's result when the first check $first last",
async ({ button, input, field, first }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve, reject) => {
checks.push({ resolve, reject });
}),
);
const secondUrl = "https://second.example.invalid";
page.node(input).value = "https://first.example.invalid";
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
page.node(input).value = secondUrl;
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
checks[1].resolve(PASSING_ANSWER);
await Promise.all(secondTap);
await page.settle();
expect(page.text("flash-msg")).toBe("Saved.");
if (first === "passes") {
checks[0].resolve(PASSING_ANSWER);
} else if (first === "answers with an error") {
checks[0].resolve(ERROR_ANSWER);
} else {
// fetch rejects when it cannot reach the endpoint.
checks[0].reject(new TypeError("Failed to fetch"));
}
await Promise.all(firstTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(secondUrl);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
// The user types a URL in Settings and taps "Save". Before the endpoint answers,
// the user empties the input and taps "Save" again. The flash message asks for
// a URL. The first check then passes. The flash message still asks for a URL,
// and the stored URL is unchanged.
test.each([
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
message: "Please enter an RPC URL.",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
message: "Please enter a Blockscout API URL.",
},
])(
"$button keeps the empty-input message when the first check passes last",
async ({ button, input, field, message }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
page.node(input).value = "https://typed.example.invalid";
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
page.node(input).value = "";
await page.click(button);
expect(page.text("flash-msg")).toBe(message);
answer(PASSING_ANSWER);
await Promise.all(firstTap);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(message);
},
);
// The user types a URL in Settings and taps "Save". Another write holds the
// lock. So the save waits for the lock once the check passes. The user then
// taps "Save" again on the same URL. When the lock is free, the first save
// stores nothing and shows no flash message. The second check then passes, and
// the second save stores the URL.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing for an earlier tap still waiting for the lock",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve) => {
checks.push(resolve);
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
checks[0](PASSING_ANSWER);
await page.settle();
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
releaseLock();
await lockHeld;
await Promise.all(firstTap);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.text("flash-msg")).toBe("Testing endpoint...");
checks[1](PASSING_ANSWER);
await Promise.all(secondTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
// The user taps "Save", and the check passes. The save then begins its storage
// write, and this test keeps the write waiting. The user taps "Save" again, and
// the second check cannot reach the endpoint. When the write ends, the flash
// message still reads "Could not reach endpoint.". The write stores the URL
// anyway, because the write began before the second tap.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button keeps the second tap's message when tapped again during the write",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve, reject) => {
checks.push({ resolve, reject });
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
const { set } = page.storage.local;
const write = set.getMockImplementation();
let writeBegan = false;
let releaseWrite;
const writeHeld = new Promise((resolve) => {
releaseWrite = resolve;
});
set.mockImplementationOnce(async (items) => {
writeBegan = true;
await writeHeld;
await write(items);
});
checks[0].resolve(PASSING_ANSWER);
await page.settle();
expect(writeBegan).toBe(true);
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
// fetch rejects when it cannot reach the endpoint.
checks[1].reject(new TypeError("Failed to fetch"));
await Promise.all(secondTap);
await page.settle();
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
releaseWrite();
await Promise.all(firstTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
},
);
+134
View File
@@ -0,0 +1,134 @@
// A token that the spoof rule refuses never shows in the balance list, even
// tracked at zero balance with "Show tracked tokens with zero balance" on, and
// the two add-token screens refuse to track one
// (https://git.eeqj.de/sneak/AutistMask/issues/705). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
// Claims the symbol of the bundled USDT from another contract.
const FAKE_USDT = {
address: TOKEN_ADDRESS,
symbol: "USDT",
decimals: 6,
name: "Tether USD",
};
// A Sepolia contract that reports USDT. The bundled list holds Ethereum Mainnet
// contracts, so the spoof rule does not check this symbol on Sepolia
// (https://git.eeqj.de/sneak/AutistMask/issues/632).
const SEPOLIA_USDT = {
address: TOKEN_ADDRESS,
symbol: "USDT",
decimals: 6,
name: "Test USDT",
};
// A symbol the bundled list does not have, so the spoof rule passes it.
const TEST_TOKEN = {
address: "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const REFUSED_FLASH = "Not added: this token fakes a known symbol.";
afterEach(() => {
cleanupPopup();
});
test("on Ethereum Mainnet, a fake token tracked at zero balance is not listed while zero balances are shown", async () => {
const boot = await bootPopup(
unversionedValidProfile({
networkId: "mainnet",
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [FAKE_USDT, TEST_TOKEN],
showZeroBalanceTokens: true,
}),
);
const rows = boot.node("address-balances").innerHTML;
expect(rows).toContain(`data-token="${TEST_TOKEN.address.toLowerCase()}"`);
expect(rows).not.toContain(
`data-token="${FAKE_USDT.address.toLowerCase()}"`,
);
});
// Each screen is opened from AddressDetail by the buttons named, in order.
describe.each([
[
"AddToken",
["btn-add-token"],
"add-token",
"add-token-address",
"btn-add-token-confirm",
],
[
"SettingsAddToken",
["btn-settings", "btn-settings-add-token"],
"settings-addtoken",
"settings-addtoken-address",
"btn-settings-addtoken-manual",
],
])("%s", (_, opening, view, field, button) => {
test("on Ethereum Mainnet, refuses a fake token by contract address", async () => {
const boot = await bootPopup(
unversionedValidProfile({
networkId: "mainnet",
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
for (const id of opening) await boot.click(id);
expect(boot.visibleViews()).toEqual([view]);
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: FAKE_USDT.symbol,
decimals: FAKE_USDT.decimals,
name: FAKE_USDT.name,
});
boot.node(field).value = FAKE_USDT.address;
await boot.click(button);
expect(boot.text("flash-msg")).toBe(REFUSED_FLASH);
expect(boot.visibleViews()).toEqual([view]);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([]);
});
test("on Sepolia, adds a contract that reports USDT and lists it", async () => {
const boot = await bootPopup(
unversionedValidProfile({
networkId: "sepolia",
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
showZeroBalanceTokens: true,
}),
);
for (const id of opening) await boot.click(id);
expect(boot.visibleViews()).toEqual([view]);
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: SEPOLIA_USDT.symbol,
decimals: SEPOLIA_USDT.decimals,
name: SEPOLIA_USDT.name,
});
boot.node(field).value = SEPOLIA_USDT.address;
await boot.click(button);
expect(boot.text("flash-msg")).not.toBe(REFUSED_FLASH);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
expect.objectContaining(SEPOLIA_USDT),
]);
// SettingsAddToken stays up after adding, so this draws AddressDetail.
require("../src/popup/views/addressDetail").show();
expect(boot.node("address-balances").innerHTML).toContain(
`data-token="${SEPOLIA_USDT.address.toLowerCase()}"`,
);
});
});
+3 -2
View File
@@ -279,10 +279,11 @@ async function bootPopup(stored, options) {
getAddressValue: () => ({ usd: null, partial: false }),
}));
// scanForAddresses() answers in the real one's shape, a scan that found
// no used address: AddWallet reads `addresses` from it.
// no used address: AddWallet reads `addresses` from it. refreshBalances()
// answers as a refresh that read no tracked token's balance.
jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => {}),
refreshBalances: jest.fn(async () => []),
dropUntrackedTokens: jest.requireActual("../../src/shared/balances")
.dropUntrackedTokens,
lookupTokenInfo: jest.fn(async () => null),
+110 -16
View File
@@ -72,6 +72,11 @@ const {
const { renderSendTokenSelect } = require("../src/popup/views/send");
const { state } = require("../src/shared/state");
const { debugFetch } = require("../src/shared/log");
const { NETWORKS } = require("../src/shared/networks");
// The bundled list's symbols are known symbols only on Ethereum Mainnet.
const MAINNET = NETWORKS.mainnet.chainId;
const SEPOLIA = NETWORKS.sepolia.chainId;
// The fake "Ethereum" token with symbol "ETH" from the attack documented in
// README.md, given a holder count high enough to clear every other filter so
@@ -116,14 +121,29 @@ describe("the shared rule", () => {
});
test("a known symbol from its own contract is not a spoof", () => {
expect(isSpoofedSymbol("USDC", USDC_CONTRACT)).toBe(false);
expect(isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase())).toBe(
false,
);
expect(isSpoofedSymbol("USDC", USDC_CONTRACT, MAINNET)).toBe(false);
expect(
isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase(), MAINNET),
).toBe(false);
});
test("a known symbol from another contract is a spoof", () => {
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT, MAINNET)).toBe(true);
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so on Sepolia any contract may carry one of its
// symbols.
test("on Sepolia, a bundled symbol from any contract is not a spoof", () => {
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT, SEPOLIA)).toBe(false);
expect(isSpoofedSymbol("USDC", USDC_CONTRACT, SEPOLIA)).toBe(false);
});
test("on Sepolia, the native asset's labels are still reserved", () => {
expect(isSpoofedSymbol("ETH", FAKE_ETH_CONTRACT, SEPOLIA)).toBe(true);
expect(isSpoofedSymbol("SepoliaETH", FAKE_ETH_CONTRACT, SEPOLIA)).toBe(
true,
);
});
test("a symbol that is not in the table is not judged here", () => {
@@ -164,7 +184,9 @@ describe("the shared rule: symbols that render as a known symbol", () => {
test("ASCII padding does not buy a pass", () => {
expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("\tETH\n", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT, MAINNET)).toBe(
true,
);
});
test("non-breaking and other Unicode spaces do not either", () => {
@@ -243,7 +265,9 @@ describe("the shared rule: symbols that render as a known symbol", () => {
test("compatibility forms fold onto the symbol they imitate", () => {
expect(isSpoofedSymbol(FULLWIDTH_ETH, FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT)).toBe(true);
expect(
isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT, MAINNET),
).toBe(true);
});
// The two knowingly open classes, asserted here so that the boundary is
@@ -271,8 +295,10 @@ describe("the shared rule: symbols that render as a known symbol", () => {
});
test("a genuine contract still bears its own padded symbol", () => {
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT)).toBe(false);
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT)).toBe(false);
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT, MAINNET)).toBe(false);
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT, MAINNET)).toBe(
false,
);
});
// Normalization must not invent a match. Interior ASCII whitespace is
@@ -296,7 +322,7 @@ describe("the shared rule: symbols that render as a known symbol", () => {
expect(symbol).toMatch(/^[!-~]+$/);
if (addresses === null) continue;
for (const address of addresses) {
expect(isSpoofedSymbol(symbol, address)).toBe(false);
expect(isSpoofedSymbol(symbol, address, MAINNET)).toBe(false);
}
}
});
@@ -317,7 +343,7 @@ describe("the shared rule: symbols that render as a known symbol", () => {
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),
isSpoofedSymbol(t.symbol, t.address, MAINNET),
).map((t) => t.symbol + " @ " + t.address);
expect(filtered).toEqual([]);
});
@@ -360,7 +386,9 @@ describe("the shipped token list", () => {
expect(shared.length).toBeGreaterThan(0);
for (const [, list] of shared) {
for (const t of list) {
expect(isSpoofedSymbol(t.symbol, t.address)).toBe(false);
expect(isSpoofedSymbol(t.symbol, t.address, MAINNET)).toBe(
false,
);
}
}
});
@@ -404,7 +432,7 @@ describe("the shipped token list", () => {
[...addresses].sort(),
);
for (const address of addresses) {
expect(isSpoofedSymbol(symbol, address)).toBe(false);
expect(isSpoofedSymbol(symbol, address, MAINNET)).toBe(false);
}
}
});
@@ -421,7 +449,9 @@ describe("the shipped token list", () => {
}
for (const [symbol, list] of bySymbol) {
if (list.length < 2) continue;
expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT, MAINNET)).toBe(
true,
);
}
});
});
@@ -506,10 +536,40 @@ describe("surface 2: the Send token selector", () => {
}
beforeEach(() => {
state.networkId = "mainnet";
state.fraudContracts = [];
state.hideLowHolderTokens = true;
});
afterAll(() => {
state.networkId = "mainnet";
});
// A token reporting USDC's symbol from another contract.
function otherUsdc() {
return {
address: FAKE_ETH_CONTRACT,
symbol: "USDC",
decimals: 6,
balance: "12.5",
holders: 900000,
};
}
test("a bundled symbol from another contract is not selectable", () => {
render([otherUsdc()]);
expect(select.children).toEqual([]);
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so on Sepolia any contract may carry its symbols.
test("with Sepolia active, a bundled symbol from another contract is selectable", () => {
state.networkId = "sepolia";
render([otherUsdc()]);
expect(select.children).toHaveLength(1);
expect(select.children[0].value).toBe(FAKE_ETH_CONTRACT);
});
test("a fake ETH token is not selectable", () => {
render([
{
@@ -627,7 +687,12 @@ describe("surface 3: the balance list", () => {
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
[],
MAINNET,
);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe(" USDC ");
});
@@ -641,11 +706,40 @@ describe("surface 3: the balance list", () => {
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
[],
MAINNET,
);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC");
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so on Sepolia any contract may carry its symbols.
test("a bundled symbol from another contract is listed on Sepolia only", async () => {
respondWith([fakeEthItem({ symbol: "USDC", decimals: "6" })]);
expect(
await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], MAINNET),
).toEqual([]);
const sepolia = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
[],
SEPOLIA,
);
expect(sepolia).toHaveLength(1);
expect(sepolia[0].symbol).toBe("USDC");
});
test("a fake ETH token is filtered on Sepolia too", async () => {
respondWith([fakeEthItem()]);
expect(
await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], SEPOLIA),
).toEqual([]);
});
// The trap in this change: the user's real ETH balance is not an ERC-20
// and is fetched over RPC in refreshBalances, so it never passes through
// this loop at all. An explorer row that is not an ERC-20 is dropped
+103 -2
View File
@@ -133,13 +133,13 @@ function trackedRow(balance, decimals = null, holders = null) {
};
}
async function refresh(addresses, trackedTokens) {
async function refresh(addresses, trackedTokens, networkId = "mainnet") {
await refreshBalances(
[{ addresses }],
RPC_URL,
EXPLORER_URL,
trackedTokens,
"mainnet",
networkId,
new AbortController().signal,
);
return addresses;
@@ -307,3 +307,104 @@ test("a tracked token wearing a known symbol from another contract is not listed
);
expect(first.tokenBalances).toEqual([]);
});
test("the refresh resolves with each tracked token whose balance it read at every address", async () => {
const flaky = "0x9999999999999999999999999999999999999999";
holdings[TOKEN] = { [FIRST]: 2000000n, [SECOND]: 0n };
// balanceOf answers at FIRST and fails at SECOND.
holdings[flaky] = { [FIRST]: 2000000n };
const read = await refreshBalances(
[
{
addresses: [
{ address: FIRST, tokenBalances: [] },
{ address: SECOND, tokenBalances: [] },
],
},
],
RPC_URL,
EXPLORER_URL,
[
TRACKED,
{ address: flaky, symbol: "FLK", name: "Flaky", decimals: 6 },
// A known symbol from another contract: its balance is not read.
{ address: OTHER, symbol: "USDC", name: "Fake", decimals: 6 },
],
"mainnet",
new AbortController().signal,
);
expect(read).toEqual([TOKEN]);
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// Ethereum Mainnet contracts. A token outside the tracked tokens with an
// unknown holder count is listed only when the bundled list names it.
test("an untracked bundled token with an unknown holder count is listed on Ethereum Mainnet only", async () => {
explorerRows = [explorerRow(USDC, "USDC", 5000000, null)];
const [mainnet] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[],
);
expect(mainnet.tokenBalances).toEqual([
{
address: USDC,
name: "USDC",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: null,
},
]);
const [sepolia] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[],
"sepolia",
);
expect(sepolia.tokenBalances).toEqual([]);
});
test("a tracked token at a bundled token's address takes the bundled scale on Ethereum Mainnet only", async () => {
holdings[USDC] = { [FIRST]: 5000000n };
// The tracked entry records no decimals, and Blockscout fails.
const tracked = [
{ address: USDC, symbol: "USDC", name: "USD Coin", decimals: null },
];
const row = (balance) => ({
address: USDC,
name: "USD Coin",
symbol: "USDC",
decimals: null,
balance: balance,
holders: null,
});
const [mainnet] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
tracked,
);
expect(mainnet.tokenBalances).toEqual([row("5.0")]);
const [sepolia] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
tracked,
"sepolia",
);
expect(sepolia.tokenBalances).toEqual([row(null)]);
});
test("with Sepolia active, a tracked token wearing a bundled symbol from another contract is listed", async () => {
holdings[OTHER] = { [FIRST]: 5000000n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[{ address: OTHER, symbol: "USDC", name: "Other", decimals: 6 }],
"sepolia",
);
expect(first.tokenBalances).toEqual([
{
address: OTHER,
name: "Other",
symbol: "USDC",
decimals: null,
balance: "5.0",
holders: null,
},
]);
});
+349 -2
View File
@@ -162,7 +162,9 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
"sepolia",
expect.anything(),
);
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
// No refresh has read the new token's balance yet.
const added = { ...SEPOLIA_TOKEN, balanceNotFetched: true };
expect(stored().trackedTokens).toEqual([added]);
await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet");
@@ -174,7 +176,7 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
await chooseNetwork(boot, "sepolia");
expect(stored().networkId).toBe("sepolia");
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored().trackedTokens).toEqual([added]);
const onSepolia = boot.node("settings-tracked-tokens").innerHTML;
expect(onSepolia).toContain("TST");
expect(onSepolia).not.toContain("DAI");
@@ -267,6 +269,192 @@ test("a token tracked on Sepolia when another page switched there is not tracked
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// Another popup page saves a switch to Sepolia, where TST is tracked. Before
// taking that switch, this page adds a token on Ethereum Mainnet. The add's
// save adds the token to Ethereum Mainnet's list and leaves Sepolia's list as
// it was.
test("a token added before this page takes another page's switch is tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet", {
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
});
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [SEPOLIA_TOKEN],
networkTrackedTokens: {
mainnet: [MAINNET_TOKEN],
sepolia: [SEPOLIA_TOKEN],
},
});
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
await boot.click("btn-settings-addtoken-manual");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
balanceNotFetched: true,
},
]);
});
// Another popup page tracks DAI on Ethereum Mainnet and then saves a switch to
// Sepolia. This page does not track DAI. Before taking that switch, this page
// adds a token on Ethereum Mainnet. The add's save puts both DAI and the new
// token on Ethereum Mainnet's list.
test("a token added before this page takes another page's switch joins that page's tokens on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
await boot.click("btn-settings-addtoken-manual");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
balanceNotFetched: true,
},
]);
});
// Another popup page saves a switch to Sepolia, where TST is tracked. Before
// taking that switch, this page removes DAI on Ethereum Mainnet, as the `[x]`
// on a tracked token in Settings does. The removal's save drops DAI from
// Ethereum Mainnet's list and leaves Sepolia's list as it was.
test("a token removed before this page takes another page's switch is gone from Ethereum Mainnet", async () => {
const boot = await bootPopup(
unversionedValidProfile({
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
}),
);
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [SEPOLIA_TOKEN],
networkTrackedTokens: {
mainnet: [MAINNET_TOKEN],
sepolia: [SEPOLIA_TOKEN],
},
});
const { state, saveState } = require("../src/shared/state");
state.trackedTokens.splice(0, 1);
await saveState();
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// Another popup page tracks DAI on Ethereum Mainnet and then saves a switch to
// Sepolia. That switch puts DAI away under Ethereum Mainnet. This page has not
// taken the switch yet. A switch to Sepolia in this page's Settings keeps DAI
// on Ethereum Mainnet's list.
test("a token another page tracked before its own switch stays tracked on Ethereum Mainnet", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// This page switches to Sepolia. Another popup page has not taken that switch,
// and its save adds a token to Ethereum Mainnet's list. This page switches back
// and tracks that token.
test("a token another page added on Ethereum Mainnet is tracked when this page switches back there", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const newToken = {
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
};
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkTrackedTokens: { mainnet: [MAINNET_TOKEN, newToken] },
});
await chooseNetwork(boot, "mainnet");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN, newToken]);
});
// This page tracks DAI on Ethereum Mainnet and switches to Sepolia. Another
// popup page has not taken that switch, and its save removes DAI from Ethereum
// Mainnet's list. This page switches back and does not track DAI again.
test("a token another page removed on Ethereum Mainnet stays removed when this page switches back there", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkTrackedTokens: { mainnet: [] },
});
await chooseNetwork(boot, "mainnet");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.trackedTokens).toEqual([]);
});
// Another popup page records a fraud contract on Ethereum Mainnet after this
// page's last save. The switch to Sepolia drops that fraud contract, as the
// switch drops this page's own.
@@ -334,10 +522,169 @@ test("a token added while a switch in Settings waits for the lock stays tracked
symbol: "NEW",
decimals: 18,
name: "New Token",
balanceNotFetched: true,
},
]);
});
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while
// this page's switch to Sepolia waits for the lock. Another popup page holds
// the lock and stores its own switch to Sepolia, with DAI tracked on Ethereum
// Mainnet. This page's switch keeps DAI and the new token on Ethereum Mainnet's
// list.
test("a token added while a switch in Settings waits for another page's switch stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.click("btn-settings");
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
const select = boot.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await boot.settle();
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
await boot.settle();
releaseLock();
await lockHeld;
await switching;
await adding;
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
balanceNotFetched: true,
},
]);
});
// Another popup page holds the lock and stores its switch to Sepolia, with DAI
// tracked on Ethereum Mainnet. This page waits for the lock to take that
// switch. Meanwhile, the contract lookup on SettingsAddToken answers on
// Ethereum Mainnet. When this page takes that switch, DAI and the new token
// stay on Ethereum Mainnet's list.
test("a token added while this page waits to take another page's switch stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
await boot.storage.local.set({
autistmask: {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
},
});
await boot.settle();
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.settle();
releaseLock();
await lockHeld;
await adding;
await boot.settle();
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
balanceNotFetched: true,
},
]);
});
// This page tracks DAI on Ethereum Mainnet. Another popup page holds the lock
// and stores its switch to Sepolia, with DAI on Ethereum Mainnet's list. This
// page waits for the lock to take that switch. Meanwhile, the user removes DAI,
// as the `[x]` on a tracked token in Settings does. When this page takes that
// switch, DAI is gone from Ethereum Mainnet's list.
test("a token removed while this page waits to take another page's switch is gone from Ethereum Mainnet", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
await boot.storage.local.set({
autistmask: {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
},
});
await boot.settle();
const { state, saveState } = require("../src/shared/state");
state.trackedTokens.splice(0, 1);
const removing = saveState();
releaseLock();
await lockHeld;
await removing;
await boot.settle();
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
describe("Back after a switch from the Settings gear", () => {
test("SettingsAddToken no longer offers the common tokens", async () => {
const boot = await bootOnAddToken("mainnet");
+62
View File
@@ -144,6 +144,7 @@ function tokenTx(overrides = {}) {
isError: false,
contractAddress: USDC_CONTRACT,
holders: 3500000,
chainId: "0x1",
...overrides,
};
}
@@ -259,6 +260,32 @@ describe("known-symbol spoof verification", () => {
expect(result.newFraudContracts).toEqual([NOVEL_SPAM_CONTRACT]);
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so its symbols are not known symbols on Sepolia.
test("on Sepolia, a bundled symbol from another contract is kept", () => {
const sepoliaToken = tokenTx({
symbol: "USDC",
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 5000000,
chainId: "0xaa36a7",
});
const result = filterTransactions([sepoliaToken], filters());
expect(result.transactions).toEqual([sepoliaToken]);
expect(result.newFraudContracts).toEqual([]);
});
test('on Sepolia, "ETH" as an ERC-20 is still spoofed', () => {
const fakeEth = tokenTx({
symbol: "ETH",
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 5000000,
chainId: "0xaa36a7",
});
expect(filterTransactions([fakeEth], filters()).transactions).toEqual(
[],
);
});
test("the genuine contract for that symbol is not spoofed", () => {
const genuine = tokenTx();
const result = filterTransactions([genuine], filters());
@@ -1495,6 +1522,41 @@ describe("fetchRecentTransactions merge and dedup", () => {
expect(mainnet[0].chainId).toBe("0x1");
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled token list
// names a contract only on Ethereum Mainnet. On Sepolia, a call to the
// contract at USDC's mainnet address is labelled with the network's native
// currency. A call to any contract the wallet does not name gets that label.
test("a contract call on Sepolia is not named by the bundled list", async () => {
respondWith(
[
{
hash: "0x" + "b".repeat(64),
block_number: 21000095,
timestamp: TS,
from: { hash: VICTIM },
to: { hash: USDC_CONTRACT, is_contract: true },
value: "0",
method: "approve",
status: "ok",
},
],
[],
);
const sepolia = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
"0xaa36a7",
);
expect(sepolia[0].directionLabel).toBe("Approve");
expect(sepolia[0].symbol).toBe("SepoliaETH");
const mainnet = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
"0x1",
);
expect(mainnet[0].symbol).toBe("USDC");
});
test("the fake token transfer survives fetching and is then filtered", async () => {
respondWith(
[],
+854 -5
View File
@@ -1428,8 +1428,8 @@ describe("uniswap decoder", () => {
];
// execute() of these command bytes and inputs, decoded as sent by
// the user.
function decodeSteps(commands, inputs) {
// the user to `router`.
function decodeSteps(commands, inputs, router = ROUTER_ADDR) {
const data = buildExecute(
solidityPacked(
commands.map(() => "uint8"),
@@ -1438,7 +1438,7 @@ describe("uniswap decoder", () => {
inputs,
9999999999n,
);
return uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
return uniswap.decode(data, router, "0x1", {}, USER_ADDR);
}
// The first step: a V3 exact-in of 600 USDT for USDC, paid by the
@@ -2424,6 +2424,504 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/665 — a WRAP_ETH that
// sets the input side and pays its WETH to anyone but the router
// names that recipient on its Moved to line. On next each row read
// The swap step that spends it.
const ONE_ETH = 1000000000000000000n;
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
// The lines of a WRAP_ETH of 1 ETH whose Moved to line reads `to`.
const wrapLines = (to) => [
["Moved from the sender", "1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", to],
];
test.each([
{
kind: "a WRAP_ETH of 1 ETH to another address, then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x21],
inputs: [encodeWrapEth(OTHER_ADDR, ONE_ETH), SUB_PLAN],
lines: [
...wrapLines(OTHER_ADDR),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a WRAP_ETH of 1 ETH to address(1), then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x21],
inputs: [encodeWrapEth(MSG_SENDER, ONE_ETH), SUB_PLAN],
lines: [
...wrapLines("The sender"),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a WRAP_ETH of 1 ETH to another address, then a V3 exact-in of 600 USDT",
commands: [0x0a, 0x0b, 0x00],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
encodeWrapEth(OTHER_ADDR, ONE_ETH),
V3_600,
],
lines: [["Amount", "5000.0000 USDT"], ...wrapLines(OTHER_ADDR)],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/665 — V4Router pays a
// SETTLE of ETH from the router's own ETH, whichever payer it names.
// After an earlier step that may leave ETH in the router, the ETH that
// the user pays a V4 step reads as a maximum, on lines of its own.
// Every row reads at least the ETH that can leave the wallet. The two
// rows marked "reads as on next" read the same on next. On next every
// other row added that ETH to the Amount line, or read it as an exact
// amount.
//
// ADDRESS_THIS is the router on every release.
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
const WRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, ONE_ETH);
// UNWRAP_WETH has the layout of WRAP_ETH.
const UNWRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, 0n);
// A V4 step swapping `amount` of ETH for USDC, with a SETTLE of
// OPEN_DELTA by the user.
const v4EthForUsdc = (amount) =>
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(ZERO_ADDR, USDC_ADDR, amount, 1n),
settle(ZERO_ADDR, 0n, true),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const V4_ONE_ETH = v4EthForUsdc(ONE_ETH);
// A TRANSFER or PAY_PORTION of `amount` of `token` to another
// address.
const payOut = (token, amount) =>
coder.encode(
["address", "address", "uint256"],
[token, OTHER_ADDR, amount],
);
// An ACROSS_V4_DEPOSIT_V3 of `amount` of ETH (`useNative` set), from
// the sender to the sender on chain id 8453.
const acrossEth = (amount) =>
coder.encode(
[
"tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)",
],
[
[
USER_ADDR,
USER_ADDR,
WETH_ADDR,
WETH_ADDR,
amount,
1n,
8453n,
ZERO_ADDR,
0,
0,
0,
"0x",
true,
],
],
);
// The lines of ETH the sender pays a V4 step, of `amount`.
const ethLines = (amount) => [
["Moved from the sender", amount],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
];
test.each([
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 ETH that the user settles, reads as on next",
commands: [0x0b, 0x10],
inputs: [WRAP_INTO_ROUTER, V4_ONE_ETH],
lines: [["Amount", "2.0000 ETH"]],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x0c, 0x10],
inputs: [WRAP_INTO_ROUTER, UNWRAP_INTO_ROUTER, V4_ONE_ETH],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a TRANSFER of 1 ETH to another address after the UNWRAP_WETH",
commands: [0x0b, 0x0c, 0x05, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
payOut(ZERO_ADDR, ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a PAY_PORTION of 5,000 bips of WETH to another address before the UNWRAP_WETH",
commands: [0x0b, 0x06, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
payOut(WETH_ADDR, 5000n),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a WRAP_ETH of 1 ETH to another address after the UNWRAP_WETH",
commands: [0x0b, 0x0c, 0x0b, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
encodeWrapEth(OTHER_ADDR, ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", OTHER_ADDR],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same on UniversalRouterV2_1_2 with an ACROSS_V4_DEPOSIT_V3 of 1 ETH after the UNWRAP_WETH",
router: ROUTER_V2_1_2_ADDR,
commands: [0x0b, 0x0c, 0x40, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
acrossEth(ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V3 exact-in of 600 USDT that the user pays, a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x00, 0x0b, 0x0c, 0x10],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V3 exact-in of 600 USDT for WETH paid to the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x00, 0x0c, 0x10],
inputs: [
encodeV3SwapExactIn(ADDRESS_THIS, USDT_600, 1n, [
USDT_ADDR,
WETH_ADDR,
]),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V4 swap of 600 USDT that takes ETH into the router, then a V4 swap of 5 ETH that the user settles",
commands: [0x10, 0x10],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
ZERO_ADDR,
USDT_600,
1n,
),
settle(USDT_ADDR, 0n, true),
take(ZERO_ADDR, ADDRESS_THIS, 0n),
],
),
v4EthForUsdc(5n * ONE_ETH),
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 5.0000 ETH"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 exact-out paid in ETH with a SETTLE_ALL of at most 3 ETH, reads as on next",
commands: [0x0b, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
390000000n,
3n * ONE_ETH,
),
encodeV4SettleAll(ZERO_ADDR, 3n * ONE_ETH),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
],
lines: [
...wrapLines("The swap step that spends it"),
...ethLines(UNDECODED),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, a V3 exact-out of at most 1 WETH paid from the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x01, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
encodeV3SwapExactOut(
USER_ADDR,
390000000n,
ONE_ETH,
[USDC_ADDR, WETH_ADDR],
false,
),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "Up to 1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, a V4 exact-out paid from the router's WETH, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x10, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
390000000n,
ONE_ETH,
),
settle(WETH_ADDR, 0n, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
])("$kind", ({ router, commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs, router);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/721 — a WRAP_ETH that
// does not set the input side takes the amount it states from the
// sender. On next no line gave that ETH. The rows marked "reads as on
// next" take nothing from the sender. Uniswap's own routes wrap
// CONTRACT_BALANCE after their first step, as the last two rows do.
const CONTRACT_BALANCE = 2n ** 255n;
const WRAP_TO_OTHER = encodeWrapEth(OTHER_ADDR, ONE_ETH);
test.each([
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to another address",
commands: [0x00, 0x0b],
inputs: [V3_600, WRAP_TO_OTHER],
lines: [["Amount", "600.0000 USDT"], ...wrapLines(OTHER_ADDR)],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to address(1)",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(MSG_SENDER, ONE_ETH)],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The sender"),
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to address(2)",
commands: [0x00, 0x0b],
inputs: [V3_600, WRAP_INTO_ROUTER],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to the router by its own address",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(ROUTER_ADDR, ONE_ETH)],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a WRAP_ETH of 1 ETH to another address",
commands: [0x0b, 0x0b],
inputs: [WRAP_INTO_ROUTER, WRAP_TO_OTHER],
lines: [["Amount", "1.0000 ETH"], ...wrapLines(OTHER_ADDR)],
},
{
kind: "two WRAP_ETHs of 1 ETH into the router, which the Amount line adds up",
commands: [0x0b, 0x0b],
inputs: [WRAP_INTO_ROUTER, WRAP_INTO_ROUTER],
lines: [["Amount", "2.0000 ETH"]],
},
{
kind: "a V3 exact-in of 600 USDT for WETH paid to the router, an UNWRAP_WETH into the router, then a WRAP_ETH of 1 ETH to another address",
commands: [0x00, 0x0c, 0x0b],
inputs: [
encodeV3SwapExactIn(ADDRESS_THIS, USDT_600, 1n, [
USDT_ADDR,
WETH_ADDR,
]),
UNWRAP_INTO_ROUTER,
WRAP_TO_OTHER,
],
lines: [
["Amount", "600.0000 USDT"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", OTHER_ADDR],
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a WRAP_ETH of 1 ETH into the router",
commands: [0x0b, 0x0c, 0x0b],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
WRAP_INTO_ROUTER,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to another address",
commands: [0x0a, 0x00, 0x0b],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
V3_600,
WRAP_TO_OTHER,
],
lines: [["Amount", "5000.0000 USDT"], ...wrapLines(OTHER_ADDR)],
},
{
kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH to another address, then an EXECUTE_SUB_PLAN",
commands: [0x00, 0x0b, 0x21],
inputs: [V3_600, WRAP_TO_OTHER, SUB_PLAN],
lines: [
...USDT_600_GROUP,
...wrapLines(OTHER_ADDR),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH whose input this decoder cannot read",
commands: [0x00, 0x0b],
inputs: [V3_600, "0x1234"],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of nothing to another address, reads as on next",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(OTHER_ADDR, 0n)],
lines: [["Amount", "600.0000 USDT"]],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to the sender, reads as on next",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(MSG_SENDER, CONTRACT_BALANCE)],
lines: [["Amount", "600.0000 USDT"]],
},
{
kind: "a V4 swap of 600 USDT that takes ETH into the router, a WRAP_ETH of CONTRACT_BALANCE into the router, then a V3 exact-in of it, reads as on next",
commands: [0x10, 0x0b, 0x00],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
ZERO_ADDR,
USDT_600,
1n,
),
settle(USDT_ADDR, 0n, true),
take(ZERO_ADDR, ADDRESS_THIS, 0n),
],
),
encodeWrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
CONTRACT_BALANCE,
1n,
encodeV3Path([WETH_ADDR, USDC_ADDR]),
false,
],
),
],
lines: [["Amount", "600.0000 USDT"]],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax`
// USDT for USDC.
const v3ExactOut = (amountInMax) =>
@@ -2871,8 +3369,9 @@ describe("uniswap decoder", () => {
...USDT_400_GROUP,
...ONE_USDT_LINES(OTHER_ADDR),
],
// The last swap step's minimum.
minimum: "390.0000 USDC",
// Both swap steps pay USDC to the user, so the minimum
// is the sum of their two minimums.
minimum: "980.0000 USDC",
},
])(
"$kind shows each take's own lines and the allowances, and no Amount figure or Min. received",
@@ -3638,6 +4137,356 @@ describe("uniswap decoder", () => {
]);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/647 — on next at
// f550d8c, a split whose steps each pay WETH to the user showed only
// the last step's minimum.
describe("the minimum of a split that pays WETH to the user", () => {
const WETH_01 = 100000000000000000n;
const WETH_02 = 200000000000000000n;
const WETH_03 = 300000000000000000n;
// A V3 exact-in of 600 USDT for at least 0.3 WETH, and a V2
// exact-in of 400 USDT for at least 0.2 WETH, each paid to the
// user.
const V3_TO_WETH = encodeV3SwapExactIn(
USER_ADDR,
USDT_600,
WETH_03,
[USDT_ADDR, WETH_ADDR],
);
const V2_TO_WETH = encodeV2SwapExactIn(
USER_ADDR,
USDT_400,
WETH_02,
[USDT_ADDR, WETH_ADDR],
);
// A V4 step that swaps 400 USDT for at least 0.2 WETH, with these
// TAKEs of the WETH.
const v4ToWeth = (...takes) =>
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
...takes.map(() => V4_TAKE_ID),
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
USDT_400,
WETH_02,
),
encodeV4Settle(USDT_ADDR), // paid by the user
...takes,
],
);
// The swap's minimum line as [label, value], or undefined.
const minimum = (result) =>
result.details
.filter(
(d) =>
d.label === "Min. received" ||
d.label === "Min. paid out",
)
.map((d) => [d.label, d.value])[0];
test("a V3 and a V2 exact-in show the total of their minimums", () => {
const result = decodeSteps(
[0x00, 0x08],
[V3_TO_WETH, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.5000 WETH",
]);
});
test("a V3 exact-in and a V4 step show the total of their minimums", () => {
const result = decodeSteps(
[0x00, 0x10],
[V3_TO_WETH, v4ToWeth(encodeV4Take(WETH_ADDR))],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.5000 WETH",
]);
});
// The second route buys DAI for the router, then swaps all of it
// from the router for at least 0.2 WETH.
test("a route that hops through DAI counts its last hop's minimum", () => {
const result = decodeSteps(
[0x00, 0x08, 0x00],
[
V3_TO_WETH,
encodeV2SwapExactIn(ROUTER_ADDR, USDT_400, DAI_400, [
USDT_ADDR,
DAI_ADDR,
]),
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
2n ** 255n, // CONTRACT_BALANCE
WETH_02,
encodeV3Path([DAI_ADDR, WETH_ADDR]),
false, // paid from the router
],
),
],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.5000 WETH",
]);
});
// The middle step swaps 0.3 of the user's WETH for USDC, so the
// sum of minimums starts again after it.
test.each([
{
kind: "a V3 exact-in",
command: 0x00,
input: encodeV3SwapExactIn(USER_ADDR, WETH_03, 1n, [
WETH_ADDR,
USDC_ADDR,
]),
},
{
kind: "a V2 exact-in",
command: 0x08,
input: encodeV2SwapExactIn(USER_ADDR, WETH_03, 1n, [
WETH_ADDR,
USDC_ADDR,
]),
},
{
kind: "a V2 exact-out",
command: 0x09,
input: encodeV2SwapExactOut(USER_ADDR, 1n, WETH_03, [
WETH_ADDR,
USDC_ADDR,
]),
},
{
kind: "a V3 exact-out",
command: 0x01,
input: encodeV3SwapExactOut(USER_ADDR, 1n, WETH_03, [
USDC_ADDR,
WETH_ADDR,
]),
},
])(
"$kind that spends WETH between them leaves the last step's minimum",
({ command, input }) => {
const result = decodeSteps(
[0x00, command, 0x08],
[V3_TO_WETH, input, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.2000 WETH",
]);
},
);
// The middle step swaps 0.3 WETH and 400 DAI for USDC, both paid
// by the user. It spends WETH whichever SETTLE comes last.
test.each([
{ order: "WETH first", settled: [WETH_ADDR, DAI_ADDR] },
{ order: "DAI first", settled: [DAI_ADDR, WETH_ADDR] },
])(
"a V4 step that spends WETH and DAI between them leaves the last step's minimum, with the SETTLE of $order",
({ settled }) => {
const wethAndDaiForUsdc = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
WETH_03,
1n,
),
encodeV4ExactInSingle(
DAI_ADDR,
USDC_ADDR,
DAI_400,
1n,
),
...settled.map((token) => encodeV4Settle(token)),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const result = decodeSteps(
[0x00, 0x10, 0x08],
[V3_TO_WETH, wethAndDaiForUsdc, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.2000 WETH",
]);
},
);
// The middle step's swap actions buy WETH with the user's USDT,
// then swap all of that WETH for USDC.
test("a V4 step that swaps through WETH between them leaves the last step's minimum", () => {
const usdtToUsdcThroughWeth = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
USDT_400,
WETH_01,
),
encodeV4ExactInSingle(WETH_ADDR, USDC_ADDR, 0n, 1n),
encodeV4Settle(USDT_ADDR), // paid by the user
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const result = decodeSteps(
[0x00, 0x10, 0x08],
[V3_TO_WETH, usdtToUsdcThroughWeth, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.2000 WETH",
]);
});
// The middle step has no swap action. Its SETTLE or SETTLE_ALL
// pays 0.3 WETH, and its TAKE pays that WETH to another address.
test.each([
{
payment: "a SETTLE of WETH paid by the user",
actions: [V4_SETTLE_ID, V4_TAKE_ID],
params: [
settle(WETH_ADDR, WETH_03, true),
take(WETH_ADDR, OTHER_ADDR, 0n),
],
},
{
payment: "a SETTLE of WETH paid from the router",
actions: [V4_SETTLE_ID, V4_TAKE_ID],
params: [
settle(WETH_ADDR, WETH_03, false),
take(WETH_ADDR, OTHER_ADDR, 0n),
],
},
{
payment: "a SETTLE_ALL of WETH",
actions: [V4_TAKE_ID, V4_SETTLE_ALL_ID],
params: [
take(WETH_ADDR, OTHER_ADDR, WETH_03),
encodeV4SettleAll(WETH_ADDR, WETH_03),
],
},
])(
"a V4 step with $payment between them leaves the last step's minimum",
({ actions, params }) => {
const wethToOther = encodeV4Swap(
new Uint8Array(actions),
params,
);
const result = decodeSteps(
[0x00, 0x10, 0x08],
[V3_TO_WETH, wethToOther, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. paid out",
"0.2000 WETH",
]);
},
);
test("a PERMIT2_TRANSFER_FROM of WETH between them leaves the last step's minimum", () => {
const result = decodeSteps(
[0x00, 0x02, 0x08],
[
V3_TO_WETH,
permit2TransferFrom(WETH_ADDR, OTHER_ADDR, WETH_03),
V2_TO_WETH,
],
);
expect(minimum(result)).toEqual([
"Min. paid out",
"0.2000 WETH",
]);
});
// The decoder reads no minimum from a V4 exact-out action.
test("a step that states no minimum leaves no figure", () => {
const exactOut = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
USDT_ADDR,
WETH_ADDR,
WETH_02,
USDT_400,
),
encodeV4SettleAll(USDT_ADDR, USDT_400),
encodeV4TakeAll(WETH_ADDR, 0n),
],
);
const result = decodeSteps(
[0x10, 0x00],
[exactOut, V3_TO_WETH],
);
expect(minimum(result)).toBeUndefined();
expect(detail(result, "Paid to")).toBeUndefined();
});
test("a step that pays some of its WETH to the router leaves no figure", () => {
const result = decodeSteps(
[0x00, 0x10],
[
V3_TO_WETH,
v4ToWeth(
take(WETH_ADDR, USER_ADDR, WETH_01),
take(WETH_ADDR, ROUTER_ADDR, 0n),
),
],
);
expect(minimum(result)).toBeUndefined();
});
// The router still runs a swap step whose recipient cannot be
// read, and that step may spend the WETH the first step paid.
test("a step between them that cannot be read leaves no figure", () => {
const result = decodeSteps(
[0x00, 0x00, 0x08],
[
V3_TO_WETH,
dirtyWord(
encodeV3SwapExactIn(USER_ADDR, WETH_03, 1n, [
WETH_ADDR,
USDC_ADDR,
]),
0,
),
V2_TO_WETH,
],
);
expect(minimum(result)).toBeUndefined();
});
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
+18 -7
View File
@@ -370,7 +370,8 @@ const PAID_ELSEWHERE = [
"Steps",
"Deadline",
];
// `lines` with the lines of one PERMIT2_TRANSFER_FROM after Amount.
// `lines` with the lines of one PERMIT2_TRANSFER_FROM or later WRAP_ETH after
// Amount.
const withMove = (lines) =>
lines.flatMap((l) =>
l === "Amount"
@@ -985,7 +986,7 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
);
// The SWEEP pays the user the swap's WETH, so the UNWRAP_WETH unwraps the
// WRAP_ETH's.
// WRAP_ETH's. The WRAP_ETH's 1 ETH gets lines of its own.
test("a SWEEP of the swap's WETH to the user, then a WRAP_ETH into the router, reads as ETH with no minimum", () => {
const result = decode(
execute(
@@ -996,7 +997,10 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
expect(labels(result)).toEqual(withMove(NO_MINIMUM_TO_USER));
expect(detail(result, "Moved from the sender").value).toBe(
"1.0000 ETH",
);
});
test("an UNWRAP_WETH into the router, then a WRAP_ETH of all its ETH to the user, reads as WETH with no minimum", () => {
@@ -1097,7 +1101,9 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
},
);
// The user receives 0.5 WETH and the rest of the swap's output as ETH.
// The user receives 0.5 WETH and the rest of the swap's output as ETH. The
// router holds ETH when the WRAP_ETH runs, so the sender pays at most its
// 0.5 ETH.
test("an UNWRAP_WETH into the router, then a WRAP_ETH of 0.5 ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
@@ -1108,7 +1114,11 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
expect(labels(result)).toEqual(withMove(NO_MINIMUM_TO_USER));
expect(detail(result, "Moved from the sender").value).toBe(
"Up to 0.5000 ETH",
);
expect(detail(result, "Moved to").value).toBe("The sender");
});
// The UNWRAP_WETH pays the user the swap's WETH as ETH; the WRAP_ETH wraps
@@ -1348,6 +1358,7 @@ describe("any token left in the router is not received", () => {
),
),
left: tokenLine(WETH, "WETH"),
spent: "1.0000 ETH",
},
// A SETTLE of OPEN_DELTA from the router pays only what the step
// still owes of its currency when it runs.
@@ -1516,8 +1527,8 @@ describe("any token left in the router is not received", () => {
"Moved from the sender",
]
: ["Amount"]),
// A later swap step the user pays for spends `spent` of a token
// other than Token In.
// A later swap step or WRAP_ETH the user pays for spends `spent`
// of a token other than Token In.
...(spent
? ["Moved from the sender", "Token moved", "Moved to"]
: []),
+3 -2
View File
@@ -167,7 +167,8 @@ function row(token = {}, value = FIVE_WETH) {
}
// Fetch the explorer's rows through the real fetcher and put them exactly where
// refreshBalances() puts them. The node reports the holding the explorer does.
// refreshBalances() puts them on Ethereum Mainnet. The node reports the holding
// the explorer does.
async function fetchOnto(items) {
mockProvider.tokenUnits = BigInt(items[0].value);
debugFetch.mockImplementation(async () => ({
@@ -176,7 +177,7 @@ async function fetchOnto(items) {
statusText: "OK",
json: async () => items,
}));
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], "0x1");
state.wallets = [
{
name: "Wallet 1",