Compare commits
50
Commits
f942794997
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c150b83322 | ||
|
|
ee370b5ef7 | ||
|
|
e438272125 | ||
|
|
39c58b8775 | ||
|
|
39c1ad09c0 | ||
|
|
a65e84c2dd | ||
|
|
64ec571a17 | ||
|
|
1d40e2d7d5 | ||
|
|
9c691dfa6b | ||
|
|
448afdb5ce | ||
|
|
49713e1cdb | ||
|
|
1ea0730a81 | ||
|
|
4531e8bce5 | ||
|
|
f79390cad5 | ||
|
|
21d5e42528 | ||
|
|
ba61bedaaf | ||
|
|
42a5d94b49 | ||
|
|
267b69382e | ||
|
|
1a4fb0803d | ||
|
|
076726b13d | ||
|
|
c90efa988b | ||
|
|
754cf9686f | ||
|
|
430ea0166e | ||
|
|
ae0b86a8ac | ||
|
|
d89099d90a | ||
|
|
664629a1b6 | ||
|
|
26a2a51387 | ||
|
|
b5737f915c | ||
|
|
016acbcda5 | ||
|
|
7ababcf69e | ||
|
|
3191577f73 | ||
|
|
acb2577fec | ||
|
|
a33a40a6fa | ||
|
|
5192419cac | ||
|
|
41842b197e | ||
|
|
8998d6c661 | ||
|
|
08e47c39ad | ||
|
|
7ba0a5ff75 | ||
|
|
cd5951a4f8 | ||
|
|
bcdffa56a4 | ||
|
|
c0a3a02418 | ||
|
|
f550d8c8d2 | ||
|
|
fc6cffcaba | ||
|
|
5615b08760 | ||
|
|
ec6b6b57a3 | ||
|
|
36b200247a | ||
|
|
b4e166cdc7 | ||
|
|
53551c6382 | ||
|
|
33ab99b7be | ||
|
|
b58ecec1f9 |
@@ -19,13 +19,10 @@ on: [push]
|
|||||||
# by the host daemon and mounts an empty directory, and the runner image's
|
# by the host daemon and mounts an empty directory, and the runner image's
|
||||||
# node is too old to install this repo's dependencies.
|
# node is too old to install this repo's dependencies.
|
||||||
#
|
#
|
||||||
# These jobs REPORT, they do not gate. Whether a check blocks a merge is
|
# These jobs REPORT, they do not gate. No branch requires these two jobs, so
|
||||||
# Gitea branch protection, which this repo does not configure, so a failure
|
# a failure is a red mark a reviewer has to account for. The Chrome suite has
|
||||||
# here is a red mark a reviewer has to account for rather than a hard
|
# failed under load before, and no such report is open. A gate that fails at
|
||||||
# block. Making e2e-chrome a required check is blocked while reports of the
|
# random teaches people to merge past red.
|
||||||
# Chrome suite failing under load are still open; the "In CI" section of
|
|
||||||
# README.md names them. A gate that fails at random teaches people to merge
|
|
||||||
# past red.
|
|
||||||
#
|
#
|
||||||
# Nothing here may pass vacuously. There is no continue-on-error and no
|
# Nothing here may pass vacuously. There is no continue-on-error and no
|
||||||
# `|| true`. Both scripts exit non-zero when docker is missing, when the
|
# `|| true`. Both scripts exit non-zero when docker is missing, when the
|
||||||
@@ -36,8 +33,7 @@ jobs:
|
|||||||
e2e-chrome:
|
e2e-chrome:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Bounds the image build, a cold cache included, and the three Chrome
|
# Bounds the image build, a cold cache included, and the three Chrome
|
||||||
# programs, so a hung browser frees the shared runner. README.md
|
# programs, so a hung browser frees the shared runner.
|
||||||
# "In CI" has the measured times.
|
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
@@ -47,8 +43,7 @@ jobs:
|
|||||||
e2e-firefox:
|
e2e-firefox:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Bounds the image build, a cold cache included, and both Firefox
|
# Bounds the image build, a cold cache included, and both Firefox
|
||||||
# programs, so a hung browser frees the shared runner. README.md
|
# programs, so a hung browser frees the shared runner.
|
||||||
# "In CI" has the measured times.
|
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY script/ script/
|
COPY script/ script/
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
|
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
|
||||||
RUN script/bootstrap
|
RUN script/bootstrap
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
+656
-385
File diff suppressed because it is too large
Load Diff
+213
-75
@@ -1,87 +1,225 @@
|
|||||||
# Releasing AutistMask
|
# Releasing AutistMask
|
||||||
|
|
||||||
This is the procedure that turns a green `main` into a tagged, packaged release.
|
A release is a tagged commit on the `main` branch and the two archives that
|
||||||
It gathers into one place what is otherwise spread across the `Makefile` and
|
`make package` writes from that commit. This file is the procedure, in order.
|
||||||
three `README.md` sections, so the person cutting a release does not have to
|
|
||||||
reconstruct the order from them.
|
|
||||||
|
|
||||||
There is one version, declared in three files (`package.json`,
|
Only the owner ([@sneak](https://sneak.berlin)):
|
||||||
`manifest/chrome.json`, `manifest/firefox.json`), and `make package` builds and
|
|
||||||
packages but publishes nothing. `make build` and `make package` can be run by
|
- decides whether and when a release is tagged;
|
||||||
anyone; tagging, signing, packing a CRX and any upload need credentials only the
|
- creates, moves or deletes a tag;
|
||||||
owner ([@sneak](https://sneak.berlin)) holds and are marked **owner-only**
|
- decides where the archives are published.
|
||||||
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.
|
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
|
## Procedure
|
||||||
|
|
||||||
1. **Confirm `main` is green in CI.** The `check` workflow
|
### 1. Confirm the release commit is green
|
||||||
(`.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.
|
|
||||||
|
|
||||||
2. **Confirm the version matches the intended tag.** `package.json`,
|
The `check` workflow (`.gitea/workflows/check.yml`) runs `script/cibuild` on
|
||||||
`manifest/chrome.json` and `manifest/firefox.json` must all declare the same
|
every push. `script/cibuild` runs:
|
||||||
`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`.
|
|
||||||
|
|
||||||
3. **Build and package from a clean checkout of that commit.** From a fresh
|
- `script/bootstrap`, which installs the dependencies;
|
||||||
clone, or a working tree with no local modifications (`git status` clean),
|
- `script/check`, which is what `make check` runs;
|
||||||
checked out at the release commit: run `make setup`, then `make package`.
|
- the Docker image build (an image is Docker's packaged form of a program).
|
||||||
`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`).
|
|
||||||
|
|
||||||
4. **Verify `SHA256SUMS`.** The archives are deterministic — sorted entries,
|
`script/check` runs:
|
||||||
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.
|
|
||||||
|
|
||||||
5. **Tag the release commit.** _(owner-only)_ Create an annotated tag `vX.Y.Z`
|
- `script/test`: the tests, in Docker;
|
||||||
on the release commit and push it: `git tag -a vX.Y.Z` (with a message), then
|
- `script/lint`, which runs three checks in Docker:
|
||||||
`git push origin vX.Y.Z`. _Check:_ `git tag` lists `vX.Y.Z`, and
|
- ESLint, a code checker;
|
||||||
`git rev-parse vX.Y.Z^{commit}` resolves to the release commit.
|
- `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
|
The `e2e` workflow (`.gitea/workflows/e2e.yml`) has two jobs. One runs
|
||||||
https://git.eeqj.de/sneak/AutistMask/issues/386)_ How 1.0.0 is distributed on
|
`script/test-e2e`, the browser tests in Chrome. The other runs
|
||||||
each browser is not yet decided; it is the open question on that issue, and
|
`script/test-e2e-firefox`, the browser tests in Firefox. A red `e2e` job does
|
||||||
the concrete steps cannot be written until the owner records a choice there.
|
not fail the `check` run.
|
||||||
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.
|
|
||||||
|
|
||||||
Once the owner decides, the chosen steps — including which credentials they
|
Run `script/cibuild` on the clean checkout. `script/cibuild` runs the same steps
|
||||||
need and who holds them — are written into this section and `README.md`'s
|
as the `check` run, and must exit 0.
|
||||||
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
|
Check: on the Actions page, the `check` run for the release commit succeeded,
|
||||||
store or AMO route, the artifact installs from the store or AMO on a clean
|
and both `e2e` jobs for that commit succeeded. `script/cibuild` exited 0 on the
|
||||||
browser profile; for the CRX or unpacked route, the documented load succeeds
|
clean checkout.
|
||||||
and Chrome reports the extension id `gipbhkogfopeahplcjhipkgpcimdpkip`.
|
|
||||||
|
### 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.
|
||||||
|
|||||||
+1
-2
@@ -153,8 +153,7 @@ module.exports = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// The helpers the script/ entrypoints call: plain node programs too, run
|
// The files in script/lib/ run on plain node too, and are never bundled.
|
||||||
// from a shell script rather than from yarn, and never bundled.
|
|
||||||
{
|
{
|
||||||
files: ["script/lib/**/*.js"],
|
files: ["script/lib/**/*.js"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest --forceExit --maxWorkers=3",
|
"test": "node script/lib/jest.js --maxWorkers=3",
|
||||||
"test:verbose": "jest --forceExit --maxWorkers=3 --verbose",
|
"test:verbose": "node script/lib/jest.js --maxWorkers=3 --verbose",
|
||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
"lint": "eslint . && prettier --check .",
|
"lint": "eslint . && prettier --check .",
|
||||||
"fmt": "prettier --write .",
|
"fmt": "prettier --write .",
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ ensure_yarn() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
|
||||||
install_js_deps() {
|
install_js_deps() {
|
||||||
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
|
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
|
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Runs jest with the arguments it is given, and fails the run when jest had to
|
||||||
|
// force-stop the worker process that ran a test file. Jest itself only prints a
|
||||||
|
// warning when that happens, and its run still passes. The warning means a test
|
||||||
|
// left a timer or another open handle running. The test scripts in package.json
|
||||||
|
// run jest through this file.
|
||||||
|
//
|
||||||
|
// Jest runs inside this process and writes to the terminal directly, so its
|
||||||
|
// output looks the same as a plain jest run. This file only checks each write
|
||||||
|
// to stderr for the warning below.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// The start of the warning that jest prints on stderr after it force-stops a
|
||||||
|
// worker process.
|
||||||
|
const WORKER_WARNING = "A worker process has failed to exit gracefully";
|
||||||
|
|
||||||
|
let forceStopped = false;
|
||||||
|
|
||||||
|
const writeStderr = process.stderr.write.bind(process.stderr);
|
||||||
|
process.stderr.write = (chunk, ...rest) => {
|
||||||
|
if (String(chunk).includes(WORKER_WARNING)) {
|
||||||
|
forceStopped = true;
|
||||||
|
}
|
||||||
|
return writeStderr(chunk, ...rest);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runs after jest has finished, as the process exits.
|
||||||
|
process.on("exit", () => {
|
||||||
|
if (forceStopped) {
|
||||||
|
console.error(
|
||||||
|
"script/lib/jest.js: failing the run, because jest force-stopped " +
|
||||||
|
"a worker process. A test left a timer or another open " +
|
||||||
|
"handle running.",
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Jest's own command-line program. It reads its arguments from process.argv.
|
||||||
|
require("jest/bin/jest");
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Turn a verified dist/ into the two distributable archives.
|
// Turn a verified dist/ into the two distributable archives.
|
||||||
//
|
//
|
||||||
// Invoked by script/package, which runs `make build` first so that dist/ has
|
// Invoked by script/package, which runs `make build` first so that dist/ has
|
||||||
// already been checked against the build's own receipt (see the Build Receipts
|
// already been checked against the build's own receipt (see the Builds
|
||||||
// section of README.md). This program does not build anything and does not
|
// section of README.md). This program does not build anything and does not
|
||||||
// write into dist/: it reads the emitted tree and writes release/.
|
// write into dist/: it reads the emitted tree and writes release/.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -60,12 +60,12 @@ main() {
|
|||||||
|
|
||||||
echo "Running the Firefox e2e suite..."
|
echo "Running the Firefox e2e suite..."
|
||||||
# --shm-size=1g: Firefox needs more than the default 64MB /dev/shm.
|
# --shm-size=1g: Firefox needs more than the default 64MB /dev/shm.
|
||||||
# --network none: the suite stubs nothing, so this is what keeps the
|
# --network none: the container has no network, and the suite reaches
|
||||||
# run offline and deterministic. The extension swallows its own
|
# only the test page and stub node on loopback (README.md, Browser
|
||||||
# fetch failures, so the popup flows work unchanged; see the
|
# tests). The extension swallows its own fetch failures, so the popup
|
||||||
# network note in README.md. Weaker than the Chrome suite's
|
# flows work unchanged. Weaker than the Chrome suite's fixture
|
||||||
# fixture interception, and honestly so — it proves no request
|
# interception, and honestly so — it proves no request escaped, but it
|
||||||
# escaped, but it cannot report which ones were attempted.
|
# cannot report which ones were attempted.
|
||||||
# HOME=/tmp: the image's root home is not a reliable place for the
|
# HOME=/tmp: the image's root home is not a reliable place for the
|
||||||
# browser profile.
|
# browser profile.
|
||||||
#
|
#
|
||||||
|
|||||||
+70
-64
@@ -161,9 +161,9 @@ let txApprovalSlot = null;
|
|||||||
const APPROVAL_PENDING_CODE = -32002;
|
const APPROVAL_PENDING_CODE = -32002;
|
||||||
|
|
||||||
// True at every moment this can be sent: the slot is taken immediately before
|
// True at every moment this can be sent: the slot is taken immediately before
|
||||||
// the transaction is populated, so the other request is either being prepared
|
// the transaction is populated, so the other request is being prepared, is on
|
||||||
// or on screen. It does not claim the other one is displayed yet, because for
|
// screen, or waits with its window closed. It does not claim the other one is
|
||||||
// the length of one network round trip it is not.
|
// displayed yet, because for the length of one network round trip it is not.
|
||||||
const TX_APPROVAL_PENDING_MESSAGE =
|
const TX_APPROVAL_PENDING_MESSAGE =
|
||||||
"AutistMask handles one transaction at a time, and another one is" +
|
"AutistMask handles one transaction at a time, and another one is" +
|
||||||
" already in progress, so this one was not sent. Please finish that" +
|
" already in progress, so this one was not sent. Please finish that" +
|
||||||
@@ -383,14 +383,26 @@ async function proxyRpc(method, params) {
|
|||||||
return json.result;
|
return json.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The site-connection approval the toolbar popup is set to open, or null while
|
// The approval the toolbar popup is set to open, or null while it opens the
|
||||||
// it opens the wallet. Set only by resetPopupUrl() and showInToolbarPopup().
|
// wallet. Set only by updateToolbarPopup() and showInToolbarPopup().
|
||||||
let toolbarPopupApprovalId = null;
|
let toolbarPopupApprovalId = null;
|
||||||
|
|
||||||
function resetPopupUrl() {
|
// Point the toolbar popup at the oldest pending approval whose window closed,
|
||||||
toolbarPopupApprovalId = null;
|
// or at the wallet when there is none. The user can then answer that approval
|
||||||
|
// from the toolbar popup. pendingApprovals lists approvals in the order they
|
||||||
|
// were added, so the first one found is the oldest. An approval held by a
|
||||||
|
// running attempt is skipped until that attempt ends.
|
||||||
|
function updateToolbarPopup() {
|
||||||
|
const closed = Object.values(pendingApprovals).find(
|
||||||
|
(approval) => approval.windowClosed && !approval.attemptInFlight,
|
||||||
|
);
|
||||||
|
toolbarPopupApprovalId = closed ? closed.id : null;
|
||||||
if (actionNs && typeof actionNs.setPopup === "function") {
|
if (actionNs && typeof actionNs.setPopup === "function") {
|
||||||
actionNs.setPopup({ popup: "src/popup/index.html" });
|
actionNs.setPopup({
|
||||||
|
popup: closed
|
||||||
|
? "src/popup/index.html?approval=" + closed.id
|
||||||
|
: "src/popup/index.html",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,15 +432,16 @@ function settleApproval(id, result, options) {
|
|||||||
// approval stops being alive.
|
// approval stops being alive.
|
||||||
releaseTxApprovalSlotFor(id);
|
releaseTxApprovalSlotFor(id);
|
||||||
approval.resolve(result);
|
approval.resolve(result);
|
||||||
resetPopupUrl();
|
updateToolbarPopup();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What a pending approval resolves to when it is given up on rather than
|
// What a pending approval resolves to when it is given up on rather than
|
||||||
// answered: the window was closed, or could not be opened at all. A tx or sign
|
// answered: a site-connection or network-switch window was closed, or the
|
||||||
// approval answers the requesting page in EIP-1193 shape; a site-connection or
|
// window could not be opened at all. An address switch in the wallet gives up
|
||||||
// network-switch approval answers its handler in the shape the popup's
|
// pending approvals too. A tx or sign approval answers the requesting page in
|
||||||
// decision has, as a refusal.
|
// EIP-1193 shape; a site-connection or network-switch approval answers its
|
||||||
|
// handler in the shape the popup's decision has, as a refusal.
|
||||||
function abandonedResult(approval, code, message) {
|
function abandonedResult(approval, code, message) {
|
||||||
if (approval.type === "tx" || approval.type === "sign") {
|
if (approval.type === "tx" || approval.type === "sign") {
|
||||||
return { error: { code, message } };
|
return { error: { code, message } };
|
||||||
@@ -436,8 +449,9 @@ function abandonedResult(approval, code, message) {
|
|||||||
return { approved: false, remember: false };
|
return { approved: false, remember: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// A window the user closed without answering is a refusal by the user, which
|
// A site-connection or network-switch window the user closed without answering
|
||||||
// is 4001 and the wording every other rejection path already uses.
|
// is a refusal by the user, which is 4001 and the wording every other rejection
|
||||||
|
// path already uses.
|
||||||
const APPROVAL_REJECTED_CODE = 4001;
|
const APPROVAL_REJECTED_CODE = 4001;
|
||||||
const APPROVAL_REJECTED_MESSAGE = "User rejected the request.";
|
const APPROVAL_REJECTED_MESSAGE = "User rejected the request.";
|
||||||
|
|
||||||
@@ -465,31 +479,16 @@ const APPROVAL_WINDOW_FAILED_MESSAGE =
|
|||||||
function claimApproval(approval) {
|
function claimApproval(approval) {
|
||||||
if (approval.attemptInFlight) return false;
|
if (approval.attemptInFlight) return false;
|
||||||
approval.attemptInFlight = true;
|
approval.attemptInFlight = true;
|
||||||
|
updateToolbarPopup();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release an approval whose attempt failed in a way the user can retry.
|
// Release an approval whose attempt failed in a way the user can retry.
|
||||||
// Nothing was broadcast, so the next attempt may claim it.
|
// Nothing was broadcast, so the next attempt may claim it. If the user closed
|
||||||
//
|
// its window while the attempt ran, the toolbar popup opens it for that retry.
|
||||||
// Unless the window it would be retried in is already gone. The user closed it
|
|
||||||
// while the attempt was running and settleApproval() declined then, correctly,
|
|
||||||
// because the attempt still owned the approval; the attempt has now failed, so
|
|
||||||
// nothing owns it and nothing can reach it. Left standing it would hold the
|
|
||||||
// requesting page's promise open forever and, with it, the transaction
|
|
||||||
// approval slot. It is settled here as the rejection the closed window
|
|
||||||
// already meant.
|
|
||||||
function releaseApproval(approval) {
|
function releaseApproval(approval) {
|
||||||
approval.attemptInFlight = false;
|
approval.attemptInFlight = false;
|
||||||
if (approval.windowClosed) {
|
updateToolbarPopup();
|
||||||
settleApproval(
|
|
||||||
approval.id,
|
|
||||||
abandonedResult(
|
|
||||||
approval,
|
|
||||||
APPROVAL_REJECTED_CODE,
|
|
||||||
APPROVAL_REJECTED_MESSAGE,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open approval in a separate popup window, unless it is no longer pending.
|
// Open approval in a separate popup window, unless it is no longer pending.
|
||||||
@@ -712,9 +711,7 @@ function isExtensionSender(sender) {
|
|||||||
// had refused.
|
// had refused.
|
||||||
//
|
//
|
||||||
// TX and sign approvals do not decide here. They stay pending across a
|
// TX and sign approvals do not decide here. They stay pending across a
|
||||||
// disconnect. When their window closes, the windows.onRemoved listener below
|
// disconnect, and across their window closing, until the user answers them.
|
||||||
// rejects them, unless an approved attempt is still running, in which case
|
|
||||||
// that attempt answers the page.
|
|
||||||
runtime.onConnect.addListener((port) => {
|
runtime.onConnect.addListener((port) => {
|
||||||
if (port.name.startsWith("approval:")) {
|
if (port.name.startsWith("approval:")) {
|
||||||
const id = port.name.split(":")[1];
|
const id = port.name.split(":")[1];
|
||||||
@@ -742,9 +739,7 @@ runtime.onConnect.addListener((port) => {
|
|||||||
const approval = pendingApprovals[id];
|
const approval = pendingApprovals[id];
|
||||||
if (approval) {
|
if (approval) {
|
||||||
if (approval.type === "tx" || approval.type === "sign") {
|
if (approval.type === "tx" || approval.type === "sign") {
|
||||||
// When its window closes, windows.onRemoved rejects it,
|
// It stays pending until the user answers it.
|
||||||
// unless an approved attempt is still running, in which
|
|
||||||
// case that attempt answers the page.
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settleApproval(id, { approved: false, remember: false });
|
settleApproval(id, { approved: false, remember: false });
|
||||||
@@ -1297,7 +1292,7 @@ async function broadcastAccountsChanged() {
|
|||||||
windowsRemove(approval.windowId).catch(() => {});
|
windowsRemove(approval.windowId).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resetPopupUrl();
|
updateToolbarPopup();
|
||||||
// An address switch ends every connection allowed without "Remember". This
|
// An address switch ends every connection allowed without "Remember". This
|
||||||
// waits on storage, so it comes after the pending approvals above, which
|
// waits on storage, so it comes after the pending approvals above, which
|
||||||
// are still rejected the moment the switch arrives.
|
// are still rejected the moment the switch arrives.
|
||||||
@@ -1489,36 +1484,44 @@ if (runtime.onStartup) {
|
|||||||
}
|
}
|
||||||
startBackgroundJobs();
|
startBackgroundJobs();
|
||||||
|
|
||||||
// When approval window is closed without a response, treat as rejection.
|
// The toolbar popup keeps its URL when the worker stops. A worker that starts
|
||||||
// "Without a response" is the operative part: the popup stays open across the
|
// holds no approvals, so the toolbar popup is pointed back at the wallet.
|
||||||
// verify and broadcast it is waiting on, so a user closing an apparently-hung
|
updateToolbarPopup();
|
||||||
// window is an ordinary event with an attempt already in flight behind it.
|
|
||||||
// settleApproval() refuses those, which leaves the attempt to report its real
|
// A transaction or signature approval stays pending when its window closes.
|
||||||
// outcome to the page — and the window is recorded as gone, so that an attempt
|
// The browser can close that window when the user clicks away from it, for
|
||||||
// which then fails retryably settles instead of waiting in a window that no
|
// example to copy the password from a password manager. The approval is marked
|
||||||
// longer exists.
|
// as closed, so the toolbar popup can open it (see updateToolbarPopup()). An
|
||||||
|
// attempt already running when the window closes goes on and reports its
|
||||||
|
// outcome to the page.
|
||||||
//
|
//
|
||||||
// A site-connection or network-switch approval whose popup connected its port
|
// This listener rejects a site-connection or network-switch approval whose
|
||||||
// is not decided here. That popup approves and closes in the same breath, and
|
// window closes before its popup connected its port. Once the port is
|
||||||
// this event races the decision on a channel of its own — the same race the
|
// connected, the approval is left to the port. That popup approves and closes
|
||||||
// port exists to end. Its port disconnect says the same thing this event does,
|
// in the same breath, and this event races the decision on a channel of its
|
||||||
// in an order that is defined, so the disconnect is left to say it. The window
|
// own — the same race the port exists to end. Its port disconnect says the
|
||||||
// closing before any port connected is the one case with nothing else to speak
|
// same thing this event does, in an order that is defined, so the disconnect is
|
||||||
// for it, and is rejected here so the dApp is not left waiting on a window that
|
// left to say it. The window closing before any port connected is the one case
|
||||||
// is gone.
|
// with nothing else to speak for it, and is rejected here so the dApp is not
|
||||||
|
// left waiting on a window that is gone.
|
||||||
if (windowsNs && windowsNs.onRemoved) {
|
if (windowsNs && windowsNs.onRemoved) {
|
||||||
windowsNs.onRemoved.addListener((windowId) => {
|
windowsNs.onRemoved.addListener((windowId) => {
|
||||||
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
||||||
if (approval.windowId !== windowId) continue;
|
if (approval.windowId !== windowId) continue;
|
||||||
const decidedOnPort =
|
if (approval.type === "tx" || approval.type === "sign") {
|
||||||
approval.type !== "tx" && approval.type !== "sign";
|
approval.windowClosed = true;
|
||||||
if (decidedOnPort && approval.portConnected) continue;
|
updateToolbarPopup();
|
||||||
const rejection = abandonedResult(
|
continue;
|
||||||
|
}
|
||||||
|
if (approval.portConnected) continue;
|
||||||
|
settleApproval(
|
||||||
|
id,
|
||||||
|
abandonedResult(
|
||||||
approval,
|
approval,
|
||||||
APPROVAL_REJECTED_CODE,
|
APPROVAL_REJECTED_CODE,
|
||||||
APPROVAL_REJECTED_MESSAGE,
|
APPROVAL_REJECTED_MESSAGE,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (!settleApproval(id, rejection)) approval.windowClosed = true;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1663,7 +1666,9 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
// The popup waits for this answer before it closes its window.
|
||||||
|
sendResponse(null);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The popup signs; it reports back here when it could not. Keep the
|
// The popup signs; it reports back here when it could not. Keep the
|
||||||
@@ -1863,7 +1868,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
sendResponse(null);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The popup signs; it reports back here when it could not. Keep the
|
// The popup signs; it reports back here when it could not. Keep the
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
// updateState(fn) read fresh, apply fn to that fresh record, write it
|
// updateState(fn) read fresh, apply fn to that fresh record, write it
|
||||||
// back — all holding the lock every write of the stored
|
// back — all holding the lock every write of the stored
|
||||||
// record takes, so no other write lands in between, by
|
// record takes, so no other write lands in between, by
|
||||||
// the background or by any page (README, "Stored state
|
// the background or by any page (README, "Stored data").
|
||||||
// and its version"). The read is one storage round trip
|
// The read is one storage round trip ahead of the write
|
||||||
// ahead of the write rather than a page lifetime ahead of
|
// rather than a page lifetime ahead of it (which is what
|
||||||
// it (which is what made the popup's saveState() need a
|
// made the popup's saveState() need a per-field merge
|
||||||
// per-field merge against a baseline at all).
|
// against a baseline at all).
|
||||||
//
|
//
|
||||||
// A handler that must both read and write therefore does its network work
|
// A handler that must both read and write therefore does its network work
|
||||||
// against a snapshot it owns, and applies the RESULT inside updateState().
|
// against a snapshot it owns, and applies the RESULT inside updateState().
|
||||||
@@ -72,7 +72,7 @@ async function updateStateOnce(mutate) {
|
|||||||
// src/shared/browserApi.js) is held from the read to the write: two
|
// src/shared/browserApi.js) is held from the read to the write: two
|
||||||
// background turns, or a turn and a page's save, never interleave
|
// background turns, or a turn and a page's save, never interleave
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits
|
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits
|
||||||
// while it is held (README, "Stored state and its version").
|
// while it is held (README, "Stored data").
|
||||||
//
|
//
|
||||||
// `mutate` must also not call updateState() itself, directly or through
|
// `mutate` must also not call updateState() itself, directly or through
|
||||||
// anything it awaits: the inner turn waits for the lock the outer one holds,
|
// anything it awaits: the inner turn waits for the lock the outer one holds,
|
||||||
|
|||||||
+23
-10
@@ -810,7 +810,10 @@
|
|||||||
<h2 class="font-bold mb-2">Transaction Broadcast</h2>
|
<h2 class="font-bold mb-2">Transaction Broadcast</h2>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Amount</div>
|
<div class="text-xs text-muted mb-1">Amount</div>
|
||||||
<div id="wait-tx-summary" class="font-bold"></div>
|
<div
|
||||||
|
id="wait-tx-summary"
|
||||||
|
class="font-bold break-words"
|
||||||
|
></div>
|
||||||
<div id="wait-tx-token-note"></div>
|
<div id="wait-tx-token-note"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -830,7 +833,10 @@
|
|||||||
<div id="success-tx-decoded" class="mb-3 hidden text-xs"></div>
|
<div id="success-tx-decoded" class="mb-3 hidden text-xs"></div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Amount</div>
|
<div class="text-xs text-muted mb-1">Amount</div>
|
||||||
<div id="success-tx-summary" class="font-bold"></div>
|
<div
|
||||||
|
id="success-tx-summary"
|
||||||
|
class="font-bold break-words"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">To</div>
|
<div class="text-xs text-muted mb-1">To</div>
|
||||||
@@ -857,7 +863,10 @@
|
|||||||
<h2 class="font-bold mb-2">Transaction Failed</h2>
|
<h2 class="font-bold mb-2">Transaction Failed</h2>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Amount</div>
|
<div class="text-xs text-muted mb-1">Amount</div>
|
||||||
<div id="error-tx-summary" class="font-bold"></div>
|
<div
|
||||||
|
id="error-tx-summary"
|
||||||
|
class="font-bold break-words"
|
||||||
|
></div>
|
||||||
<div id="error-tx-token-note"></div>
|
<div id="error-tx-token-note"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -1637,7 +1646,7 @@
|
|||||||
<div class="text-xs text-muted mb-1">Action</div>
|
<div class="text-xs text-muted mb-1">Action</div>
|
||||||
<div
|
<div
|
||||||
id="tx-detail-calldata-action"
|
id="tx-detail-calldata-action"
|
||||||
class="text-xs font-bold mb-2"
|
class="text-xs font-bold mb-2 break-words"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
id="tx-detail-calldata-details"
|
id="tx-detail-calldata-details"
|
||||||
@@ -1712,7 +1721,7 @@
|
|||||||
<div class="text-xs text-muted mb-1">Action</div>
|
<div class="text-xs text-muted mb-1">Action</div>
|
||||||
<div
|
<div
|
||||||
id="approve-tx-action"
|
id="approve-tx-action"
|
||||||
class="text-xs font-bold mb-2"
|
class="text-xs font-bold mb-2 break-words"
|
||||||
></div>
|
></div>
|
||||||
<div id="approve-tx-decoded-details" class="text-xs"></div>
|
<div id="approve-tx-decoded-details" class="text-xs"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1727,7 +1736,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Value</div>
|
<div class="text-xs text-muted mb-1">Value</div>
|
||||||
<div id="approve-tx-value" class="text-xs font-bold"></div>
|
<div
|
||||||
|
id="approve-tx-value"
|
||||||
|
class="text-xs font-bold break-words"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Network fee (max)</div>
|
<div class="text-xs text-muted mb-1">Network fee (max)</div>
|
||||||
@@ -1994,10 +2006,11 @@
|
|||||||
>Erasing the saved data deletes every wallet stored in
|
>Erasing the saved data deletes every wallet stored in
|
||||||
this browser.</strong
|
this browser.</strong
|
||||||
>
|
>
|
||||||
Nothing on the blockchain changes and no money is moved, but
|
Nothing on the blockchain changes and no money is moved. If
|
||||||
without the exported copy above, or the recovery phrase for
|
you kept a copy of a wallet's recovery phrase, private key
|
||||||
each wallet written down, everything they hold is gone
|
or extended private key, you can add that wallet again.
|
||||||
forever.
|
Without that copy or the exported copy above, everything the
|
||||||
|
wallet holds is gone forever.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs mb-1">
|
<p class="text-xs mb-1">
|
||||||
To confirm, type
|
To confirm, type
|
||||||
|
|||||||
+58
-15
@@ -6,6 +6,7 @@ const {
|
|||||||
saveState,
|
saveState,
|
||||||
onSaveFailure,
|
onSaveFailure,
|
||||||
takeStoredNetwork,
|
takeStoredNetwork,
|
||||||
|
clearBalanceNotFetched,
|
||||||
loadState,
|
loadState,
|
||||||
} = require("../shared/state");
|
} = require("../shared/state");
|
||||||
const { StateUnusableError } = require("../shared/stateSchema");
|
const { StateUnusableError } = require("../shared/stateSchema");
|
||||||
@@ -65,7 +66,14 @@ function renderWalletList() {
|
|||||||
const pageClosed = new AbortController();
|
const pageClosed = new AbortController();
|
||||||
window.addEventListener("pagehide", () => pageClosed.abort());
|
window.addEventListener("pagehide", () => pageClosed.abort());
|
||||||
|
|
||||||
|
// While a refresh runs, a request through ctx is not dropped. The request sets
|
||||||
|
// refreshAgain, and one more refresh runs once the running one ends. Several
|
||||||
|
// requests in that time lead to one more refresh, not several. An import asks
|
||||||
|
// for a refresh after its address scan. A refresh already running does not
|
||||||
|
// have the addresses the scan found
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/581).
|
||||||
let refreshInFlight = false;
|
let refreshInFlight = false;
|
||||||
|
let refreshAgain = false;
|
||||||
|
|
||||||
// The ten-second refresh startRefresh() starts, stopped when the popup moves to
|
// The ten-second refresh startRefresh() starts, stopped when the popup moves to
|
||||||
// the recovery screen: there is no profile left to refresh.
|
// the recovery screen: there is no profile left to refresh.
|
||||||
@@ -88,15 +96,33 @@ let refreshTimer = null;
|
|||||||
// removed meanwhile is left as it is. Blockscout may have answered before the
|
// 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
|
// user turned "Use Blockscout" off. So the save drops every token the user has
|
||||||
// not tracked if the stored setting is off by then.
|
// 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. A refresh that did not read every
|
||||||
|
// address this page now holds, and every address in the stored record, deletes
|
||||||
|
// no `balanceNotFetched`. Adding a token sets that field (trackToken() in
|
||||||
|
// src/popup/views/helpers.js). So does adding an address, and so does a network
|
||||||
|
// switch. 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. When Send is up, its
|
||||||
|
// From address and current balance are drawn again, and its inputs and dropdown
|
||||||
|
// stay as the user left them. No other screen is drawn again, DeleteAddress and
|
||||||
|
// its balance lines included.
|
||||||
async function doRefreshAndRender() {
|
async function doRefreshAndRender() {
|
||||||
if (refreshInFlight) return;
|
if (refreshInFlight) {
|
||||||
|
refreshAgain = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
refreshInFlight = true;
|
refreshInFlight = true;
|
||||||
try {
|
try {
|
||||||
await followStoredSettings();
|
await followStoredSettings();
|
||||||
const networkId = state.networkId;
|
const networkId = state.networkId;
|
||||||
const wallets = structuredClone(state.wallets);
|
const wallets = structuredClone(state.wallets);
|
||||||
await Promise.all([
|
const [, readTokens] = await Promise.all([
|
||||||
refreshPrices(),
|
refreshPrices(pageClosed.signal),
|
||||||
refreshBalances(
|
refreshBalances(
|
||||||
wallets,
|
wallets,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
@@ -127,33 +153,50 @@ async function doRefreshAndRender() {
|
|||||||
if (!stored.useBlockscout) {
|
if (!stored.useBlockscout) {
|
||||||
dropUntrackedTokens(state.wallets, stored.trackedTokens);
|
dropUntrackedTokens(state.wallets, stored.trackedTokens);
|
||||||
}
|
}
|
||||||
|
const readEveryAddress = [
|
||||||
|
...state.wallets,
|
||||||
|
...stored.wallets,
|
||||||
|
].every((wallet) =>
|
||||||
|
wallet.addresses.every((addr) => refreshed.has(addr.address)),
|
||||||
|
);
|
||||||
|
if (readEveryAddress) clearBalanceNotFetched(stored, readTokens);
|
||||||
state.lastBalanceRefresh = Date.now();
|
state.lastBalanceRefresh = Date.now();
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
renderWalletList();
|
renderWalletList();
|
||||||
|
if (state.currentView === "address") addressDetail.renderBalances();
|
||||||
|
if (state.currentView === "address-token") addressToken.renderBalance();
|
||||||
|
if (state.currentView === "send") send.updateSendBalance();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Every call site fires this and walks away — the boot below, the ten
|
// No caller waits for this function, so it must never reject. An
|
||||||
// second interval, and eight views through ctx — so it must never
|
// unhandled rejection reports nothing. The save inside it reports its
|
||||||
// reject: an unhandled rejection is not a report of anything. The save
|
// own failure through onSaveFailure() (see src/shared/state.js); what
|
||||||
// inside it reports its own failure through onSaveFailure() (see
|
// is left here is a failed network round trip, which the next tick
|
||||||
// src/shared/state.js); what is left here is a failed network round
|
// retries.
|
||||||
// trip, which the next tick retries.
|
|
||||||
log.errorf("popup: background refresh failed:", e);
|
log.errorf("popup: background refresh failed:", e);
|
||||||
} finally {
|
} finally {
|
||||||
refreshInFlight = false;
|
refreshInFlight = false;
|
||||||
}
|
}
|
||||||
|
if (refreshAgain) {
|
||||||
|
refreshAgain = false;
|
||||||
|
doRefreshAndRender();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refreshes now and every ten seconds from then on. Called once a wallet
|
// Refreshes now and every ten seconds from then on. Called once a wallet
|
||||||
// exists: by init() when the popup opens with one, and by AddWallet when it
|
// exists: by init() when the popup opens with one, and by AddWallet when it
|
||||||
// adds one, which in a popup that opened on Welcome is the first
|
// adds one, which in a popup that opened on Welcome is the first
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the refresh already
|
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the ten-second
|
||||||
// running it starts no second one, and on the recovery screen it does nothing.
|
// refresh already started, it starts no second one. On the recovery screen it
|
||||||
|
// does nothing.
|
||||||
function startRefresh() {
|
function startRefresh() {
|
||||||
if (!$("view-state-recovery").classList.contains("hidden")) return;
|
if (!$("view-state-recovery").classList.contains("hidden")) return;
|
||||||
doRefreshAndRender();
|
doRefreshAndRender();
|
||||||
if (refreshTimer === null) {
|
if (refreshTimer === null) {
|
||||||
refreshTimer = setInterval(doRefreshAndRender, 10000);
|
// A tick that finds a refresh running is dropped.
|
||||||
|
refreshTimer = setInterval(() => {
|
||||||
|
if (!refreshInFlight) return doRefreshAndRender();
|
||||||
|
}, 10000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,9 +324,9 @@ async function followStoredSettings() {
|
|||||||
// zoom it reports, 170% among them, so once the page has its new width (its
|
// zoom it reports, 170% among them, so once the page has its new width (its
|
||||||
// resize event) it is measured again, and the window widened again while the
|
// resize event) it is measured again, and the window widened again while the
|
||||||
// page still falls short, three times at most. A window that cannot grow sends
|
// page still falls short, three times at most. A window that cannot grow sends
|
||||||
// no resize event, and the page stops there. The connection prompt can also
|
// no resize event, and the page stops there. An approval can also show in the
|
||||||
// show in the toolbar popup, whose window is the user's browser window; that
|
// toolbar popup, whose window is the user's browser window; that one is left
|
||||||
// one is left alone.
|
// alone.
|
||||||
async function widenApprovalWindow() {
|
async function widenApprovalWindow() {
|
||||||
const page = document.documentElement;
|
const page = document.documentElement;
|
||||||
if (page.scrollWidth <= page.clientWidth) return;
|
if (page.scrollWidth <= page.clientWidth) return;
|
||||||
|
|||||||
@@ -116,21 +116,6 @@ function isAddressText(value) {
|
|||||||
return isText(value);
|
return isText(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decoded calldata, as decodedDetailsHtml() (src/popup/views/txStatus.js)
|
|
||||||
// walks it: `for (const d of decoded.details)` needs an iterable, and each
|
|
||||||
// entry's `address` reaches toAddressHtml(). Absent or falsy is the ordinary
|
|
||||||
// case and short-circuits before either.
|
|
||||||
function isRenderableDecoded(value) {
|
|
||||||
if (!value) return true;
|
|
||||||
if (!isRecord(value)) return false;
|
|
||||||
if (!value.details) return true;
|
|
||||||
if (!Array.isArray(value.details)) return false;
|
|
||||||
return value.details.every(
|
|
||||||
(entry) =>
|
|
||||||
isRecord(entry) && (!entry.address || isAddressText(entry.address)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The pending transaction confirmTx.show() renders: `token` reaches
|
// The pending transaction confirmTx.show() renders: `token` reaches
|
||||||
// renderAddressHtml() when it is not "ETH", `from`/`to` reach
|
// renderAddressHtml() when it is not "ETH", `from`/`to` reach
|
||||||
// addressTitle(), makeBlockie() and getLocalWarnings(), and `amount` reaches
|
// addressTitle(), makeBlockie() and getLocalWarnings(), and `amount` reaches
|
||||||
@@ -209,7 +194,6 @@ function renderView(view, state, views) {
|
|||||||
case "success-tx":
|
case "success-tx":
|
||||||
if (!data.hash) return false;
|
if (!data.hash) return false;
|
||||||
if (!isAddressText(data.to)) return false;
|
if (!isAddressText(data.to)) return false;
|
||||||
if (!isRenderableDecoded(data.decoded)) return false;
|
|
||||||
// `chainId` picks the network whose explorer the links open, so
|
// `chainId` picks the network whose explorer the links open, so
|
||||||
// when it is there it has to name one. Likewise on error-tx.
|
// when it is there it has to name one. Likewise on error-tx.
|
||||||
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
|
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
|
||||||
|
|||||||
@@ -1,11 +1,29 @@
|
|||||||
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
|
const {
|
||||||
|
$,
|
||||||
|
showView,
|
||||||
|
showFlash,
|
||||||
|
escapeHtml,
|
||||||
|
goBack,
|
||||||
|
trackToken,
|
||||||
|
} = require("./helpers");
|
||||||
const { getTopTokens } = require("../../shared/tokenList");
|
const { getTopTokens } = require("../../shared/tokenList");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { lookupTokenInfo } = require("../../shared/balances");
|
const { lookupTokenInfo } = require("../../shared/balances");
|
||||||
const { isScamAddress } = require("../../shared/scamlist");
|
const { isScamAddress } = require("../../shared/scamlist");
|
||||||
|
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
|
// Counts the times AddToken has opened. A contract lookup started before
|
||||||
|
// AddToken was last opened changes neither the screen nor the status line.
|
||||||
|
let addTokenScreenOpenings = 0;
|
||||||
|
|
||||||
|
function findTracked(address) {
|
||||||
|
const lower = address.toLowerCase();
|
||||||
|
return state.trackedTokens.find((t) => t.address.toLowerCase() === lower);
|
||||||
|
}
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
|
addTokenScreenOpenings++;
|
||||||
$("add-token-address").value = "";
|
$("add-token-address").value = "";
|
||||||
$("add-token-info").textContent = "";
|
$("add-token-info").textContent = "";
|
||||||
$("add-token-info").style.visibility = "hidden";
|
$("add-token-info").style.visibility = "hidden";
|
||||||
@@ -37,9 +55,7 @@ function init(ctx) {
|
|||||||
showFlash("Enter a valid contract address starting with 0x.");
|
showFlash("Enter a valid contract address starting with 0x.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const already = state.trackedTokens.find(
|
const already = findTracked(contractAddr);
|
||||||
(t) => t.address.toLowerCase() === contractAddr.toLowerCase(),
|
|
||||||
);
|
|
||||||
if (already) {
|
if (already) {
|
||||||
showFlash(already.symbol + " is already being tracked.");
|
showFlash(already.symbol + " is already being tracked.");
|
||||||
return;
|
return;
|
||||||
@@ -55,6 +71,7 @@ function init(ctx) {
|
|||||||
// The network the lookup asks, which Settings may switch away from
|
// The network the lookup asks, which Settings may switch away from
|
||||||
// before it answers, as for a transaction in confirmTx.js.
|
// before it answers, as for a transaction in confirmTx.js.
|
||||||
const chainId = currentNetwork().chainId;
|
const chainId = currentNetwork().chainId;
|
||||||
|
const opening = addTokenScreenOpenings;
|
||||||
try {
|
try {
|
||||||
const info = await lookupTokenInfo(
|
const info = await lookupTokenInfo(
|
||||||
contractAddr,
|
contractAddr,
|
||||||
@@ -71,8 +88,29 @@ function init(ctx) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Another click on Add may have tracked this token while this
|
||||||
|
// lookup waited.
|
||||||
|
const trackedMeanwhile = findTracked(contractAddr);
|
||||||
|
if (trackedMeanwhile) {
|
||||||
|
showFlash(
|
||||||
|
trackedMeanwhile.symbol + " is already being tracked.",
|
||||||
|
);
|
||||||
|
if (addTokenScreenOpenings !== opening) return;
|
||||||
|
infoEl.textContent = "";
|
||||||
|
infoEl.style.visibility = "hidden";
|
||||||
|
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);
|
log.infof("Adding token", info.symbol, contractAddr);
|
||||||
state.trackedTokens.push({
|
trackToken({
|
||||||
address: contractAddr,
|
address: contractAddr,
|
||||||
symbol: info.symbol,
|
symbol: info.symbol,
|
||||||
decimals: info.decimals,
|
decimals: info.decimals,
|
||||||
@@ -80,6 +118,17 @@ function init(ctx) {
|
|||||||
});
|
});
|
||||||
await saveState();
|
await saveState();
|
||||||
ctx.doRefreshAndRender();
|
ctx.doRefreshAndRender();
|
||||||
|
// If the lookup answers after AddToken was opened again, the
|
||||||
|
// token is still added, but the screen and the status line stay
|
||||||
|
// as they are.
|
||||||
|
if (addTokenScreenOpenings !== opening) return;
|
||||||
|
// The lookup is over, so a later Back onto AddToken shows no
|
||||||
|
// status.
|
||||||
|
infoEl.textContent = "";
|
||||||
|
infoEl.style.visibility = "hidden";
|
||||||
|
// If the lookup answers while another screen shows, the token is
|
||||||
|
// still added but the screen does not change.
|
||||||
|
if (state.currentView !== "add-token") return;
|
||||||
// Pop the stack (back to address detail) and re-render it
|
// Pop the stack (back to address detail) and re-render it
|
||||||
// so the newly added token is visible immediately.
|
// so the newly added token is visible immediately.
|
||||||
if (state.viewStack.length > 0) {
|
if (state.viewStack.length > 0) {
|
||||||
@@ -100,6 +149,7 @@ function init(ctx) {
|
|||||||
? detail
|
? detail
|
||||||
: "Could not add the token.",
|
: "Could not add the token.",
|
||||||
);
|
);
|
||||||
|
if (addTokenScreenOpenings !== opening) return;
|
||||||
infoEl.textContent = "";
|
infoEl.textContent = "";
|
||||||
infoEl.style.visibility = "hidden";
|
infoEl.style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const {
|
|||||||
goBack,
|
goBack,
|
||||||
clearViewStack,
|
clearViewStack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
|
markTrackedTokensNotFetched,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const {
|
const {
|
||||||
generateMnemonic,
|
generateMnemonic,
|
||||||
@@ -189,12 +190,13 @@ async function importMnemonic(ctx) {
|
|||||||
{
|
{
|
||||||
address: firstAddress,
|
address: firstAddress,
|
||||||
index: 0,
|
index: 0,
|
||||||
balance: "0.0000",
|
balance: null,
|
||||||
tokenBalances: [],
|
tokenBalances: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
state.wallets.push(wallet);
|
state.wallets.push(wallet);
|
||||||
|
markTrackedTokensNotFetched();
|
||||||
state.hasWallet = true;
|
state.hasWallet = true;
|
||||||
if (state.activeAddress === null) {
|
if (state.activeAddress === null) {
|
||||||
state.activeAddress = firstAddress;
|
state.activeAddress = firstAddress;
|
||||||
@@ -216,10 +218,11 @@ async function importMnemonic(ctx) {
|
|||||||
wallet.addresses = scan.addresses.map((a) => ({
|
wallet.addresses = scan.addresses.map((a) => ({
|
||||||
address: a.address,
|
address: a.address,
|
||||||
index: a.index,
|
index: a.index,
|
||||||
balance: "0.0000",
|
balance: null,
|
||||||
tokenBalances: [],
|
tokenBalances: [],
|
||||||
}));
|
}));
|
||||||
wallet.nextIndex = scan.nextIndex;
|
wallet.nextIndex = scan.nextIndex;
|
||||||
|
markTrackedTokensNotFetched();
|
||||||
// The new list leaves out the first address when the scan found it
|
// The new list leaves out the first address when the scan found it
|
||||||
// unused. If it was the active one, the new list's first takes over,
|
// unused. If it was the active one, the new list's first takes over,
|
||||||
// and the background is told as when the user picks one on Home.
|
// and the background is told as when the user picks one on Home.
|
||||||
@@ -264,8 +267,9 @@ async function importPrivateKey(ctx) {
|
|||||||
type: "key",
|
type: "key",
|
||||||
name: "Wallet " + walletNum,
|
name: "Wallet " + walletNum,
|
||||||
encryptedSecret: encrypted,
|
encryptedSecret: encrypted,
|
||||||
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
|
addresses: [{ address: addr, balance: null, tokenBalances: [] }],
|
||||||
});
|
});
|
||||||
|
markTrackedTokensNotFetched();
|
||||||
state.hasWallet = true;
|
state.hasWallet = true;
|
||||||
if (state.activeAddress === null) {
|
if (state.activeAddress === null) {
|
||||||
state.activeAddress = addr;
|
state.activeAddress = addr;
|
||||||
@@ -324,12 +328,13 @@ async function importXprvKey(ctx) {
|
|||||||
{
|
{
|
||||||
address: firstAddress,
|
address: firstAddress,
|
||||||
index: 0,
|
index: 0,
|
||||||
balance: "0.0000",
|
balance: null,
|
||||||
tokenBalances: [],
|
tokenBalances: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
state.wallets.push(wallet);
|
state.wallets.push(wallet);
|
||||||
|
markTrackedTokensNotFetched();
|
||||||
state.hasWallet = true;
|
state.hasWallet = true;
|
||||||
if (state.activeAddress === null) {
|
if (state.activeAddress === null) {
|
||||||
state.activeAddress = firstAddress;
|
state.activeAddress = firstAddress;
|
||||||
@@ -351,10 +356,11 @@ async function importXprvKey(ctx) {
|
|||||||
wallet.addresses = scan.addresses.map((a) => ({
|
wallet.addresses = scan.addresses.map((a) => ({
|
||||||
address: a.address,
|
address: a.address,
|
||||||
index: a.index,
|
index: a.index,
|
||||||
balance: "0.0000",
|
balance: null,
|
||||||
tokenBalances: [],
|
tokenBalances: [],
|
||||||
}));
|
}));
|
||||||
wallet.nextIndex = scan.nextIndex;
|
wallet.nextIndex = scan.nextIndex;
|
||||||
|
markTrackedTokensNotFetched();
|
||||||
// As for a recovery phrase: an active first address the scan left
|
// As for a recovery phrase: an active first address the scan left
|
||||||
// out gives way to the new list's first.
|
// out gives way to the new list's first.
|
||||||
const activeMoved =
|
const activeMoved =
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const {
|
|||||||
filterTransactions,
|
filterTransactions,
|
||||||
} = require("../../shared/transactions");
|
} = require("../../shared/transactions");
|
||||||
const { resolveEnsNames } = require("../../shared/ens");
|
const { resolveEnsNames } = require("../../shared/ens");
|
||||||
|
const { storedNetworkIs } = require("../../shared/storedSettings");
|
||||||
const {
|
const {
|
||||||
updateSendBalance,
|
updateSendBalance,
|
||||||
renderSendTokenSelect,
|
renderSendTokenSelect,
|
||||||
@@ -66,11 +67,27 @@ function show() {
|
|||||||
});
|
});
|
||||||
$("address-line").dataset.full = addr.address;
|
$("address-line").dataset.full = addr.address;
|
||||||
attachCopyHandlers($("address-line"));
|
attachCopyHandlers($("address-line"));
|
||||||
const usdTotal = formatAddressTotal(getAddressValue(addr));
|
|
||||||
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || " ";
|
|
||||||
const ensEl = $("address-ens");
|
const ensEl = $("address-ens");
|
||||||
// ENS is now shown inside renderAddressHtml, hide the separate element
|
// ENS is now shown inside renderAddressHtml, hide the separate element
|
||||||
ensEl.classList.add("hidden");
|
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) || " ";
|
||||||
$("address-balances").innerHTML = balanceLinesForAddress(
|
$("address-balances").innerHTML = balanceLinesForAddress(
|
||||||
addr,
|
addr,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
@@ -84,13 +101,6 @@ function show() {
|
|||||||
ctx.showAddressToken();
|
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 = [];
|
let loadedTxs = [];
|
||||||
@@ -98,6 +108,7 @@ let loadedTxs = [];
|
|||||||
let ensNameMap = new Map();
|
let ensNameMap = new Map();
|
||||||
|
|
||||||
async function loadTransactions(address) {
|
async function loadTransactions(address) {
|
||||||
|
const networkId = state.networkId;
|
||||||
try {
|
try {
|
||||||
const rawTxs = await fetchRecentTransactions(
|
const rawTxs = await fetchRecentTransactions(
|
||||||
address,
|
address,
|
||||||
@@ -114,43 +125,75 @@ async function loadTransactions(address) {
|
|||||||
});
|
});
|
||||||
const txs = result.transactions;
|
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
|
// Collect ALL unique addresses (from + to) for ENS resolution so
|
||||||
// that reverse lookups work for every displayed address, not just
|
// that reverse lookups work for every displayed address, not just
|
||||||
// the ones that were originally entered as ENS names.
|
// the ones that were originally entered as ENS names.
|
||||||
const counterparties = [
|
const counterparties = [
|
||||||
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
||||||
];
|
];
|
||||||
|
let names = new Map();
|
||||||
if (counterparties.length > 0) {
|
if (counterparties.length > 0) {
|
||||||
try {
|
try {
|
||||||
ensNameMap = await resolveEnsNames(
|
names = await resolveEnsNames(
|
||||||
counterparties,
|
counterparties,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
} catch {
|
} 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. The page's network
|
||||||
|
// is compared after the read of storage, because a switch made on this
|
||||||
|
// page can land during that read. See src/shared/storedSettings.js.
|
||||||
|
if (
|
||||||
|
!(await storedNetworkIs(networkId)) ||
|
||||||
|
state.networkId !== 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);
|
renderTransactions(txs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||||
// src/popup/index.js.
|
// src/popup/index.js.
|
||||||
if (ctx.pageClosed.aborted) return;
|
if (ctx.pageClosed.aborted) return;
|
||||||
log.errorf("loadTransactions failed:", e.message);
|
log.errorf("loadTransactions failed:", e.message);
|
||||||
|
// If the network changed, or the screen now shows another address,
|
||||||
|
// the failure is not shown.
|
||||||
|
if (
|
||||||
|
!(await storedNetworkIs(networkId)) ||
|
||||||
|
state.networkId !== networkId ||
|
||||||
|
$("address-line").dataset.full !== address
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$("tx-list").innerHTML =
|
$("tx-list").innerHTML =
|
||||||
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
||||||
}
|
}
|
||||||
@@ -230,7 +273,8 @@ function init(_ctx) {
|
|||||||
state.wallets[state.selectedWallet].addresses[
|
state.wallets[state.selectedWallet].addresses[
|
||||||
state.selectedAddress
|
state.selectedAddress
|
||||||
];
|
];
|
||||||
if (!addr.balance || parseFloat(addr.balance) === 0) {
|
// A null balance is one no refresh has read, so it is not refused.
|
||||||
|
if (parseFloat(addr.balance) === 0) {
|
||||||
showFlash("Cannot send \u2014 zero balance.");
|
showFlash("Cannot send \u2014 zero balance.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -287,4 +331,4 @@ function init(_ctx) {
|
|||||||
exportPrivkey.init();
|
exportPrivkey.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, show };
|
module.exports = { init, show, renderBalances };
|
||||||
|
|||||||
+111
-45
@@ -22,13 +22,14 @@ const {
|
|||||||
timeAgo,
|
timeAgo,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
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 { formatUsd, getPrice } = require("../../shared/prices");
|
||||||
const {
|
const {
|
||||||
fetchRecentTransactions,
|
fetchRecentTransactions,
|
||||||
filterTransactions,
|
filterTransactions,
|
||||||
} = require("../../shared/transactions");
|
} = require("../../shared/transactions");
|
||||||
const { resolveEnsNames } = require("../../shared/ens");
|
const { resolveEnsNames } = require("../../shared/ens");
|
||||||
|
const { storedNetworkIs } = require("../../shared/storedSettings");
|
||||||
const {
|
const {
|
||||||
updateSendBalance,
|
updateSendBalance,
|
||||||
renderSendTokenSelect,
|
renderSendTokenSelect,
|
||||||
@@ -49,37 +50,24 @@ function show() {
|
|||||||
const addr = wallet.addresses[state.selectedAddress];
|
const addr = wallet.addresses[state.selectedAddress];
|
||||||
const ai = state.selectedAddress;
|
const ai = state.selectedAddress;
|
||||||
const tokenId = state.selectedToken;
|
const tokenId = state.selectedToken;
|
||||||
|
const chainId = currentNetwork().chainId;
|
||||||
// Determine token symbol and balance
|
const knownToken = bundledToken(tokenId, chainId);
|
||||||
let symbol, amount, price;
|
currentSymbol =
|
||||||
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
|
tokenId === "ETH"
|
||||||
if (tokenId === "ETH") {
|
? nativeCurrency()
|
||||||
symbol = nativeCurrency();
|
: resolveSymbol(
|
||||||
amount = parseFloat(addr.balance || "0");
|
|
||||||
price = getPrice("ETH");
|
|
||||||
} else {
|
|
||||||
const tb = (addr.tokenBalances || []).find(
|
|
||||||
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
|
|
||||||
);
|
|
||||||
symbol = resolveSymbol(
|
|
||||||
tokenId,
|
tokenId,
|
||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
|
chainId,
|
||||||
);
|
);
|
||||||
// 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(symbol);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentSymbol = symbol;
|
|
||||||
|
|
||||||
$("address-token-title").textContent =
|
$("address-token-title").textContent =
|
||||||
wallet.name +
|
wallet.name +
|
||||||
" \u2014 Address " +
|
" \u2014 Address " +
|
||||||
(ai + 1) +
|
(ai + 1) +
|
||||||
" \u2014 " +
|
" \u2014 " +
|
||||||
displaySymbol(symbol);
|
displaySymbol(currentSymbol);
|
||||||
|
|
||||||
// Blockie
|
// Blockie
|
||||||
const blockieEl = $("address-token-jazzicon");
|
const blockieEl = $("address-token-jazzicon");
|
||||||
@@ -101,13 +89,7 @@ function show() {
|
|||||||
$("address-token-line").dataset.full = addr.address;
|
$("address-token-line").dataset.full = addr.address;
|
||||||
attachCopyHandlers($("address-token-line"));
|
attachCopyHandlers($("address-token-line"));
|
||||||
|
|
||||||
// USD total for this token only
|
renderBalance();
|
||||||
const usdVal = price && amount !== null ? amount * price : null;
|
|
||||||
const usdStr = formatUsd(usdVal);
|
|
||||||
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || " ";
|
|
||||||
|
|
||||||
// Single token balance line (no tokenId — not clickable here)
|
|
||||||
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
|
|
||||||
|
|
||||||
// Token contract details (ERC-20 only)
|
// Token contract details (ERC-20 only)
|
||||||
const contractInfo = $("address-token-contract-info");
|
const contractInfo = $("address-token-contract-info");
|
||||||
@@ -169,7 +151,50 @@ function show() {
|
|||||||
loadTransactions(addr.address, tokenId);
|
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") {
|
||||||
|
// null when no refresh has read the balance.
|
||||||
|
amount = unknownableAmount(addr.balance);
|
||||||
|
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
|
||||||
|
// token's balance: 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) || " ";
|
||||||
|
|
||||||
|
// Single token balance line (no tokenId — not clickable here)
|
||||||
|
$("address-token-balance").innerHTML = balanceLine(
|
||||||
|
currentSymbol,
|
||||||
|
amount,
|
||||||
|
price,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTransactions(address, tokenId) {
|
async function loadTransactions(address, tokenId) {
|
||||||
|
const networkId = state.networkId;
|
||||||
try {
|
try {
|
||||||
const rawTxs = await fetchRecentTransactions(
|
const rawTxs = await fetchRecentTransactions(
|
||||||
address,
|
address,
|
||||||
@@ -186,16 +211,6 @@ async function loadTransactions(address, tokenId) {
|
|||||||
});
|
});
|
||||||
let txs = result.transactions;
|
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
|
// Filter to this token only
|
||||||
if (tokenId === "ETH") {
|
if (tokenId === "ETH") {
|
||||||
txs = txs.filter((tx) => tx.contractAddress === null);
|
txs = txs.filter((tx) => tx.contractAddress === null);
|
||||||
@@ -207,32 +222,77 @@ async function loadTransactions(address, tokenId) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadedTxs = txs;
|
|
||||||
|
|
||||||
// Collect ALL unique addresses for ENS resolution so reverse
|
// Collect ALL unique addresses for ENS resolution so reverse
|
||||||
// lookups work for every displayed address.
|
// lookups work for every displayed address.
|
||||||
const counterparties = [
|
const counterparties = [
|
||||||
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
||||||
];
|
];
|
||||||
|
let names = new Map();
|
||||||
if (counterparties.length > 0) {
|
if (counterparties.length > 0) {
|
||||||
try {
|
try {
|
||||||
ensNameMap = await resolveEnsNames(
|
names = await resolveEnsNames(
|
||||||
counterparties,
|
counterparties,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
ctx.pageClosed,
|
||||||
);
|
);
|
||||||
} catch {
|
} 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. The page's
|
||||||
|
// network is compared after the read of storage, because a switch made
|
||||||
|
// on this page can land during that read. See
|
||||||
|
// src/shared/storedSettings.js.
|
||||||
|
if (
|
||||||
|
!(await storedNetworkIs(networkId)) ||
|
||||||
|
state.networkId !== 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);
|
renderTransactions(txs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||||
// src/popup/index.js.
|
// src/popup/index.js.
|
||||||
if (ctx.pageClosed.aborted) return;
|
if (ctx.pageClosed.aborted) return;
|
||||||
log.errorf("loadTransactions failed:", e.message);
|
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 (
|
||||||
|
!(await storedNetworkIs(networkId)) ||
|
||||||
|
state.networkId !== networkId ||
|
||||||
|
$("address-token-line").dataset.full !== address ||
|
||||||
|
state.selectedToken !== tokenId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$("address-token-tx-list").innerHTML =
|
$("address-token-tx-list").innerHTML =
|
||||||
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
||||||
}
|
}
|
||||||
@@ -300,6 +360,11 @@ function init(_ctx) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("btn-address-token-back").addEventListener("click", () => {
|
$("btn-address-token-back").addEventListener("click", () => {
|
||||||
|
// AddressDetail's show() clears the token, but Back onto a drawn
|
||||||
|
// screen only unhides it. "Send" below may have left the dropdown on
|
||||||
|
// the token as well.
|
||||||
|
state.selectedToken = null;
|
||||||
|
$("send-token").value = "ETH";
|
||||||
goBack();
|
goBack();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,7 +378,8 @@ function init(_ctx) {
|
|||||||
state.wallets[state.selectedWallet].addresses[
|
state.wallets[state.selectedWallet].addresses[
|
||||||
state.selectedAddress
|
state.selectedAddress
|
||||||
];
|
];
|
||||||
if (!addr.balance || parseFloat(addr.balance) === 0) {
|
// A null balance is one no refresh has read, so it is not refused.
|
||||||
|
if (parseFloat(addr.balance) === 0) {
|
||||||
if (state.selectedToken === "ETH") {
|
if (state.selectedToken === "ETH") {
|
||||||
showFlash("Cannot send \u2014 zero balance.");
|
showFlash("Cannot send \u2014 zero balance.");
|
||||||
return;
|
return;
|
||||||
@@ -348,4 +414,4 @@ function init(_ctx) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, show };
|
module.exports = { init, show, renderBalance };
|
||||||
|
|||||||
+32
-17
@@ -55,7 +55,7 @@ const {
|
|||||||
} = require("../../shared/approvalVerify");
|
} = require("../../shared/approvalVerify");
|
||||||
const txStatus = require("./txStatus");
|
const txStatus = require("./txStatus");
|
||||||
const uniswap = require("../../shared/uniswap");
|
const uniswap = require("../../shared/uniswap");
|
||||||
const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
|
const { runtimeApi, sendMessage } = require("../../shared/browserApi");
|
||||||
|
|
||||||
const erc20Iface = new Interface(ERC20_ABI);
|
const erc20Iface = new Interface(ERC20_ABI);
|
||||||
|
|
||||||
@@ -319,12 +319,12 @@ function showTxApproval(details) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// A swap the decoder read no amount for, such as one whose V4 swap
|
// A swap the decoder read no amount for, such as one whose V4 swap
|
||||||
// action could not be read or a V4 exact-out swap. The wait and error
|
// action could not be read or a V4 exact-out swap. The wait, success
|
||||||
// screens say the amount could not be decoded, with no currency after
|
// and error screens say the amount could not be decoded, with no
|
||||||
// it, rather than show the transaction's ETH value as the amount. A
|
// currency after it, rather than show the transaction's ETH value as
|
||||||
// swap paid in native ETH keeps that value: it is the ETH the swap
|
// the amount. A swap paid in native ETH keeps that value: it is the
|
||||||
// pays (for an exact-out swap, the most it pays), not a figure that
|
// ETH the swap pays (for an exact-out swap, the most it pays), not a
|
||||||
// had to be read from the calldata.
|
// figure that had to be read from the calldata.
|
||||||
const tokenIn = decoded.details.find((d) => d.label === "Token In");
|
const tokenIn = decoded.details.find((d) => d.label === "Token In");
|
||||||
if (
|
if (
|
||||||
tokenIn &&
|
tokenIn &&
|
||||||
@@ -361,7 +361,7 @@ function showTxApproval(details) {
|
|||||||
if (toAddr) {
|
if (toAddr) {
|
||||||
let toHtml = "";
|
let toHtml = "";
|
||||||
if (symbol) {
|
if (symbol) {
|
||||||
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
|
toHtml += `<div class="font-bold mb-1 break-words">${escapeHtml(symbol)}</div>`;
|
||||||
}
|
}
|
||||||
toHtml += approvalAddressHtml(toAddr, approvedTx.chainId);
|
toHtml += approvalAddressHtml(toAddr, approvedTx.chainId);
|
||||||
$("approve-tx-to").innerHTML = toHtml;
|
$("approve-tx-to").innerHTML = toHtml;
|
||||||
@@ -394,14 +394,14 @@ function showTxApproval(details) {
|
|||||||
$("approve-tx-action").textContent = decoded.name;
|
$("approve-tx-action").textContent = decoded.name;
|
||||||
let detailsHtml = "";
|
let detailsHtml = "";
|
||||||
if (decoded.description) {
|
if (decoded.description) {
|
||||||
detailsHtml += `<div class="mb-2">${escapeHtml(decoded.description)}</div>`;
|
detailsHtml += `<div class="mb-2 break-words">${escapeHtml(decoded.description)}</div>`;
|
||||||
}
|
}
|
||||||
for (const d of decoded.details) {
|
for (const d of decoded.details) {
|
||||||
detailsHtml += `<div class="mb-2">`;
|
detailsHtml += `<div class="mb-2">`;
|
||||||
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
|
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
|
||||||
if (d.address) {
|
if (d.address) {
|
||||||
if (d.isToken) {
|
if (d.isToken) {
|
||||||
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address, approvedTx.chainId) || "Unknown token")}</div>`;
|
detailsHtml += `<div class="font-bold break-words">${escapeHtml(tokenLabel(d.address, approvedTx.chainId) || "Unknown token")}</div>`;
|
||||||
detailsHtml += blockscoutOffTokenNoteHtml(
|
detailsHtml += blockscoutOffTokenNoteHtml(
|
||||||
d.address,
|
d.address,
|
||||||
approvedTx.chainId,
|
approvedTx.chainId,
|
||||||
@@ -412,7 +412,7 @@ function showTxApproval(details) {
|
|||||||
approvedTx.chainId,
|
approvedTx.chainId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
detailsHtml += `<div class="font-bold break-words">${escapeHtml(d.value)}</div>`;
|
||||||
}
|
}
|
||||||
detailsHtml += `</div>`;
|
detailsHtml += `</div>`;
|
||||||
}
|
}
|
||||||
@@ -1142,12 +1142,21 @@ function init(_ctx) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-reject-tx").addEventListener("click", () => {
|
// The window closes only once the background has the rejection. A message
|
||||||
notify({
|
// sent as the window closes can be lost, and a closed window does not
|
||||||
|
// reject the request. After Confirm, the background refuses a rejection
|
||||||
|
// while the transaction is being sent. The window then stays open and
|
||||||
|
// shows the background's message.
|
||||||
|
$("btn-reject-tx").addEventListener("click", async () => {
|
||||||
|
const response = await sendMessage({
|
||||||
type: "AUTISTMASK_TX_RESPONSE",
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
id: approvalId,
|
id: approvalId,
|
||||||
approved: false,
|
approved: false,
|
||||||
});
|
}).catch(() => null);
|
||||||
|
if (response && response.error) {
|
||||||
|
showError("approve-tx-error", response.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.close();
|
window.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1270,12 +1279,18 @@ function init(_ctx) {
|
|||||||
if (outcome.retryable) setSignButtonBusy(false);
|
if (outcome.retryable) setSignButtonBusy(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-reject-sign").addEventListener("click", () => {
|
// As for the transaction's Reject above. After Sign, the background
|
||||||
notify({
|
// refuses a rejection while the signature is being checked.
|
||||||
|
$("btn-reject-sign").addEventListener("click", async () => {
|
||||||
|
const response = await sendMessage({
|
||||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||||
id: approvalId,
|
id: approvalId,
|
||||||
approved: false,
|
approved: false,
|
||||||
});
|
}).catch(() => null);
|
||||||
|
if (response && response.error) {
|
||||||
|
showError("approve-sign-error", response.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.close();
|
window.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ function renderAmount(txInfo) {
|
|||||||
const rawSymbol = isErc20
|
const rawSymbol = isErc20
|
||||||
? txInfo.tokenSymbol || "?"
|
? txInfo.tokenSymbol || "?"
|
||||||
: nativeCurrencyByChainId(txInfo.chainId);
|
: nativeCurrencyByChainId(txInfo.chainId);
|
||||||
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
|
const price = getPrice(txInfo.token, txInfo.chainId);
|
||||||
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
||||||
$("confirm-amount").textContent = valueWithUsd(
|
$("confirm-amount").textContent = valueWithUsd(
|
||||||
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
|
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
|
||||||
@@ -157,7 +157,7 @@ function renderBalance(txInfo) {
|
|||||||
$("confirm-balance").textContent = "unknown (" + symbol + ")";
|
$("confirm-balance").textContent = "unknown (" + symbol + ")";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
|
const price = getPrice(txInfo.token, txInfo.chainId);
|
||||||
const balUsd = price ? parseFloat(bal) * price : null;
|
const balUsd = price ? parseFloat(bal) * price : null;
|
||||||
$("confirm-balance").textContent = valueWithUsd(
|
$("confirm-balance").textContent = valueWithUsd(
|
||||||
truncateAmountNeverZero(bal) + " " + symbol,
|
truncateAmountNeverZero(bal) + " " + symbol,
|
||||||
|
|||||||
@@ -83,19 +83,27 @@ function recoveryPathText(wallet) {
|
|||||||
// rounded number would report "0.0000 ETH" for an address holding real money.
|
// rounded number would report "0.0000 ETH" for an address holding real money.
|
||||||
// The lines below it carry the amounts, in the same format as Home and
|
// The lines below it carry the amounts, in the same format as Home and
|
||||||
// AddressDetail, followed by the USD total when there is one to give — no
|
// AddressDetail, followed by the USD total when there is one to give — no
|
||||||
// total line at all on testnet or before the first price fetch, and no figure
|
// total line at all on testnet, before the first price fetch or while no
|
||||||
// when every holding here is one with no price, since "$0.00" directly under
|
// refresh has read the ETH balance, and no figure when every holding here is
|
||||||
// "This address holds a balance." is a contradiction. A token holding below
|
// one with no price, since "$0.00" directly under "This address holds a
|
||||||
// 0.000001 does not count, as the lines below leave it out.
|
// balance." is a contradiction. A token holding below 0.000001 does not count,
|
||||||
|
// as the lines below leave it out.
|
||||||
|
//
|
||||||
|
// While no refresh has read the ETH balance the address may be empty, so the
|
||||||
|
// sentence says "may hold".
|
||||||
function balanceWarningHtml(addr) {
|
function balanceWarningHtml(addr) {
|
||||||
if (!addressHoldsFunds(addr)) return " ";
|
if (!addressHoldsFunds(addr)) return " ";
|
||||||
const line = formatAddressTotal(getAddressValue(addr));
|
const line = formatAddressTotal(getAddressValue(addr));
|
||||||
const total = line
|
const total = line
|
||||||
? `<div class="text-xs text-muted mt-1">${escapeHtml(line)}</div>`
|
? `<div class="text-xs text-muted mt-1">${escapeHtml(line)}</div>`
|
||||||
: "";
|
: "";
|
||||||
|
const sentence =
|
||||||
|
addr.balance == null
|
||||||
|
? "This address may hold a balance."
|
||||||
|
: "This address holds a balance.";
|
||||||
return (
|
return (
|
||||||
`<p class="mb-1">This address holds a balance. Removing it does not ` +
|
`<p class="mb-1">${sentence} Removing it does not move or spend ` +
|
||||||
`move or spend anything; the balance stays at the address.</p>` +
|
`anything; the balance stays at the address.</p>` +
|
||||||
balanceLinesForAddress(addr, state.trackedTokens, false) +
|
balanceLinesForAddress(addr, state.trackedTokens, false) +
|
||||||
total
|
total
|
||||||
);
|
);
|
||||||
|
|||||||
+54
-21
@@ -33,6 +33,7 @@ const { networkByChainId } = require("../../shared/networks");
|
|||||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { displaySymbol } = require("../../shared/symbolDisplay");
|
const { displaySymbol } = require("../../shared/symbolDisplay");
|
||||||
|
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||||
const { markViewRendered } = require("../viewRouter");
|
const { markViewRendered } = require("../viewRouter");
|
||||||
|
|
||||||
@@ -271,7 +272,7 @@ function clearFlash() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The flash line reserves exactly one line, and a message that wrapped would
|
// The flash line reserves exactly one line, and a message that wrapped would
|
||||||
// push the screen below it down (README, No Layout Shift). So #flash-msg never
|
// push the screen below it down (README, Appearance). So #flash-msg never
|
||||||
// wraps: text too long for the line is cut with an ellipsis, and the whole
|
// wraps: text too long for the line is cut with an ellipsis, and the whole
|
||||||
// message is also put in the line's title. Write messages to fit, at most 50
|
// message is also put in the line's title. Write messages to fit, at most 50
|
||||||
// characters, so none is cut.
|
// characters, so none is cut.
|
||||||
@@ -282,9 +283,10 @@ function showFlash(msg, duration = 2000) {
|
|||||||
flashTimer = setTimeout(clearFlash, duration);
|
flashTimer = setTimeout(clearFlash, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A stored token balance as a number, or null when there is no number in it.
|
// A stored balance as a number, or null when there is no number in it.
|
||||||
// balances.js writes null for a holding whose scale nothing knows, and this
|
// balances.js writes null for a holding whose scale nothing knows, and an
|
||||||
// keeps that null from becoming a zero one dereference later.
|
// address's ETH balance is null until a refresh has read it. This keeps that
|
||||||
|
// null from becoming a zero one dereference later.
|
||||||
function unknownableAmount(balance) {
|
function unknownableAmount(balance) {
|
||||||
if (balance == null) return null;
|
if (balance == null) return null;
|
||||||
const n = parseFloat(balance);
|
const n = parseFloat(balance);
|
||||||
@@ -313,14 +315,17 @@ function nativeCurrency() {
|
|||||||
// The wallets' balances hold what Blockscout reported for each token. So the
|
// The wallets' balances hold what Blockscout reported for each token. So the
|
||||||
// wallets are returned only while "Use Blockscout" is on.
|
// wallets are returned only while "Use Blockscout" is on.
|
||||||
function tokenSourcesByChainId(chainId) {
|
function tokenSourcesByChainId(chainId) {
|
||||||
const net = chainId ? networkByChainId(chainId) : currentNetwork();
|
const chainIdOrActive = chainId || currentNetwork().chainId;
|
||||||
|
const net = networkByChainId(chainIdOrActive);
|
||||||
if (net && net.id === state.networkId) {
|
if (net && net.id === state.networkId) {
|
||||||
return {
|
return {
|
||||||
|
chainId: chainIdOrActive,
|
||||||
trackedTokens: state.trackedTokens,
|
trackedTokens: state.trackedTokens,
|
||||||
wallets: state.useBlockscout ? state.wallets : [],
|
wallets: state.useBlockscout ? state.wallets : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
chainId: chainIdOrActive,
|
||||||
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
|
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
|
||||||
wallets: [],
|
wallets: [],
|
||||||
};
|
};
|
||||||
@@ -351,7 +356,7 @@ function blockscoutOffTokenNoteHtml(token, chainId) {
|
|||||||
|
|
||||||
// The symbol shown for a token line, from these sources in order:
|
// 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 tokens the user tracks
|
||||||
// - the explorer's report, while "Use Blockscout" is on
|
// - the explorer's report, while "Use Blockscout" is on
|
||||||
//
|
//
|
||||||
@@ -390,10 +395,10 @@ function formatFee(wei, symbol, chainId) {
|
|||||||
// holding renders through here.
|
// holding renders through here.
|
||||||
//
|
//
|
||||||
// `amount` is null for a holding whose scale nothing knows
|
// `amount` is null for a holding whose scale nothing knows
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349). There is no quantity to
|
// (https://git.eeqj.de/sneak/AutistMask/issues/349), and for a balance no
|
||||||
// print for it and no fiat value to derive from one, and printing 0.0000 for
|
// refresh has read. There is no quantity to print for it and no fiat value to
|
||||||
// a real holding is the failure this whole rule exists to prevent, so the row
|
// derive from one, and printing 0.0000 for a real holding is the failure this
|
||||||
// says so instead.
|
// whole rule exists to prevent, so the row says so instead.
|
||||||
function balanceLine(symbol, amount, price, tokenId) {
|
function balanceLine(symbol, amount, price, tokenId) {
|
||||||
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
||||||
const usd =
|
const usd =
|
||||||
@@ -417,10 +422,26 @@ 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gives every tracked token `balanceNotFetched: true`, as trackToken() gives a
|
||||||
|
// new one. Called when an address is added, before the save: no refresh has
|
||||||
|
// read any token's balance at that address.
|
||||||
|
function markTrackedTokensNotFetched() {
|
||||||
|
for (const token of state.trackedTokens) token.balanceNotFetched = true;
|
||||||
|
}
|
||||||
|
|
||||||
function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||||
let html = balanceLine(
|
let html = balanceLine(
|
||||||
nativeCurrency(),
|
nativeCurrency(),
|
||||||
parseFloat(addr.balance || "0"),
|
unknownableAmount(addr.balance),
|
||||||
getPrice("ETH"),
|
getPrice("ETH"),
|
||||||
"ETH",
|
"ETH",
|
||||||
);
|
);
|
||||||
@@ -439,7 +460,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
|||||||
html += balanceLine(
|
html += balanceLine(
|
||||||
t.symbol,
|
t.symbol,
|
||||||
bal,
|
bal,
|
||||||
getPrice(t.symbol),
|
getPrice(t.address),
|
||||||
t.address.toLowerCase(),
|
t.address.toLowerCase(),
|
||||||
);
|
);
|
||||||
seen.add(t.address.toLowerCase());
|
seen.add(t.address.toLowerCase());
|
||||||
@@ -447,10 +468,17 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
|||||||
if (showZero && trackedTokens) {
|
if (showZero && trackedTokens) {
|
||||||
for (const t of trackedTokens) {
|
for (const t of trackedTokens) {
|
||||||
if (seen.has(t.address.toLowerCase())) continue;
|
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(
|
html += balanceLine(
|
||||||
t.symbol,
|
t.symbol,
|
||||||
0,
|
t.balanceNotFetched ? null : 0,
|
||||||
getPrice(t.symbol),
|
getPrice(t.address),
|
||||||
t.address.toLowerCase(),
|
t.address.toLowerCase(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -466,7 +494,10 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
|||||||
// about holdings must ask this, not the rendered figure.
|
// about holdings must ask this, not the rendered figure.
|
||||||
function addressHoldsFunds(addr) {
|
function addressHoldsFunds(addr) {
|
||||||
if (!addr) return false;
|
if (!addr) return false;
|
||||||
if (parseFloat(addr.balance || "0") > 0) return true;
|
// A null or absent ETH balance reads as unknown, so the address may hold
|
||||||
|
// funds. Warning about funds must err towards warning.
|
||||||
|
if (addr.balance == null) return true;
|
||||||
|
if (parseFloat(addr.balance) > 0) return true;
|
||||||
for (const t of addr.tokenBalances || []) {
|
for (const t of addr.tokenBalances || []) {
|
||||||
if (isBelowOneMillionth(t.balance)) continue;
|
if (isBelowOneMillionth(t.balance)) continue;
|
||||||
// A null balance is a holding whose amount could not be stated —
|
// A null balance is a holding whose amount could not be stated —
|
||||||
@@ -497,9 +528,9 @@ const ADDRESS_MIN_DISPLAY_LEN = 32;
|
|||||||
|
|
||||||
// Truncate the middle of a string, replacing removed characters with "…".
|
// Truncate the middle of a string, replacing removed characters with "…".
|
||||||
// Safety: refuses to truncate more than 10 characters, which is the maximum
|
// Safety: refuses to truncate more than 10 characters, which is the maximum
|
||||||
// that still prevents address spoofing attacks (see Display Consistency in
|
// that still prevents address spoofing attacks (see Addresses in README).
|
||||||
// README). Callers that need to display less should use a different UI
|
// Callers that need to display less should use a different UI approach rather
|
||||||
// approach rather than silently making addresses insecure.
|
// than silently making addresses insecure.
|
||||||
function truncateMiddle(str, maxLen) {
|
function truncateMiddle(str, maxLen) {
|
||||||
if (str.length <= maxLen) return str;
|
if (str.length <= maxLen) return str;
|
||||||
const removed = str.length - maxLen + 1; // +1 for the ellipsis char
|
const removed = str.length - maxLen + 1; // +1 for the ellipsis char
|
||||||
@@ -602,9 +633,9 @@ function formatAddressHtml(address, ensName, maxLen, title) {
|
|||||||
return renderAddressHtml(address, { title, ensName, maxLen });
|
return renderAddressHtml(address, { title, ensName, maxLen });
|
||||||
}
|
}
|
||||||
|
|
||||||
// A transaction's time as every screen shows it (README, Display
|
// A transaction's time as every screen shows it (README, Amounts): the ISO
|
||||||
// Consistency): the ISO datetime, in UTC when the UTC Timestamps setting is
|
// datetime, in UTC when the UTC Timestamps setting is on, and the relative age.
|
||||||
// on, and the relative age. Views import these two; they keep no copies.
|
// Views import these two; they keep no copies.
|
||||||
function isoDate(timestamp) {
|
function isoDate(timestamp) {
|
||||||
const d = new Date(timestamp * 1000);
|
const d = new Date(timestamp * 1000);
|
||||||
const pad = (n) => String(n).padStart(2, "0");
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
@@ -829,6 +860,8 @@ module.exports = {
|
|||||||
flashCopyFeedback,
|
flashCopyFeedback,
|
||||||
balanceLine,
|
balanceLine,
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
|
trackToken,
|
||||||
|
markTrackedTokensNotFetched,
|
||||||
addressHoldsFunds,
|
addressHoldsFunds,
|
||||||
keyMaterialName,
|
keyMaterialName,
|
||||||
unknownableAmount,
|
unknownableAmount,
|
||||||
|
|||||||
+71
-6
@@ -3,6 +3,8 @@ const {
|
|||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
|
unknownableAmount,
|
||||||
|
markTrackedTokensNotFetched,
|
||||||
isoDate,
|
isoDate,
|
||||||
timeAgo,
|
timeAgo,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
@@ -44,6 +46,7 @@ const {
|
|||||||
fetchRecentTransactions,
|
fetchRecentTransactions,
|
||||||
filterTransactions,
|
filterTransactions,
|
||||||
} = require("../../shared/transactions");
|
} = require("../../shared/transactions");
|
||||||
|
const { storedNetworkIs } = require("../../shared/storedSettings");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
function findActiveAddr() {
|
function findActiveAddr() {
|
||||||
@@ -74,10 +77,16 @@ function renderTotalValue() {
|
|||||||
if (subEl) subEl.innerHTML = " ";
|
if (subEl) subEl.innerHTML = " ";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ethBal = parseFloat(addr.balance || "0");
|
const ethBal = unknownableAmount(addr.balance);
|
||||||
|
if (ethBal === null) {
|
||||||
|
el.textContent = "unknown (" + nativeCurrency() + ")";
|
||||||
|
} else {
|
||||||
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
|
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
|
||||||
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
|
const ethUsd = ethPrice
|
||||||
|
? " (" + formatUsd(ethBal * ethPrice) + ")"
|
||||||
|
: "";
|
||||||
el.textContent = ethStr + ethUsd;
|
el.textContent = ethStr + ethUsd;
|
||||||
|
}
|
||||||
|
|
||||||
if (subEl) {
|
if (subEl) {
|
||||||
subEl.innerHTML =
|
subEl.innerHTML =
|
||||||
@@ -98,6 +107,9 @@ function renderActiveAddress() {
|
|||||||
|
|
||||||
let homeTxs = [];
|
let homeTxs = [];
|
||||||
|
|
||||||
|
// The network that `home-tx-list` holds rows or a message for.
|
||||||
|
let listNetworkId = null;
|
||||||
|
|
||||||
function renderHomeTxList(ctx) {
|
function renderHomeTxList(ctx) {
|
||||||
const list = $("home-tx-list");
|
const list = $("home-tx-list");
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
@@ -183,6 +195,20 @@ async function loadHomeTxs(ctx) {
|
|||||||
}
|
}
|
||||||
if (allAddresses.length === 0) return;
|
if (allAddresses.length === 0) return;
|
||||||
|
|
||||||
|
const networkId = state.networkId;
|
||||||
|
|
||||||
|
// After a network switch the list still holds the previous network's rows
|
||||||
|
// or failure message. They go here, because every way onto Home draws Home
|
||||||
|
// first. A redraw on the same network keeps its rows.
|
||||||
|
if (listNetworkId !== networkId) {
|
||||||
|
listNetworkId = networkId;
|
||||||
|
const list = $("home-tx-list");
|
||||||
|
if (list) {
|
||||||
|
list.innerHTML =
|
||||||
|
'<div class="text-muted text-xs py-1">Loading...</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const filters = {
|
const filters = {
|
||||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||||
hideLowHolderTokens: state.hideLowHolderTokens,
|
hideLowHolderTokens: state.hideLowHolderTokens,
|
||||||
@@ -202,6 +228,17 @@ async function loadHomeTxs(ctx) {
|
|||||||
);
|
);
|
||||||
const results = await Promise.all(fetches);
|
const results = await Promise.all(fetches);
|
||||||
|
|
||||||
|
// If the network changed during the lookups, nothing is saved or
|
||||||
|
// drawn. The page's network is compared after the read of storage,
|
||||||
|
// because a switch made on this page can land during that read. See
|
||||||
|
// src/shared/storedSettings.js.
|
||||||
|
if (
|
||||||
|
!(await storedNetworkIs(networkId)) ||
|
||||||
|
state.networkId !== networkId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Merge, deduplicate by hash, filter, sort, take 25
|
// Merge, deduplicate by hash, filter, sort, take 25
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
let merged = [];
|
let merged = [];
|
||||||
@@ -215,25 +252,44 @@ async function loadHomeTxs(ctx) {
|
|||||||
|
|
||||||
const filtered = filterTransactions(merged, filters);
|
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) {
|
if (filtered.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 filtered.newFraudContracts) {
|
for (const addr of filtered.newFraudContracts) {
|
||||||
if (!state.fraudContracts.includes(addr)) {
|
if (!state.fraudContracts.includes(addr)) {
|
||||||
state.fraudContracts.push(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;
|
merged = filtered.transactions;
|
||||||
merged.sort((a, b) => b.blockNumber - a.blockNumber);
|
merged.sort((a, b) => b.blockNumber - a.blockNumber);
|
||||||
homeTxs = merged.slice(0, 25);
|
homeTxs = merged.slice(0, 25);
|
||||||
|
listNetworkId = networkId;
|
||||||
renderHomeTxList(ctx);
|
renderHomeTxList(ctx);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||||
// src/popup/index.js.
|
// src/popup/index.js.
|
||||||
if (ctx.pageClosed.aborted) return;
|
if (ctx.pageClosed.aborted) return;
|
||||||
log.errorf("loadHomeTxs failed:", e.message);
|
log.errorf("loadHomeTxs failed:", e.message);
|
||||||
|
// If the network changed, the failure is not shown.
|
||||||
|
if (
|
||||||
|
!(await storedNetworkIs(networkId)) ||
|
||||||
|
state.networkId !== networkId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listNetworkId = networkId;
|
||||||
const list = $("home-tx-list");
|
const list = $("home-tx-list");
|
||||||
if (list) {
|
if (list) {
|
||||||
list.innerHTML =
|
list.innerHTML =
|
||||||
@@ -362,10 +418,11 @@ function render(ctx) {
|
|||||||
wallet.addresses.push({
|
wallet.addresses.push({
|
||||||
address: newAddr,
|
address: newAddr,
|
||||||
index: wallet.nextIndex,
|
index: wallet.nextIndex,
|
||||||
balance: "0.0000",
|
balance: null,
|
||||||
tokenBalances: [],
|
tokenBalances: [],
|
||||||
});
|
});
|
||||||
wallet.nextIndex++;
|
wallet.nextIndex++;
|
||||||
|
markTrackedTokensNotFetched();
|
||||||
await saveState();
|
await saveState();
|
||||||
render(ctx);
|
render(ctx);
|
||||||
ctx.doRefreshAndRender();
|
ctx.doRefreshAndRender();
|
||||||
@@ -448,15 +505,20 @@ function init(ctx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const addr = currentAddress();
|
const addr = currentAddress();
|
||||||
if (!addr.balance || parseFloat(addr.balance) === 0) {
|
// A null balance is one no refresh has read, so it is not refused.
|
||||||
|
if (parseFloat(addr.balance) === 0) {
|
||||||
showFlash("Cannot send \u2014 zero balance.");
|
showFlash("Cannot send \u2014 zero balance.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Send uses a selected token ahead of its dropdown, and AddressToken
|
||||||
|
// can leave one selected on the way here.
|
||||||
|
state.selectedToken = null;
|
||||||
$("send-to").value = "";
|
$("send-to").value = "";
|
||||||
$("send-amount").value = "";
|
$("send-amount").value = "";
|
||||||
$("send-token-dropdown").classList.remove("hidden");
|
$("send-token-dropdown").classList.remove("hidden");
|
||||||
$("send-token-static").classList.add("hidden");
|
$("send-token-static").classList.add("hidden");
|
||||||
renderSendTokenSelect(addr);
|
renderSendTokenSelect(addr);
|
||||||
|
$("send-token").value = "ETH";
|
||||||
updateSendBalance();
|
updateSendBalance();
|
||||||
resetSendValidation();
|
resetSendValidation();
|
||||||
pushCurrentView();
|
pushCurrentView();
|
||||||
@@ -468,6 +530,9 @@ function init(ctx) {
|
|||||||
showFlash("No active address selected.");
|
showFlash("No active address selected.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Receive warns about a selected token, and AddressToken can leave one
|
||||||
|
// selected on the way here.
|
||||||
|
state.selectedToken = null;
|
||||||
ctx.showReceive();
|
ctx.showReceive();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-12
@@ -12,7 +12,7 @@ const {
|
|||||||
goBack,
|
goBack,
|
||||||
tokenSourcesByChainId,
|
tokenSourcesByChainId,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, currentAddress } = require("../../shared/state");
|
const { state, currentAddress, currentNetwork } = require("../../shared/state");
|
||||||
let ctx;
|
let ctx;
|
||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
const { resolveTokenDecimals } = require("../../shared/approvalAmount");
|
const { resolveTokenDecimals } = require("../../shared/approvalAmount");
|
||||||
@@ -154,7 +154,9 @@ function renderSendTokenSelect(addr) {
|
|||||||
// out. Its token's own screen can still send it: there
|
// out. Its token's own screen can still send it: there
|
||||||
// state.selectedToken picks the token, not this list.
|
// state.selectedToken picks the token, not this list.
|
||||||
if (isBelowOneMillionth(t.balance)) continue;
|
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;
|
if (fraudSet.has(t.address.toLowerCase())) continue;
|
||||||
// An unknown holder count does not withhold a token the user holds:
|
// An unknown holder count does not withhold a token the user holds:
|
||||||
// only a count the explorer actually reported as below the threshold
|
// 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
|
// 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
|
// 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
|
// 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) {
|
function tokenBalanceAndDecimals(addr, token) {
|
||||||
const tb = (addr.tokenBalances || []).find(
|
const tb = (addr.tokenBalances || []).find(
|
||||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||||
);
|
);
|
||||||
const tokenDecimals = resolveTokenDecimals(token, tokenSourcesByChainId());
|
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 };
|
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
|
||||||
return { tokenBalance: tb.balance ?? null, tokenDecimals };
|
return { tokenBalance: tb.balance ?? null, tokenDecimals };
|
||||||
}
|
}
|
||||||
@@ -220,9 +230,12 @@ function updateSendBalance() {
|
|||||||
attachCopyHandlers($("send-from"));
|
attachCopyHandlers($("send-from"));
|
||||||
const token = state.selectedToken || $("send-token").value;
|
const token = state.selectedToken || $("send-token").value;
|
||||||
if (token === "ETH") {
|
if (token === "ETH") {
|
||||||
|
// A null balance is one no refresh has read.
|
||||||
$("send-balance").textContent =
|
$("send-balance").textContent =
|
||||||
"Current balance: " +
|
addr.balance == null
|
||||||
truncateAmountNeverZero(addr.balance || "0") +
|
? "Current balance: unknown (" + nativeCurrency() + ")"
|
||||||
|
: "Current balance: " +
|
||||||
|
truncateAmountNeverZero(addr.balance) +
|
||||||
" " +
|
" " +
|
||||||
nativeCurrency();
|
nativeCurrency();
|
||||||
} else {
|
} else {
|
||||||
@@ -230,11 +243,13 @@ function updateSendBalance() {
|
|||||||
token,
|
token,
|
||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
|
currentNetwork().chainId,
|
||||||
);
|
);
|
||||||
// A null balance is a holding whose scale is unknown. Saying a figure
|
// A null balance is a holding whose scale is unknown, or a tracked
|
||||||
// for it would be a claim about the amount, so it reads as the
|
// token whose balance no refresh has read. Saying a figure for it would
|
||||||
// confirmation screen's balance line reads it; the send itself is
|
// be a claim about the amount, so it reads as the confirmation screen's
|
||||||
// refused later by transferAmountUnits() for the same missing scale.
|
// balance line reads it. transferAmountUnits() later refuses a send at
|
||||||
|
// a missing scale.
|
||||||
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
||||||
$("send-balance").textContent =
|
$("send-balance").textContent =
|
||||||
bal == null
|
bal == null
|
||||||
@@ -277,6 +292,12 @@ async function fillMaxAmount() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A null balance is one no refresh has read.
|
||||||
|
if (addr.balance == null) {
|
||||||
|
showFlash("This token's balance is unknown.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// The fee is estimated for this recipient, as the confirmation screen
|
// The fee is estimated for this recipient, as the confirmation screen
|
||||||
// estimates it: sending to a contract can cost more gas.
|
// estimates it: sending to a contract can cost more gas.
|
||||||
const to = $("send-to").value.trim();
|
const to = $("send-to").value.trim();
|
||||||
@@ -294,7 +315,7 @@ async function fillMaxAmount() {
|
|||||||
provider.estimateGas({
|
provider.estimateGas({
|
||||||
from: addr.address,
|
from: addr.address,
|
||||||
to,
|
to,
|
||||||
value: parseEther(addr.balance || "0"),
|
value: parseEther(addr.balance),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
feeWei = feeReserveWei(gasLimit, feeData);
|
feeWei = feeReserveWei(gasLimit, feeData);
|
||||||
@@ -309,8 +330,10 @@ async function fillMaxAmount() {
|
|||||||
}
|
}
|
||||||
// While the estimate was in flight the user left the screen (and perhaps
|
// While the estimate was in flight the user left the screen (and perhaps
|
||||||
// opened it again), typed an amount, or changed the address, the holding
|
// opened it again), typed an amount, or changed the address, the holding
|
||||||
// or the recipient: what they did wins.
|
// or the recipient: what they did wins. A network switch in that time
|
||||||
|
// leaves no balance to fill in from.
|
||||||
if (
|
if (
|
||||||
|
addr.balance == null ||
|
||||||
state.currentView !== "send" ||
|
state.currentView !== "send" ||
|
||||||
sendScreenOpenings !== opening ||
|
sendScreenOpenings !== opening ||
|
||||||
currentAddress()?.address !== addr.address ||
|
currentAddress()?.address !== addr.address ||
|
||||||
@@ -385,6 +408,7 @@ function init(_ctx) {
|
|||||||
token,
|
token,
|
||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
|
currentNetwork().chainId,
|
||||||
);
|
);
|
||||||
({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
|
({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ let ctx;
|
|||||||
let versionClickCount = 0;
|
let versionClickCount = 0;
|
||||||
let versionClickTimer = null;
|
let versionClickTimer = null;
|
||||||
|
|
||||||
|
// The network that Settings was last drawn for. A follow of a switch saved
|
||||||
|
// elsewhere sets `state` to the new network first. Settings is drawn again only
|
||||||
|
// once the follow's write returns. So both "Save" buttons take this network,
|
||||||
|
// 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
|
// One row per site origin, however many addresses it appears under, each with
|
||||||
// an [x] that hands it to onRemove.
|
// an [x] that hands it to onRemove.
|
||||||
function renderSiteList(containerId, origins, onRemove) {
|
function renderSiteList(containerId, origins, onRemove) {
|
||||||
@@ -199,6 +210,7 @@ function renderWalletListSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
|
shownNetwork = currentNetwork();
|
||||||
$("settings-rpc").value = state.rpcUrl;
|
$("settings-rpc").value = state.rpcUrl;
|
||||||
$("settings-blockscout").value = state.blockscoutUrl;
|
$("settings-blockscout").value = state.blockscoutUrl;
|
||||||
$("settings-network").value = state.networkId;
|
$("settings-network").value = state.networkId;
|
||||||
@@ -253,7 +265,7 @@ async function renderSiteLists() {
|
|||||||
// A network switch, made by the user here or saved by the background once the
|
// A network switch, made by the user here or saved by the background once the
|
||||||
// user approved a site's request (src/popup/index.js), leaves every screen on
|
// user approved a site's request (src/popup/index.js), leaves every screen on
|
||||||
// the page showing the previous network. Two functions leave the page as
|
// the page showing the previous network. Two functions leave the page as
|
||||||
// README's Screen Map describes:
|
// README's Navigation section describes:
|
||||||
//
|
//
|
||||||
// - cutViewStack(), before the switch is saved, so that save keeps the cut for
|
// - cutViewStack(), before the switch is saved, so that save keeps the cut for
|
||||||
// a popup closed straight after
|
// a popup closed straight after
|
||||||
@@ -306,6 +318,7 @@ function showNewSettings() {
|
|||||||
state.checkRecipientsOnEtherscan;
|
state.checkRecipientsOnEtherscan;
|
||||||
if (state.currentView === "settings") {
|
if (state.currentView === "settings") {
|
||||||
markViewRendered("settings");
|
markViewRendered("settings");
|
||||||
|
shownNetwork = currentNetwork();
|
||||||
$("settings-network").value = state.networkId;
|
$("settings-network").value = state.networkId;
|
||||||
$("settings-rpc").value = state.rpcUrl;
|
$("settings-rpc").value = state.rpcUrl;
|
||||||
$("settings-blockscout").value = state.blockscoutUrl;
|
$("settings-blockscout").value = state.blockscoutUrl;
|
||||||
@@ -319,17 +332,70 @@ function showNewSettings() {
|
|||||||
updateDebugBanner(state.currentView);
|
updateDebugBanner(state.currentView);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Saves `url` as `field` ("rpcUrl" or "blockscoutUrl") of the network
|
||||||
|
// `networkId`, the one that Settings showed when the user tapped "Save". The
|
||||||
|
// URL is set in `state` inside the save, holding the lock. So the page cannot
|
||||||
|
// follow a switch saved elsewhere (followStoredSettings() in
|
||||||
|
// src/popup/index.js) between setting the URL and writing it. If the active
|
||||||
|
// network is no longer `networkId`, on this page or in the stored record,
|
||||||
|
// nothing is saved. The flash message then says that the network changed.
|
||||||
|
//
|
||||||
|
// The save writes only the fields that differ from what this page last saved.
|
||||||
|
// Another page may have stored a different URL since then. So `url` is set in
|
||||||
|
// the stored record too. "Saved." then means that the stored record holds
|
||||||
|
// `url`.
|
||||||
|
//
|
||||||
|
// `input` is the id of the input that the user typed `url` in. When another
|
||||||
|
// page changes the network, "Use Blockscout" or "Check recipients on
|
||||||
|
// Etherscan", this page draws Settings again. A redraw during the endpoint
|
||||||
|
// check puts the URL from `state` back in the input. So if the input no longer
|
||||||
|
// 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`.
|
||||||
|
//
|
||||||
|
// `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;
|
||||||
|
if (refused || inputChanged) return false;
|
||||||
|
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) {
|
||||||
|
showFlash(
|
||||||
|
"The URL was not saved because it was changed during the check.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showFlash("Saved.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function init(_ctx) {
|
function init(_ctx) {
|
||||||
ctx = _ctx;
|
ctx = _ctx;
|
||||||
deleteWallet.init(ctx);
|
deleteWallet.init(ctx);
|
||||||
showPhrase.init();
|
showPhrase.init();
|
||||||
|
|
||||||
$("btn-save-rpc").addEventListener("click", async () => {
|
$("btn-save-rpc").addEventListener("click", async () => {
|
||||||
|
saveTaps.rpcUrl++;
|
||||||
|
const tap = saveTaps.rpcUrl;
|
||||||
const url = $("settings-rpc").value.trim();
|
const url = $("settings-rpc").value.trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
showFlash("Please enter an RPC URL.");
|
showFlash("Please enter an RPC URL.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The URL is for the network that Settings shows when "Save" is tapped.
|
||||||
|
const net = shownNetwork;
|
||||||
showFlash("Testing endpoint...");
|
showFlash("Testing endpoint...");
|
||||||
try {
|
try {
|
||||||
const resp = await debugFetch(url, {
|
const resp = await debugFetch(url, {
|
||||||
@@ -343,12 +409,13 @@ function init(_ctx) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
|
// The user has tapped this "Save" again since the check began.
|
||||||
|
if (tap !== saveTaps.rpcUrl) return;
|
||||||
if (json.error) {
|
if (json.error) {
|
||||||
log.errorf("RPC validation error:", json.error);
|
log.errorf("RPC validation error:", json.error);
|
||||||
showFlash("Endpoint returned an error.");
|
showFlash("Endpoint returned an error.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const net = currentNetwork();
|
|
||||||
if (json.result !== net.chainId) {
|
if (json.result !== net.chainId) {
|
||||||
showFlash("Wrong network: expected " + net.name + ".");
|
showFlash("Wrong network: expected " + net.name + ".");
|
||||||
return;
|
return;
|
||||||
@@ -357,6 +424,7 @@ function init(_ctx) {
|
|||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||||
// src/popup/index.js.
|
// src/popup/index.js.
|
||||||
if (ctx.pageClosed.aborted) return;
|
if (ctx.pageClosed.aborted) return;
|
||||||
|
if (tap !== saveTaps.rpcUrl) return;
|
||||||
// Not the error's message: fetch puts the whole URL, password and
|
// 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
|
// key included, in the message of the error it throws for a URL
|
||||||
// with a user name and password or one it cannot parse.
|
// with a user name and password or one it cannot parse.
|
||||||
@@ -364,34 +432,41 @@ function init(_ctx) {
|
|||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.rpcUrl = url;
|
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id, tap);
|
||||||
await saveState();
|
|
||||||
showFlash("Saved.");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("btn-save-blockscout").addEventListener("click", async () => {
|
$("btn-save-blockscout").addEventListener("click", async () => {
|
||||||
|
saveTaps.blockscoutUrl++;
|
||||||
|
const tap = saveTaps.blockscoutUrl;
|
||||||
const url = $("settings-blockscout").value.trim();
|
const url = $("settings-blockscout").value.trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
showFlash("Please enter a Blockscout API URL.");
|
showFlash("Please enter a Blockscout API URL.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const net = shownNetwork;
|
||||||
showFlash("Testing endpoint...");
|
showFlash("Testing endpoint...");
|
||||||
try {
|
try {
|
||||||
const resp = await debugFetch(url + "/stats");
|
const resp = await debugFetch(url + "/stats");
|
||||||
|
if (tap !== saveTaps.blockscoutUrl) return;
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (ctx.pageClosed.aborted) return;
|
if (ctx.pageClosed.aborted) return;
|
||||||
|
if (tap !== saveTaps.blockscoutUrl) return;
|
||||||
// Not the error's message, as for the RPC check above.
|
// Not the error's message, as for the RPC check above.
|
||||||
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.blockscoutUrl = url;
|
await saveEndpoint(
|
||||||
await saveState();
|
"blockscoutUrl",
|
||||||
showFlash("Saved.");
|
"settings-blockscout",
|
||||||
|
url,
|
||||||
|
net.id,
|
||||||
|
tap,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Each change is made inside the save, holding the lock. So
|
// Each change is made inside the save, holding the lock. So
|
||||||
|
|||||||
@@ -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 { getTopTokens } = require("../../shared/tokenList");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { lookupTokenInfo } = require("../../shared/balances");
|
const { lookupTokenInfo } = require("../../shared/balances");
|
||||||
const { isScamAddress } = require("../../shared/scamlist");
|
const { isScamAddress } = require("../../shared/scamlist");
|
||||||
|
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
let ctx;
|
let ctx;
|
||||||
@@ -37,13 +45,18 @@ function renderTop10() {
|
|||||||
el.querySelectorAll(".settings-addtoken-quick:not([disabled])").forEach(
|
el.querySelectorAll(".settings-addtoken-quick:not([disabled])").forEach(
|
||||||
(btn) => {
|
(btn) => {
|
||||||
btn.addEventListener("click", async () => {
|
btn.addEventListener("click", async () => {
|
||||||
|
// The button stays enabled until the first click's save ends.
|
||||||
|
if (isTracked(btn.dataset.address)) {
|
||||||
|
showFlash("Already tracked.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const token = {
|
const token = {
|
||||||
address: btn.dataset.address,
|
address: btn.dataset.address,
|
||||||
symbol: btn.dataset.symbol,
|
symbol: btn.dataset.symbol,
|
||||||
decimals: parseInt(btn.dataset.decimals, 10),
|
decimals: parseInt(btn.dataset.decimals, 10),
|
||||||
name: btn.dataset.name || btn.dataset.symbol,
|
name: btn.dataset.name || btn.dataset.symbol,
|
||||||
};
|
};
|
||||||
state.trackedTokens.push(token);
|
trackToken(token);
|
||||||
await saveState();
|
await saveState();
|
||||||
showFlash("Added " + token.symbol);
|
showFlash("Added " + token.symbol);
|
||||||
renderTop10();
|
renderTop10();
|
||||||
@@ -110,7 +123,7 @@ function init(_ctx) {
|
|||||||
decimals: parseInt(opt.dataset.decimals, 10),
|
decimals: parseInt(opt.dataset.decimals, 10),
|
||||||
name: opt.dataset.name || opt.dataset.symbol,
|
name: opt.dataset.name || opt.dataset.symbol,
|
||||||
};
|
};
|
||||||
state.trackedTokens.push(token);
|
trackToken(token);
|
||||||
await saveState();
|
await saveState();
|
||||||
showFlash("Added " + token.symbol);
|
showFlash("Added " + token.symbol);
|
||||||
renderTop10();
|
renderTop10();
|
||||||
@@ -155,8 +168,26 @@ function init(_ctx) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Another click on Add, or a "Top tokens:" button, may have
|
||||||
|
// tracked this token while this lookup waited.
|
||||||
|
if (isTracked(addr)) {
|
||||||
|
showFlash("Already tracked.");
|
||||||
|
infoEl.textContent = "";
|
||||||
|
infoEl.style.visibility = "hidden";
|
||||||
|
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);
|
log.infof("Adding token", info.symbol, addr);
|
||||||
state.trackedTokens.push({
|
trackToken({
|
||||||
address: addr,
|
address: addr,
|
||||||
symbol: info.symbol,
|
symbol: info.symbol,
|
||||||
decimals: info.decimals,
|
decimals: info.decimals,
|
||||||
|
|||||||
@@ -126,13 +126,16 @@ function render() {
|
|||||||
const exactStr = tx.exactValue
|
const exactStr = tx.exactValue
|
||||||
? tx.exactValue + " " + detailSym
|
? tx.exactValue + " " + detailSym
|
||||||
: tx.directionLabel + " " + detailSym;
|
: tx.directionLabel + " " + detailSym;
|
||||||
$("tx-detail-value").innerHTML = copyableHtml(exactStr, "font-bold");
|
$("tx-detail-value").innerHTML = copyableHtml(
|
||||||
|
exactStr,
|
||||||
|
"font-bold break-words",
|
||||||
|
);
|
||||||
|
|
||||||
// Native quantity (raw integer, copyable)
|
// Native quantity (raw integer, copyable)
|
||||||
const nativeEl = $("tx-detail-native");
|
const nativeEl = $("tx-detail-native");
|
||||||
if (tx.rawAmount && tx.rawUnit) {
|
if (tx.rawAmount && tx.rawUnit) {
|
||||||
const nativeStr = tx.rawAmount + " " + tx.rawUnit;
|
const nativeStr = tx.rawAmount + " " + tx.rawUnit;
|
||||||
nativeEl.innerHTML = copyableHtml(nativeStr, "");
|
nativeEl.innerHTML = copyableHtml(nativeStr, "break-words");
|
||||||
nativeEl.parentElement.classList.remove("hidden");
|
nativeEl.parentElement.classList.remove("hidden");
|
||||||
} else {
|
} else {
|
||||||
nativeEl.innerHTML = "";
|
nativeEl.innerHTML = "";
|
||||||
@@ -311,10 +314,21 @@ function populateOnChainDetails(txData, chainId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether TransactionDetail still holds the transaction with `txHash` on
|
||||||
|
// `chainId`. Opening another transaction replaces the one TransactionDetail
|
||||||
|
// holds. A visit to Settings does not.
|
||||||
|
function holdsTransaction(txHash, chainId) {
|
||||||
|
const tx = state.viewData.tx;
|
||||||
|
return tx?.hash === txHash && tx?.chainId === chainId;
|
||||||
|
}
|
||||||
|
|
||||||
// Reads the block, network details and calldata from Blockscout, and decodes
|
// Reads the block, network details and calldata from Blockscout, and decodes
|
||||||
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
|
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
|
||||||
// asked. An answer that lands after the setting went off is dropped
|
// asked. An answer that lands after the setting went off is dropped
|
||||||
// (src/shared/storedSettings.js). Either way none of them is shown.
|
// (src/shared/storedSettings.js). Either way none of them is shown.
|
||||||
|
//
|
||||||
|
// Blockscout can answer after the user has opened another transaction. That
|
||||||
|
// answer is dropped.
|
||||||
async function loadFullTxDetails(txHash, chainId) {
|
async function loadFullTxDetails(txHash, chainId) {
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
||||||
const section = $("tx-detail-calldata-section");
|
const section = $("tx-detail-calldata-section");
|
||||||
@@ -333,6 +347,7 @@ async function loadFullTxDetails(txHash, chainId) {
|
|||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const txData = await resp.json();
|
const txData = await resp.json();
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
||||||
|
if (!holdsTransaction(txHash, chainId)) return;
|
||||||
|
|
||||||
// Populate on-chain detail fields (block, nonce, gas, fee)
|
// Populate on-chain detail fields (block, nonce, gas, fee)
|
||||||
populateOnChainDetails(txData, chainId);
|
populateOnChainDetails(txData, chainId);
|
||||||
@@ -356,7 +371,7 @@ async function loadFullTxDetails(txHash, chainId) {
|
|||||||
actionEl.textContent = decoded.name;
|
actionEl.textContent = decoded.name;
|
||||||
let detailsHtml = "";
|
let detailsHtml = "";
|
||||||
if (decoded.description) {
|
if (decoded.description) {
|
||||||
detailsHtml += `<div class="mb-2">${escapeHtml(decoded.description)}</div>`;
|
detailsHtml += `<div class="mb-2 break-words">${escapeHtml(decoded.description)}</div>`;
|
||||||
}
|
}
|
||||||
for (const d of decoded.details || []) {
|
for (const d of decoded.details || []) {
|
||||||
detailsHtml += `<div class="mb-2">`;
|
detailsHtml += `<div class="mb-2">`;
|
||||||
@@ -365,13 +380,13 @@ async function loadFullTxDetails(txHash, chainId) {
|
|||||||
// Token entry: show symbol on its own line, then address via shared renderer
|
// Token entry: show symbol on its own line, then address via shared renderer
|
||||||
const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1];
|
const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1];
|
||||||
if (tokenSymbol) {
|
if (tokenSymbol) {
|
||||||
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
|
detailsHtml += `<div class="font-bold break-words">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
|
||||||
}
|
}
|
||||||
detailsHtml += renderAddressHtml(d.address, { chainId });
|
detailsHtml += renderAddressHtml(d.address, { chainId });
|
||||||
} else if (d.address) {
|
} else if (d.address) {
|
||||||
detailsHtml += renderAddressHtml(d.address, { chainId });
|
detailsHtml += renderAddressHtml(d.address, { chainId });
|
||||||
} else {
|
} else {
|
||||||
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
detailsHtml += `<div class="font-bold break-words">${escapeHtml(d.value)}</div>`;
|
||||||
}
|
}
|
||||||
detailsHtml += `</div>`;
|
detailsHtml += `</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-39
@@ -73,6 +73,58 @@ function toAddressHtml(address, chainId) {
|
|||||||
return renderAddressHtml(address, { title, chainId });
|
return renderAddressHtml(address, { title, chainId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A decoded record can come back from storage in any shape, so these screens
|
||||||
|
// show only the lines whose label and value are text. An address that is not
|
||||||
|
// text would throw in toAddressHtml().
|
||||||
|
function isReadableLine(d) {
|
||||||
|
return (
|
||||||
|
d &&
|
||||||
|
typeof d.label === "string" &&
|
||||||
|
typeof d.value === "string" &&
|
||||||
|
(!d.address || typeof d.address === "string")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a decoded line with an address shows: the address, under the token's
|
||||||
|
// symbol when the line names a token.
|
||||||
|
function decodedAddressHtml(d, chainId) {
|
||||||
|
if (!d.isToken) return toAddressHtml(d.address, chainId);
|
||||||
|
const sym = tokenLabel(d.address, chainId) || "Unknown token";
|
||||||
|
return (
|
||||||
|
`<div class="font-bold break-words">${escapeHtml(sym)}</div>` +
|
||||||
|
blockscoutOffTokenNoteHtml(d.address, chainId) +
|
||||||
|
toAddressHtml(d.address, chainId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each `Moved from the sender`, `Permit2 allowance` and `Allowance token` line
|
||||||
|
// of a decoded swap, for the wait and error screens to show under the Amount
|
||||||
|
// figure. That figure can leave these amounts out, so alone it can understate
|
||||||
|
// what can leave the wallet. The token line says which token an allowance
|
||||||
|
// covers, which `Unlimited` alone does not.
|
||||||
|
function movedOrAllowedHtml(decoded, chainId) {
|
||||||
|
if (!decoded || !Array.isArray(decoded.details)) return "";
|
||||||
|
let html = "";
|
||||||
|
for (const d of decoded.details) {
|
||||||
|
if (
|
||||||
|
!isReadableLine(d) ||
|
||||||
|
(d.label !== "Moved from the sender" &&
|
||||||
|
d.label !== "Permit2 allowance" &&
|
||||||
|
d.label !== "Allowance token")
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
html += `<div class="mt-2">`;
|
||||||
|
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
|
||||||
|
if (d.address) {
|
||||||
|
html += `<div class="text-xs">${decodedAddressHtml(d, chainId)}</div>`;
|
||||||
|
} else {
|
||||||
|
html += `<div class="font-bold break-words">${escapeHtml(d.value)}</div>`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
function txHashHtml(hash, chainId) {
|
function txHashHtml(hash, chainId) {
|
||||||
const link = explorerUrl("tx", hash, chainId);
|
const link = explorerUrl("tx", hash, chainId);
|
||||||
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
|
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
|
||||||
@@ -109,10 +161,9 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
|||||||
}
|
}
|
||||||
$("wait-tx-summary").textContent =
|
$("wait-tx-summary").textContent =
|
||||||
txInfo.amount + (symbol ? " " + symbol : "");
|
txInfo.amount + (symbol ? " " + symbol : "");
|
||||||
$("wait-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
|
$("wait-tx-token-note").innerHTML =
|
||||||
txInfo.token,
|
blockscoutOffTokenNoteHtml(txInfo.token, txInfo.chainId) +
|
||||||
txInfo.chainId,
|
movedOrAllowedHtml(txInfo.decoded, txInfo.chainId);
|
||||||
);
|
|
||||||
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
|
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
|
||||||
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
|
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
|
||||||
attachCopyHandlers("view-wait-tx");
|
attachCopyHandlers("view-wait-tx");
|
||||||
@@ -223,7 +274,7 @@ function showWait(txInfo, txHash) {
|
|||||||
// to name a network. txInfo.token and txInfo.tokenSymbol are not checked here.
|
// to name a network. txInfo.token and txInfo.tokenSymbol are not checked here.
|
||||||
// blockscoutOffTokenNoteHtml() shows no line for a token that is not text.
|
// blockscoutOffTokenNoteHtml() shows no line for a token that is not text.
|
||||||
// Everything else only compares and coalesces them, and tokenSymbol is null for
|
// Everything else only compares and coalesces them, and tokenSymbol is null for
|
||||||
// ETH.
|
// ETH. txInfo.decoded is not checked either: see isReadableLine().
|
||||||
function restoreWait() {
|
function restoreWait() {
|
||||||
const d = state.viewData;
|
const d = state.viewData;
|
||||||
if (!d || !d.pendingWait) return false;
|
if (!d || !d.pendingWait) return false;
|
||||||
@@ -270,31 +321,31 @@ function showSuccess(txInfo, txHash, blockNumber) {
|
|||||||
ctx.doRefreshAndRender();
|
ctx.doRefreshAndRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readableLines(decoded) {
|
||||||
|
if (!decoded || !Array.isArray(decoded.details)) return [];
|
||||||
|
return decoded.details.filter(isReadableLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The decoded action, or "" when no line of it can be read.
|
||||||
function decodedDetailsHtml(decoded, chainId) {
|
function decodedDetailsHtml(decoded, chainId) {
|
||||||
if (!decoded || !decoded.details) return "";
|
const lines = readableLines(decoded);
|
||||||
|
if (lines.length === 0) return "";
|
||||||
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
|
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
|
||||||
if (decoded.name) {
|
if (decoded.name && typeof decoded.name === "string") {
|
||||||
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Action</div>`;
|
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Action</div>`;
|
||||||
html += `<div class="font-bold">${escapeHtml(decoded.name)}</div></div>`;
|
html += `<div class="font-bold break-words">${escapeHtml(decoded.name)}</div></div>`;
|
||||||
}
|
}
|
||||||
if (decoded.description) {
|
if (decoded.description && typeof decoded.description === "string") {
|
||||||
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Description</div>`;
|
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Description</div>`;
|
||||||
html += `<div>${escapeHtml(decoded.description)}</div></div>`;
|
html += `<div class="break-words">${escapeHtml(decoded.description)}</div></div>`;
|
||||||
}
|
}
|
||||||
for (const d of decoded.details) {
|
for (const d of lines) {
|
||||||
html += `<div class="mb-2">`;
|
html += `<div class="mb-2">`;
|
||||||
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
|
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
|
||||||
if (d.address) {
|
if (d.address) {
|
||||||
if (d.isToken) {
|
html += decodedAddressHtml(d, chainId);
|
||||||
const sym = tokenLabel(d.address, chainId) || "Unknown token";
|
|
||||||
html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
|
|
||||||
html += blockscoutOffTokenNoteHtml(d.address, chainId);
|
|
||||||
html += toAddressHtml(d.address, chainId);
|
|
||||||
} else {
|
} else {
|
||||||
html += toAddressHtml(d.address, chainId);
|
html += `<div class="font-bold break-words">${escapeHtml(d.value)}</div>`;
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
|
||||||
}
|
}
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
}
|
}
|
||||||
@@ -306,30 +357,32 @@ function renderSuccess() {
|
|||||||
const d = state.viewData;
|
const d = state.viewData;
|
||||||
if (!d || !d.hash) return;
|
if (!d || !d.hash) return;
|
||||||
|
|
||||||
const hasDecoded = d.decoded && d.decoded.details;
|
const decodedHtml = decodedDetailsHtml(d.decoded, d.chainId);
|
||||||
|
|
||||||
// When decoded details are present, the Amount and To are already
|
// Amount and To are hidden only when a readable decoded line shows the same
|
||||||
// shown inside the decoded well — hide the top-level duplicates.
|
// thing: an `Amount` line, or a line with the address in `to`. Some swaps
|
||||||
const summarySection = $("success-tx-summary").parentElement;
|
// have no `Amount` line, and a saved record can have unreadable lines. An
|
||||||
const toSection = $("success-tx-to").parentElement;
|
// empty `to` is a contract creation, which no decoded line shows.
|
||||||
if (hasDecoded) {
|
const lines = readableLines(d.decoded);
|
||||||
summarySection.classList.add("hidden");
|
$("success-tx-summary").parentElement.classList.toggle(
|
||||||
toSection.classList.add("hidden");
|
"hidden",
|
||||||
} else {
|
lines.some((line) => line.label === "Amount"),
|
||||||
summarySection.classList.remove("hidden");
|
);
|
||||||
toSection.classList.remove("hidden");
|
$("success-tx-to").parentElement.classList.toggle(
|
||||||
|
"hidden",
|
||||||
|
lines.some((line) => line.address && line.address === d.to),
|
||||||
|
);
|
||||||
$("success-tx-summary").textContent =
|
$("success-tx-summary").textContent =
|
||||||
d.amount + (d.symbol ? " " + d.symbol : "");
|
d.amount + (d.symbol ? " " + d.symbol : "");
|
||||||
$("success-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
|
$("success-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
|
||||||
}
|
|
||||||
|
|
||||||
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber, d.chainId);
|
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber, d.chainId);
|
||||||
$("success-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
|
$("success-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
|
||||||
|
|
||||||
// Show decoded calldata details if present
|
// Show decoded calldata details if present
|
||||||
const decodedEl = $("success-tx-decoded");
|
const decodedEl = $("success-tx-decoded");
|
||||||
if (decodedEl && hasDecoded) {
|
if (decodedEl && decodedHtml) {
|
||||||
decodedEl.innerHTML = decodedDetailsHtml(d.decoded, d.chainId);
|
decodedEl.innerHTML = decodedHtml;
|
||||||
decodedEl.classList.remove("hidden");
|
decodedEl.classList.remove("hidden");
|
||||||
} else if (decodedEl) {
|
} else if (decodedEl) {
|
||||||
decodedEl.classList.add("hidden");
|
decodedEl.classList.add("hidden");
|
||||||
@@ -356,6 +409,7 @@ function showError(txInfo, txHash, message) {
|
|||||||
to: txInfo.to,
|
to: txInfo.to,
|
||||||
hash: txHash || null,
|
hash: txHash || null,
|
||||||
message: message,
|
message: message,
|
||||||
|
decoded: txInfo.decoded || null,
|
||||||
chainId: txInfo.chainId,
|
chainId: txInfo.chainId,
|
||||||
};
|
};
|
||||||
renderError();
|
renderError();
|
||||||
@@ -366,21 +420,20 @@ function renderError() {
|
|||||||
if (!d || !d.message) return;
|
if (!d || !d.message) return;
|
||||||
$("error-tx-summary").textContent =
|
$("error-tx-summary").textContent =
|
||||||
d.amount + (d.symbol ? " " + d.symbol : "");
|
d.amount + (d.symbol ? " " + d.symbol : "");
|
||||||
$("error-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
|
$("error-tx-token-note").innerHTML =
|
||||||
d.token,
|
blockscoutOffTokenNoteHtml(d.token, d.chainId) +
|
||||||
d.chainId,
|
movedOrAllowedHtml(d.decoded, d.chainId);
|
||||||
);
|
|
||||||
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
|
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
|
||||||
$("error-tx-message").textContent = d.message;
|
$("error-tx-message").textContent = d.message;
|
||||||
|
|
||||||
if (d.hash) {
|
if (d.hash) {
|
||||||
$("error-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
|
$("error-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
|
||||||
$("error-tx-hash-section").classList.remove("hidden");
|
$("error-tx-hash-section").classList.remove("hidden");
|
||||||
attachCopyHandlers("view-error-tx");
|
|
||||||
} else {
|
} else {
|
||||||
$("error-tx-hash-section").classList.add("hidden");
|
$("error-tx-hash-section").classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
attachCopyHandlers("view-error-tx");
|
||||||
showView("error-tx");
|
showView("error-tx");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// The 4-decimal amount rule from README.md's Display Consistency section, and
|
// The 4-decimal amount rule from README.md's Amounts section, and
|
||||||
// the one exception to it, in one place. Three call sites had grown their own
|
// the one exception to it, in one place. Three call sites had grown their own
|
||||||
// copy of the truncation — the history and balance lists
|
// copy of the truncation — the history and balance lists
|
||||||
// (`src/shared/transactions.js`), the approval screen's ERC-20 amount line
|
// (`src/shared/transactions.js`), the approval screen's ERC-20 amount line
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
// string, and a token the user added by hand carries whatever lookupTokenInfo()
|
// string, and a token the user added by hand carries whatever lookupTokenInfo()
|
||||||
// got back, so the accepted types are enumerated rather than coerced.
|
// got back, so the accepted types are enumerated rather than coerced.
|
||||||
const { toDecimals } = require("./transferAmount");
|
const { toDecimals } = require("./transferAmount");
|
||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { bundledToken } = require("./tokenList");
|
||||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||||
|
|
||||||
// Every decimals the explorer reported for this contract, across all the
|
// 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.
|
// 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) {
|
function resolveTokenDecimals(tokenAddress, sources) {
|
||||||
const lower = (tokenAddress || "").toLowerCase();
|
const lower = (tokenAddress || "").toLowerCase();
|
||||||
if (!lower) return null;
|
if (!lower) return null;
|
||||||
|
|
||||||
const bundled = TOKEN_BY_ADDRESS.get(lower);
|
const bundled = bundledToken(lower, sources && sources.chainId);
|
||||||
if (bundled) {
|
if (bundled) {
|
||||||
const d = toDecimals(bundled.decimals);
|
const d = toDecimals(bundled.decimals);
|
||||||
if (d !== null) return d;
|
if (d !== null) return d;
|
||||||
@@ -100,18 +102,19 @@ function explorerSymbol(lower, wallets) {
|
|||||||
// explorer reported: the same sources and the same precedence
|
// explorer reported: the same sources and the same precedence
|
||||||
// resolveTokenDecimals() uses, so a token's name and its scale are drawn from
|
// 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
|
// 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
|
// 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
|
// held to the spoof rule (symbolSpoof.js): a candidate that the rule calls a
|
||||||
// native ticker from a contract not entitled to it is refused and the next
|
// spoof is refused and the next source tried, so resolving a symbol never
|
||||||
// source tried, so resolving a symbol never becomes a new way to claim a known
|
// becomes a new way to claim a known ticker. The bundled list is the wallet's
|
||||||
// ticker. The bundled list is the wallet's own data and is trusted as it is.
|
// own data and is trusted as it is.
|
||||||
function resolveTokenSymbol(tokenAddress, sources) {
|
function resolveTokenSymbol(tokenAddress, sources) {
|
||||||
const lower = (tokenAddress || "").toLowerCase();
|
const lower = (tokenAddress || "").toLowerCase();
|
||||||
if (!lower) return null;
|
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;
|
if (bundled && bundled.symbol) return bundled.symbol;
|
||||||
|
|
||||||
const tracked = ((sources && sources.trackedTokens) || []).find(
|
const tracked = ((sources && sources.trackedTokens) || []).find(
|
||||||
@@ -123,7 +126,7 @@ function resolveTokenSymbol(tokenAddress, sources) {
|
|||||||
if (reported) candidates.push(reported);
|
if (reported) candidates.push(reported);
|
||||||
|
|
||||||
for (const symbol of candidates) {
|
for (const symbol of candidates) {
|
||||||
if (!isSpoofedSymbol(symbol, tokenAddress)) return symbol;
|
if (!isSpoofedSymbol(symbol, tokenAddress, chainId)) return symbol;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-11
@@ -17,7 +17,7 @@ const { ERC20_ABI } = require("./constants");
|
|||||||
const { NETWORKS } = require("./networks");
|
const { NETWORKS } = require("./networks");
|
||||||
const { log, debugFetch, urlOrigin } = require("./log");
|
const { log, debugFetch, urlOrigin } = require("./log");
|
||||||
const { deriveAddressFromXpub } = require("./wallet");
|
const { deriveAddressFromXpub } = require("./wallet");
|
||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { bundledToken } = require("./tokenList");
|
||||||
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
||||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||||
const { toDecimals } = require("./transferAmount");
|
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
|
// balance is read through the RPC endpoint instead, and its row here gives that
|
||||||
// entry its holder count and `decimals` (fetchTrackedTokenBalances()).
|
// 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
|
// `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
|
// 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
|
// upstream of every screen that displays a token amount, so a value invented
|
||||||
@@ -117,6 +120,7 @@ async function fetchTokenBalances(
|
|||||||
address,
|
address,
|
||||||
blockscoutUrl,
|
blockscoutUrl,
|
||||||
trackedTokens,
|
trackedTokens,
|
||||||
|
chainId,
|
||||||
signal,
|
signal,
|
||||||
) {
|
) {
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return [];
|
if (!(await storedSettingIsOn("useBlockscout"))) return [];
|
||||||
@@ -175,7 +179,10 @@ async function fetchTokenBalances(
|
|||||||
// either way: copying another source into it would make
|
// either way: copying another source into it would make
|
||||||
// explorerDecimals()'s disagreement check compare something other
|
// explorerDecimals()'s disagreement check compare something other
|
||||||
// than explorer values.
|
// than explorer values.
|
||||||
const known = resolveTokenDecimals(tokenAddr, { trackedTokens });
|
const known = resolveTokenDecimals(tokenAddr, {
|
||||||
|
chainId,
|
||||||
|
trackedTokens,
|
||||||
|
});
|
||||||
const scale = known !== null ? known : decimals;
|
const scale = known !== null ? known : decimals;
|
||||||
// null is a holding of an amount that cannot be stated, which is
|
// 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.
|
// 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
|
// through to the views, where the two low-holder filters treat
|
||||||
// an unknown count as "do not judge" rather than as zero.
|
// an unknown count as "do not judge" rather than as zero.
|
||||||
const holders = parseHoldersCount(item.token.holders_count);
|
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 isTracked = trackedSet.has(tokenAddr);
|
||||||
const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD;
|
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
|
// 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
|
// contract address; the native ETH balance is fetched over RPC in
|
||||||
// refreshBalances and never passes through this loop.
|
// refreshBalances and never passes through this loop.
|
||||||
if (isSpoofedSymbol(item.token.symbol, tokenAddr)) continue;
|
if (isSpoofedSymbol(item.token.symbol, tokenAddr, chainId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
balances.push({
|
balances.push({
|
||||||
address: item.token.address_hash,
|
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
|
// 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 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
|
// 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(
|
async function fetchTrackedTokenBalances(
|
||||||
provider,
|
provider,
|
||||||
address,
|
address,
|
||||||
trackedTokens,
|
trackedTokens,
|
||||||
previous,
|
previous,
|
||||||
explorerRequest,
|
explorerRequest,
|
||||||
|
chainId,
|
||||||
signal,
|
signal,
|
||||||
) {
|
) {
|
||||||
const listed = trackedTokens.filter(
|
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.
|
// Each token's balanceOf answer, or null where it failed.
|
||||||
const balances = await Promise.all(
|
const balances = await Promise.all(
|
||||||
@@ -288,12 +302,16 @@ async function fetchTrackedTokenBalances(
|
|||||||
const reported = explorerRows ?? previous;
|
const reported = explorerRows ?? previous;
|
||||||
|
|
||||||
const rows = [];
|
const rows = [];
|
||||||
|
const unread = trackedTokens
|
||||||
|
.filter((token) => !listed.includes(token))
|
||||||
|
.map((token) => token.address.toLowerCase());
|
||||||
listed.forEach((token, i) => {
|
listed.forEach((token, i) => {
|
||||||
const lower = token.address.toLowerCase();
|
const lower = token.address.toLowerCase();
|
||||||
const entryIn = (list) =>
|
const entryIn = (list) =>
|
||||||
list.find((row) => String(row.address).toLowerCase() === lower);
|
list.find((row) => String(row.address).toLowerCase() === lower);
|
||||||
const raw = balances[i];
|
const raw = balances[i];
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
|
unread.push(lower);
|
||||||
const kept = entryIn(previous) || entryIn(explorerRows || []);
|
const kept = entryIn(previous) || entryIn(explorerRows || []);
|
||||||
if (kept) rows.push(kept);
|
if (kept) rows.push(kept);
|
||||||
return;
|
return;
|
||||||
@@ -301,7 +319,7 @@ async function fetchTrackedTokenBalances(
|
|||||||
if (raw === 0n) return;
|
if (raw === 0n) return;
|
||||||
const entry = entryIn(reported);
|
const entry = entryIn(reported);
|
||||||
const decimals = entry ? entry.decimals : null;
|
const decimals = entry ? entry.decimals : null;
|
||||||
const known = resolveTokenDecimals(lower, { trackedTokens });
|
const known = resolveTokenDecimals(lower, { chainId, trackedTokens });
|
||||||
const scale = known !== null ? known : decimals;
|
const scale = known !== null ? known : decimals;
|
||||||
rows.push({
|
rows.push({
|
||||||
address: token.address,
|
address: token.address,
|
||||||
@@ -314,12 +332,15 @@ async function fetchTrackedTokenBalances(
|
|||||||
holders: entry ? entry.holders : null,
|
holders: entry ? entry.holders : null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return rows;
|
return { rows, unread };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
|
// 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
|
// `trackedTokens` are the tokens tracked on the active network. `signal` is as
|
||||||
// for fetchTokenBalances().
|
// for fetchTokenBalances().
|
||||||
|
//
|
||||||
|
// Resolves with the lowercased contract addresses of the tracked tokens whose
|
||||||
|
// balance it read at every address.
|
||||||
async function refreshBalances(
|
async function refreshBalances(
|
||||||
wallets,
|
wallets,
|
||||||
rpcUrl,
|
rpcUrl,
|
||||||
@@ -330,8 +351,11 @@ async function refreshBalances(
|
|||||||
) {
|
) {
|
||||||
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
|
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
|
||||||
const provider = getProvider(rpcUrl, networkId);
|
const provider = getProvider(rpcUrl, networkId);
|
||||||
|
const { chainId } = requireNetworkId(networkId);
|
||||||
const tracked = trackedTokens || [];
|
const tracked = trackedTokens || [];
|
||||||
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
|
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 = [];
|
const updates = [];
|
||||||
|
|
||||||
for (const wallet of wallets) {
|
for (const wallet of wallets) {
|
||||||
@@ -393,6 +417,7 @@ async function refreshBalances(
|
|||||||
addr.address,
|
addr.address,
|
||||||
blockscoutUrl,
|
blockscoutUrl,
|
||||||
tracked,
|
tracked,
|
||||||
|
chainId,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
updates.push(
|
updates.push(
|
||||||
@@ -403,15 +428,18 @@ async function refreshBalances(
|
|||||||
tracked,
|
tracked,
|
||||||
previous,
|
previous,
|
||||||
explorerRequest,
|
explorerRequest,
|
||||||
|
chainId,
|
||||||
signal,
|
signal,
|
||||||
),
|
),
|
||||||
explorerRequest,
|
explorerRequest,
|
||||||
]).then(([trackedRows, explorerRows]) => {
|
]).then(([trackedResult, explorerRows]) => {
|
||||||
|
for (const lower of trackedResult.unread) unread.add(lower);
|
||||||
const untrackedRows = (explorerRows ?? previous).filter(
|
const untrackedRows = (explorerRows ?? previous).filter(
|
||||||
(row) =>
|
(row) =>
|
||||||
!trackedSet.has(String(row.address).toLowerCase()),
|
!trackedSet.has(String(row.address).toLowerCase()),
|
||||||
);
|
);
|
||||||
addr.tokenBalances = trackedRows.concat(untrackedRows);
|
addr.tokenBalances =
|
||||||
|
trackedResult.rows.concat(untrackedRows);
|
||||||
log.debugf(
|
log.debugf(
|
||||||
"Token balances",
|
"Token balances",
|
||||||
addr.address,
|
addr.address,
|
||||||
@@ -425,6 +453,7 @@ async function refreshBalances(
|
|||||||
|
|
||||||
await Promise.all(updates);
|
await Promise.all(updates);
|
||||||
log.debugf("refreshBalances done");
|
log.debugf("refreshBalances done");
|
||||||
|
return [...trackedSet].filter((lower) => !unread.has(lower));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drops the balance of each token not in `trackedTokens` from every address.
|
// Drops the balance of each token not in `trackedTokens` from every address.
|
||||||
|
|||||||
@@ -240,10 +240,9 @@ function onStorageChanged(listener) {
|
|||||||
/**
|
/**
|
||||||
* Run `fn` while holding the browser's lock called `name`
|
* Run `fn` while holding the browser's lock called `name`
|
||||||
* (`navigator.locks`), and settle as `fn` settles. Every page of the extension
|
* (`navigator.locks`), and settle as `fn` settles. Every page of the extension
|
||||||
* and its background share these locks, except a page in a Firefox private
|
* and its background share these locks, so while one of them holds `name`
|
||||||
* window (README, "Stored state and its version"), so while one of them holds
|
* another asking for it waits, in the order they asked. The lock is let go when
|
||||||
* `name` another asking for it waits, in the order they asked. The lock is let
|
* `fn` settles, whether it resolved or rejected.
|
||||||
* go when `fn` settles, whether it resolved or rejected.
|
|
||||||
*
|
*
|
||||||
* Every write of the stored record holds the lock named after its key,
|
* Every write of the stored record holds the lock named after its key,
|
||||||
* "autistmask", from its read to its write: saveState() in
|
* "autistmask", from its read to its write: saveState() in
|
||||||
@@ -266,8 +265,8 @@ function withLock(name, fn) {
|
|||||||
|
|
||||||
// `storage.session`, the browser's session storage: what is kept there lasts
|
// `storage.session`, the browser's session storage: what is kept there lasts
|
||||||
// until the browser closes. It survives the background worker being stopped
|
// until the browser closes. It survives the background worker being stopped
|
||||||
// and started again, which module-level variables do not (README, Background
|
// and started again, which module-level variables do not (README, The
|
||||||
// scheduling). The background keeps the connections allowed without
|
// background worker). The background keeps the connections allowed without
|
||||||
// "Remember" there; see src/background/index.js. Firefox has it from version
|
// "Remember" there; see src/background/index.js. Firefox has it from version
|
||||||
// 115, the minimum manifest/firefox.json declares.
|
// 115, the minimum manifest/firefox.json declares.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -47,12 +47,63 @@ function clearPopupCaches() {
|
|||||||
// lock, so it is never switched while a follow of a switch saved elsewhere
|
// lock, so it is never switched while a follow of a switch saved elsewhere
|
||||||
// waits for the lock (followStoredSettings() in src/popup/index.js): that
|
// waits for the lock (followStoredSettings() in src/popup/index.js): that
|
||||||
// follow runs first, and this switch is made after it.
|
// follow runs first, and this switch is made after it.
|
||||||
|
//
|
||||||
|
// The save writes only the fields that differ from what this page last saved.
|
||||||
|
// When this page last saved a balance of null, clearing that balance is no
|
||||||
|
// change. So the switch also resets these fields in the stored record:
|
||||||
|
//
|
||||||
|
// - every address's balance and token balances;
|
||||||
|
// - the time of the last balance refresh;
|
||||||
|
// - the fraud contracts.
|
||||||
|
//
|
||||||
|
// A balance or a fraud contract that the background or another page stored
|
||||||
|
// since this page's last save is then not kept under the new network
|
||||||
|
// (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.
|
||||||
async function onChainSwitch(newNetworkId) {
|
async function onChainSwitch(newNetworkId) {
|
||||||
const { state, saveState } = require("./state");
|
const {
|
||||||
|
state,
|
||||||
|
saveState,
|
||||||
|
takeStoredNetwork,
|
||||||
|
takeStoredTrackedTokens,
|
||||||
|
takeStoredNetworkTrackedTokens,
|
||||||
|
} = require("./state");
|
||||||
let net = null;
|
let net = null;
|
||||||
await saveState(() => {
|
await saveState((stored) => {
|
||||||
if (state.networkId === newNetworkId) return false;
|
if (state.networkId === newNetworkId) return false;
|
||||||
|
if (stored.networkId !== state.networkId) {
|
||||||
|
takeStoredNetwork(stored);
|
||||||
|
} else {
|
||||||
|
takeStoredNetworkTrackedTokens(stored);
|
||||||
|
}
|
||||||
|
takeStoredTrackedTokens(stored);
|
||||||
net = applyChainSwitchFields(state, newNetworkId);
|
net = applyChainSwitchFields(state, newNetworkId);
|
||||||
|
stored.lastBalanceRefresh = 0;
|
||||||
|
stored.fraudContracts = [];
|
||||||
|
for (const wallet of stored.wallets) {
|
||||||
|
for (const addr of wallet.addresses) {
|
||||||
|
addr.balance = null;
|
||||||
|
addr.tokenBalances = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
clearPopupCaches();
|
clearPopupCaches();
|
||||||
});
|
});
|
||||||
return net;
|
return net;
|
||||||
|
|||||||
@@ -42,11 +42,20 @@ function applyChainSwitchFields(s, newNetworkId) {
|
|||||||
// Tracked tokens are kept per network the same way, because a contract
|
// Tracked tokens are kept per network the same way, because a contract
|
||||||
// address names a token on one network only. s.trackedTokens stays the
|
// address names a token on one network only. s.trackedTokens stays the
|
||||||
// active network's list, so nothing that reads it changes: the outgoing
|
// active network's list, so nothing that reads it changes: the outgoing
|
||||||
// list is put away under its network, and the incoming network's list, or
|
// list is put away under its network, and a copy of the incoming network's
|
||||||
// an empty one, takes its place.
|
// list, or an empty list, takes its place.
|
||||||
|
//
|
||||||
|
// It is a copy because a save writes a changed s.networkTrackedTokens whole,
|
||||||
|
// over any list another page stored. An add or removal on the active network
|
||||||
|
// must change only s.trackedTokens.
|
||||||
|
//
|
||||||
|
// Each token of the copy gets `balanceNotFetched`, because the balances are
|
||||||
|
// cleared below and no refresh has read this network's yet. Each reads
|
||||||
|
// `quantity unknown` until one has, not 0.0000.
|
||||||
if (!s.networkTrackedTokens) s.networkTrackedTokens = {};
|
if (!s.networkTrackedTokens) s.networkTrackedTokens = {};
|
||||||
s.networkTrackedTokens[s.networkId] = s.trackedTokens;
|
s.networkTrackedTokens[s.networkId] = s.trackedTokens;
|
||||||
s.trackedTokens = s.networkTrackedTokens[net.id] || [];
|
s.trackedTokens = structuredClone(s.networkTrackedTokens[net.id] || []);
|
||||||
|
for (const token of s.trackedTokens) token.balanceNotFetched = true;
|
||||||
|
|
||||||
s.networkId = net.id;
|
s.networkId = net.id;
|
||||||
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
|
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
|
||||||
@@ -59,10 +68,11 @@ function applyChainSwitchFields(s, newNetworkId) {
|
|||||||
|
|
||||||
// Clear per-address balances and token balances so stale data
|
// Clear per-address balances and token balances so stale data
|
||||||
// from the previous chain is never displayed while the first
|
// from the previous chain is never displayed while the first
|
||||||
// refresh on the new chain is in flight.
|
// refresh on the new chain is in flight. A balance of null is one no
|
||||||
|
// refresh has read: it reads as unknown until one has, not as 0.0000.
|
||||||
for (const wallet of s.wallets || []) {
|
for (const wallet of s.wallets || []) {
|
||||||
for (const addr of wallet.addresses || []) {
|
for (const addr of wallet.addresses || []) {
|
||||||
addr.balance = "0";
|
addr.balance = null;
|
||||||
addr.tokenBalances = [];
|
addr.tokenBalances = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,7 +109,7 @@ function copyChainSwitchFields(s, switched) {
|
|||||||
for (const wallet of s.wallets) {
|
for (const wallet of s.wallets) {
|
||||||
for (const addr of wallet.addresses) {
|
for (const addr of wallet.addresses) {
|
||||||
const got = stored.get(addr.address);
|
const got = stored.get(addr.address);
|
||||||
addr.balance = got ? got.balance : "0";
|
addr.balance = got ? got.balance : null;
|
||||||
addr.tokenBalances = got ? got.tokenBalances : [];
|
addr.tokenBalances = got ? got.tokenBalances : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-23
@@ -1,31 +1,105 @@
|
|||||||
// Price fetching with 5-minute cache, USD formatting, value aggregation.
|
// USD prices, read from Chainlink price feeds on Ethereum mainnet, with USD
|
||||||
|
// formatting and value aggregation. The feeds are in src/shared/tokenList.js.
|
||||||
|
|
||||||
const { getTopTokenPrices } = require("./tokenList");
|
const { Contract } = require("ethers");
|
||||||
|
const { ETH_USD_FEED, PRICE_FEED_BY_ADDRESS } = require("./tokenList");
|
||||||
|
const { getProvider } = require("./balances");
|
||||||
const { isBelowOneMillionth } = require("./amountDisplay");
|
const { isBelowOneMillionth } = require("./amountDisplay");
|
||||||
const { networkByChainId } = require("./networks");
|
const { networkByChainId } = require("./networks");
|
||||||
|
const { log } = require("./log");
|
||||||
|
|
||||||
const PRICE_CACHE_TTL = 300000; // 5 minutes
|
const PRICE_CACHE_TTL = 300000; // 5 minutes
|
||||||
|
|
||||||
|
// A reading older than its feed's heartbeat plus this many seconds is no price.
|
||||||
|
const STALE_MARGIN = 1800; // 30 minutes
|
||||||
|
|
||||||
|
const FEED_ABI = [
|
||||||
|
"function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)",
|
||||||
|
];
|
||||||
|
|
||||||
|
// USD prices: ether's under "ETH", each token's under its lowercased contract
|
||||||
|
// address.
|
||||||
const prices = {};
|
const prices = {};
|
||||||
let lastFetchedAt = 0;
|
let lastFetchedAt = 0;
|
||||||
|
|
||||||
async function refreshPrices() {
|
// Reads the price of ether and of each token any address holds or the user
|
||||||
// Testnet tokens have no real market value — skip price fetching
|
// tracks, when that token has a feed. Mainnet only. The reads go to the RPC
|
||||||
// and clear any stale mainnet prices so the UI shows no USD values.
|
// endpoint as one batched request, at most every 5 minutes. A failed read is
|
||||||
const { currentNetwork } = require("./state");
|
// logged, and that token then has no price.
|
||||||
if (currentNetwork().isTestnet) {
|
//
|
||||||
|
// `signal`, passed by the popup, is aborted when the popup closes. A read that
|
||||||
|
// fails once `signal` is aborted is not logged, because closing the popup
|
||||||
|
// cancelled it.
|
||||||
|
async function refreshPrices(signal) {
|
||||||
|
const { state, currentNetwork } = require("./state");
|
||||||
|
if (currentNetwork().id !== "mainnet") {
|
||||||
clearPrices();
|
clearPrices();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastFetchedAt < PRICE_CACHE_TTL) return;
|
if (now - lastFetchedAt < PRICE_CACHE_TTL) return;
|
||||||
try {
|
const feeds = new Map([["ETH", ETH_USD_FEED]]);
|
||||||
const fetched = await getTopTokenPrices(25);
|
for (const address of heldOrTrackedTokens(state)) {
|
||||||
Object.assign(prices, fetched);
|
const feed = PRICE_FEED_BY_ADDRESS.get(address);
|
||||||
lastFetchedAt = now;
|
if (feed) feeds.set(address, feed);
|
||||||
} catch {
|
|
||||||
// prices stay stale on error
|
|
||||||
}
|
}
|
||||||
|
// ethers sends calls made together on one provider as one batch.
|
||||||
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
|
const read = {};
|
||||||
|
await Promise.all(
|
||||||
|
[...feeds].map(async ([key, feed]) => {
|
||||||
|
try {
|
||||||
|
const contract = new Contract(feed.address, FEED_ABI, provider);
|
||||||
|
const round = await contract.latestRoundData();
|
||||||
|
const price = priceFromRound(round, feed, now);
|
||||||
|
if (price === null) {
|
||||||
|
log.warnf(
|
||||||
|
"price feed reading is stale or not positive",
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
read[key] = price;
|
||||||
|
} catch (e) {
|
||||||
|
if (signal?.aborted) return;
|
||||||
|
log.errorf(
|
||||||
|
"price feed read failed",
|
||||||
|
key,
|
||||||
|
e.shortMessage || e.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
clearPrices();
|
||||||
|
Object.assign(prices, read);
|
||||||
|
lastFetchedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lowercased contract address of each token that any address holds or
|
||||||
|
// that the user tracks.
|
||||||
|
function heldOrTrackedTokens(state) {
|
||||||
|
const addresses = new Set();
|
||||||
|
for (const wallet of state.wallets) {
|
||||||
|
for (const addr of wallet.addresses) {
|
||||||
|
for (const token of addr.tokenBalances || []) {
|
||||||
|
addresses.add(token.address.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const token of state.trackedTokens || []) {
|
||||||
|
addresses.add(token.address.toLowerCase());
|
||||||
|
}
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The USD price in a feed's latestRoundData() result, or null when the answer
|
||||||
|
// is not positive or was written longer ago than the feed's heartbeat plus
|
||||||
|
// STALE_MARGIN. `now` is in milliseconds.
|
||||||
|
function priceFromRound(round, feed, now) {
|
||||||
|
const ageSeconds = now / 1000 - Number(round.updatedAt);
|
||||||
|
if (ageSeconds > feed.heartbeat + STALE_MARGIN) return null;
|
||||||
|
if (round.answer <= 0n) return null;
|
||||||
|
return Number(round.answer) / 10 ** feed.decimals;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all cached prices and reset the fetch timestamp so the
|
// Clear all cached prices and reset the fetch timestamp so the
|
||||||
@@ -37,14 +111,15 @@ function clearPrices() {
|
|||||||
lastFetchedAt = 0;
|
lastFetchedAt = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the USD price for a symbol on the network with `chainId`, or on the
|
// Return the USD price of the token at the contract address `token`, or of
|
||||||
// active network when no chain id is given; null on testnet, on a chain id
|
// ether when `token` is "ETH". The price is for the network with `chainId`, or
|
||||||
// that names no network, or for a symbol with no price.
|
// for the active network when no chain id is given. Returns null on testnet,
|
||||||
function getPrice(symbol, chainId) {
|
// on a chain id that names no network, and for a token with no price.
|
||||||
|
function getPrice(token, chainId) {
|
||||||
const { currentNetwork } = require("./state");
|
const { currentNetwork } = require("./state");
|
||||||
const network = chainId ? networkByChainId(chainId) : currentNetwork();
|
const network = chainId ? networkByChainId(chainId) : currentNetwork();
|
||||||
if (!network || network.isTestnet) return null;
|
if (!network || network.isTestnet) return null;
|
||||||
return prices[symbol] || null;
|
return prices[token === "ETH" ? "ETH" : token.toLowerCase()] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUsd(amount) {
|
function formatUsd(amount) {
|
||||||
@@ -62,7 +137,7 @@ function formatUsd(amount) {
|
|||||||
|
|
||||||
// What an address is worth, as { usd, partial }.
|
// What an address is worth, as { usd, partial }.
|
||||||
//
|
//
|
||||||
// Prices are fetched for the top 25 tokens only, so an address can hold real
|
// Only a token with a price feed has a price, so an address can hold real
|
||||||
// assets this code has no price for. Adding up the priced ones and calling the
|
// assets this code has no price for. Adding up the priced ones and calling the
|
||||||
// result the total states a number the holdings do not support: an address
|
// result the total states a number the holdings do not support: an address
|
||||||
// holding nothing but unpriced tokens comes out at $0.00, which tells the user
|
// holding nothing but unpriced tokens comes out at $0.00, which tells the user
|
||||||
@@ -71,7 +146,8 @@ function formatUsd(amount) {
|
|||||||
// way an absent holders_count is not a count of zero.
|
// way an absent holders_count is not a count of zero.
|
||||||
//
|
//
|
||||||
// usd: the value of the holdings a price is known for, or null when
|
// usd: the value of the holdings a price is known for, or null when
|
||||||
// nothing is knowable at all — testnet, or before the first fetch.
|
// no total can be stated — testnet, before the first fetch, or an
|
||||||
|
// ETH balance no refresh has read.
|
||||||
// partial: the address also holds a token with no price, so usd is a floor
|
// partial: the address also holds a token with no price, so usd is a floor
|
||||||
// and not the total.
|
// and not the total.
|
||||||
//
|
//
|
||||||
@@ -80,7 +156,8 @@ function getAddressValue(addr) {
|
|||||||
const { currentNetwork } = require("./state");
|
const { currentNetwork } = require("./state");
|
||||||
if (currentNetwork().isTestnet) return { usd: null, partial: false };
|
if (currentNetwork().isTestnet) return { usd: null, partial: false };
|
||||||
if (!prices.ETH) return { usd: null, partial: false };
|
if (!prices.ETH) return { usd: null, partial: false };
|
||||||
let usd = parseFloat(addr.balance || "0") * prices.ETH;
|
if (addr.balance == null) return { usd: null, partial: false };
|
||||||
|
let usd = parseFloat(addr.balance) * prices.ETH;
|
||||||
let partial = false;
|
let partial = false;
|
||||||
for (const token of addr.tokenBalances || []) {
|
for (const token of addr.tokenBalances || []) {
|
||||||
// A holding below 0.000001 is left out, as the balance lists leave it
|
// A holding below 0.000001 is left out, as the balance lists leave it
|
||||||
@@ -99,8 +176,9 @@ function getAddressValue(addr) {
|
|||||||
// nor make it incomplete. Anything that is not a number at all is not
|
// nor make it incomplete. Anything that is not a number at all is not
|
||||||
// a holding this can price either, and is left to the same rule.
|
// a holding this can price either, and is left to the same rule.
|
||||||
if (!(tokenBal > 0)) continue;
|
if (!(tokenBal > 0)) continue;
|
||||||
if (prices[token.symbol]) {
|
const price = prices[token.address.toLowerCase()];
|
||||||
usd += tokenBal * prices[token.symbol];
|
if (price) {
|
||||||
|
usd += tokenBal * price;
|
||||||
} else {
|
} else {
|
||||||
partial = true;
|
partial = true;
|
||||||
}
|
}
|
||||||
|
|||||||
+178
-20
@@ -125,11 +125,14 @@ function currentNetwork() {
|
|||||||
return networkById(state.networkId);
|
return networkById(state.networkId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The persisted fields as this page held them when its last loadState()
|
// `baseline` holds the persisted fields as this page held them when its last
|
||||||
// finished, or when its last successful saveState() began, with any network
|
// loadState() finished, or when its last successful saveState() began.
|
||||||
// switch saved elsewhere that this page has taken since (takeStoredNetwork()).
|
// takeStoredNetwork(), takeStoredTrackedTokens() and
|
||||||
// saveState() diffs the live state against this to find only the fields THIS
|
// takeStoredNetworkTrackedTokens() also write into it what this page has taken
|
||||||
// page changed since.
|
// 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
|
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
|
||||||
// in place (state.wallets.push(...)), and a reference baseline would mutate
|
// in place (state.wallets.push(...)), and a reference baseline would mutate
|
||||||
@@ -401,6 +404,51 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
return mergeMapByKey(base, ours, theirs, mergeEndpointEntry);
|
return mergeMapByKey(base, ours, theirs, mergeEndpointEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merges one network's tracked tokens by contract address:
|
||||||
|
//
|
||||||
|
// - A token another page added or removed since this page's last save stays
|
||||||
|
// added or removed.
|
||||||
|
// - A token that this page and `theirs` both hold keeps this page's record.
|
||||||
|
// - The result holds one record per contract address.
|
||||||
|
//
|
||||||
|
// mergeListByIdentity() keeps records that share an identity, on purpose, for
|
||||||
|
// wallets. A contract address names one token, so this keeps one record for it.
|
||||||
|
// Without that, a token that `ours` and `theirs` each hold twice comes back four
|
||||||
|
// times, and each later merge multiplies it again.
|
||||||
|
//
|
||||||
|
// `theirs` takes this page's records first. mergeListByIdentity() would
|
||||||
|
// otherwise log an error for two different records of one token.
|
||||||
|
function mergeTrackedTokens(base, ours, theirs) {
|
||||||
|
const contract = (token) => token.address.toLowerCase();
|
||||||
|
const oursByContract = new Map(
|
||||||
|
ours.map((token) => [contract(token), token]),
|
||||||
|
);
|
||||||
|
const merged = mergeListByIdentity(
|
||||||
|
base,
|
||||||
|
ours,
|
||||||
|
theirs.map((token) => oursByContract.get(contract(token)) || token),
|
||||||
|
contract,
|
||||||
|
(baseToken, ourToken) => ourToken,
|
||||||
|
);
|
||||||
|
const byContract = new Map(merged.map((token) => [contract(token), token]));
|
||||||
|
return [...byContract.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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]: mergeTrackedTokens(
|
||||||
|
baseline.trackedTokens,
|
||||||
|
page.trackedTokens,
|
||||||
|
stored.networkTrackedTokens[page.networkId] || [],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Read-modify-write, merged per field, rather than one full-blob write.
|
// Read-modify-write, merged per field, rather than one full-blob write.
|
||||||
//
|
//
|
||||||
// Every extension page (the toolbar popup, a dApp approval window) holds its
|
// Every extension page (the toolbar popup, a dApp approval window) holds its
|
||||||
@@ -441,16 +489,27 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
// comment at mergeEndpointEntry() for why the collision this closes is
|
// comment at mergeEndpointEntry() for why the collision this closes is
|
||||||
// milder than the other two.
|
// milder than the other two.
|
||||||
//
|
//
|
||||||
|
// `trackedTokens` is merged by contract address (mergeTrackedTokens()) when this
|
||||||
|
// page has changed it, so a token another page added or removed since this
|
||||||
|
// page's last save stays added or removed. A token both lists hold keeps this
|
||||||
|
// page's record. Two saves differ:
|
||||||
|
//
|
||||||
|
// - 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.
|
||||||
|
// - A save that switches networks writes this page's list whole. A switch in
|
||||||
|
// Settings first merges that list by contract address
|
||||||
|
// (takeStoredTrackedTokens()).
|
||||||
|
//
|
||||||
// Every other persisted field stays a whole-field diff:
|
// Every other persisted field stays a whole-field diff:
|
||||||
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
|
// `fraudContracts`/`viewStack` are arrays of scalars with no per-element
|
||||||
// per-element identity to merge by; `networkTrackedTokens` is diffed whole like
|
// identity to merge by; `networkTrackedTokens` is diffed whole like `networkId`,
|
||||||
// `trackedTokens` and `networkId`, the two fields a network switch moves
|
// which a network switch moves together with it; `tokenHolderCache` is a map
|
||||||
// together with it; `tokenHolderCache` is a map shaped like the ones above,
|
// shaped like the ones above, but nothing in src/ ever writes an entry into it —
|
||||||
// but nothing in src/ ever writes an entry into it — it is only ever reset
|
// it is only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there
|
||||||
// wholesale to `{}` (applyChainSwitchFields()) — so there is no in-place
|
// is no in-place mutation for a whole-field diff to collide with; `viewData` is
|
||||||
// mutation for a whole-field diff to collide with; `viewData` is this page's
|
// this page's own UI scratch space, not data another page has any reason to
|
||||||
// own UI scratch space, not data another page has any reason to share
|
// share membership of.
|
||||||
// membership of.
|
|
||||||
//
|
//
|
||||||
// This does not make two pages that both change the SAME leaf concurrently
|
// 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
|
// safe: last write wins on that one leaf, same as before. What it removes
|
||||||
@@ -524,6 +583,34 @@ async function saveStateOnce(beforeWrite) {
|
|||||||
merged[key] = current[key];
|
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,
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
baseline !== null &&
|
||||||
|
baseline.networkId === current.networkId &&
|
||||||
|
!deepEqual(current.trackedTokens, baseline.trackedTokens)
|
||||||
|
) {
|
||||||
|
// This page's network is the one it last saved on, and the stored
|
||||||
|
// record is on it too, so all three lists are of one network. This
|
||||||
|
// page's change goes into the stored list, and another page's add or
|
||||||
|
// removal stays (https://git.eeqj.de/sneak/AutistMask/issues/717).
|
||||||
|
merged.trackedTokens = mergeTrackedTokens(
|
||||||
|
baseline.trackedTokens,
|
||||||
|
current.trackedTokens,
|
||||||
|
fresh.trackedTokens,
|
||||||
|
);
|
||||||
|
}
|
||||||
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
|
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
|
||||||
// Stamped on every write, never merged or diffed: the record that goes to
|
// 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
|
// storage is in THIS build's shape whatever shape it was read in, which is
|
||||||
@@ -591,8 +678,10 @@ function reportSaveFailure(err) {
|
|||||||
//
|
//
|
||||||
// `beforeWrite`, when given, is called inside the lock with the stored record
|
// `beforeWrite`, when given, is called inside the lock with the stored record
|
||||||
// as just read, before this page's state is compared against it. It may change
|
// as just read, before this page's state is compared against it. It may change
|
||||||
// `state`, and this save writes that change. When it returns false, nothing is
|
// `state`, and this save writes that change. It may also change the stored
|
||||||
// written; any other return lets the save go ahead.
|
// record. A field that this page did not change is then written as
|
||||||
|
// `beforeWrite` left it. When it returns false, nothing is written; any other
|
||||||
|
// return lets the save go ahead.
|
||||||
function saveState(beforeWrite) {
|
function saveState(beforeWrite) {
|
||||||
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
|
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
|
||||||
// Every failed save is reported, whether or not the caller awaited this
|
// Every failed save is reported, whether or not the caller awaited this
|
||||||
@@ -602,15 +691,81 @@ function saveState(beforeWrite) {
|
|||||||
return turn;
|
return turn;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Give this page the network switch held by `stored`, a record a save of this
|
// Give this page the network switch held by `stored`, in `state` and in
|
||||||
// page has just read (followStoredSettings() in src/popup/index.js calls this
|
// `baseline` alike, as loadState() does with a whole record. `stored` is the
|
||||||
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does
|
// record that a save of this page has just read. followStoredSettings() in
|
||||||
// with a whole record. The switch is then not this page's own change, even when
|
// 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
|
// that save fails to write: a later switch back to the previous network still
|
||||||
// differs from `baseline`, and is stored.
|
// 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) {
|
function takeStoredNetwork(stored) {
|
||||||
|
const networkTrackedTokens = networkTrackedTokensWithChange(
|
||||||
|
stored,
|
||||||
|
rawState,
|
||||||
|
);
|
||||||
copyChainSwitchFields(rawState, stored);
|
copyChainSwitchFields(rawState, stored);
|
||||||
copyChainSwitchFields(baseline, structuredClone(stored));
|
copyChainSwitchFields(baseline, structuredClone(stored));
|
||||||
|
rawState.networkTrackedTokens = networkTrackedTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give this page the tracked tokens of `stored`, 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`):
|
||||||
|
//
|
||||||
|
// - A token that another page tracked or removed since this page's last save
|
||||||
|
// is tracked or removed in `state` too.
|
||||||
|
// - A token that this page tracked or removed since then stays as this page
|
||||||
|
// has it.
|
||||||
|
//
|
||||||
|
// `baseline` takes the stored list, so the save stores the list in `state`
|
||||||
|
// whenever the two differ.
|
||||||
|
function takeStoredTrackedTokens(stored) {
|
||||||
|
rawState.trackedTokens = mergeTrackedTokens(
|
||||||
|
baseline.trackedTokens,
|
||||||
|
rawState.trackedTokens,
|
||||||
|
stored.trackedTokens,
|
||||||
|
);
|
||||||
|
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
|
// Rejects with StateUnusableError for a stored record this build cannot make
|
||||||
@@ -659,6 +814,9 @@ module.exports = {
|
|||||||
saveState,
|
saveState,
|
||||||
onSaveFailure,
|
onSaveFailure,
|
||||||
takeStoredNetwork,
|
takeStoredNetwork,
|
||||||
|
takeStoredTrackedTokens,
|
||||||
|
takeStoredNetworkTrackedTokens,
|
||||||
|
clearBalanceNotFetched,
|
||||||
loadState,
|
loadState,
|
||||||
currentAddress,
|
currentAddress,
|
||||||
currentNetwork,
|
currentNetwork,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
|
// Reads "Use Blockscout", "Check recipients on Etherscan" or the active network
|
||||||
// profile.
|
// from the stored profile.
|
||||||
//
|
//
|
||||||
// Before each request that sends the user's data to Blockscout or Etherscan, the
|
// 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
|
// 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
|
// 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
|
// own copy of the setting is not used, because another page may have just
|
||||||
// changed it.
|
// 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 { storageGet } = require("./browserApi");
|
||||||
const { normalizePersisted } = require("./persistedState");
|
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 };
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
// them hid the other one's holders' money (issue #276). Membership, not
|
// 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
|
// 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
|
// 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
|
// The symbol is attacker-controlled — it is whatever the ERC-20 contract
|
||||||
// returns — so the lookup is done on a normalized form (issue #260): the
|
// returns — so the lookup is done on a normalized form (issue #260): the
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
// since that is what the user acts on.
|
// since that is what the user acts on.
|
||||||
|
|
||||||
const { KNOWN_SYMBOLS } = require("./tokenList");
|
const { KNOWN_SYMBOLS } = require("./tokenList");
|
||||||
|
const { NETWORKS } = require("./networks");
|
||||||
|
|
||||||
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
||||||
// over the address, not part of its identity.
|
// 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
|
// 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:
|
// 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
|
// 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.
|
// any symbol that becomes null-mapped later, not just for ETH.
|
||||||
function isSpoofedSymbol(symbol, contractAddress) {
|
function isSpoofedSymbol(symbol, contractAddress, chainId) {
|
||||||
const contract = normalizeAddress(contractAddress);
|
const contract = normalizeAddress(contractAddress);
|
||||||
if (!contract) return false;
|
if (!contract) return false;
|
||||||
const sym = normalizeSymbol(symbol);
|
const sym = normalizeSymbol(symbol);
|
||||||
if (!KNOWN_SYMBOLS.has(sym)) return false;
|
if (!KNOWN_SYMBOLS.has(sym)) return false;
|
||||||
const legit = KNOWN_SYMBOLS.get(sym);
|
const legit = KNOWN_SYMBOLS.get(sym);
|
||||||
if (legit === null) return true;
|
if (legit === null) return true;
|
||||||
|
if (chainId !== NETWORKS.mainnet.chainId) return false;
|
||||||
return !legit.has(contract);
|
return !legit.has(contract);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+367
-35
@@ -4,12 +4,12 @@
|
|||||||
// Addresses are EIP-55 checksummed.
|
// Addresses are EIP-55 checksummed.
|
||||||
//
|
//
|
||||||
// 511 tokens.
|
// 511 tokens.
|
||||||
|
//
|
||||||
|
// Below the list, USD_PRICE_FEEDS gives the Chainlink USD price feed of each
|
||||||
|
// token that has one.
|
||||||
|
|
||||||
const { debugFetch } = require("./log");
|
|
||||||
const { NETWORKS } = require("./networks");
|
const { NETWORKS } = require("./networks");
|
||||||
|
|
||||||
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
|
|
||||||
|
|
||||||
const TOKENS = [
|
const TOKENS = [
|
||||||
{
|
{
|
||||||
address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
|
address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
|
||||||
@@ -3597,6 +3597,351 @@ const TOKENS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Chainlink price feeds on Ethereum mainnet. Each feed is a contract whose
|
||||||
|
// latestRoundData() returns a USD price and the time the price was written.
|
||||||
|
// The fields of a feed:
|
||||||
|
//
|
||||||
|
// - address: the feed's contract
|
||||||
|
// - heartbeat: the most seconds the feed goes without a new price
|
||||||
|
// - decimals: the number of decimal places in the price
|
||||||
|
//
|
||||||
|
// Each feed's address and heartbeat come from Chainlink's published list of
|
||||||
|
// mainnet feeds. Each feed was checked on mainnet by reading its description()
|
||||||
|
// and decimals().
|
||||||
|
|
||||||
|
// The ETH/USD feed, which prices ether and WETH.
|
||||||
|
const ETH_USD_FEED = {
|
||||||
|
address: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419",
|
||||||
|
heartbeat: 3600,
|
||||||
|
decimals: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The USD feed of each token in TOKENS that has one, keyed by the token's
|
||||||
|
// contract address as TOKENS spells it. A token is paired with its feed by its
|
||||||
|
// address alone, so a token that copies a listed token's symbol gets no feed.
|
||||||
|
const USD_PRICE_FEEDS = {
|
||||||
|
// WETH
|
||||||
|
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": ETH_USD_FEED,
|
||||||
|
// USDT
|
||||||
|
"0xdAC17F958D2ee523a2206206994597C13D831ec7": {
|
||||||
|
address: "0x3E7d1eAB13ad0104d2750B8863b489D65364e32D",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// BNB
|
||||||
|
"0xB8c77482e45F1F44dE1745F52C74426C631bDD52": {
|
||||||
|
address: "0x14e613AC84a31f709eadbdF89C6CC390fDc9540A",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// USDC
|
||||||
|
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": {
|
||||||
|
address: "0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6",
|
||||||
|
heartbeat: 82800,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// USDS
|
||||||
|
"0xdC035D45d973E3EC169d2276DDab16f1e407384F": {
|
||||||
|
address: "0xfF30586cD0F29eD462364C7e81375FC0C71219b1",
|
||||||
|
heartbeat: 82800,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// LINK
|
||||||
|
"0x514910771AF9Ca656af840dff83E8264EcF986CA": {
|
||||||
|
address: "0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c",
|
||||||
|
heartbeat: 3600,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// USDE
|
||||||
|
"0x4c9EDD5852cd905f086C759E8383e09bff1E68B3": {
|
||||||
|
address: "0xa569d910839Ae8865Da8F8e70FfFb0cBA869F961",
|
||||||
|
heartbeat: 82800,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// USD1
|
||||||
|
"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d": {
|
||||||
|
address: "0xF0d9bb015Cd7BfAb877B7156146dc09Bf461370d",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// DAI
|
||||||
|
"0x6B175474E89094C44Da98b954EedeAC495271d0F": {
|
||||||
|
address: "0xAed0c38402a5d19df6E4c03F4E2DceD6e29c1ee9",
|
||||||
|
heartbeat: 3600,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// PYUSD
|
||||||
|
"0x6c3ea9036406852006290770BEdFcAbA0e23A0e8": {
|
||||||
|
address: "0x8f1dF6D7F2db73eECE86a18b4381F4707b918FB1",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// WLFI
|
||||||
|
"0xdA5e1988097297dCdc1f90D4dFE7909e847CBeF6": {
|
||||||
|
address: "0x14E5FC91Ddb3f97C33013Cc9fA74F54062Ad1Aa1",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// UNI
|
||||||
|
"0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984": {
|
||||||
|
address: "0x553303d460EE0afB37EdFf9bE42922D8FF63220e",
|
||||||
|
heartbeat: 3600,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// PAXG
|
||||||
|
"0x45804880De22913dAFE09f4980848ECE6EcbAf78": {
|
||||||
|
address: "0x9944D86CEB9160aF5C5feB251FD671923323f8C3",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// AAVE
|
||||||
|
"0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9": {
|
||||||
|
address: "0x547a514d5e3769680Ce22B2361c10Ea13619e8a9",
|
||||||
|
heartbeat: 3600,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// USDF
|
||||||
|
"0xFa2B947eEc368f42195f24F36d2aF29f7c24CeC2": {
|
||||||
|
address: "0xb177857a1799aA5F7fEb5799Fdf12CbE8fdF78B1",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// USDG
|
||||||
|
"0xe343167631d89B6Ffc58B88d6b7fB0228795491D": {
|
||||||
|
address: "0x14f0737d6b705259e521EA6E9E3506AC78dBd311",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// SKY
|
||||||
|
"0x56072C95FAA701256059aa122697B133aDEd9279": {
|
||||||
|
address: "0xee10fE5E7aa92dd7b136597449c3d5813cFC5F18",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// RLUSD
|
||||||
|
"0x8292Bb45bf1Ee4d140127049757C2E0fF06317eD": {
|
||||||
|
address: "0x26C46B7aD0012cA71F2298ada567dC9Af14E7f2A",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// USDTB
|
||||||
|
"0xC139190F447e929f090Edeb554D95AbB8b18aC1C": {
|
||||||
|
address: "0x66704DAD467A7cA508B3be15865D9B9F3E186c90",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// ARB
|
||||||
|
"0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1": {
|
||||||
|
address: "0x31697852a68433DbCc2Ff612c516d69E3D9bd08F",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// USD0
|
||||||
|
"0x73A15FeD60Bf67631dC6cd7Bc5B6e8da8190aCF5": {
|
||||||
|
address: "0x7e891DEbD8FA0A4Cf6BE58Ddff5a8ca174FebDCB",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// GHO
|
||||||
|
"0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f": {
|
||||||
|
address: "0x3f12643D3f6f874d39C2a4c9f2Cd6f2DbAC877FC",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// TUSD
|
||||||
|
"0x0000000000085d4780B73119b644AE5ecd22b376": {
|
||||||
|
address: "0xec746eCF986E2927Abd291a2A1716c940100f8Ba",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// EURC
|
||||||
|
"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c": {
|
||||||
|
address: "0x04F84020Fdf10d9ee64D1dcC2986EDF2F556DA11",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// FDUSD
|
||||||
|
"0xc5f0f7b66764F6ec8C8Dff7BA683102295E16409": {
|
||||||
|
address: "0xfAA9147190c2C2cc5B8387B4f49016bDB3380572",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// CRV
|
||||||
|
"0xD533a949740bb3306d119CC777fa900bA034cd52": {
|
||||||
|
address: "0xCd627aA160A6fA45Eb793D19Ef54f5062F20f33f",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// GRT
|
||||||
|
"0xc944E90C64B2c07662A292be6244BDf05Cda44a7": {
|
||||||
|
address: "0x86cF33a451dE9dc61a2862FD94FF4ad4Bd65A5d2",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// CRVUSD
|
||||||
|
"0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E": {
|
||||||
|
address: "0xEEf0C605546958c1f899b6fB336C20671f9cD49F",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// FRAX, the Legacy Frax Dollar, priced by Chainlink's FRAX / USD feed
|
||||||
|
"0x853d955aCEf822Db058eb8505911ED77F175b99e": {
|
||||||
|
address: "0xB9E1E3A9feFf48998E45Fa90847ed4D467E8BcfD",
|
||||||
|
heartbeat: 3600,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// ENS
|
||||||
|
"0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72": {
|
||||||
|
address: "0x5C00128d4d1c2F4f652C267d7bcdD7aC99C16E16",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// SAND
|
||||||
|
"0x3845badAde8e6dFF049820680d1F14bD3903a5d0": {
|
||||||
|
address: "0x35E3f7E558C04cE7eEE1629258EcbbA03B36Ec56",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// AUSD
|
||||||
|
"0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a": {
|
||||||
|
address: "0xB00341502DfEA6Ced8A5786b4059d29dA5E4D1FD",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 18,
|
||||||
|
},
|
||||||
|
// COMP
|
||||||
|
"0xc00e94Cb662C3520282E6f5717214004A7f26888": {
|
||||||
|
address: "0xdbd020CAeF83eFd542f4De03e3cF0C28A4428bd5",
|
||||||
|
heartbeat: 3600,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// XCN
|
||||||
|
"0xA2cd3D43c775978A96BdBf12d733D5A1ED94fb18": {
|
||||||
|
address: "0xeb988B77b94C186053282BfcD8B7ED55142D3cAB",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// CVX
|
||||||
|
"0x4e3FBD56CD56c3e72c1403e103b45Db9da5B9D2B": {
|
||||||
|
address: "0xd962fC30A72A84cE50161031391756Bf2876Af5D",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// IMX
|
||||||
|
"0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF": {
|
||||||
|
address: "0xBAEbEFc1D023c0feCcc047Bff42E75F15Ff213E6",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// 1INCH
|
||||||
|
"0x111111111117dC0aa78b770fA6A738034120C302": {
|
||||||
|
address: "0xc929ad75B72593967DE83E7F7Cda0493458261D9",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// SNX
|
||||||
|
"0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F": {
|
||||||
|
address: "0xDC3EA94CD0AC27d9A86C180091e7f78C683d3699",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// DOLA
|
||||||
|
"0x865377367054516e17014CcdED1e7d814EDC9ce4": {
|
||||||
|
address: "0xE33592594f72Cc7ec8a05788be8E8455746C3a32",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// FRXUSD
|
||||||
|
"0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29": {
|
||||||
|
address: "0x9B4a96210bc8D9D55b1908B465D8B0de68B7fF83",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// YFI
|
||||||
|
"0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e": {
|
||||||
|
address: "0xA027702dbb89fbd58938e4324ac03B58d812b0E1",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// RSR
|
||||||
|
"0x320623b8E4fF03373931769A31Fc52A4E78B5d70": {
|
||||||
|
address: "0x759bBC1be8F90eE6457C44abc7d443842a976d02",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// ZRX
|
||||||
|
"0xE41d2489571d322189246DaFA5ebDe1F4699F498": {
|
||||||
|
address: "0x2885d15b8Af22648b98B122b22FDF4D2a56c6023",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// FRAX, formerly FXS, priced by Chainlink's FXS / USD feed
|
||||||
|
"0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0": {
|
||||||
|
address: "0x6Ebc52C8C1089be9eB3945C4350B68B8E4C2233f",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// SXT
|
||||||
|
"0xE6Bfd33F52d82Ccb5b37E16D3dD81f9FFDAbB195": {
|
||||||
|
address: "0x2D27d9e1b74936D8E83c4BA118F09A4c4a897f62",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// SUSHI
|
||||||
|
"0x6B3595068778DD592e39A122f4f5a5cF09C90fE2": {
|
||||||
|
address: "0xCc70F09A6CC17553b2E31954cD36E4A2d89501f7",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// RPL
|
||||||
|
"0xD33526068D116cE69F19A9ee46F0bd304F21A51f": {
|
||||||
|
address: "0x4E155eD98aFE9034b7A5962f6C84c86d869daA9d",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// USDP
|
||||||
|
"0x8E870D67F660D95d5be530380D0eC0bd388289E1": {
|
||||||
|
address: "0x09023c0DA49Aaf8fc3fA3ADF34C6A7016D38D5e3",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// SPELL
|
||||||
|
"0x090185f2135308BaD17527004364eBcC2D37e5F6": {
|
||||||
|
address: "0x8c110B94C5f1d347fAcF5E1E938AB2db60E3c9a8",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// LUSD
|
||||||
|
"0x5f98805A4E8be255a32880FDeC7F6728C6568bA0": {
|
||||||
|
address: "0x3D7aE7E594f2f2091Ad8798313450130d0Aba3a0",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// STG
|
||||||
|
"0xAf5191B0De278C7286d6C7CC6ab6BB8A73bA2Cd6": {
|
||||||
|
address: "0x7A9f34a0Aa917D438e9b6E630067062B7F8f6f3d",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// KNC
|
||||||
|
"0xdeFA4e8a7bcBA345F687a2f1456F5Edd9CE97202": {
|
||||||
|
address: "0xf8fF43E991A81e6eC886a3D281A2C6cC19aE70Fc",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
// MUSD (the other MUSD in TOKENS has no feed)
|
||||||
|
"0xacA92E438df0B2401fF60dA7E4337B687a2435DA": {
|
||||||
|
address: "0xc90E3460424fb8ea79775089E9053113FEE34Ed0",
|
||||||
|
heartbeat: 86400,
|
||||||
|
decimals: 8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a map of token address (lowercased) -> its USD price feed.
|
||||||
|
const PRICE_FEED_BY_ADDRESS = new Map();
|
||||||
|
for (const [address, feed] of Object.entries(USD_PRICE_FEEDS)) {
|
||||||
|
PRICE_FEED_BY_ADDRESS.set(address.toLowerCase(), feed);
|
||||||
|
}
|
||||||
|
|
||||||
// Return the top N tokens from the list.
|
// Return the top N tokens from the list.
|
||||||
function getTopTokens(n) {
|
function getTopTokens(n) {
|
||||||
return TOKENS.slice(0, n);
|
return TOKENS.slice(0, n);
|
||||||
@@ -3608,6 +3953,15 @@ for (const t of TOKENS) {
|
|||||||
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
|
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
|
// Build a map of symbol (uppercased) -> the set of contract addresses
|
||||||
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
|
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
|
||||||
// "ETH" maps to null: the native asset has no contract, so no contract may
|
// "ETH" maps to null: the native asset has no contract, so no contract may
|
||||||
@@ -3624,7 +3978,8 @@ for (const t of TOKENS) {
|
|||||||
// hides from the balance list, the history and the send selector at its own
|
// 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
|
// 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
|
// 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();
|
const KNOWN_SYMBOLS = new Map();
|
||||||
KNOWN_SYMBOLS.set("ETH", null);
|
KNOWN_SYMBOLS.set("ETH", null);
|
||||||
for (const network of Object.values(NETWORKS)) {
|
for (const network of Object.values(NETWORKS)) {
|
||||||
@@ -3643,41 +3998,15 @@ for (const t of TOKENS) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch USD prices for the top N tokens from CoinDesk.
|
// Resolve a token symbol from multiple sources, never returning "?". The token
|
||||||
// Also always includes ETH. Returns { ETH: 1234.56, LINK: 8.60, ... }.
|
// is a contract on the network with chain id `chainId`.
|
||||||
// n must be <= 30 (API and sanity limit).
|
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens, chainId) {
|
||||||
async function getTopTokenPrices(n) {
|
|
||||||
if (n > 30) {
|
|
||||||
throw new Error("getTopTokenPrices: n must be <= 30, got " + n);
|
|
||||||
}
|
|
||||||
const symbols = ["ETH", ...TOKENS.slice(0, n).map((t) => t.symbol)];
|
|
||||||
const unique = [...new Set(symbols)];
|
|
||||||
const instruments = unique.map((s) => s + "-USD").join(",");
|
|
||||||
const url =
|
|
||||||
COINDESK_API +
|
|
||||||
"?market=cadli&instruments=" +
|
|
||||||
instruments +
|
|
||||||
"&apply_mapping=true";
|
|
||||||
const resp = await debugFetch(url);
|
|
||||||
const json = await resp.json();
|
|
||||||
const prices = {};
|
|
||||||
for (const [instrument, data] of Object.entries(json.Data || {})) {
|
|
||||||
const symbol = instrument.split("-")[0];
|
|
||||||
if (data.VALUE !== undefined) {
|
|
||||||
prices[symbol] = data.VALUE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return prices;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve a token symbol from multiple sources, never returning "?".
|
|
||||||
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
|
|
||||||
const lower = (tokenAddress || "").toLowerCase();
|
const lower = (tokenAddress || "").toLowerCase();
|
||||||
const tb = (tokenBalances || []).find(
|
const tb = (tokenBalances || []).find(
|
||||||
(t) => t.address.toLowerCase() === lower,
|
(t) => t.address.toLowerCase() === lower,
|
||||||
);
|
);
|
||||||
if (tb && tb.symbol) return tb.symbol;
|
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;
|
if (known && known.symbol) return known.symbol;
|
||||||
const tracked = (trackedTokens || []).find(
|
const tracked = (trackedTokens || []).find(
|
||||||
(t) => t.address.toLowerCase() === lower,
|
(t) => t.address.toLowerCase() === lower,
|
||||||
@@ -3690,7 +4019,10 @@ module.exports = {
|
|||||||
TOKENS,
|
TOKENS,
|
||||||
TOKEN_BY_ADDRESS,
|
TOKEN_BY_ADDRESS,
|
||||||
KNOWN_SYMBOLS,
|
KNOWN_SYMBOLS,
|
||||||
|
ETH_USD_FEED,
|
||||||
|
USD_PRICE_FEEDS,
|
||||||
|
PRICE_FEED_BY_ADDRESS,
|
||||||
getTopTokens,
|
getTopTokens,
|
||||||
getTopTokenPrices,
|
bundledToken,
|
||||||
resolveSymbol,
|
resolveSymbol,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
const { formatEther, formatUnits } = require("ethers");
|
const { formatEther, formatUnits } = require("ethers");
|
||||||
const { log, debugFetch } = require("./log");
|
const { log, debugFetch } = require("./log");
|
||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { bundledToken } = require("./tokenList");
|
||||||
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
||||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||||
const { nativeCurrencyByChainId } = require("./networks");
|
const { nativeCurrencyByChainId } = require("./networks");
|
||||||
@@ -47,7 +47,7 @@ function parseTx(tx, addrLower, chainId) {
|
|||||||
let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
|
let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
|
||||||
let directionLabel = direction === "sent" ? "Sent" : "Received";
|
let directionLabel = direction === "sent" ? "Sent" : "Received";
|
||||||
if (toIsContract && method && method !== "transfer") {
|
if (toIsContract && method && method !== "transfer") {
|
||||||
const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
|
const token = bundledToken(to, chainId);
|
||||||
if (token) {
|
if (token) {
|
||||||
symbol = token.symbol;
|
symbol = token.symbol;
|
||||||
}
|
}
|
||||||
@@ -317,7 +317,10 @@ function filterTransactions(txs, filters = {}) {
|
|||||||
const contract = normalizeAddress(tx.contractAddress);
|
const contract = normalizeAddress(tx.contractAddress);
|
||||||
|
|
||||||
// Filter spoofed known symbols and record the fraud contract
|
// 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)) {
|
if (contract && !fraudSet.has(contract)) {
|
||||||
fraudSet.add(contract);
|
fraudSet.add(contract);
|
||||||
newFraud.push(contract);
|
newFraud.push(contract);
|
||||||
|
|||||||
+372
-91
@@ -404,6 +404,11 @@ const OPEN_DELTA = Symbol("v4-open-delta");
|
|||||||
// that nothing is swapped.
|
// that nothing is swapped.
|
||||||
const ALREADY_PAID = Symbol("v2-already-paid");
|
const ALREADY_PAID = Symbol("v2-already-paid");
|
||||||
|
|
||||||
|
// A later WRAP_ETH of CONTRACT_BALANCE wraps all the ETH the router holds, and
|
||||||
|
// so all the ETH the transaction carries. The calldata states no quantity for
|
||||||
|
// it. The approval screen's Value line gives that ETH.
|
||||||
|
const TRANSACTION_ETH = Symbol("transaction-eth");
|
||||||
|
|
||||||
// The router's whole-balance amount, universal-router
|
// The router's whole-balance amount, universal-router
|
||||||
// `ActionConstants.CONTRACT_BALANCE`: a V2 exact-in given it as `amountIn`
|
// `ActionConstants.CONTRACT_BALANCE`: a V2 exact-in given it as `amountIn`
|
||||||
// pays the pair all the router holds of the input token.
|
// pays the pair all the router holds of the input token.
|
||||||
@@ -416,6 +421,7 @@ const CONTRACT_BALANCE = 2n ** 255n;
|
|||||||
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
|
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
|
||||||
const ALREADY_PAID_AMOUNT =
|
const ALREADY_PAID_AMOUNT =
|
||||||
"Whatever an earlier step sent to the pair (V2 already paid)";
|
"Whatever an earlier step sent to the pair (V2 already paid)";
|
||||||
|
const TRANSACTION_ETH_AMOUNT = "All the ETH the transaction carries";
|
||||||
const NO_MINIMUM = "None (no minimum guaranteed)";
|
const NO_MINIMUM = "None (no minimum guaranteed)";
|
||||||
|
|
||||||
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
|
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
|
||||||
@@ -452,8 +458,8 @@ const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
|
|||||||
// `decimals` is null when nothing knows this token's scale. It is not
|
// `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
|
// 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
|
// 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
|
// 0.000000000001. `sources` is as for resolveTokenDecimals(), which reads the
|
||||||
// on `state`; resolveTokenDecimals() reads the bundled list, then those.
|
// bundled list on Ethereum Mainnet, then the tracked tokens and the wallets.
|
||||||
//
|
//
|
||||||
// A null `address` means UNDETERMINED — the calldata named no currency for
|
// 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
|
// that side — and is refused rather than named. It is not native ETH: Uniswap
|
||||||
@@ -479,6 +485,17 @@ function tokenInfo(address, sources) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The token of a Permit2 allowance or of a PERMIT2_TRANSFER_FROM, in the
|
||||||
|
// shape tokenInfo() gives. Permit2 has no allowance of ETH and moves no ETH.
|
||||||
|
// The zero address there reads as a token nothing knows: its amount in base
|
||||||
|
// units, and the zero address as its token.
|
||||||
|
function permit2TokenInfo(address, sources) {
|
||||||
|
if (address === "0x0000000000000000000000000000000000000000") {
|
||||||
|
return { symbol: null, decimals: null, address };
|
||||||
|
}
|
||||||
|
return tokenInfo(address, sources);
|
||||||
|
}
|
||||||
|
|
||||||
// A swap amount line. With a scale it is the token quantity; with none it is
|
// A swap amount line. With a scale it is the token quantity; with none it is
|
||||||
// the base-unit integer with the unknown scale stated, the same refusal the
|
// the base-unit integer with the unknown scale stated, the same refusal the
|
||||||
// ERC-20 amount line makes, so the screen has one way of saying it. `display`
|
// ERC-20 amount line makes, so the screen has one way of saying it. `display`
|
||||||
@@ -497,16 +514,18 @@ function amountText(raw, info) {
|
|||||||
|
|
||||||
// The line giving a moved token, or the token of a permit, under `label`, in
|
// The line giving a moved token, or the token of a permit, under `label`, in
|
||||||
// the shape of the Token In line, so the screens show it as they show that
|
// the shape of the Token In line, so the screens show it as they show that
|
||||||
// one. ETH has no address.
|
// one. `info` is what tokenInfo() or permit2TokenInfo() gives for the token.
|
||||||
function tokenMovedLine(label, token, sources) {
|
// ETH has no address.
|
||||||
const info = tokenInfo(token, sources);
|
function tokenMovedLine(label, info) {
|
||||||
if (!present(info.address)) {
|
if (!present(info.address)) {
|
||||||
return { label, value: info.symbol };
|
return { label, value: info.symbol };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
label,
|
label,
|
||||||
value: info.symbol ? info.symbol + " (" + token + ")" : token,
|
value: info.symbol
|
||||||
address: token,
|
? info.symbol + " (" + info.address + ")"
|
||||||
|
: info.address,
|
||||||
|
address: info.address,
|
||||||
isToken: true,
|
isToken: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -975,6 +994,9 @@ function totalAmount(amounts) {
|
|||||||
// pays it of Token In as the step's amount in place of the rules' Amount, or
|
// 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.
|
// 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
|
// `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
|
// 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.
|
// a share of the currency, so it does not name the output side as TAKE does.
|
||||||
@@ -1150,6 +1172,8 @@ function decodeV4Swap(input) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const spent = swaps.map((s) => s.tokenIn);
|
||||||
|
|
||||||
// Rule 3.
|
// Rule 3.
|
||||||
if (swapUndecoded) {
|
if (swapUndecoded) {
|
||||||
return {
|
return {
|
||||||
@@ -1157,6 +1181,7 @@ function decodeV4Swap(input) {
|
|||||||
tokenOut: takeToken,
|
tokenOut: takeToken,
|
||||||
amountIn: null,
|
amountIn: null,
|
||||||
amountOutMin: null,
|
amountOutMin: null,
|
||||||
|
spent,
|
||||||
takes,
|
takes,
|
||||||
settledFromRouter,
|
settledFromRouter,
|
||||||
paidByUser,
|
paidByUser,
|
||||||
@@ -1167,7 +1192,6 @@ function decodeV4Swap(input) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const spent = swaps.map((s) => s.tokenIn);
|
|
||||||
const bought = swaps.map((s) => s.tokenOut);
|
const bought = swaps.map((s) => s.tokenOut);
|
||||||
|
|
||||||
// Rules 1 and 2 for Token In.
|
// Rules 1 and 2 for Token In.
|
||||||
@@ -1203,6 +1227,7 @@ function decodeV4Swap(input) {
|
|||||||
tokenOut,
|
tokenOut,
|
||||||
amountIn,
|
amountIn,
|
||||||
amountOutMin,
|
amountOutMin,
|
||||||
|
spent,
|
||||||
takes,
|
takes,
|
||||||
settledFromRouter,
|
settledFromRouter,
|
||||||
paidByUser,
|
paidByUser,
|
||||||
@@ -1227,6 +1252,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
const release = routerRelease(toAddress, chainId);
|
const release = routerRelease(toAddress, chainId);
|
||||||
if (release === null) return null;
|
if (release === null) return null;
|
||||||
const { commandTypeMask, commands, allowRevert } = release;
|
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 {
|
try {
|
||||||
const parsed = ROUTER_IFACE.parseTransaction({ data });
|
const parsed = ROUTER_IFACE.parseTransaction({ data });
|
||||||
if (!parsed) return null;
|
if (!parsed) return null;
|
||||||
@@ -1240,10 +1268,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
let inputEstablished = false;
|
let inputEstablished = false;
|
||||||
// Whether the user pays the step that set the input side: a V2 or V3
|
// Whether the user pays the step that set the input side: a V2 or V3
|
||||||
// swap step with `payerIsUser` true, a V4 step the user pays some of
|
// swap step with `payerIsUser` true, a V4 step the user pays some of
|
||||||
// its input currency (see decodeV4Swap()), or a WRAP_ETH, whose ETH
|
// its input currency (see paidByUser in the loop), or a WRAP_ETH,
|
||||||
// comes from the transaction's value. A step paid from the router
|
// whose ETH comes from the transaction's value. A step that pays an
|
||||||
// takes nothing from the sender.
|
// ERC-20 token from the router takes none of that token from the
|
||||||
|
// sender.
|
||||||
let inputPaidByUser = false;
|
let inputPaidByUser = false;
|
||||||
|
// The recipient of a WRAP_ETH that set the input side, as written;
|
||||||
|
// otherwise null. That WRAP_ETH pays its WETH there (see the Moved to
|
||||||
|
// lines).
|
||||||
|
let wrapEthRecipient = null;
|
||||||
// The last PERMIT2_PERMIT this decoder read before a step set the
|
// The last PERMIT2_PERMIT this decoder read before a step set the
|
||||||
// input side, or null (see permitStands after the loop).
|
// input side, or null (see permitStands after the loop).
|
||||||
let permit = null;
|
let permit = null;
|
||||||
@@ -1262,6 +1295,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
let minOutput = null;
|
let minOutput = null;
|
||||||
let outputEstablished = false;
|
let outputEstablished = false;
|
||||||
let hasUnwrapWeth = false;
|
let hasUnwrapWeth = false;
|
||||||
|
// Set once an UNWRAP_WETH pays ETH to the router, which a later
|
||||||
|
// WRAP_ETH of CONTRACT_BALANCE then wraps.
|
||||||
|
let unwrappedIntoRouter = false;
|
||||||
// Set when an UNWRAP_WETH pays out, as ETH, the output side's WETH,
|
// Set when an UNWRAP_WETH pays out, as ETH, the output side's WETH,
|
||||||
// which the router holds; cleared whenever the output side is set
|
// which the router holds; cleared whenever the output side is set
|
||||||
// again, or a later step may pay that ETH out as WETH (see after the
|
// again, or a later step may pay that ETH out as WETH (see after the
|
||||||
@@ -1293,8 +1329,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
const commandNames = [];
|
const commandNames = [];
|
||||||
|
|
||||||
// THE INVARIANT: an amount and the token it is counted in always come
|
// 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
|
// from the same hop, or from hops that all spend that token or all buy
|
||||||
// is never rendered against a token that did not supply it.
|
// 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
|
// 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
|
// never on truthiness. An address is never falsy once set but an
|
||||||
@@ -1338,18 +1375,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// What the steps take from the sender beyond the amount of the step
|
// What the steps take from the sender beyond the amount of the step
|
||||||
// that sets the input side, as { token, amount }, in step order.
|
// that sets the input side, as { token, amount }, in step order.
|
||||||
// After the loop each joins the Amount line or gets lines of its own
|
// After the loop each joins the Amount line or gets lines of its own
|
||||||
// (see permitStands).
|
// (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. A take with a `recipient` is the ETH of a
|
||||||
|
// later WRAP_ETH. If that recipient is not the router, the take never
|
||||||
|
// joins the Amount line either: its Moved to line names that recipient.
|
||||||
const userPaid = [];
|
const userPaid = [];
|
||||||
// A swap step the user pays for (`payerIsUser` true) takes `amount`
|
// A swap step the user pays for (`payerIsUser` true) takes `amount`
|
||||||
// of `token` from the sender. A V2 exact-in of ALREADY_PAID takes
|
// of `token` from the sender, or at most that when `atMost` is set. A
|
||||||
// nothing.
|
// V2 exact-in of ALREADY_PAID takes nothing.
|
||||||
const addUserPaid = (token, amount) => {
|
const addUserPaid = (token, amount, atMost = false) => {
|
||||||
if (amount !== ALREADY_PAID) userPaid.push({ token, amount });
|
if (amount !== ALREADY_PAID) {
|
||||||
|
userPaid.push({ token, amount, atMost });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// A step may take from the sender what this decoder did not read: a
|
// 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
|
// 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
|
// cannot read; a V2 or V3 swap step, PERMIT2_TRANSFER_FROM or WRAP_ETH
|
||||||
// input it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; an
|
// 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
|
// 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
|
// 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
|
// figure may then be too low, and the amount of a permit after it
|
||||||
@@ -1407,6 +1450,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
const r = recipient.toLowerCase();
|
const r = recipient.toLowerCase();
|
||||||
return r === ADDRESS_THIS || r === toAddress.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
|
// 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
|
// own balance. CONTRACT_BALANCE pays all of it. Any other quantity
|
||||||
// counts against the amount a WRAP_ETH stated, and the steps pay all
|
// counts against the amount a WRAP_ETH stated, and the steps pay all
|
||||||
@@ -1439,17 +1486,73 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// A step pays `recipient`. `output` is the token a swap step or a V4
|
// A step pays `recipient`. `output` is the token a swap step or a V4
|
||||||
// TAKE or TAKE_PORTION pays; other steps pass none.
|
// TAKE or TAKE_PORTION pays; other steps pass none.
|
||||||
const addPayee = (recipient, output) => {
|
const addPayee = (recipient, output) => {
|
||||||
const r = recipient.toLowerCase();
|
|
||||||
if (isRouter(recipient)) {
|
if (isRouter(recipient)) {
|
||||||
if (present(output)) {
|
if (present(output)) {
|
||||||
heldByRouter.set(output.toLowerCase(), null);
|
heldByRouter.set(output.toLowerCase(), null);
|
||||||
}
|
}
|
||||||
} else if (r === MSG_SENDER || r === sender) {
|
} else if (isSender(recipient)) {
|
||||||
paidToSender = true;
|
paidToSender = true;
|
||||||
} else {
|
} else {
|
||||||
addPaidElsewhere(recipient);
|
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,
|
// 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
|
// 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())
|
// paid, whose path starts at `token`, and whose pair (see v2PairFor())
|
||||||
@@ -1591,6 +1694,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (p) dropFromSum(p.token);
|
||||||
} else if (command === "BALANCE_CHECK_ERC20") {
|
} else if (command === "BALANCE_CHECK_ERC20") {
|
||||||
const b = decodeBalanceCheck(inputs[i]);
|
const b = decodeBalanceCheck(inputs[i]);
|
||||||
if (!b) {
|
if (!b) {
|
||||||
@@ -1613,13 +1717,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
} else {
|
} else {
|
||||||
// The router passes this check whenever the owner
|
// The router passes this check whenever the owner
|
||||||
// holds at least minBalance, so a zero one guarantees
|
// holds at least minBalance, so a zero one guarantees
|
||||||
// nothing and does not replace a minimum an earlier
|
// nothing. A check the router lets fail guarantees
|
||||||
// step stated. A check the router lets fail guarantees
|
// nothing either, so it is read as a zero one. A zero
|
||||||
// nothing either, so it is read as a zero one. Any
|
// check sets neither Token Out nor the minimum. Any
|
||||||
// other minBalance sets the output side as a swap
|
// other minBalance sets the output side as a swap
|
||||||
// does. It pays nobody.
|
// does. The check pays nobody.
|
||||||
const minBalance = allowedToFail ? 0n : b.minBalance;
|
const minBalance = allowedToFail ? 0n : b.minBalance;
|
||||||
if (!(minBalance === 0n && present(minOutput))) {
|
if (minBalance !== 0n) {
|
||||||
setOutput(i, b.token, minBalance, []);
|
setOutput(i, b.token, minBalance, []);
|
||||||
}
|
}
|
||||||
keepsRouterWeth = true;
|
keepsRouterWeth = true;
|
||||||
@@ -1633,10 +1737,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
} else if (s.payerIsUser) {
|
} else if (s.payerIsUser) {
|
||||||
addUserPaid(s.tokenIn, s.amountIn);
|
addUserPaid(s.tokenIn, s.amountIn);
|
||||||
}
|
}
|
||||||
|
dropFromSum(s.tokenIn);
|
||||||
// Always update output: in multi-step swaps (V3 → V4),
|
// Always update output: in multi-step swaps (V3 → V4),
|
||||||
// the last swap step determines the final output token
|
// the last swap step determines the final output
|
||||||
// and minimum received amount.
|
// token. swapMinimum() gives the minimum.
|
||||||
setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
|
setOutput(
|
||||||
|
i,
|
||||||
|
s.tokenOut,
|
||||||
|
swapMinimum(i, s.tokenOut, s.amountOutMin, [
|
||||||
|
s.recipient,
|
||||||
|
]),
|
||||||
|
[s.recipient],
|
||||||
|
);
|
||||||
if (!s.payerIsUser) {
|
if (!s.payerIsUser) {
|
||||||
spendFromRouter(s.tokenIn, s.amountIn);
|
spendFromRouter(s.tokenIn, s.amountIn);
|
||||||
}
|
}
|
||||||
@@ -1657,7 +1769,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
} else if (s.payerIsUser) {
|
} else if (s.payerIsUser) {
|
||||||
addUserPaid(s.tokenIn, s.amountIn);
|
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) {
|
if (!s.payerIsUser) {
|
||||||
spendFromRouter(s.tokenIn, s.amountIn);
|
spendFromRouter(s.tokenIn, s.amountIn);
|
||||||
}
|
}
|
||||||
@@ -1689,7 +1809,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
} else if (s.payerIsUser) {
|
} else if (s.payerIsUser) {
|
||||||
addUserPaid(s.tokenIn, s.amountInMax);
|
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)) {
|
if (!isHop(i, s.tokenOut, s.recipient)) {
|
||||||
addPayee(s.recipient, s.tokenOut);
|
addPayee(s.recipient, s.tokenOut);
|
||||||
}
|
}
|
||||||
@@ -1787,6 +1915,42 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
w.amount,
|
w.amount,
|
||||||
);
|
);
|
||||||
inputPaidByUser = true;
|
inputPaidByUser = true;
|
||||||
|
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.
|
||||||
|
userPaid.push({
|
||||||
|
token: "0x0000000000000000000000000000000000000000",
|
||||||
|
amount: w.amount,
|
||||||
|
atMost: heldByRouter.has(
|
||||||
|
"0x0000000000000000000000000000000000000000",
|
||||||
|
),
|
||||||
|
recipient: w.recipient,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
w.amount === CONTRACT_BALANCE &&
|
||||||
|
!isRouter(w.recipient) &&
|
||||||
|
!unwrappedIntoRouter &&
|
||||||
|
!heldByRouter.has(
|
||||||
|
"0x0000000000000000000000000000000000000000",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// It pays the recipient all the ETH the transaction
|
||||||
|
// carries. It gets no lines after an UNWRAP_WETH
|
||||||
|
// into the router, or while the router holds ETH
|
||||||
|
// from a V4 step: Uniswap's routes pay back what
|
||||||
|
// their swap did not spend this way, and carry no
|
||||||
|
// ETH.
|
||||||
|
userPaid.push({
|
||||||
|
token: "0x0000000000000000000000000000000000000000",
|
||||||
|
amount: TRANSACTION_ETH,
|
||||||
|
recipient: w.recipient,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
addPayee(w.recipient);
|
addPayee(w.recipient);
|
||||||
// CONTRACT_BALANCE wraps all the router's ETH.
|
// CONTRACT_BALANCE wraps all the router's ETH.
|
||||||
@@ -1811,10 +1975,46 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
} else {
|
} else {
|
||||||
payeeUnread = true;
|
payeeUnread = true;
|
||||||
stepsUnread.push(i);
|
stepsUnread.push(i);
|
||||||
|
spentUnread();
|
||||||
}
|
}
|
||||||
} else if (command === "V4_SWAP") {
|
} else if (command === "V4_SWAP") {
|
||||||
const v4 = decodeV4Swap(inputs[i]);
|
const v4 = decodeV4Swap(inputs[i]);
|
||||||
const inputSetBefore = inputEstablished;
|
const inputSetBefore = inputEstablished;
|
||||||
|
// V4Router pays a SETTLE of ETH from the router's own
|
||||||
|
// ETH, whichever payer the SETTLE names. The router holds
|
||||||
|
// the transaction's value, and any ETH an earlier step
|
||||||
|
// left it (see heldByRouter). So the user also pays a
|
||||||
|
// SETTLE of ETH that names the router as payer. The user
|
||||||
|
// pays all of its amount when the router holds no other
|
||||||
|
// ETH, and up to its amount otherwise. CONTRACT_BALANCE
|
||||||
|
// pays all the router holds, an amount the calldata does
|
||||||
|
// not state.
|
||||||
|
const routerHeldEth = heldByRouter.has(
|
||||||
|
"0x0000000000000000000000000000000000000000",
|
||||||
|
);
|
||||||
|
const ethFromRouter = (v4?.settledFromRouter ?? [])
|
||||||
|
.filter(
|
||||||
|
(s) =>
|
||||||
|
s.currency ===
|
||||||
|
"0x0000000000000000000000000000000000000000",
|
||||||
|
)
|
||||||
|
.map((s) => ({
|
||||||
|
currency: s.currency,
|
||||||
|
amount:
|
||||||
|
s.amount === CONTRACT_BALANCE ? null : s.amount,
|
||||||
|
}));
|
||||||
|
// What the user pays the step: what decodeV4Swap() says,
|
||||||
|
// and the ETH of each SETTLE from the router of a known
|
||||||
|
// amount when the router holds no other ETH.
|
||||||
|
const paidByUser = v4
|
||||||
|
? v4.paidByUser.concat(
|
||||||
|
routerHeldEth
|
||||||
|
? []
|
||||||
|
: ethFromRouter.filter((s) =>
|
||||||
|
present(s.amount),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
if (v4) {
|
if (v4) {
|
||||||
// Each SETTLE paid from the router spends the amount
|
// Each SETTLE paid from the router spends the amount
|
||||||
// decodeV4Swap() says it pays.
|
// decodeV4Swap() says it pays.
|
||||||
@@ -1826,6 +2026,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
addPayee(t.recipient, t.currency);
|
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) {
|
if (!v4 || v4.recipientUnread) {
|
||||||
payeeUnread = true;
|
payeeUnread = true;
|
||||||
@@ -1845,10 +2050,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
commandNames[i] += " (could not be decoded)";
|
commandNames[i] += " (could not be decoded)";
|
||||||
}
|
}
|
||||||
// The step's amount, if it sets the input side: what the
|
// The step's amount, if it sets the input side: what the
|
||||||
// user pays it of its input currency (see
|
// user pays it of its input currency, or, when the user
|
||||||
// decodeV4Swap()), or, when the user pays none of it,
|
// pays none of it, what its swap actions spend of it,
|
||||||
// what its swap actions spend of it, whoever pays that.
|
// whoever pays that.
|
||||||
const paidIn = v4?.paidByUser.find(
|
const paidIn = paidByUser.find(
|
||||||
(p) => p.currency === v4.tokenIn,
|
(p) => p.currency === v4.tokenIn,
|
||||||
);
|
);
|
||||||
const amountIn = paidIn ? paidIn.amount : v4?.amountIn;
|
const amountIn = paidIn ? paidIn.amount : v4?.amountIn;
|
||||||
@@ -1874,7 +2079,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
setInput(v4.tokenIn, amountIn);
|
setInput(v4.tokenIn, amountIn);
|
||||||
}
|
}
|
||||||
outputToken = v4.tokenOut;
|
outputToken = v4.tokenOut;
|
||||||
minOutput = v4.amountOutMin;
|
minOutput = swapMinimum(
|
||||||
|
i,
|
||||||
|
v4.tokenOut,
|
||||||
|
v4.amountOutMin,
|
||||||
|
takeRecipients,
|
||||||
|
);
|
||||||
outputEstablished = true;
|
outputEstablished = true;
|
||||||
outputUnwrapped = false;
|
outputUnwrapped = false;
|
||||||
unwrapIntoRouterStep = null;
|
unwrapIntoRouterStep = null;
|
||||||
@@ -1902,10 +2112,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// router still runs it.
|
// router still runs it.
|
||||||
const setInputHere = inputEstablished && !inputSetBefore;
|
const setInputHere = inputEstablished && !inputSetBefore;
|
||||||
if (setInputHere) inputPaidByUser = paidIn !== undefined;
|
if (setInputHere) inputPaidByUser = paidIn !== undefined;
|
||||||
if (v4) {
|
// ETH an earlier step left in the router may pay part of
|
||||||
for (const p of v4.paidByUser) {
|
// 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;
|
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
|
||||||
|
// the input side. Either this decoder does not know its
|
||||||
|
// amount, or ETH an earlier step left in the router may
|
||||||
|
// pay part of that amount (see userPaid).
|
||||||
|
for (const p of ethFromRouter) {
|
||||||
|
if (!present(p.amount)) {
|
||||||
|
addUserPaid(p.currency, null);
|
||||||
|
} else if (routerHeldEth) {
|
||||||
|
userPaid.push({
|
||||||
|
token: p.currency,
|
||||||
|
amount: p.amount,
|
||||||
|
atMost: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!v4 || v4.settleUnread) {
|
if (!v4 || v4.settleUnread) {
|
||||||
@@ -1959,6 +2192,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
heldByRouter.has(w),
|
heldByRouter.has(w),
|
||||||
);
|
);
|
||||||
WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w));
|
WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w));
|
||||||
|
if (isRouter(u.recipient)) {
|
||||||
|
unwrappedIntoRouter = true;
|
||||||
|
}
|
||||||
if (isRouter(u.recipient) && heldWeth) {
|
if (isRouter(u.recipient) && heldWeth) {
|
||||||
heldByRouter.set(
|
heldByRouter.set(
|
||||||
"0x0000000000000000000000000000000000000000",
|
"0x0000000000000000000000000000000000000000",
|
||||||
@@ -2013,10 +2249,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// The total is what the steps take of Token In: the amount of the
|
// The total is what the steps take of Token In: the amount of the
|
||||||
// step that set the input side, with each later take of that token
|
// step that set the input side, with each later take of that token
|
||||||
// added to it while both are quantities. A take that cannot be added,
|
// added to it while both are quantities. A take that cannot be added,
|
||||||
// being of another token or coming after an amount that is not a
|
// being of another token, coming after an amount that is not a
|
||||||
// quantity (a V4 open delta, ALREADY_PAID, or none stated), gets
|
// quantity (a V4 open delta, ALREADY_PAID, or none stated), or having
|
||||||
// lines of its own. One whose amount is not known also leaves the
|
// `atMost` or a `recipient` set, gets lines of its own. One whose
|
||||||
// total unknown, with no figure.
|
// 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
|
// 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
|
// Amount figure in place of the total, which it does only in the
|
||||||
@@ -2032,12 +2268,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// it may take more. A later permit or batch, or a later step this
|
// it may take more. A later permit or batch, or a later step this
|
||||||
// decoder did not read, may raise what Permit2 lets the router take.
|
// decoder did not read, may raise what Permit2 lets the router take.
|
||||||
// Every other PERMIT2_PERMIT leaves Token In and the Amount line as
|
// Every other PERMIT2_PERMIT leaves Token In and the Amount line as
|
||||||
// they read with no permit, and gets lines of its own below. A step
|
// they read with no permit, and gets lines of its own below. A permit
|
||||||
// that may take from the sender what this decoder did not read leaves
|
// on the zero address never stands as the Amount figure, because
|
||||||
// no figure, permit or not (see spentUnread()), and so do a permit
|
// Permit2 has no allowance of ETH. A step that may take from the
|
||||||
// or batch whose input it cannot read (see permitUnread()) and an
|
// sender what this decoder did not read leaves no figure, permit or
|
||||||
// allowance of Token In to another spender (see toAnotherSpender). An
|
// not (see spentUnread()), and so do a permit or batch whose input it
|
||||||
// EXECUTE_SUB_PLAN step leaves both sides unknown (below).
|
// cannot read (see permitUnread()) and an allowance of Token In to
|
||||||
|
// another spender (see toAnotherSpender). An EXECUTE_SUB_PLAN step
|
||||||
|
// leaves both sides unknown (below).
|
||||||
const stepCommands = Array.from(
|
const stepCommands = Array.from(
|
||||||
commandsBytes,
|
commandsBytes,
|
||||||
(b) => commands[b & commandTypeMask],
|
(b) => commands[b & commandTypeMask],
|
||||||
@@ -2047,24 +2285,27 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 &&
|
stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 &&
|
||||||
!stepCommands.includes("PERMIT2_PERMIT_BATCH") &&
|
!stepCommands.includes("PERMIT2_PERMIT_BATCH") &&
|
||||||
sameAddress(permit.spender, toAddress) &&
|
sameAddress(permit.spender, toAddress) &&
|
||||||
|
permit.token !== "0x0000000000000000000000000000000000000000" &&
|
||||||
!permit.allowedToFail &&
|
!permit.allowedToFail &&
|
||||||
permit.beforeEveryTake &&
|
permit.beforeEveryTake &&
|
||||||
!stepsUnread.some((step) => step > permit.step);
|
!stepsUnread.some((step) => step > permit.step);
|
||||||
// What the steps take that the Amount line does not give, as
|
// 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
|
// { token, amount, atMost, recipient }. A null token is a step this
|
||||||
// (see spentUnread()), or a token it does not know.
|
// 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 has
|
||||||
|
// a recipient (see wrapEthRecipient and userPaid).
|
||||||
const spentFromSender = [];
|
const spentFromSender = [];
|
||||||
let total = null;
|
let total = null;
|
||||||
// The takes from the sender that `total` adds up, each a quantity, as
|
// The takes from the sender that `total` adds up, each a quantity, in
|
||||||
// { token, amount }. When there is no Amount line, each gets lines of
|
// the shape of `spentFromSender`. When there is no Amount line, each
|
||||||
// its own (see below). The amount of a step that set the input side
|
// gets lines of its own (see below). The amount of the step that set
|
||||||
// and is paid from the router counts in `total` but is not among them.
|
// 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 = [];
|
const inTotal = [];
|
||||||
// Whether the step that set the input side counts as a take from the
|
// 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
|
// sender, which may get lines of its own. The amount it stated counts
|
||||||
// whoever pays it. Stating none, it counts only when the user pays it
|
// whoever pays it. Stating none, it counts only when the user pays it
|
||||||
// some of its input token: paid from the router, it takes nothing
|
// some of its input token.
|
||||||
// from the sender.
|
|
||||||
const hasFirstTake =
|
const hasFirstTake =
|
||||||
inputEstablished && (present(inputAmount) || inputPaidByUser);
|
inputEstablished && (present(inputAmount) || inputPaidByUser);
|
||||||
// The take of the step that set the input side, when the total
|
// The take of the step that set the input side, when the total
|
||||||
@@ -2073,7 +2314,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
if (!permitStands || sameAddress(inputToken, permit.token)) {
|
if (!permitStands || sameAddress(inputToken, permit.token)) {
|
||||||
total = inputAmount;
|
total = inputAmount;
|
||||||
if (typeof inputAmount === "bigint" && inputPaidByUser) {
|
if (typeof inputAmount === "bigint" && inputPaidByUser) {
|
||||||
inTotal.push({ token: inputToken, amount: inputAmount });
|
inTotal.push({
|
||||||
|
token: inputToken,
|
||||||
|
amount: inputAmount,
|
||||||
|
recipient: wrapEthRecipient,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (hasFirstTake && !present(inputAmount)) {
|
if (hasFirstTake && !present(inputAmount)) {
|
||||||
firstTakeUnstated = { token: inputToken, amount: null };
|
firstTakeUnstated = { token: inputToken, amount: null };
|
||||||
@@ -2081,7 +2326,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
} else if (hasFirstTake) {
|
} else if (hasFirstTake) {
|
||||||
// The step that set the input side spent a token other than the
|
// 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.
|
// 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;
|
if (permitStands) inputToken = permit.token;
|
||||||
for (const t of userPaid) {
|
for (const t of userPaid) {
|
||||||
@@ -2090,7 +2339,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
if (
|
if (
|
||||||
ofTokenIn &&
|
ofTokenIn &&
|
||||||
typeof total === "bigint" &&
|
typeof total === "bigint" &&
|
||||||
typeof t.amount === "bigint"
|
typeof t.amount === "bigint" &&
|
||||||
|
!t.atMost &&
|
||||||
|
(!present(t.recipient) || isRouter(t.recipient))
|
||||||
) {
|
) {
|
||||||
total += t.amount;
|
total += t.amount;
|
||||||
inTotal.push(t);
|
inTotal.push(t);
|
||||||
@@ -2139,11 +2390,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// Any other output keeps its own token and figure: a swap that pays
|
// 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
|
// 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.
|
// 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 =
|
const outInfo =
|
||||||
outputUnwrapped || (hasUnwrapWeth && !outputEstablished)
|
outputUnwrapped || (hasUnwrapWeth && !outputEstablished)
|
||||||
? { symbol: "ETH", decimals: 18, address: null }
|
? { symbol: "ETH", decimals: 18, address: null }
|
||||||
: tokenInfo(outputToken, sources);
|
: tokenInfo(outputToken, tokenSources);
|
||||||
|
|
||||||
const inSymbol = inInfo.symbol;
|
const inSymbol = inInfo.symbol;
|
||||||
const outSymbol = outInfo.symbol;
|
const outSymbol = outInfo.symbol;
|
||||||
@@ -2188,13 +2439,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The text of an amount the swap steps spend, for the Amount line and
|
// The text of an amount the swap steps spend, for the Amount line and
|
||||||
// the lines for other tokens below it. Three amounts need no scale to
|
// the lines for other tokens below it. Four amounts need no scale to
|
||||||
// describe and are named rather than formatted: V4's open delta and
|
// describe and are named rather than formatted: V4's open delta, V2's
|
||||||
// V2's already-paid zero, neither of which is a quantity at all (see
|
// already-paid zero and the ETH the transaction carries, none of which
|
||||||
// OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two
|
// is a quantity at all (see OPEN_DELTA, ALREADY_PAID and
|
||||||
// tests come first — the sentinels are not bigints and cannot be
|
// TRANSACTION_ETH), and an unbounded permit. Those three tests come
|
||||||
// compared with one.
|
// first — the sentinels are not bigints and cannot be compared with
|
||||||
const spentText = (amount, info) => {
|
// one. `atMost` marks an amount that is only the most a take may be
|
||||||
|
// (see userPaid).
|
||||||
|
const spentText = (amount, info, atMost) => {
|
||||||
if (amount === OPEN_DELTA) {
|
if (amount === OPEN_DELTA) {
|
||||||
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
||||||
}
|
}
|
||||||
@@ -2204,10 +2457,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
display: ALREADY_PAID_AMOUNT,
|
display: ALREADY_PAID_AMOUNT,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (amount === TRANSACTION_ETH) {
|
||||||
|
return {
|
||||||
|
raw: TRANSACTION_ETH_AMOUNT,
|
||||||
|
display: TRANSACTION_ETH_AMOUNT,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (amount >= MAX_UINT160) {
|
if (amount >= MAX_UINT160) {
|
||||||
return { raw: "Unlimited", display: "Unlimited" };
|
return { raw: "Unlimited", display: "Unlimited" };
|
||||||
}
|
}
|
||||||
if (hasV2OrV3ExactOut) {
|
if (hasV2OrV3ExactOut || atMost) {
|
||||||
// A V2 or V3 exact-out swap spends at most this figure,
|
// A V2 or V3 exact-out swap spends at most this figure,
|
||||||
// whichever steps gave it (an amountInMax, the WRAP_ETH of a
|
// whichever steps gave it (an amountInMax, the WRAP_ETH of a
|
||||||
// swap paid in ETH, a permit, or several steps' total), so it
|
// swap paid in ETH, a permit, or several steps' total), so it
|
||||||
@@ -2234,17 +2493,19 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// A null token in `spentFromSender`, a step this decoder did not read
|
// A null token in `spentFromSender`, a step this decoder did not read
|
||||||
// or a token it does not know, and an allowance to another spender
|
// or a token it does not know, and an allowance to another spender
|
||||||
// that may be of Token In, leave no Amount figure, which may then be
|
// that may be of Token In, leave no Amount figure, which may then be
|
||||||
// too low. With no Amount line, each take the total adds up gets
|
// too low. An allowance of the zero address is never one of Token In,
|
||||||
// lines of its own, first, so that every amount this decoder read is
|
// because Permit2 has no allowance of ETH. With no Amount line, each
|
||||||
// shown.
|
// take the total adds up gets lines of its own, first, so that every
|
||||||
|
// amount this decoder read is shown.
|
||||||
if (
|
if (
|
||||||
present(inputAmount) &&
|
present(inputAmount) &&
|
||||||
spentFromSender.every((s) => present(s.token)) &&
|
spentFromSender.every((s) => present(s.token)) &&
|
||||||
toAnotherSpender.every(
|
toAnotherSpender.every(
|
||||||
(p) =>
|
(p) =>
|
||||||
present(p.token) &&
|
p.token === "0x0000000000000000000000000000000000000000" ||
|
||||||
|
(present(p.token) &&
|
||||||
present(inputToken) &&
|
present(inputToken) &&
|
||||||
!sameAddress(p.token, inputToken),
|
!sameAddress(p.token, inputToken)),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
const amount = spentText(inputAmount, inInfo);
|
const amount = spentText(inputAmount, inInfo);
|
||||||
@@ -2260,32 +2521,55 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
// What steps take from the sender beyond the Amount line, in the shape
|
// 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
|
// 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
|
// 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:
|
||||||
|
// the router, the sender as `MSG_SENDER`, or an address.
|
||||||
for (const s of spentFromSender) {
|
for (const s of spentFromSender) {
|
||||||
details.push({
|
details.push({
|
||||||
label: "Moved from the sender",
|
label: "Moved from the sender",
|
||||||
value: present(s.amount)
|
value: present(s.amount)
|
||||||
? spentText(s.amount, tokenInfo(s.token, sources)).display
|
? spentText(
|
||||||
|
s.amount,
|
||||||
|
tokenInfo(s.token, tokenSources),
|
||||||
|
s.atMost,
|
||||||
|
).display
|
||||||
: UNDECODED_CURRENCY,
|
: UNDECODED_CURRENCY,
|
||||||
});
|
});
|
||||||
if (present(s.token)) {
|
if (present(s.token)) {
|
||||||
details.push(tokenMovedLine("Token moved", s.token, sources));
|
details.push(
|
||||||
|
tokenMovedLine(
|
||||||
|
"Token moved",
|
||||||
|
tokenInfo(s.token, tokenSources),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!present(s.recipient)) {
|
||||||
details.push({
|
details.push({
|
||||||
label: "Moved to",
|
label: "Moved to",
|
||||||
value: "The swap step that spends it",
|
value: "The swap step that spends it",
|
||||||
});
|
});
|
||||||
|
} else if (isRouter(s.recipient)) {
|
||||||
|
details.push({ label: "Moved to", value: "The router" });
|
||||||
|
} 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
|
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
|
||||||
// where it goes, an address on a line of its own.
|
// where it goes, an address on a line of its own.
|
||||||
for (const m of movedFromSender) {
|
for (const m of movedFromSender) {
|
||||||
|
const info = permit2TokenInfo(m.token, tokenSources);
|
||||||
details.push({
|
details.push({
|
||||||
label: "Moved from the sender",
|
label: "Moved from the sender",
|
||||||
value: amountText(m.amount, tokenInfo(m.token, sources))
|
value: amountText(m.amount, info).display,
|
||||||
.display,
|
|
||||||
});
|
});
|
||||||
details.push(tokenMovedLine("Token moved", m.token, sources));
|
details.push(tokenMovedLine("Token moved", info));
|
||||||
details.push(
|
details.push(
|
||||||
present(m.to)
|
present(m.to)
|
||||||
? { label: "Moved to", value: m.to }
|
? { label: "Moved to", value: m.to }
|
||||||
@@ -2315,15 +2599,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const info = permit2TokenInfo(p.token, tokenSources);
|
||||||
details.push({
|
details.push({
|
||||||
label: "Permit2 allowance",
|
label: "Permit2 allowance",
|
||||||
value:
|
value:
|
||||||
p.amount >= MAX_UINT160
|
p.amount >= MAX_UINT160
|
||||||
? "Unlimited"
|
? "Unlimited"
|
||||||
: amountText(p.amount, tokenInfo(p.token, sources))
|
: amountText(p.amount, info).display,
|
||||||
.display,
|
|
||||||
});
|
});
|
||||||
details.push(tokenMovedLine("Allowance token", p.token, sources));
|
details.push(tokenMovedLine("Allowance token", info));
|
||||||
details.push({
|
details.push({
|
||||||
label: "Allowance spender",
|
label: "Allowance spender",
|
||||||
value: p.spender,
|
value: p.spender,
|
||||||
@@ -2381,7 +2665,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
|
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
|
||||||
}
|
}
|
||||||
for (const m of movedIntoRouter) {
|
for (const m of movedIntoRouter) {
|
||||||
const info = tokenInfo(m.token, sources);
|
const info = permit2TokenInfo(m.token, tokenSources);
|
||||||
paidTo.push({
|
paidTo.push({
|
||||||
label: "Paid to",
|
label: "Paid to",
|
||||||
value:
|
value:
|
||||||
@@ -2389,9 +2673,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
amountText(m.amount, info).display +
|
amountText(m.amount, info).display +
|
||||||
" from the sender (later steps may not spend all of it)",
|
" from the sender (later steps may not spend all of it)",
|
||||||
});
|
});
|
||||||
paidTo.push(
|
paidTo.push(tokenMovedLine("Token moved into the router", info));
|
||||||
tokenMovedLine("Token moved into the router", m.token, sources),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (outputHeld) {
|
if (outputHeld) {
|
||||||
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
|
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
|
||||||
@@ -2408,8 +2690,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
paidTo.push(
|
paidTo.push(
|
||||||
tokenMovedLine(
|
tokenMovedLine(
|
||||||
"Token moved into the router",
|
"Token moved into the router",
|
||||||
getAddress(token),
|
tokenInfo(getAddress(token), tokenSources),
|
||||||
sources,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// A contract lookup on AddToken can answer after the user has left AddToken.
|
||||||
|
// The token is still added, and the screen the user is on does not change
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/603). An answer while AddToken
|
||||||
|
// still shows goes to AddressDetail. On either add-token screen, an answer for a
|
||||||
|
// token tracked while the lookup waited tracks nothing
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/727). A second click on a "Top
|
||||||
|
// tokens:" button tracks nothing
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/729). Driven through the real
|
||||||
|
// popup over a stored profile.
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
TOKEN_ADDRESS,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
|
||||||
|
const TOKEN = {
|
||||||
|
address: TOKEN_ADDRESS,
|
||||||
|
symbol: "TST",
|
||||||
|
decimals: 18,
|
||||||
|
name: "Test Token",
|
||||||
|
balanceNotFetched: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A second contract address, typed after AddToken was opened again.
|
||||||
|
const OTHER_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The popup reopened onto AddressDetail, then AddToken opened from it with
|
||||||
|
// TOKEN's contract address typed in.
|
||||||
|
async function openAddToken() {
|
||||||
|
const boot = await bootPopup(
|
||||||
|
unversionedValidProfile({
|
||||||
|
currentView: "address",
|
||||||
|
selectedWallet: 0,
|
||||||
|
selectedAddress: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
await boot.click("btn-add-token");
|
||||||
|
expect(boot.visibleViews()).toEqual(["add-token"]);
|
||||||
|
boot.node("add-token-address").value = TOKEN.address;
|
||||||
|
return boot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presses `button` with a contract lookup that answers only when the function
|
||||||
|
// returned here is called. That function waits for the add to finish.
|
||||||
|
function addWithPendingLookup(boot, button = "btn-add-token-confirm") {
|
||||||
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||||
|
let answer;
|
||||||
|
lookupTokenInfo.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const adding = boot.click(button);
|
||||||
|
return async () => {
|
||||||
|
answer({
|
||||||
|
symbol: TOKEN.symbol,
|
||||||
|
decimals: TOKEN.decimals,
|
||||||
|
name: TOKEN.name,
|
||||||
|
});
|
||||||
|
await adding;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an answer after the user opened Settings adds the token and Settings stays", async () => {
|
||||||
|
const boot = await openAddToken();
|
||||||
|
const answerLookup = addWithPendingLookup(boot);
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||||
|
|
||||||
|
await answerLookup();
|
||||||
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||||
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Back from Settings onto AddToken after the lookup answered shows no lookup status", async () => {
|
||||||
|
const boot = await openAddToken();
|
||||||
|
const answerLookup = addWithPendingLookup(boot);
|
||||||
|
expect(boot.text("add-token-info")).toBe("Looking up token...");
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await answerLookup();
|
||||||
|
|
||||||
|
await boot.click("btn-settings-back");
|
||||||
|
expect(boot.visibleViews()).toEqual(["add-token"]);
|
||||||
|
expect(boot.text("add-token-info")).toBe("");
|
||||||
|
expect(boot.node("add-token-info").style.visibility).toBe("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer after AddToken was opened again adds the token and leaves AddToken and its status line as they are", async () => {
|
||||||
|
const boot = await openAddToken();
|
||||||
|
const answerFirstLookup = addWithPendingLookup(boot);
|
||||||
|
await boot.click("btn-add-token-back");
|
||||||
|
await boot.click("btn-add-token");
|
||||||
|
expect(boot.visibleViews()).toEqual(["add-token"]);
|
||||||
|
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
|
||||||
|
// The second lookup is never answered.
|
||||||
|
addWithPendingLookup(boot);
|
||||||
|
|
||||||
|
await answerFirstLookup();
|
||||||
|
expect(boot.visibleViews()).toEqual(["add-token"]);
|
||||||
|
expect(boot.text("add-token-info")).toBe("Looking up token...");
|
||||||
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a lookup that fails after AddToken was opened again leaves the status line as it is", async () => {
|
||||||
|
const boot = await openAddToken();
|
||||||
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||||
|
let failFirstLookup;
|
||||||
|
lookupTokenInfo.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
failFirstLookup = reject;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const firstAdd = boot.click("btn-add-token-confirm");
|
||||||
|
await boot.click("btn-add-token-back");
|
||||||
|
await boot.click("btn-add-token");
|
||||||
|
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
|
||||||
|
// The second lookup is never answered.
|
||||||
|
addWithPendingLookup(boot);
|
||||||
|
|
||||||
|
failFirstLookup(new Error("lookup failed"));
|
||||||
|
await firstAdd;
|
||||||
|
expect(boot.text("add-token-info")).toBe("Looking up token...");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer while AddToken still shows goes to AddressDetail with the token listed", async () => {
|
||||||
|
const boot = await openAddToken();
|
||||||
|
const answerLookup = addWithPendingLookup(boot);
|
||||||
|
|
||||||
|
await answerLookup();
|
||||||
|
expect(boot.visibleViews()).toEqual(["address"]);
|
||||||
|
expect(boot.node("address-balances").innerHTML).toContain("TST");
|
||||||
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer for a token tracked after AddToken was opened again leaves the status line as it is", async () => {
|
||||||
|
const boot = await openAddToken();
|
||||||
|
const answerFirstLookup = addWithPendingLookup(boot);
|
||||||
|
await boot.click("btn-add-token-back");
|
||||||
|
await boot.click("btn-add-token");
|
||||||
|
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
|
||||||
|
// The second lookup is never answered.
|
||||||
|
addWithPendingLookup(boot);
|
||||||
|
require("../src/popup/views/helpers").trackToken(TOKEN);
|
||||||
|
|
||||||
|
await answerFirstLookup();
|
||||||
|
expect(boot.text("flash-msg")).toBe("TST is already being tracked.");
|
||||||
|
expect(boot.text("add-token-info")).toBe("Looking up token...");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Each screen is opened from AddressDetail by the buttons named, in order.
|
||||||
|
describe.each([
|
||||||
|
[
|
||||||
|
"AddToken",
|
||||||
|
["btn-add-token"],
|
||||||
|
"add-token-address",
|
||||||
|
"btn-add-token-confirm",
|
||||||
|
"add-token-info",
|
||||||
|
"TST is already being tracked.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"SettingsAddToken",
|
||||||
|
["btn-settings", "btn-settings-add-token"],
|
||||||
|
"settings-addtoken-address",
|
||||||
|
"btn-settings-addtoken-manual",
|
||||||
|
"settings-addtoken-info",
|
||||||
|
"Already tracked.",
|
||||||
|
],
|
||||||
|
])("%s", (_, opening, field, button, statusLine, alreadyTrackedFlash) => {
|
||||||
|
// The screen open with TOKEN's contract address typed in.
|
||||||
|
async function openScreen() {
|
||||||
|
const boot = await bootPopup(
|
||||||
|
unversionedValidProfile({
|
||||||
|
currentView: "address",
|
||||||
|
selectedWallet: 0,
|
||||||
|
selectedAddress: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
for (const id of opening) await boot.click(id);
|
||||||
|
boot.node(field).value = TOKEN.address;
|
||||||
|
return boot;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a second click on Add while the lookup waits tracks the token once", async () => {
|
||||||
|
const boot = await openScreen();
|
||||||
|
const answerFirstClick = addWithPendingLookup(boot, button);
|
||||||
|
const answerSecondClick = addWithPendingLookup(boot, button);
|
||||||
|
|
||||||
|
await answerFirstClick();
|
||||||
|
await answerSecondClick();
|
||||||
|
expect(boot.text("flash-msg")).toBe(alreadyTrackedFlash);
|
||||||
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
expect(state.trackedTokens).toEqual([TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer for a token tracked while the lookup waited clears the status line", async () => {
|
||||||
|
const boot = await openScreen();
|
||||||
|
const answerLookup = addWithPendingLookup(boot, button);
|
||||||
|
expect(boot.text(statusLine)).toBe("Looking up token...");
|
||||||
|
require("../src/popup/views/helpers").trackToken(TOKEN);
|
||||||
|
|
||||||
|
await answerLookup();
|
||||||
|
expect(boot.text("flash-msg")).toBe(alreadyTrackedFlash);
|
||||||
|
expect(boot.text(statusLine)).toBe("");
|
||||||
|
expect(boot.node(statusLine).style.visibility).toBe("hidden");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
expect(state.trackedTokens).toEqual([TOKEN]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a double click on a "Top tokens:" button tracks the token once', async () => {
|
||||||
|
const boot = await bootPopup(
|
||||||
|
unversionedValidProfile({
|
||||||
|
currentView: "address",
|
||||||
|
selectedWallet: 0,
|
||||||
|
selectedAddress: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
// The page stub does not turn the markup the screen draws into elements.
|
||||||
|
// So the list hands the screen a stand-in for TOKEN's button.
|
||||||
|
let press;
|
||||||
|
boot.node("settings-addtoken-top10").querySelectorAll = () => [
|
||||||
|
{
|
||||||
|
dataset: {
|
||||||
|
address: TOKEN.address,
|
||||||
|
symbol: TOKEN.symbol,
|
||||||
|
decimals: String(TOKEN.decimals),
|
||||||
|
name: TOKEN.name,
|
||||||
|
},
|
||||||
|
addEventListener: (name, fn) => {
|
||||||
|
press = fn;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await boot.click("btn-settings-add-token");
|
||||||
|
|
||||||
|
const firstClick = press();
|
||||||
|
const secondClick = press();
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
expect(state.trackedTokens).toEqual([TOKEN]);
|
||||||
|
// Read before the first click's save ends, when "Added TST" replaces it.
|
||||||
|
expect(boot.text("flash-msg")).toBe("Already tracked.");
|
||||||
|
|
||||||
|
await Promise.all([firstClick, secondClick]);
|
||||||
|
await boot.settle();
|
||||||
|
expect(state.trackedTokens).toEqual([TOKEN]);
|
||||||
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -161,7 +161,7 @@ function click(element) {
|
|||||||
|
|
||||||
// show() starts the fee estimate without awaiting it; this lets it settle.
|
// show() starts the fee estimate without awaiting it; this lets it settle.
|
||||||
async function settle() {
|
async function settle() {
|
||||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ harness
|
// ------------------------------------------------------------ harness
|
||||||
@@ -188,7 +188,11 @@ let sent;
|
|||||||
// handler to the buttons it drew, as the browser's fresh list would have.
|
// handler to the buttons it drew, as the browser's fresh list would have.
|
||||||
let plusButton;
|
let plusButton;
|
||||||
|
|
||||||
|
// showFlash() starts a timer that clears the message one or two seconds later.
|
||||||
|
// Each test here runs on fake timers and drops them in afterEach, so that timer
|
||||||
|
// never outlives the test.
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
approvalDetails = null;
|
approvalDetails = null;
|
||||||
sent = [];
|
sent = [];
|
||||||
mockNode.broadcast = [];
|
mockNode.broadcast = [];
|
||||||
@@ -239,6 +243,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ jest.doMock("../src/popup/views/helpers", () => ({
|
|||||||
goBack: () => {},
|
goBack: () => {},
|
||||||
clearViewStack: () => {},
|
clearViewStack: () => {},
|
||||||
onViewLeave: () => {},
|
onViewLeave: () => {},
|
||||||
|
markTrackedTokensNotFetched: () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// state.js reads chrome.storage.local at load, and saveState() writes it.
|
// state.js reads chrome.storage.local at load, and saveState() writes it.
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// "Back" from AddressToken leaves no token selected. AddressDetail's show()
|
||||||
|
// would clear it, but Back onto a drawn screen only unhides that screen, and
|
||||||
|
// Send and Receive both read the field
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/751). Driven through the real
|
||||||
|
// popup over a stored profile.
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
TOKEN_ADDRESS,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
|
||||||
|
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
|
||||||
|
const TOKEN = {
|
||||||
|
address: TOKEN_ADDRESS,
|
||||||
|
symbol: "TST",
|
||||||
|
decimals: 18,
|
||||||
|
name: "Test Token",
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The popup reopened onto AddressDetail, at an address holding 1.5 ETH and
|
||||||
|
// 2.5 TST.
|
||||||
|
function bootOnAddress() {
|
||||||
|
const profile = unversionedValidProfile({
|
||||||
|
currentView: "address",
|
||||||
|
viewStack: ["main"],
|
||||||
|
selectedWallet: 0,
|
||||||
|
selectedAddress: 0,
|
||||||
|
trackedTokens: [TOKEN],
|
||||||
|
});
|
||||||
|
profile.wallets[0].addresses[0].tokenBalances = [
|
||||||
|
{ ...TOKEN, balance: "2.5" },
|
||||||
|
];
|
||||||
|
return bootPopup(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddressToken opened from AddressDetail, as tapping the token's row does.
|
||||||
|
async function openToken(page) {
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
state.selectedToken = TOKEN_ADDRESS;
|
||||||
|
require("../src/popup/views/helpers").pushCurrentView();
|
||||||
|
require("../src/popup/views/addressToken").show();
|
||||||
|
await page.settle();
|
||||||
|
expect(page.visibleViews()).toEqual(["address-token"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backFromToken(page) {
|
||||||
|
await page.click("btn-address-token-back");
|
||||||
|
expect(page.visibleViews()).toEqual(["address"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ETH chosen in Send's dropdown, as the user does.
|
||||||
|
async function chooseEth(page) {
|
||||||
|
const dropdown = page.node("send-token");
|
||||||
|
dropdown.value = "ETH";
|
||||||
|
for (const fn of dropdown.listeners.change) await fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review for 1 of the token Send has, to RECIPIENT.
|
||||||
|
async function review(page) {
|
||||||
|
page.node("send-to").value = RECIPIENT;
|
||||||
|
page.node("send-amount").value = "1";
|
||||||
|
await page.click("btn-send-review");
|
||||||
|
expect(page.visibleViews()).toEqual(["confirm-tx"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("after Back from AddressToken", () => {
|
||||||
|
test("no token is selected, on the page or in the stored record", async () => {
|
||||||
|
const page = await bootOnAddress();
|
||||||
|
await openToken(page);
|
||||||
|
await backFromToken(page);
|
||||||
|
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
expect(state.selectedToken).toBeNull();
|
||||||
|
expect(page.storage.read("autistmask").selectedToken).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Send with ETH chosen shows the ETH balance", async () => {
|
||||||
|
const page = await bootOnAddress();
|
||||||
|
await openToken(page);
|
||||||
|
await backFromToken(page);
|
||||||
|
|
||||||
|
await page.click("btn-send");
|
||||||
|
await chooseEth(page);
|
||||||
|
|
||||||
|
expect(page.text("send-balance")).toBe("Current balance: 1.5000 ETH");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Send with ETH chosen reviews ETH", async () => {
|
||||||
|
const page = await bootOnAddress();
|
||||||
|
await openToken(page);
|
||||||
|
await backFromToken(page);
|
||||||
|
|
||||||
|
await page.click("btn-send");
|
||||||
|
await chooseEth(page);
|
||||||
|
await review(page);
|
||||||
|
|
||||||
|
expect(page.text("confirm-type")).toBe("Native ETH transfer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Receive shows no token warning", async () => {
|
||||||
|
const page = await bootOnAddress();
|
||||||
|
await openToken(page);
|
||||||
|
await backFromToken(page);
|
||||||
|
|
||||||
|
await page.click("btn-receive");
|
||||||
|
|
||||||
|
expect(page.visibleViews()).toEqual(["receive"]);
|
||||||
|
expect(page.text("receive-erc20-warning")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
// AddressToken's Send sets the dropdown to the token, and nothing else
|
||||||
|
// rebuilds it on the way back to AddressDetail.
|
||||||
|
test("Send opens on ETH after AddressToken's Send has been used", async () => {
|
||||||
|
const page = await bootOnAddress();
|
||||||
|
await openToken(page);
|
||||||
|
await page.click("btn-address-token-send");
|
||||||
|
await page.click("btn-send-back");
|
||||||
|
await backFromToken(page);
|
||||||
|
|
||||||
|
await page.click("btn-send");
|
||||||
|
|
||||||
|
expect(page.value("send-token")).toBe("ETH");
|
||||||
|
expect(page.text("send-balance")).toBe("Current balance: 1.5000 ETH");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Send opened from AddressToken sends that token", () => {
|
||||||
|
async function expectSendsToken(page) {
|
||||||
|
await page.click("btn-address-token-send");
|
||||||
|
expect(page.text("send-balance")).toBe("Current balance: 2.5000 TST");
|
||||||
|
await review(page);
|
||||||
|
expect(page.text("confirm-type")).toBe("ERC-20 token transfer (TST)");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the first time", async () => {
|
||||||
|
const page = await bootOnAddress();
|
||||||
|
await openToken(page);
|
||||||
|
|
||||||
|
await expectSendsToken(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("after Back from Send", async () => {
|
||||||
|
const page = await bootOnAddress();
|
||||||
|
await openToken(page);
|
||||||
|
await page.click("btn-address-token-send");
|
||||||
|
await page.click("btn-send-back");
|
||||||
|
|
||||||
|
await expectSendsToken(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("when the token is opened again after Back from AddressToken", async () => {
|
||||||
|
const page = await bootOnAddress();
|
||||||
|
await openToken(page);
|
||||||
|
await backFromToken(page);
|
||||||
|
await openToken(page);
|
||||||
|
|
||||||
|
await expectSendsToken(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// The USD total of an address that holds something this build cannot price
|
// The USD total of an address that holds something this build cannot price
|
||||||
// (issue #261).
|
// (issue #261).
|
||||||
//
|
//
|
||||||
// Prices exist for the top 25 tokens only, so an address can hold real assets
|
// Only a token with a price feed has a price, so an address can hold real
|
||||||
// with no price attached. Summing what is priced and printing the result as
|
// assets with no price attached. Summing what is priced and printing the result
|
||||||
// the total says "$0.00" for an address holding nothing but unpriced tokens —
|
// as the total says "$0.00" for an address holding nothing but unpriced tokens —
|
||||||
// worth-nothing and worth-an-unknown-amount collapsed into one number, in the
|
// worth-nothing and worth-an-unknown-amount collapsed into one number, in the
|
||||||
// direction that matters. The two are separate facts here, the same way an
|
// direction that matters. The two are separate facts here, the same way an
|
||||||
// absent holders_count is not a count of zero.
|
// absent holders_count is not a count of zero.
|
||||||
@@ -63,7 +63,7 @@ const PARTLY_PRICED = {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clearPrices();
|
clearPrices();
|
||||||
prices.ETH = 2000;
|
prices.ETH = 2000;
|
||||||
prices.USDC = 1;
|
prices[USDC] = 1;
|
||||||
state.wallets = [];
|
state.wallets = [];
|
||||||
state.trackedTokens = [];
|
state.trackedTokens = [];
|
||||||
state.showZeroBalanceTokens = false;
|
state.showZeroBalanceTokens = false;
|
||||||
@@ -125,6 +125,14 @@ describe("the value of an address, and whether it is the whole value", () => {
|
|||||||
expect(getAddressValue(addr)).toEqual({ usd: 0, partial: false });
|
expect(getAddressValue(addr)).toEqual({ usd: 0, partial: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// No total can include an ETH balance no refresh has read, and "unpriced
|
||||||
|
// tokens only" would be untrue of an address with nothing known at all.
|
||||||
|
test("an ETH balance no refresh has read means no value", () => {
|
||||||
|
const addr = { address: "0x1", balance: null, tokenBalances: [] };
|
||||||
|
expect(getAddressValue(addr)).toEqual({ usd: null, partial: false });
|
||||||
|
expect(formatAddressTotal(getAddressValue(addr))).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
// Before the first price fetch, and on testnet, nothing is knowable: that
|
// Before the first price fetch, and on testnet, nothing is knowable: that
|
||||||
// is a third state, and it stays distinct from both of the others.
|
// is a third state, and it stays distinct from both of the others.
|
||||||
test("no prices at all means no value, not an incomplete one", () => {
|
test("no prices at all means no value, not an incomplete one", () => {
|
||||||
|
|||||||
@@ -90,9 +90,11 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveTokenDecimals", () => {
|
describe("resolveTokenDecimals", () => {
|
||||||
|
// The bundled list is read only for a token on Ethereum Mainnet.
|
||||||
test("prefers the bundled list", () => {
|
test("prefers the bundled list", () => {
|
||||||
state.trackedTokens = [{ address: USDC, symbol: "USDC", decimals: 2 }];
|
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", () => {
|
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.
|
// answers 0 is used rather than fallen past like the unusable entry above.
|
||||||
test("uses a bundled scale of zero", () => {
|
test("uses a bundled scale of zero", () => {
|
||||||
state.trackedTokens = [{ address: SLP, symbol: "SLP", decimals: 18 }];
|
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", () => {
|
test("uses a tracked scale of zero", () => {
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// 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 break-words">Unknown token</div>',
|
||||||
|
);
|
||||||
|
expect(decodedLine("Amount")).toContain(
|
||||||
|
'<div class="font-bold break-words">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 break-words">USDC</div>',
|
||||||
|
);
|
||||||
|
expect(decodedLine("Amount")).toContain(
|
||||||
|
'<div class="font-bold break-words">1234.5000 USDC</div>',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
// The floor of the approval screen's amount line.
|
// The floor of the approval screen's amount line.
|
||||||
//
|
//
|
||||||
// Amounts are truncated to four decimal places for scannability (README.md,
|
// Amounts are truncated to four decimal places for scannability (README.md,
|
||||||
// Display Consistency). With the token's true scale resolved, that truncation
|
// Amounts). With the token's true scale resolved, that truncation can still
|
||||||
// can still take a real amount below the floor and print it as `0.0000`: one
|
// take a real amount below the floor and print it as `0.0000`: one base unit of
|
||||||
// base unit of an 18-decimal token, or a few hundred of an 8-decimal one. On
|
// an 18-decimal token, or a few hundred of an 8-decimal one. On the one screen
|
||||||
// the one screen whose job is to state what is being authorized, a nonzero
|
// whose job is to state what is being authorized, a nonzero transfer or
|
||||||
// transfer or allowance then reads as nothing.
|
// allowance then reads as nothing.
|
||||||
//
|
//
|
||||||
// The invariant asserted here is narrow: a nonzero amount never renders as
|
// The invariant asserted here is narrow: a nonzero amount never renders as
|
||||||
// zero. The four-decimal rule itself is unchanged, and the string the
|
// zero. The four-decimal rule itself is unchanged, and the string the
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ globalThis.chrome = {
|
|||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
const { Interface } = require("ethers");
|
const { Interface } = require("ethers");
|
||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
const { NETWORKS } = require("../src/shared/networks");
|
const { NETWORKS } = require("../src/shared/networks");
|
||||||
@@ -193,13 +195,13 @@ describe("a token tracked on Sepolia only", () => {
|
|||||||
test("is named on the Contract line and the decoded Token line, with its amount at its scale", async () => {
|
test("is named on the Contract line and the decoded Token line, with its amount at its scale", async () => {
|
||||||
await showTokenTransfer();
|
await showTokenTransfer();
|
||||||
expect(node("approve-tx-to").innerHTML).toContain(
|
expect(node("approve-tx-to").innerHTML).toContain(
|
||||||
'<div class="font-bold mb-1">TDOL</div>',
|
'<div class="font-bold mb-1 break-words">TDOL</div>',
|
||||||
);
|
);
|
||||||
expect(decodedLine("Token")).toContain(
|
expect(decodedLine("Token")).toContain(
|
||||||
'<div class="font-bold">TDOL</div>',
|
'<div class="font-bold break-words">TDOL</div>',
|
||||||
);
|
);
|
||||||
expect(decodedLine("Amount")).toContain(
|
expect(decodedLine("Amount")).toContain(
|
||||||
'<div class="font-bold">1234.5000 TDOL</div>',
|
'<div class="font-bold break-words">1234.5000 TDOL</div>',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,13 +222,51 @@ test("a token tracked and held on mainnet only is not named, and its amount is i
|
|||||||
]);
|
]);
|
||||||
expect(node("approve-tx-to").innerHTML).not.toContain("MDOL");
|
expect(node("approve-tx-to").innerHTML).not.toContain("MDOL");
|
||||||
expect(decodedLine("Token")).toContain(
|
expect(decodedLine("Token")).toContain(
|
||||||
'<div class="font-bold">Unknown token</div>',
|
'<div class="font-bold break-words">Unknown token</div>',
|
||||||
);
|
);
|
||||||
expect(decodedLine("Amount")).toContain(
|
expect(decodedLine("Amount")).toContain(
|
||||||
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
|
'<div class="font-bold break-words">1234500000 base units (decimals unknown)</div>',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nothing limits the length of a token's symbol. Without the break-words class
|
||||||
|
// a long one runs past the right edge of the screen and is cut off.
|
||||||
|
describe("a long token symbol", () => {
|
||||||
|
const LONG = "L".repeat(60);
|
||||||
|
|
||||||
|
test("wraps on every decoded line and on the Contract line", async () => {
|
||||||
|
state.networkTrackedTokens = {
|
||||||
|
sepolia: [{ address: TOKEN, symbol: LONG, decimals: 6 }],
|
||||||
|
};
|
||||||
|
await showTokenTransfer();
|
||||||
|
expect(node("approve-tx-to").innerHTML).toContain(
|
||||||
|
`<div class="font-bold mb-1 break-words">${LONG}</div>`,
|
||||||
|
);
|
||||||
|
expect(node("approve-tx-decoded-details").innerHTML).toContain(
|
||||||
|
`<div class="mb-2 break-words">Transfer ${LONG}</div>`,
|
||||||
|
);
|
||||||
|
expect(decodedLine("Token")).toContain(
|
||||||
|
`<div class="font-bold break-words">${LONG}</div>`,
|
||||||
|
);
|
||||||
|
expect(decodedLine("Amount")).toContain(
|
||||||
|
`<div class="font-bold break-words">1234.5000 ${LONG}</div>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wraps on the Action line, and a long amount wraps on the Value line", () => {
|
||||||
|
const html = fs.readFileSync(
|
||||||
|
path.join(__dirname, "..", "src", "popup", "index.html"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(html).toMatch(
|
||||||
|
/id="approve-tx-action"\s+class="text-xs font-bold mb-2 break-words"/,
|
||||||
|
);
|
||||||
|
expect(html).toMatch(
|
||||||
|
/id="approve-tx-value"\s+class="text-xs font-bold break-words"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// The From and Contract lines, and the decoded Token and Recipient lines.
|
// The From and Contract lines, and the decoded Token and Recipient lines.
|
||||||
test("every address links to the Sepolia explorer", async () => {
|
test("every address links to the Sepolia explorer", async () => {
|
||||||
await showTokenTransfer();
|
await showTokenTransfer();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const {
|
|||||||
cleanupPopup,
|
cleanupPopup,
|
||||||
settle,
|
settle,
|
||||||
unversionedValidProfile,
|
unversionedValidProfile,
|
||||||
|
ADDRESS,
|
||||||
} = require("./support/popupBoot");
|
} = require("./support/popupBoot");
|
||||||
|
|
||||||
afterEach(cleanupPopup);
|
afterEach(cleanupPopup);
|
||||||
@@ -174,3 +175,136 @@ test("a connection prompt in the toolbar popup leaves the browser window alone",
|
|||||||
expect(windows.updates).toEqual([]);
|
expect(windows.updates).toEqual([]);
|
||||||
expect(env.pageErrors).toEqual([]);
|
expect(env.pageErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Each row holds a transaction or signature approval as the background
|
||||||
|
// describes it, plus the element ids of the screen that shows it. `refusal` is
|
||||||
|
// the message the background refuses a Reject with.
|
||||||
|
const TX_AND_SIGN_APPROVALS = [
|
||||||
|
{
|
||||||
|
kind: "transaction",
|
||||||
|
view: "approve-tx",
|
||||||
|
reject: "btn-reject-tx",
|
||||||
|
error: "approve-tx-error",
|
||||||
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
|
refusal: "This transaction is already being sent.",
|
||||||
|
approval: {
|
||||||
|
type: "tx",
|
||||||
|
approvedFrom: ADDRESS,
|
||||||
|
approvedTx: {
|
||||||
|
chainId: 1,
|
||||||
|
to: ADDRESS,
|
||||||
|
value: "0",
|
||||||
|
data: "0x",
|
||||||
|
gasLimit: "21000",
|
||||||
|
maxFeePerGas: "1000000000",
|
||||||
|
maxPriorityFeePerGas: "1000000000",
|
||||||
|
nonce: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "signature",
|
||||||
|
view: "approve-sign",
|
||||||
|
reject: "btn-reject-sign",
|
||||||
|
error: "approve-sign-error",
|
||||||
|
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||||
|
refusal: "This request is already being signed.",
|
||||||
|
approval: {
|
||||||
|
type: "sign",
|
||||||
|
approvedFrom: ADDRESS,
|
||||||
|
signParams: {
|
||||||
|
method: "personal_sign",
|
||||||
|
message: "0x48656c6c6f",
|
||||||
|
from: ADDRESS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// A closed transaction or signature window leaves its request pending
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/673). So Reject closes the
|
||||||
|
// window only once the background has the rejection. A message sent as the
|
||||||
|
// window closes can be lost, and the request would stay pending although the
|
||||||
|
// user rejected it.
|
||||||
|
test.each(TX_AND_SIGN_APPROVALS)(
|
||||||
|
"Reject on a $kind approval closes the window only once the background has answered",
|
||||||
|
async ({ view, reject, type, approval }) => {
|
||||||
|
let describeApproval = null;
|
||||||
|
let answerReject = null;
|
||||||
|
const sent = [];
|
||||||
|
const env = await bootPopup(unversionedValidProfile(), {
|
||||||
|
search: "?approval=approval-1",
|
||||||
|
runtime: {
|
||||||
|
connect: () => ({ postMessage: () => {} }),
|
||||||
|
sendMessage: (msg, reply) => {
|
||||||
|
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
||||||
|
describeApproval = reply;
|
||||||
|
}
|
||||||
|
if (msg.type === type) {
|
||||||
|
sent.push(msg);
|
||||||
|
answerReject = reply;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
describeApproval({
|
||||||
|
...approval,
|
||||||
|
origin: "https://dapp.example",
|
||||||
|
isPhishingDomain: false,
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
expect(env.visibleViews()).toEqual([view]);
|
||||||
|
const close = jest.fn();
|
||||||
|
globalThis.window.close = close;
|
||||||
|
|
||||||
|
const clicked = env.click(reject);
|
||||||
|
await settle();
|
||||||
|
expect(sent).toEqual([{ type, id: "approval-1", approved: false }]);
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
answerReject(null);
|
||||||
|
await clicked;
|
||||||
|
expect(close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(env.pageErrors).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// After Confirm or Sign, the background refuses a Reject while the transaction
|
||||||
|
// is being sent or the signature checked. The request is not rejected then, so
|
||||||
|
// the window stays open and shows the background's message.
|
||||||
|
test.each(TX_AND_SIGN_APPROVALS)(
|
||||||
|
"Reject on a $kind approval that the background refuses shows the background's message and keeps the window open",
|
||||||
|
async ({ view, reject, error, type, refusal, approval }) => {
|
||||||
|
let describeApproval = null;
|
||||||
|
let answerReject = null;
|
||||||
|
const env = await bootPopup(unversionedValidProfile(), {
|
||||||
|
search: "?approval=approval-1",
|
||||||
|
runtime: {
|
||||||
|
connect: () => ({ postMessage: () => {} }),
|
||||||
|
sendMessage: (msg, reply) => {
|
||||||
|
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
||||||
|
describeApproval = reply;
|
||||||
|
}
|
||||||
|
if (msg.type === type) answerReject = reply;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
describeApproval({
|
||||||
|
...approval,
|
||||||
|
origin: "https://dapp.example",
|
||||||
|
isPhishingDomain: false,
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
const close = jest.fn();
|
||||||
|
globalThis.window.close = close;
|
||||||
|
|
||||||
|
const clicked = env.click(reject);
|
||||||
|
await settle();
|
||||||
|
answerReject({ error: refusal, retryable: false });
|
||||||
|
await clicked;
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
expect(env.visibleViews()).toEqual([view]);
|
||||||
|
expect(env.text(error)).toBe(refusal);
|
||||||
|
expect(env.pageErrors).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -241,10 +241,11 @@ function loadBackground(options) {
|
|||||||
let connectListener = null;
|
let connectListener = null;
|
||||||
const created = [];
|
const created = [];
|
||||||
const removed = [];
|
const removed = [];
|
||||||
// Every URL the background put on the browser action. A site approval
|
// Every URL the background put on the browser action. When a test gives
|
||||||
// raised through action.openPopup() opens no window at all, so this is
|
// the URL the action holds before the worker starts, that URL comes
|
||||||
// the only place its id appears.
|
// first. A site approval raised through action.openPopup() opens no
|
||||||
const actionPopups = [];
|
// window at all, so this is the only place its id appears.
|
||||||
|
const actionPopups = opts.toolbarPopup ? [opts.toolbarPopup] : [];
|
||||||
const openPopup = jest.fn(() => Promise.resolve());
|
const openPopup = jest.fn(() => Promise.resolve());
|
||||||
|
|
||||||
global.chrome = {
|
global.chrome = {
|
||||||
@@ -536,7 +537,18 @@ async function settleIncludingRejections() {
|
|||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// While an approval is pending, the background starts a 20-second timer to
|
||||||
|
// keep itself running. The timer is stubbed and never starts, so no test in
|
||||||
|
// this file leaves it running.
|
||||||
|
let keepRunningTimer;
|
||||||
|
beforeEach(() => {
|
||||||
|
keepRunningTimer = jest
|
||||||
|
.spyOn(global, "setInterval")
|
||||||
|
.mockImplementation(() => 1);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
keepRunningTimer.mockRestore();
|
||||||
delete global.chrome;
|
delete global.chrome;
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
});
|
});
|
||||||
@@ -774,10 +786,9 @@ describe("one transaction approval at a time", () => {
|
|||||||
|
|
||||||
// The user closes an approval window that looks hung while the attempt
|
// The user closes an approval window that looks hung while the attempt
|
||||||
// behind it is still running, and that attempt then fails in a way that
|
// behind it is still running, and that attempt then fails in a way that
|
||||||
// would normally leave the approval standing for a retry. There is no
|
// leaves the approval standing for a retry. The toolbar popup opens it, so
|
||||||
// window left to retry in, so leaving it standing answers the requesting
|
// the user can still retry it, and answering it frees the slot.
|
||||||
// page never — and holds the slot for the life of the worker with it.
|
test("an approval whose window closed under a failed attempt is retried from the toolbar popup, and frees the next request", async () => {
|
||||||
test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
|
|
||||||
const stalled = deferred();
|
const stalled = deferred();
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
|
|
||||||
@@ -808,11 +819,28 @@ describe("one transaction approval at a time", () => {
|
|||||||
|
|
||||||
stalled.resolve();
|
stalled.resolve();
|
||||||
await settle();
|
await settle();
|
||||||
expect(first.result()).toEqual({
|
expect(first.result()).toBeNull();
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
||||||
});
|
"src/popup/index.html?approval=" + first.id(),
|
||||||
|
);
|
||||||
|
|
||||||
bg.setStateReadHook(null);
|
bg.setStateReadHook(null);
|
||||||
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
||||||
|
bg.send(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
|
id: first.id(),
|
||||||
|
approved: true,
|
||||||
|
rawSignedTx: await signedAtNonce(NONCE),
|
||||||
|
},
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(first.result()).toEqual({ result: "0xfeed" });
|
||||||
|
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
||||||
|
"src/popup/index.html",
|
||||||
|
);
|
||||||
|
|
||||||
const second = bg.requestTx();
|
const second = bg.requestTx();
|
||||||
await settle();
|
await settle();
|
||||||
expect(second.result()).toBeNull();
|
expect(second.result()).toBeNull();
|
||||||
@@ -853,8 +881,10 @@ describe("one transaction approval at a time", () => {
|
|||||||
const first = bg.requestTx();
|
const first = bg.requestTx();
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
// The user closes the approval window, which rejects it.
|
bg.send(
|
||||||
bg.closeWindow(1);
|
{ type: "AUTISTMASK_TX_RESPONSE", id: first.id(), approved: false },
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
await settle();
|
await settle();
|
||||||
expect(first.result()).toEqual({
|
expect(first.result()).toEqual({
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
@@ -897,7 +927,7 @@ describe("the worker keeps running while an approval is pending", () => {
|
|||||||
try {
|
try {
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
const calls = global.chrome.runtime.getPlatformInfo;
|
const calls = global.chrome.runtime.getPlatformInfo;
|
||||||
bg.requestTx();
|
const pending = bg.requestTx();
|
||||||
await settle();
|
await settle();
|
||||||
expect(startTimer).toHaveBeenCalledTimes(1);
|
expect(startTimer).toHaveBeenCalledTimes(1);
|
||||||
expect(startTimer.mock.calls[0][1]).toBe(20000);
|
expect(startTimer.mock.calls[0][1]).toBe(20000);
|
||||||
@@ -906,11 +936,23 @@ describe("the worker keeps running while an approval is pending", () => {
|
|||||||
tick();
|
tick();
|
||||||
expect(calls).toHaveBeenCalledTimes(1);
|
expect(calls).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// The user closes the approval window, which rejects it.
|
// A closed window leaves the approval pending, so the calls go on.
|
||||||
bg.closeWindow(1);
|
bg.closeWindow(1);
|
||||||
await settle();
|
await settle();
|
||||||
tick();
|
tick();
|
||||||
expect(calls).toHaveBeenCalledTimes(1);
|
expect(calls).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
bg.send(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
|
id: pending.id(),
|
||||||
|
approved: false,
|
||||||
|
},
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
tick();
|
||||||
|
expect(calls).toHaveBeenCalledTimes(2);
|
||||||
expect(stopTimer).toHaveBeenCalledWith(1);
|
expect(stopTimer).toHaveBeenCalledWith(1);
|
||||||
} finally {
|
} finally {
|
||||||
startTimer.mockRestore();
|
startTimer.mockRestore();
|
||||||
@@ -1841,6 +1883,225 @@ describe("the interlock releases a failed attempt", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A transaction or signature approval stays pending when its window closes.
|
||||||
|
// The browser can close that window when the user clicks away from it, for
|
||||||
|
// example to copy the password from a password manager. The toolbar popup then
|
||||||
|
// opens the approval, and the user answers it there
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/673).
|
||||||
|
describe("a closed approval window leaves the request pending", () => {
|
||||||
|
// The URL the toolbar popup opens now.
|
||||||
|
function toolbarPopup(bg) {
|
||||||
|
return bg.actionPopups[bg.actionPopups.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the toolbar popup opens a closed transaction approval, and approving it there sends", async () => {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestTx();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
bg.closeWindow(1);
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toBeNull();
|
||||||
|
expect(toolbarPopup(bg)).toBe(
|
||||||
|
"src/popup/index.html?approval=" + pending.id(),
|
||||||
|
);
|
||||||
|
|
||||||
|
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
||||||
|
bg.send(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
|
id: pending.id(),
|
||||||
|
approved: true,
|
||||||
|
rawSignedTx: await signedAtNonce(NONCE),
|
||||||
|
},
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||||
|
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Reject in the toolbar popup answers 4001, and the toolbar popup opens the wallet again", async () => {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestSign();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
bg.closeWindow(1);
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toBeNull();
|
||||||
|
expect(toolbarPopup(bg)).toBe(
|
||||||
|
"src/popup/index.html?approval=" + pending.id(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const reject = bg.send(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||||
|
id: pending.id(),
|
||||||
|
approved: false,
|
||||||
|
},
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toEqual({
|
||||||
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
|
});
|
||||||
|
// The popup closes its window only once this answer arrives.
|
||||||
|
expect(reject.sendResponse).toHaveBeenCalledWith(null);
|
||||||
|
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with two closed, the toolbar popup opens the older one first, then the other", async () => {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const sign = bg.requestSign();
|
||||||
|
await settle();
|
||||||
|
const signId = sign.id();
|
||||||
|
const tx = bg.requestTx();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// The signature request is in window 1 and the transaction in window
|
||||||
|
// 2. The newer window is closed first.
|
||||||
|
bg.closeWindow(2);
|
||||||
|
bg.closeWindow(1);
|
||||||
|
await settle();
|
||||||
|
expect(toolbarPopup(bg)).toBe(
|
||||||
|
"src/popup/index.html?approval=" + signId,
|
||||||
|
);
|
||||||
|
|
||||||
|
bg.send(
|
||||||
|
{ type: "AUTISTMASK_SIGN_RESPONSE", id: signId, approved: false },
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(toolbarPopup(bg)).toBe(
|
||||||
|
"src/popup/index.html?approval=" + tx.id(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const reject = bg.send(
|
||||||
|
{ type: "AUTISTMASK_TX_RESPONSE", id: tx.id(), approved: false },
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(tx.result()).toEqual({
|
||||||
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
|
});
|
||||||
|
expect(reject.sendResponse).toHaveBeenCalledWith(null);
|
||||||
|
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The user can close the window while the attempt after Confirm runs. That
|
||||||
|
// attempt answers the request, so meanwhile the toolbar popup opens the
|
||||||
|
// wallet. Opened from the toolbar, the approval would look unanswered.
|
||||||
|
test("when the window closes during an attempt that sends, the toolbar popup keeps opening the wallet", async () => {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestTx();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const inFlight = deferred();
|
||||||
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||||
|
bg.send(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
|
id: pending.id(),
|
||||||
|
approved: true,
|
||||||
|
rawSignedTx: await signedAtNonce(NONCE),
|
||||||
|
},
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
bg.closeWindow(1);
|
||||||
|
await settle();
|
||||||
|
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
||||||
|
|
||||||
|
inFlight.resolve({ hash: "0xfeed" });
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||||
|
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("when the window closes during an attempt that fails retryably, the toolbar popup opens the wallet until the attempt ends, then the approval", async () => {
|
||||||
|
const stalled = deferred();
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestTx();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
bg.setStateReadHook(async () => {
|
||||||
|
await stalled.promise;
|
||||||
|
throw new Error("The wallet data could not be read.");
|
||||||
|
});
|
||||||
|
bg.send(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
|
id: pending.id(),
|
||||||
|
approved: true,
|
||||||
|
rawSignedTx: await signedAtNonce(NONCE),
|
||||||
|
},
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
bg.closeWindow(1);
|
||||||
|
await settle();
|
||||||
|
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
||||||
|
|
||||||
|
stalled.resolve();
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toBeNull();
|
||||||
|
expect(toolbarPopup(bg)).toBe(
|
||||||
|
"src/popup/index.html?approval=" + pending.id(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A retry from the toolbar popup holds the approval again, so the
|
||||||
|
// toolbar popup opens the wallet while that retry runs.
|
||||||
|
bg.setStateReadHook(null);
|
||||||
|
const inFlight = deferred();
|
||||||
|
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
||||||
|
bg.send(
|
||||||
|
{
|
||||||
|
type: "AUTISTMASK_TX_RESPONSE",
|
||||||
|
id: pending.id(),
|
||||||
|
approved: true,
|
||||||
|
rawSignedTx: await signedAtNonce(NONCE),
|
||||||
|
},
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
||||||
|
|
||||||
|
inFlight.resolve({ hash: "0xfeed" });
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toEqual({ result: "0xfeed" });
|
||||||
|
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an address switch still rejects a closed approval", async () => {
|
||||||
|
const bg = loadBackground();
|
||||||
|
const pending = bg.requestTx();
|
||||||
|
await settle();
|
||||||
|
bg.closeWindow(1);
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toBeNull();
|
||||||
|
|
||||||
|
bg.send(
|
||||||
|
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
||||||
|
{ url: bg.fromPopup.url },
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(pending.result()).toEqual({
|
||||||
|
error: { code: 4001, message: "User rejected the request." },
|
||||||
|
});
|
||||||
|
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The worker can stop while an approval waits with its window closed. The
|
||||||
|
// approval is lost with the worker, but the toolbar popup keeps its URL.
|
||||||
|
// The next worker points the toolbar popup back at the wallet.
|
||||||
|
test("a worker that starts points the toolbar popup back at the wallet", () => {
|
||||||
|
const bg = loadBackground({
|
||||||
|
toolbarPopup: "src/popup/index.html?approval=lost-approval",
|
||||||
|
});
|
||||||
|
expect(toolbarPopup(bg)).toBe("src/popup/index.html");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// The claim is what makes one approval one broadcast, so it has to hold
|
// The claim is what makes one approval one broadcast, so it has to hold
|
||||||
// against everything else that retires an approval, not just against a second
|
// against everything else that retires an approval, not just against a second
|
||||||
// AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting
|
// AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting
|
||||||
@@ -1961,20 +2222,6 @@ describe("a claimed approval outlives every other retirement path", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The refusals above must not cost the rejection its ordinary meaning.
|
// The refusals above must not cost the rejection its ordinary meaning.
|
||||||
test("with no attempt running, closing the window still rejects", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const pending = bg.requestTx();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
bg.closeWindow(1);
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(pending.result()).toEqual({
|
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
|
||||||
});
|
|
||||||
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("with no attempt running, an active-address switch still rejects and closes", async () => {
|
test("with no attempt running, an active-address switch still rejects and closes", async () => {
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
const pending = bg.requestTx();
|
const pending = bg.requestTx();
|
||||||
@@ -2584,15 +2831,12 @@ describe("a site connection decided as the popup closes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The `isSite &&` half of that skip, which is what keeps it from reaching
|
// That skip must not reach a tx or sign approval. The popup connects its
|
||||||
// a tx or sign approval. The popup connects its port in show() before it
|
// port in show() before it knows the approval's type, and the background
|
||||||
// knows the approval's type, and the background sets portConnected without
|
// sets portConnected without looking at the type either, so a tx approval
|
||||||
// looking at the type either, so a tx approval in the fallback window
|
// carries the flag too. If the window event skipped it, windowClosed would
|
||||||
// carries the flag too. Without the conjunct the window event would skip
|
// never be set, and the toolbar popup would never open the approval.
|
||||||
// it, windowClosed would never be set, releaseApproval() would never settle
|
test("a tx window closed with the port connected is still opened by the toolbar popup", async () => {
|
||||||
// it, and the page would hang — the #271 regression this guard is written
|
|
||||||
// around.
|
|
||||||
test("a tx window closed with the port connected still rejects", async () => {
|
|
||||||
const bg = loadBackground();
|
const bg = loadBackground();
|
||||||
const pending = bg.requestTx();
|
const pending = bg.requestTx();
|
||||||
await settle();
|
await settle();
|
||||||
@@ -2600,9 +2844,10 @@ describe("a site connection decided as the popup closes", () => {
|
|||||||
await settle();
|
await settle();
|
||||||
bg.closeWindow(1);
|
bg.closeWindow(1);
|
||||||
await settle();
|
await settle();
|
||||||
expect(pending.result()).toEqual({
|
expect(pending.result()).toBeNull();
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
||||||
});
|
"src/popup/index.html?approval=" + pending.id(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2994,13 +3239,14 @@ describe("where an approval window opens", () => {
|
|||||||
expect(bg.created[1].left).toBeUndefined();
|
expect(bg.created[1].left).toBeUndefined();
|
||||||
expect(bg.created[1].top).toBeUndefined();
|
expect(bg.created[1].top).toBeUndefined();
|
||||||
|
|
||||||
// The request waits on the second window rather than failing:
|
// The request waits on the second window rather than failing. Once
|
||||||
// closing that window is refusing the prompt.
|
// that window closes, the toolbar popup opens the request.
|
||||||
expect(sign.result()).toBeNull();
|
expect(sign.result()).toBeNull();
|
||||||
bg.closeWindow(2);
|
bg.closeWindow(2);
|
||||||
await settle();
|
await settle();
|
||||||
expect(sign.result()).toEqual({
|
expect(sign.result()).toBeNull();
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
||||||
});
|
"src/popup/index.html?approval=" + sign.id(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,7 +103,18 @@ function deferred() {
|
|||||||
return { promise, resolve };
|
return { promise, resolve };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// While an approval is pending, the background starts a 20-second timer to
|
||||||
|
// keep itself running. The timer is stubbed and never starts, so no test in
|
||||||
|
// this file leaves it running.
|
||||||
|
let keepRunningTimer;
|
||||||
|
beforeEach(() => {
|
||||||
|
keepRunningTimer = jest
|
||||||
|
.spyOn(global, "setInterval")
|
||||||
|
.mockImplementation(() => 1);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
keepRunningTimer.mockRestore();
|
||||||
delete global.chrome;
|
delete global.chrome;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -434,7 +445,7 @@ describe("a balance refresh under a network switch", () => {
|
|||||||
|
|
||||||
const after = bg.persisted();
|
const after = bg.persisted();
|
||||||
expect(after.networkId).toBe("mainnet");
|
expect(after.networkId).toBe("mainnet");
|
||||||
expect(after.wallets[0].addresses[0].balance).toBe("0");
|
expect(after.wallets[0].addresses[0].balance).toBeNull();
|
||||||
expect(after.lastBalanceRefresh).toBe(0);
|
expect(after.lastBalanceRefresh).toBe(0);
|
||||||
|
|
||||||
await bg.fireBalanceAlarm();
|
await bg.fireBalanceAlarm();
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
// approved a site's request, which changes only the stored record. Driven
|
// approved a site's request, which changes only the stored record. Driven
|
||||||
// through the real popup over a stored profile on Ethereum Mainnet. The same
|
// through the real popup over a stored profile on Ethereum Mainnet. The same
|
||||||
// refresh in the background is in tests/backgroundStateIsolation.test.js.
|
// refresh in the background is in tests/backgroundStateIsolation.test.js.
|
||||||
|
//
|
||||||
|
// The last test covers a balance refresh by the background that is saved while
|
||||||
|
// a switch in Settings waits for the lock
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/653).
|
||||||
|
|
||||||
const {
|
const {
|
||||||
bootPopup,
|
bootPopup,
|
||||||
@@ -41,6 +45,7 @@ async function holdRefresh(page) {
|
|||||||
holders: null,
|
holders: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
return [];
|
||||||
});
|
});
|
||||||
const ticking = page.tick();
|
const ticking = page.tick();
|
||||||
await page.settle();
|
await page.settle();
|
||||||
@@ -65,7 +70,7 @@ async function chooseNetwork(page, networkId) {
|
|||||||
function expectNothingStored(page) {
|
function expectNothingStored(page) {
|
||||||
const stored = page.storage.read("autistmask");
|
const stored = page.storage.read("autistmask");
|
||||||
expect(stored.networkId).toBe("sepolia");
|
expect(stored.networkId).toBe("sepolia");
|
||||||
expect(stored.wallets[0].addresses[0].balance).toBe("0");
|
expect(stored.wallets[0].addresses[0].balance).toBeNull();
|
||||||
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
||||||
expect(stored.lastBalanceRefresh).toBe(0);
|
expect(stored.lastBalanceRefresh).toBe(0);
|
||||||
}
|
}
|
||||||
@@ -81,13 +86,13 @@ test("a refresh that returns after a switch in Settings writes nothing, and the
|
|||||||
await chooseNetwork(page, "sepolia");
|
await chooseNetwork(page, "sepolia");
|
||||||
await refresh.finish();
|
await refresh.finish();
|
||||||
|
|
||||||
expect(state.wallets[0].addresses[0].balance).toBe("0");
|
expect(state.wallets[0].addresses[0].balance).toBeNull();
|
||||||
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
||||||
expectNothingStored(page);
|
expectNothingStored(page);
|
||||||
|
|
||||||
await page.click("btn-settings-back");
|
await page.click("btn-settings-back");
|
||||||
expect(page.visibleViews()).toEqual(["main"]);
|
expect(page.visibleViews()).toEqual(["main"]);
|
||||||
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
|
expect(page.text("total-value")).toBe("unknown (SepoliaETH)");
|
||||||
|
|
||||||
await page.tick();
|
await page.tick();
|
||||||
|
|
||||||
@@ -133,7 +138,7 @@ test("a refresh whose save is reading the stored record when the user switches i
|
|||||||
await page.settle();
|
await page.settle();
|
||||||
|
|
||||||
expect(state.networkId).toBe("sepolia");
|
expect(state.networkId).toBe("sepolia");
|
||||||
expect(state.wallets[0].addresses[0].balance).toBe("0");
|
expect(state.wallets[0].addresses[0].balance).toBeNull();
|
||||||
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
|
||||||
expectNothingStored(page);
|
expectNothingStored(page);
|
||||||
});
|
});
|
||||||
@@ -159,3 +164,50 @@ test("a refresh that returns after the background switched the network writes no
|
|||||||
|
|
||||||
expectNothingStored(page);
|
expectNothingStored(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The background's refresh saves Ethereum Mainnet's balances while the switch
|
||||||
|
// in Settings waits for the lock. This page last saved a balance of null and no
|
||||||
|
// token balances, the values that the switch sets.
|
||||||
|
test("a background refresh saved while a switch in Settings waits for the lock leaves none of its balances stored", async () => {
|
||||||
|
const profile = unversionedValidProfile();
|
||||||
|
profile.wallets[0].addresses[0].balance = null;
|
||||||
|
const page = await bootPopup(profile);
|
||||||
|
expect(page.pageErrors).toEqual([]);
|
||||||
|
const background = require("../src/background/state");
|
||||||
|
await page.click("btn-settings");
|
||||||
|
|
||||||
|
// The background takes the lock first and holds it until the test calls
|
||||||
|
// `letGo()`. It then saves balances as backgroundRefresh() in
|
||||||
|
// src/background/index.js does.
|
||||||
|
let letGo;
|
||||||
|
const refreshSave = background.updateState(async (fresh) => {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
letGo = resolve;
|
||||||
|
});
|
||||||
|
fresh.wallets[0].addresses[0].balance = "7.0";
|
||||||
|
fresh.wallets[0].addresses[0].tokenBalances = [
|
||||||
|
{
|
||||||
|
address: TOKEN_ADDRESS,
|
||||||
|
name: "Test Token",
|
||||||
|
symbol: "TST",
|
||||||
|
decimals: 18,
|
||||||
|
balance: "5.0",
|
||||||
|
holders: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
fresh.lastBalanceRefresh = Date.now();
|
||||||
|
});
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
const select = page.node("settings-network");
|
||||||
|
select.value = "sepolia";
|
||||||
|
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
letGo();
|
||||||
|
await refreshSave;
|
||||||
|
await switching;
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
expectNothingStored(page);
|
||||||
|
});
|
||||||
|
|||||||
@@ -720,6 +720,7 @@ describe("the popup", () => {
|
|||||||
tokenRow(TRACKED, "TRK"),
|
tokenRow(TRACKED, "TRK"),
|
||||||
tokenRow(UNTRACKED, "TST"),
|
tokenRow(UNTRACKED, "TST"),
|
||||||
];
|
];
|
||||||
|
return [];
|
||||||
});
|
});
|
||||||
const ticking = page.tick();
|
const ticking = page.tick();
|
||||||
await page.settle();
|
await page.settle();
|
||||||
@@ -764,6 +765,7 @@ describe("the popup", () => {
|
|||||||
addr.tokenBalances = after
|
addr.tokenBalances = after
|
||||||
? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")]
|
? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")]
|
||||||
: [tokenRow(TRACKED, "TRK")];
|
: [tokenRow(TRACKED, "TRK")];
|
||||||
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
anotherPageSaves(page, { useBlockscout: after });
|
anotherPageSaves(page, { useBlockscout: after });
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,7 +56,18 @@ async function settle() {
|
|||||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// While an approval is pending, the background starts a 20-second timer to
|
||||||
|
// keep itself running. The timer is stubbed and never starts, so no test in
|
||||||
|
// this file leaves it running.
|
||||||
|
let keepRunningTimer;
|
||||||
|
beforeEach(() => {
|
||||||
|
keepRunningTimer = jest
|
||||||
|
.spyOn(global, "setInterval")
|
||||||
|
.mockImplementation(() => 1);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
keepRunningTimer.mockRestore();
|
||||||
delete global.chrome;
|
delete global.chrome;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -317,7 +328,7 @@ describe("only the user switches the network", () => {
|
|||||||
const after = bg.walletState();
|
const after = bg.walletState();
|
||||||
expect(after.networkId).toBe("sepolia");
|
expect(after.networkId).toBe("sepolia");
|
||||||
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
|
||||||
expect(after.wallets[0].addresses[0].balance).toBe("0");
|
expect(after.wallets[0].addresses[0].balance).toBeNull();
|
||||||
expect(bg.chainChangedEvents()).toEqual([
|
expect(bg.chainChangedEvents()).toEqual([
|
||||||
{
|
{
|
||||||
type: "AUTISTMASK_EVENT",
|
type: "AUTISTMASK_EVENT",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function walletFixture() {
|
|||||||
{
|
{
|
||||||
name: "Wallet 1",
|
name: "Wallet 1",
|
||||||
type: "hd",
|
type: "hd",
|
||||||
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
|
addresses: [{ address: ADDRESS, balance: null, tokenBalances: [] }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,18 @@ async function settle() {
|
|||||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// While an approval is pending, the background starts a 20-second timer to
|
||||||
|
// keep itself running. The timer is stubbed and never starts, so no test in
|
||||||
|
// this file leaves it running.
|
||||||
|
let keepRunningTimer;
|
||||||
|
beforeEach(() => {
|
||||||
|
keepRunningTimer = jest
|
||||||
|
.spyOn(global, "setInterval")
|
||||||
|
.mockImplementation(() => 1);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
keepRunningTimer.mockRestore();
|
||||||
delete global.chrome;
|
delete global.chrome;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,7 +242,12 @@ describe("a chain switch on a worker that never loaded state", () => {
|
|||||||
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
|
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
|
||||||
expect(bg.persisted().wallets).toEqual(walletFixture());
|
expect(bg.persisted().wallets).toEqual(walletFixture());
|
||||||
expect(bg.persisted().trackedTokens).toEqual([
|
expect(bg.persisted().trackedTokens).toEqual([
|
||||||
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
{
|
||||||
|
address: TOKEN,
|
||||||
|
symbol: "DAI",
|
||||||
|
decimals: 18,
|
||||||
|
balanceNotFetched: true,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,18 @@ async function settle() {
|
|||||||
for (let i = 0; i < 60; i++) await Promise.resolve();
|
for (let i = 0; i < 60; i++) await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// While an approval is pending, the background starts a 20-second timer to
|
||||||
|
// keep itself running. The timer is stubbed and never starts, so no test in
|
||||||
|
// this file leaves it running.
|
||||||
|
let keepRunningTimer;
|
||||||
|
beforeEach(() => {
|
||||||
|
keepRunningTimer = jest
|
||||||
|
.spyOn(global, "setInterval")
|
||||||
|
.mockImplementation(() => 1);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
keepRunningTimer.mockRestore();
|
||||||
delete global.chrome;
|
delete global.chrome;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ const PRIVATE_KEY = "0x" + "11".repeat(32);
|
|||||||
const HOLDER = new Wallet(PRIVATE_KEY).address;
|
const HOLDER = new Wallet(PRIVATE_KEY).address;
|
||||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||||
const TOKEN = "0x7070707070707070707070707070707070707070";
|
const TOKEN = "0x7070707070707070707070707070707070707070";
|
||||||
|
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
||||||
const PASSWORD = "correct horse battery staple";
|
const PASSWORD = "correct horse battery staple";
|
||||||
|
|
||||||
function el(id) {
|
function el(id) {
|
||||||
@@ -318,7 +319,7 @@ async function switchReopenAndGoBack(reviewedOn, switchedTo) {
|
|||||||
setBackRenderer(makeBackRenderer(state, { confirmTx }));
|
setBackRenderer(makeBackRenderer(state, { confirmTx }));
|
||||||
confirmTx.init({});
|
confirmTx.init({});
|
||||||
prices.ETH = 2000;
|
prices.ETH = 2000;
|
||||||
prices.USDC = 1;
|
prices[USDC] = 1;
|
||||||
mockNode.asked = [];
|
mockNode.asked = [];
|
||||||
global.fetch.mockClear();
|
global.fetch.mockClear();
|
||||||
goBack();
|
goBack();
|
||||||
@@ -641,7 +642,7 @@ describe.each([
|
|||||||
test("of an ERC-20 token, links the token contract to that network's explorer and prices the token by that network", async () => {
|
test("of an ERC-20 token, links the token contract to that network's explorer and prices the token by that network", async () => {
|
||||||
mockNode.tokenUnits[reviewedOn] = 50000000n;
|
mockNode.tokenUnits[reviewedOn] = 50000000n;
|
||||||
await review(reviewedOn, {
|
await review(reviewedOn, {
|
||||||
token: TOKEN,
|
token: USDC,
|
||||||
tokenSymbol: "USDC",
|
tokenSymbol: "USDC",
|
||||||
tokenDecimals: 6,
|
tokenDecimals: 6,
|
||||||
amount: "10",
|
amount: "10",
|
||||||
@@ -652,7 +653,7 @@ describe.each([
|
|||||||
"ERC-20 token transfer (USDC)",
|
"ERC-20 token transfer (USDC)",
|
||||||
);
|
);
|
||||||
expect(el("confirm-token-contract").innerHTML).toContain(
|
expect(el("confirm-token-contract").innerHTML).toContain(
|
||||||
`href="${explorerUrl}/address/${TOKEN}"`,
|
`href="${explorerUrl}/address/${USDC}"`,
|
||||||
);
|
);
|
||||||
expect(el("confirm-amount").textContent).toBe(
|
expect(el("confirm-amount").textContent).toBe(
|
||||||
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
|
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
|
||||||
|
|||||||
@@ -57,7 +57,18 @@ async function settle() {
|
|||||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// While an approval is pending, the background starts a 20-second timer to
|
||||||
|
// keep itself running. The timer is stubbed and never starts, so no test in
|
||||||
|
// this file leaves it running.
|
||||||
|
let keepRunningTimer;
|
||||||
|
beforeEach(() => {
|
||||||
|
keepRunningTimer = jest
|
||||||
|
.spyOn(global, "setInterval")
|
||||||
|
.mockImplementation(() => 1);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
keepRunningTimer.mockRestore();
|
||||||
delete global.chrome;
|
delete global.chrome;
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
|||||||
const EMPTY = { address: "0x1", balance: "0.0000", tokenBalances: [] };
|
const EMPTY = { address: "0x1", balance: "0.0000", tokenBalances: [] };
|
||||||
const ETH_ONLY = { address: "0x1", balance: "1.5", tokenBalances: [] };
|
const ETH_ONLY = { address: "0x1", balance: "1.5", tokenBalances: [] };
|
||||||
const DUST = { address: "0x1", balance: "0.00001", tokenBalances: [] };
|
const DUST = { address: "0x1", balance: "0.00001", tokenBalances: [] };
|
||||||
|
const NOT_READ = { address: "0x1", balance: null, tokenBalances: [] };
|
||||||
const TOKEN_ONLY = {
|
const TOKEN_ONLY = {
|
||||||
address: "0x1",
|
address: "0x1",
|
||||||
balance: "0.0000",
|
balance: "0.0000",
|
||||||
@@ -106,14 +107,23 @@ describe("whether an address holds anything", () => {
|
|||||||
expect(addressHoldsFunds(DUST)).toBe(true);
|
expect(addressHoldsFunds(DUST)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// No refresh has read it, so the address may hold funds.
|
||||||
|
test("an ETH balance no refresh has read counts", () => {
|
||||||
|
expect(addressHoldsFunds(NOT_READ)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("an address holding nothing does not", () => {
|
test("an address holding nothing does not", () => {
|
||||||
expect(addressHoldsFunds(EMPTY)).toBe(false);
|
expect(addressHoldsFunds(EMPTY)).toBe(false);
|
||||||
expect(addressHoldsFunds(ZERO_TOKEN)).toBe(false);
|
expect(addressHoldsFunds(ZERO_TOKEN)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a missing address or missing fields do not", () => {
|
test("a missing address does not", () => {
|
||||||
expect(addressHoldsFunds(undefined)).toBe(false);
|
expect(addressHoldsFunds(undefined)).toBe(false);
|
||||||
expect(addressHoldsFunds({ address: "0x1" })).toBe(false);
|
});
|
||||||
|
|
||||||
|
// It reads as unknown, as a null balance does.
|
||||||
|
test("an address with no ETH balance field counts", () => {
|
||||||
|
expect(addressHoldsFunds({ address: "0x1" })).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,6 +150,20 @@ describe("the balance warning on the removal confirmation", () => {
|
|||||||
expect(html).toContain("<span>0.0000</span>");
|
expect(html).toContain("<span>0.0000</span>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("an ETH balance no refresh has read is warned about, and reads as unknown", () => {
|
||||||
|
const html = balanceWarningHtml(NOT_READ);
|
||||||
|
expect(html).toContain("This address may hold a balance.");
|
||||||
|
expect(html).not.toContain("This address holds a balance.");
|
||||||
|
expect(html).toContain("quantity unknown");
|
||||||
|
expect(html).not.toContain("0.0000");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an address with no ETH balance field reads the same", () => {
|
||||||
|
expect(balanceWarningHtml({ address: "0x1" })).toContain(
|
||||||
|
"This address may hold a balance.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// The sentence must not assert an amount, because any amount it could
|
// The sentence must not assert an amount, because any amount it could
|
||||||
// assert has been rounded: "This address holds 0.0000 ETH." is what the
|
// assert has been rounded: "This address holds 0.0000 ETH." is what the
|
||||||
// rounded form produces for an address that holds real money.
|
// rounded form produces for an address that holds real money.
|
||||||
@@ -153,7 +177,7 @@ describe("the balance warning on the removal confirmation", () => {
|
|||||||
|
|
||||||
test("the USD total is shown when prices are known", () => {
|
test("the USD total is shown when prices are known", () => {
|
||||||
prices.ETH = 2000;
|
prices.ETH = 2000;
|
||||||
prices.USDC = 1;
|
prices[USDC] = 1;
|
||||||
expect(balanceWarningHtml(TOKEN_ONLY)).toContain("Total: $2,500.00");
|
expect(balanceWarningHtml(TOKEN_ONLY)).toContain("Total: $2,500.00");
|
||||||
expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00");
|
expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,6 +102,17 @@ function makeDocument() {
|
|||||||
|
|
||||||
// ------------------------------------------------------------ harness
|
// ------------------------------------------------------------ harness
|
||||||
|
|
||||||
|
// showFlash() starts a timer that clears the message two seconds later. Each
|
||||||
|
// test here runs on fake timers and drops them in afterEach, so that timer
|
||||||
|
// never outlives the test.
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
function wallet(name, secret, addresses) {
|
function wallet(name, secret, addresses) {
|
||||||
return {
|
return {
|
||||||
type: "hd",
|
type: "hd",
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ describe("parsing the dust threshold", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("the rejection message", () => {
|
describe("the rejection message", () => {
|
||||||
// README, Language & Labeling: error messages are full sentences.
|
// README, Wording: error messages are full sentences.
|
||||||
test("is a full sentence naming the constraint", () => {
|
test("is a full sentence naming the constraint", () => {
|
||||||
expect(DUST_THRESHOLD_MESSAGE).toMatch(/^[A-Z].*\.$/);
|
expect(DUST_THRESHOLD_MESSAGE).toMatch(/^[A-Z].*\.$/);
|
||||||
expect(DUST_THRESHOLD_MESSAGE).toContain("whole number of gwei");
|
expect(DUST_THRESHOLD_MESSAGE).toContain("whole number of gwei");
|
||||||
@@ -95,9 +95,9 @@ describe("the flash line the message is shown in", () => {
|
|||||||
// NOT and CANNOT assert that the message fits inside it: jest runs on
|
// NOT and CANNOT assert that the message fits inside it: jest runs on
|
||||||
// the node environment here, with no layout engine, so every rendered
|
// the node environment here, with no layout engine, so every rendered
|
||||||
// height is zero. An earlier version of this block claimed to pin the
|
// height is zero. An earlier version of this block claimed to pin the
|
||||||
// No Layout Shift policy with this regex, and it passed at any message
|
// reserved space rule in README, Appearance, with this regex, and it
|
||||||
// length, including one that wrapped to two lines and pushed the
|
// passed at any message length, including one that wrapped to two lines
|
||||||
// settings view down 12px.
|
// and pushed the settings view down 12px.
|
||||||
//
|
//
|
||||||
// The line cuts a message too long for it with an ellipsis (see
|
// The line cuts a message too long for it with an ellipsis (see
|
||||||
// showFlash() in src/popup/views/helpers.js). The assertions that
|
// showFlash() in src/popup/views/helpers.js). The assertions that
|
||||||
|
|||||||
+13
-1
@@ -43,12 +43,15 @@ const GAS_PRICE_WEI = BASE_FEE_WEI + PRIORITY_FEE_WEI;
|
|||||||
|
|
||||||
const STUB_BLOCK_NUMBER = 21000000;
|
const STUB_BLOCK_NUMBER = 21000000;
|
||||||
|
|
||||||
// A 32-byte zero word, returned for every eth_call. It is what makes ethers'
|
// A 32-byte zero word, returned for every eth_call but a price feed's
|
||||||
|
// latestRoundData() (see rpcResult() below). It is what makes ethers'
|
||||||
// ENS reverse lookup resolve to "no resolver set" instead of throwing, and a
|
// ENS reverse lookup resolve to "no resolver set" instead of throwing, and a
|
||||||
// throw there reaches the console through src/shared/log.js, which fails the
|
// throw there reaches the console through src/shared/log.js, which fails the
|
||||||
// run on its own.
|
// run on its own.
|
||||||
const ZERO_WORD = "0x" + "0".repeat(64);
|
const ZERO_WORD = "0x" + "0".repeat(64);
|
||||||
|
|
||||||
|
const SELECTOR_LATEST_ROUND_DATA = "0xfeaf968c";
|
||||||
|
|
||||||
// One ETH, so the popup's balance lines render something and the wallet does
|
// One ETH, so the popup's balance lines render something and the wallet does
|
||||||
// not look empty on the approval screen.
|
// not look empty on the approval screen.
|
||||||
const STUB_BALANCE_WEI = 10n ** 18n;
|
const STUB_BALANCE_WEI = 10n ** 18n;
|
||||||
@@ -111,6 +114,15 @@ function rpcResult(req, state) {
|
|||||||
return latestBlock();
|
return latestBlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A price feed's latestRoundData() gets five zero words, as in network.js:
|
||||||
|
// a price of zero, which the wallet counts as no price.
|
||||||
|
const data = String(
|
||||||
|
(req.params && req.params[0] && req.params[0].data) || "",
|
||||||
|
);
|
||||||
|
if (method === "eth_call" && data.startsWith(SELECTOR_LATEST_ROUND_DATA)) {
|
||||||
|
return "0x" + "0".repeat(64 * 5);
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(RPC_RESULTS, method)) {
|
if (Object.prototype.hasOwnProperty.call(RPC_RESULTS, method)) {
|
||||||
return RPC_RESULTS[method];
|
return RPC_RESULTS[method];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1035,11 +1035,14 @@ step(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A closed approval window leaves its request pending, and the toolbar button
|
||||||
|
// opens it (https://git.eeqj.de/sneak/AutistMask/issues/673). WebDriver cannot
|
||||||
|
// see the toolbar popup, so its page is opened in a tab, at the URL the
|
||||||
|
// extension put on the button.
|
||||||
step(
|
step(
|
||||||
"closing an approval window rejects the request with 4001",
|
"a closed approval window leaves the request pending until it is rejected",
|
||||||
async (env) => {
|
async (env) => {
|
||||||
const d = env.driver;
|
const d = env.driver;
|
||||||
const before = env.server.broadcast.length;
|
|
||||||
|
|
||||||
await d.switchToWindow(env.dappWindow);
|
await d.switchToWindow(env.dappWindow);
|
||||||
await startRequest(d, "sign-closed", "personal_sign", [
|
await startRequest(d, "sign-closed", "personal_sign", [
|
||||||
@@ -1053,15 +1056,45 @@ step(
|
|||||||
|
|
||||||
// Closed, not rejected: this is the windows.onRemoved path, which can
|
// Closed, not rejected: this is the windows.onRemoved path, which can
|
||||||
// only fire if windows.create() handed back a window id for the
|
// only fire if windows.create() handed back a window id for the
|
||||||
// approval to be matched against — call site 4 in the issue, and the
|
// approval to be matched against. Without that id the toolbar button
|
||||||
// reason suppressing that write-back turns this step red.
|
// never opens the approval, and this step fails.
|
||||||
await d.closeWindow(env.dappWindow);
|
await d.closeWindow(env.dappWindow);
|
||||||
|
const outcome = await settleRequest(d, "sign-closed", 2000);
|
||||||
await assertUserRejection(d, "sign-closed", "a closed approval window");
|
|
||||||
assert(
|
assert(
|
||||||
env.server.broadcast.length === before,
|
outcome.settled === "pending",
|
||||||
"a closed approval window still put a transaction on the node",
|
"closing the approval window answered the page: " +
|
||||||
|
JSON.stringify(outcome),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await d.switchToWindow(env.popupWindow);
|
||||||
|
const url = await d.executeAsync(
|
||||||
|
`const done = arguments[arguments.length - 1];
|
||||||
|
browser.browserAction.getPopup({}).then(done, (e) => done(String(e)));`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
url.includes("?approval="),
|
||||||
|
"the toolbar button does not open the pending approval: " + url,
|
||||||
|
);
|
||||||
|
const reopened = await d.newWindow("tab");
|
||||||
|
await d.switchToWindow(reopened);
|
||||||
|
await d.navigate(url);
|
||||||
|
await d.waitVisible("#view-approve-sign");
|
||||||
|
await d.click("#btn-reject-sign");
|
||||||
|
|
||||||
|
await d.switchToWindow(env.dappWindow);
|
||||||
|
await assertUserRejection(
|
||||||
|
d,
|
||||||
|
"sign-closed",
|
||||||
|
"the signature request rejected from the toolbar button",
|
||||||
|
);
|
||||||
|
|
||||||
|
// A page may close only a window a script opened, so the tab can
|
||||||
|
// still be open.
|
||||||
|
const left = await d.findWindow((u) => u.includes("?approval="));
|
||||||
|
if (left) {
|
||||||
|
await d.switchToWindow(left);
|
||||||
|
await d.closeWindow(env.dappWindow);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -251,8 +251,13 @@ const SELECTOR_DECIMALS = "0x313ce567";
|
|||||||
const SELECTOR_SYMBOL = "0x95d89b41";
|
const SELECTOR_SYMBOL = "0x95d89b41";
|
||||||
const SELECTOR_NAME = "0x06fdde03";
|
const SELECTOR_NAME = "0x06fdde03";
|
||||||
const SELECTOR_BALANCE_OF = "0x70a08231";
|
const SELECTOR_BALANCE_OF = "0x70a08231";
|
||||||
|
const SELECTOR_LATEST_ROUND_DATA = "0xfeaf968c";
|
||||||
|
|
||||||
// Every eth_call still answers with a zero word except decimals(), symbol(),
|
// On any contract, a latestRoundData() call is answered with five zero words.
|
||||||
|
// That is a round with a price of zero, which the wallet counts as no
|
||||||
|
// price. So no screen shows a USD value beside the amounts the tests compare.
|
||||||
|
//
|
||||||
|
// Every other eth_call answers with a zero word except decimals(), symbol(),
|
||||||
// name() and balanceOf() on the stub token. The wallet reads decimals() back
|
// name() and balanceOf() on the stub token. The wallet reads decimals() back
|
||||||
// at signing time to compare with the scale the confirmation screen rendered
|
// at signing time to compare with the scale the confirmation screen rendered
|
||||||
// (issue #305). Adding the token by its contract address reads the first three
|
// (issue #305). Adding the token by its contract address reads the first three
|
||||||
@@ -273,6 +278,9 @@ function ethCallResult(req, opts) {
|
|||||||
if (!call || typeof call !== "object") return ZERO_WORD;
|
if (!call || typeof call !== "object") return ZERO_WORD;
|
||||||
const data = String(call.data || call.input || "").toLowerCase();
|
const data = String(call.data || call.input || "").toLowerCase();
|
||||||
const to = String(call.to || "").toLowerCase();
|
const to = String(call.to || "").toLowerCase();
|
||||||
|
if (data.startsWith(SELECTOR_LATEST_ROUND_DATA)) {
|
||||||
|
return "0x" + "0".repeat(64 * 5);
|
||||||
|
}
|
||||||
if (to !== STUB_TOKEN.address) return ZERO_WORD;
|
if (to !== STUB_TOKEN.address) return ZERO_WORD;
|
||||||
if (data.startsWith(SELECTOR_DECIMALS)) {
|
if (data.startsWith(SELECTOR_DECIMALS)) {
|
||||||
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals);
|
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals);
|
||||||
@@ -764,11 +772,6 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CoinDesk price tick
|
|
||||||
if (url.hostname.endsWith("coindesk.com")) {
|
|
||||||
return jsonResponse(route, { Data: {} });
|
|
||||||
}
|
|
||||||
|
|
||||||
// The interception canary's own request. Answered with nothing: what
|
// The interception canary's own request. Answered with nothing: what
|
||||||
// is being observed is that it arrived here at all.
|
// is being observed is that it arrived here at all.
|
||||||
if (url.href === WORKER_PROBE_URL) {
|
if (url.href === WORKER_PROBE_URL) {
|
||||||
|
|||||||
+144
-24
@@ -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");
|
await visible(env.page, "#view-address");
|
||||||
|
|
||||||
// No wait: the confirm renders the balance list before it shows the
|
// 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(
|
const row = env.page.locator(
|
||||||
'#address-balances [data-token="' + STUB_TOKEN.address + '"]',
|
'#address-balances [data-token="' + STUB_TOKEN.address + '"]',
|
||||||
{ hasText: STUB_TOKEN.symbol },
|
{ hasText: STUB_TOKEN.symbol },
|
||||||
@@ -1279,7 +1280,7 @@ test("the correct password reveals the full phrase, and nothing logs it (#161)",
|
|||||||
);
|
);
|
||||||
assert(!promptShown, "the password prompt is still shown after unlock");
|
assert(!promptShown, "the password prompt is still shown after unlock");
|
||||||
|
|
||||||
// Full Identifiers Policy: shown whole, and copyable.
|
// README, ShowRecoveryPhrase: shown whole, and copyable.
|
||||||
const title = await env.page.getAttribute(
|
const title = await env.page.getAttribute(
|
||||||
"#show-phrase-value",
|
"#show-phrase-value",
|
||||||
"title",
|
"title",
|
||||||
@@ -2254,7 +2255,7 @@ async function waitForFilledFlashLine(page) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// README, No Layout Shift: the rejection message goes into #flash-msg,
|
// README, Appearance: the rejection message goes into #flash-msg,
|
||||||
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs, and which
|
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs, and which
|
||||||
// cuts a message too long for that line with an ellipsis rather than wrap
|
// cuts a message too long for that line with an ellipsis rather than wrap
|
||||||
// it. This shows the real message and measures that nothing moves; the
|
// it. This shows the real message and measures that nothing moves; the
|
||||||
@@ -2602,7 +2603,7 @@ function toHexWei(wei) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A fee in wei as the confirmation screen writes it: truncated to four decimal
|
// A fee in wei as the confirmation screen writes it: truncated to four decimal
|
||||||
// places (README.md, Display Consistency). Deliberately a second
|
// places (README.md, Amounts). Deliberately a second
|
||||||
// implementation rather than an import of src/shared/amountDisplay.js:
|
// implementation rather than an import of src/shared/amountDisplay.js:
|
||||||
// asserting against an independent rendering is stronger than asserting a
|
// asserting against an independent rendering is stronger than asserting a
|
||||||
// function equals itself. The fixture's fees are above 0.0001 ETH, so the
|
// function equals itself. The fixture's fees are above 0.0001 ETH, so the
|
||||||
@@ -3297,7 +3298,7 @@ async function goToTokenConfirm(env) {
|
|||||||
amount: TOKEN_AMOUNT,
|
amount: TOKEN_AMOUNT,
|
||||||
});
|
});
|
||||||
await waitForEstimate(env.page);
|
await waitForEstimate(env.page);
|
||||||
// TOKEN_AMOUNT to four decimal places (README.md, Display Consistency).
|
// TOKEN_AMOUNT to four decimal places (README.md, Amounts).
|
||||||
const shown = await shownAmount(env.page);
|
const shown = await shownAmount(env.page);
|
||||||
assert(
|
assert(
|
||||||
shown === "0.2500 " + STUB_TOKEN.symbol,
|
shown === "0.2500 " + STUB_TOKEN.symbol,
|
||||||
@@ -3452,7 +3453,7 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
|
|||||||
|
|
||||||
// ------------------------- a send Sign & Send refuses moves nothing (#583)
|
// ------------------------- a send Sign & Send refuses moves nothing (#583)
|
||||||
//
|
//
|
||||||
// README, No Layout Shift. Sign & Send refuses a send on the confirmation
|
// README, Appearance. Sign & Send refuses a send on the confirmation
|
||||||
// screen when the active network changed since review and when its gas limit
|
// screen when the active network changed since review and when its gas limit
|
||||||
// or fee is over its bound, and each refusal's sentence sits in a box that held
|
// or fee is over its bound, and each refusal's sentence sits in a box that held
|
||||||
// its space from the first paint. This drives the total fee refusal, the longer
|
// its space from the first paint. This drives the total fee refusal, the longer
|
||||||
@@ -4110,11 +4111,12 @@ async function waitForApprovalWindow(ctx, timeout = 30000) {
|
|||||||
// reservation exists.
|
// reservation exists.
|
||||||
// How long the tab creation above is given to take effect before the request
|
// How long the tab creation above is given to take effect before the request
|
||||||
// that raises the next prompt is issued. Dismissing a browser-action popup
|
// that raises the next prompt is issued. Dismissing a browser-action popup
|
||||||
// disconnects its port, and src/background/index.js calls resetPopupUrl() on
|
// disconnects its port, and src/background/index.js calls
|
||||||
// that disconnect; issued too early, the next request's setPopup() is undone
|
// updateToolbarPopup() on that disconnect; issued too early, the next
|
||||||
// by the previous popup's teardown, chrome.action.openPopup() then rejects,
|
// request's setPopup() is undone by the previous popup's teardown,
|
||||||
// and the fallback window it opens dismisses the popup that was just raised —
|
// chrome.action.openPopup() then rejects, and the fallback window it opens
|
||||||
// which settles the fresh approval as a rejection before it can be seen.
|
// dismisses the popup that was just raised — which settles the fresh approval
|
||||||
|
// as a rejection before it can be seen.
|
||||||
// Every one of those steps was observed. 1.5s is comfortably past it.
|
// Every one of those steps was observed. 1.5s is comfortably past it.
|
||||||
const APPROVAL_TAB_SETTLE_MS = 1500;
|
const APPROVAL_TAB_SETTLE_MS = 1500;
|
||||||
|
|
||||||
@@ -4140,6 +4142,17 @@ async function reserveApprovalTab(env) {
|
|||||||
return env.approvalTab;
|
return env.approvalTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The URL the extension has put on the action: the page the toolbar button
|
||||||
|
// opens.
|
||||||
|
function toolbarPopupUrl(env) {
|
||||||
|
return env.page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
chrome.action.getPopup({}, (u) => resolve(String(u)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The site-connection prompt: the window the extension opened if it managed
|
// The site-connection prompt: the window the extension opened if it managed
|
||||||
// to open one, and the reserved tab at the action's approval URL otherwise.
|
// to open one, and the reserved tab at the action's approval URL otherwise.
|
||||||
async function openSiteApprovalPopup(env, timeout = 30000) {
|
async function openSiteApprovalPopup(env, timeout = 30000) {
|
||||||
@@ -4150,12 +4163,7 @@ async function openSiteApprovalPopup(env, timeout = 30000) {
|
|||||||
.find((p) => !p.isClosed() && p.url().includes("?approval="));
|
.find((p) => !p.isClosed() && p.url().includes("?approval="));
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const url = await env.page.evaluate(
|
const url = await toolbarPopupUrl(env);
|
||||||
() =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
chrome.action.getPopup({}, (u) => resolve(String(u)));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (url.includes("?approval=")) {
|
if (url.includes("?approval=")) {
|
||||||
await env.approvalTab.goto(url);
|
await env.approvalTab.goto(url);
|
||||||
return env.approvalTab;
|
return env.approvalTab;
|
||||||
@@ -5027,6 +5035,116 @@ test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A transaction or signature approval stays pending when its window closes.
|
||||||
|
// The browser can close that window when the user clicks away from it, for
|
||||||
|
// example to copy the password from a password manager. The toolbar button then
|
||||||
|
// opens the approval, and the user answers it there
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/673).
|
||||||
|
//
|
||||||
|
// This closes the approval window of request `key` and checks that the page
|
||||||
|
// has no answer. It then opens the approval again from the toolbar button.
|
||||||
|
// Playwright cannot click that button, so its page is opened in a tab, at the
|
||||||
|
// URL the extension put on the button.
|
||||||
|
async function closeAndReopen(env, key, view) {
|
||||||
|
const approvalWindow = await waitForApprovalWindow(env.ctx);
|
||||||
|
await visible(approvalWindow, view);
|
||||||
|
await approvalWindow.close();
|
||||||
|
|
||||||
|
const outcome = await settleRequest(env.dapp, key, 2000);
|
||||||
|
assert(
|
||||||
|
outcome.settled === "pending",
|
||||||
|
"closing the approval window answered the page: " +
|
||||||
|
JSON.stringify(outcome),
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = await toolbarPopupUrl(env);
|
||||||
|
assert(
|
||||||
|
url.includes("?approval="),
|
||||||
|
"the toolbar button does not open the pending approval: " + url,
|
||||||
|
);
|
||||||
|
const reopened = await env.ctx.newPage();
|
||||||
|
await reopened.goto(url);
|
||||||
|
await visible(reopened, view);
|
||||||
|
return reopened;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stub node answers every nonce request with 0, and the wallet refuses a
|
||||||
|
// nonce it has already broadcast. A run can therefore approve one dApp
|
||||||
|
// transaction only, so here the signature is approved and the transaction
|
||||||
|
// rejected.
|
||||||
|
test("a closed signature approval is signed from the toolbar button (#673)", async (env) => {
|
||||||
|
await startRequest(env.dapp, "sign-closed", "personal_sign", [
|
||||||
|
SIGN_HEX,
|
||||||
|
env.expectedAddress,
|
||||||
|
]);
|
||||||
|
const popup = await closeAndReopen(
|
||||||
|
env,
|
||||||
|
"sign-closed",
|
||||||
|
"#view-approve-sign",
|
||||||
|
);
|
||||||
|
let outcome;
|
||||||
|
try {
|
||||||
|
await popup.fill("#approve-sign-password", PASSWORD);
|
||||||
|
await clickAndClose(popup, "#btn-approve-sign");
|
||||||
|
outcome = await settleRequest(env.dapp, "sign-closed");
|
||||||
|
} finally {
|
||||||
|
await closeApprovalPages(env.ctx);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
outcome.settled === "resolved",
|
||||||
|
"personal_sign did not resolve: " + JSON.stringify(outcome),
|
||||||
|
);
|
||||||
|
const recovered = getAddress(
|
||||||
|
verifyMessage(getBytes(SIGN_HEX), outcome.result),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
recovered === env.expectedAddress,
|
||||||
|
"the personal_sign signature recovers to " +
|
||||||
|
recovered +
|
||||||
|
", not to the approved address " +
|
||||||
|
env.expectedAddress,
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = await toolbarPopupUrl(env);
|
||||||
|
assert(
|
||||||
|
!url.includes("?approval="),
|
||||||
|
"the toolbar button still opens the answered approval: " + url,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a closed transaction approval is rejected from the toolbar button (#673)", async (env) => {
|
||||||
|
const before = env.routeOpts.broadcastTransactions.length;
|
||||||
|
await startRequest(env.dapp, "tx-closed", "eth_sendTransaction", [
|
||||||
|
{
|
||||||
|
from: env.expectedAddress,
|
||||||
|
to: STUB_COUNTERPARTY,
|
||||||
|
value: toQuantity(TX_VALUE_WEI),
|
||||||
|
data: TX_DATA,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const popup = await closeAndReopen(env, "tx-closed", "#view-approve-tx");
|
||||||
|
try {
|
||||||
|
await clickAndClose(popup, "#btn-reject-tx");
|
||||||
|
await assertUserRejection(
|
||||||
|
env.dapp,
|
||||||
|
"tx-closed",
|
||||||
|
"the transaction rejected from the toolbar button",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await closeApprovalPages(env.ctx);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
env.routeOpts.broadcastTransactions.length === before,
|
||||||
|
"a rejected transaction still reached the RPC",
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = await toolbarPopupUrl(env);
|
||||||
|
assert(
|
||||||
|
!url.includes("?approval="),
|
||||||
|
"the toolbar button still opens the answered approval: " + url,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// The extension used to centre every approval window on the last focused
|
// The extension used to centre every approval window on the last focused
|
||||||
// window. Centred on another approval window, the new one landed where the
|
// window. Centred on another approval window, the new one landed where the
|
||||||
// browser refused to create it, and the request failed with no window at all
|
// browser refused to create it, and the request failed with no window at all
|
||||||
@@ -5070,8 +5188,8 @@ test("a prompt raised while another approval window has focus opens its own (#29
|
|||||||
});
|
});
|
||||||
await visible(txWindow, "#view-approve-tx");
|
await visible(txWindow, "#view-approve-tx");
|
||||||
|
|
||||||
// Closing a window is refusing its prompt, so both answer 4001.
|
await clickAndClose(txWindow, "#btn-reject-tx");
|
||||||
await closeApprovalPages(env.ctx);
|
await clickAndClose(signWindow, "#btn-reject-sign");
|
||||||
await assertUserRejection(env.dapp, "focus-tx", "the transaction prompt");
|
await assertUserRejection(env.dapp, "focus-tx", "the transaction prompt");
|
||||||
await assertUserRejection(env.dapp, "focus-sign", "the sign prompt");
|
await assertUserRejection(env.dapp, "focus-sign", "the sign prompt");
|
||||||
});
|
});
|
||||||
@@ -5652,8 +5770,8 @@ async function main() {
|
|||||||
// There is no fourth: once the context is closed nothing can record,
|
// There is no fourth: once the context is closed nothing can record,
|
||||||
// because the route handler and the console listeners died with it.
|
// because the route handler and the console listeners died with it.
|
||||||
// Traffic that a test defers past the trailing drain is therefore
|
// Traffic that a test defers past the trailing drain is therefore
|
||||||
// never observed at all — a real limit of this design, stated in the
|
// never observed at all — a real limit of this design, and not one
|
||||||
// README, and not one any post-teardown hook could close.
|
// any post-teardown hook could close.
|
||||||
//
|
//
|
||||||
// Two green-but-vacuous runs on this harness were the same shape: a
|
// Two green-but-vacuous runs on this harness were the same shape: a
|
||||||
// record falling outside somebody's window and being dropped. First
|
// record falling outside somebody's window and being dropped. First
|
||||||
@@ -5678,9 +5796,11 @@ async function main() {
|
|||||||
// for the new one. After a test that passed, its windows get five
|
// for the new one. After a test that passed, its windows get five
|
||||||
// seconds to close, and one still open then fails the test: it should
|
// seconds to close, and one still open then fails the test: it should
|
||||||
// have closed itself, and closing it here unreported would hide that.
|
// have closed itself, and closing it here unreported would hide that.
|
||||||
// After a test that already failed, they are closed unreported, so
|
// After a test that already failed, they are closed unreported. That
|
||||||
// that a prompt it left unanswered does not refuse the next one from
|
// rejects a site-connection or network-switch prompt it left
|
||||||
// its site.
|
// unanswered, so the prompt does not refuse the next one from its
|
||||||
|
// site. A transaction or signature approval stays pending, and can
|
||||||
|
// fail the tests after it.
|
||||||
if (!failure) {
|
if (!failure) {
|
||||||
const deadline = Date.now() + 5000;
|
const deadline = Date.now() + 5000;
|
||||||
let open;
|
let open;
|
||||||
|
|||||||
@@ -111,13 +111,15 @@ function respondWith(items) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch and place the result exactly where refreshBalances() places it, so the
|
// 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 = []) {
|
async function fetchOnto(items, trackedTokens = []) {
|
||||||
respondWith(items);
|
respondWith(items);
|
||||||
const balances = await fetchTokenBalances(
|
const balances = await fetchTokenBalances(
|
||||||
HOLDER,
|
HOLDER,
|
||||||
BLOCKSCOUT,
|
BLOCKSCOUT,
|
||||||
trackedTokens,
|
trackedTokens,
|
||||||
|
"0x1",
|
||||||
);
|
);
|
||||||
state.trackedTokens = trackedTokens;
|
state.trackedTokens = trackedTokens;
|
||||||
state.wallets = [
|
state.wallets = [
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ async function boot(stored) {
|
|||||||
for (const wallet of wallets) {
|
for (const wallet of wallets) {
|
||||||
for (const addr of wallet.addresses) addr.balance = chainBalance;
|
for (const addr of wallet.addresses) addr.balance = chainBalance;
|
||||||
}
|
}
|
||||||
|
return [];
|
||||||
});
|
});
|
||||||
return { page, refreshBalances };
|
return { page, refreshBalances };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ describe.each([
|
|||||||
showFlash: (msg) => flashes.push(msg),
|
showFlash: (msg) => flashes.push(msg),
|
||||||
escapeHtml: (s) => s,
|
escapeHtml: (s) => s,
|
||||||
goBack: () => {},
|
goBack: () => {},
|
||||||
|
trackToken: () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
require("../src/popup/views/" + view).init({
|
require("../src/popup/views/" + view).init({
|
||||||
|
|||||||
@@ -0,0 +1,634 @@
|
|||||||
|
// 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). Home's list
|
||||||
|
// shows nothing of the previous network once it is drawn after a switch
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/718).
|
||||||
|
//
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every read of storage waits until the returned function is called. Each read
|
||||||
|
// then answers with what storage holds at that moment, the oldest read first.
|
||||||
|
function holdReads() {
|
||||||
|
const read = storage.local.get;
|
||||||
|
const waiting = [];
|
||||||
|
storage.local.get = (key) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
waiting.push(() => resolve(read(key)));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
storage.local.get = read;
|
||||||
|
for (const answer of waiting) answer();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The user picks Sepolia in Settings, and the switch reads storage. The
|
||||||
|
// answer lands during that read, so the screen's own read of storage still
|
||||||
|
// finds Ethereum Mainnet. The switch changes the page before the screen's
|
||||||
|
// read ends. The answer is the transfer of ETH alone, so nothing is saved,
|
||||||
|
// and only a check after the screen's read can drop the answer.
|
||||||
|
test("an answer that lands during the first read of a switch made in Settings adds nothing", async () => {
|
||||||
|
const land = holdHistory(history().slice(0, 1));
|
||||||
|
screen.open();
|
||||||
|
await settle();
|
||||||
|
const before = node(screen.listId).innerHTML;
|
||||||
|
const answerReads = holdReads();
|
||||||
|
const switching = onChainSwitch("sepolia");
|
||||||
|
await settle();
|
||||||
|
land();
|
||||||
|
await settle();
|
||||||
|
answerReads();
|
||||||
|
await switching;
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expectNothingAdded(screen, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a request that fails during the first read of a switch made in Settings shows no message", async () => {
|
||||||
|
const fail = holdFailure();
|
||||||
|
screen.open();
|
||||||
|
await settle();
|
||||||
|
const before = node(screen.listId).innerHTML;
|
||||||
|
const answerReads = holdReads();
|
||||||
|
const switching = onChainSwitch("sepolia");
|
||||||
|
await settle();
|
||||||
|
fail();
|
||||||
|
await settle();
|
||||||
|
answerReads();
|
||||||
|
await switching;
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(node(screen.listId).innerHTML).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Home's list", () => {
|
||||||
|
test.each([
|
||||||
|
{ what: "rows", hold: holdHistory, shown: "tx-row" },
|
||||||
|
{
|
||||||
|
what: "failure message",
|
||||||
|
hold: holdFailure,
|
||||||
|
shown: "Failed to load transactions.",
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"shows the previous network's $what neither before nor after the new network's answer lands",
|
||||||
|
async ({ hold, shown }) => {
|
||||||
|
const answer = hold();
|
||||||
|
HOME.open();
|
||||||
|
answer();
|
||||||
|
await settle();
|
||||||
|
expect(node(HOME.listId).innerHTML).toContain(shown);
|
||||||
|
|
||||||
|
await onChainSwitch("sepolia");
|
||||||
|
const land = holdHistory([]);
|
||||||
|
HOME.open();
|
||||||
|
await settle();
|
||||||
|
expect(node(HOME.listId).innerHTML).toContain("Loading...");
|
||||||
|
|
||||||
|
land();
|
||||||
|
await settle();
|
||||||
|
expect(node(HOME.listId).innerHTML).toContain(
|
||||||
|
"No transactions found.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The user leaves Ethereum Mainnet with a request waiting, comes back, and
|
||||||
|
// that request lands. The list then holds Ethereum Mainnet's answer, so the
|
||||||
|
// next draw on Sepolia must replace it.
|
||||||
|
test.each([
|
||||||
|
{ what: "rows", hold: holdHistory },
|
||||||
|
{ what: "failure message", hold: holdFailure },
|
||||||
|
])(
|
||||||
|
"shows no $what of an older request that lands after a switch back",
|
||||||
|
async ({ hold }) => {
|
||||||
|
const answer = hold();
|
||||||
|
HOME.open();
|
||||||
|
|
||||||
|
await onChainSwitch("sepolia");
|
||||||
|
holdHistory();
|
||||||
|
HOME.open();
|
||||||
|
|
||||||
|
await onChainSwitch("mainnet");
|
||||||
|
answer();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
await onChainSwitch("sepolia");
|
||||||
|
holdHistory();
|
||||||
|
HOME.open();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
const list = node(HOME.listId).innerHTML;
|
||||||
|
expect(list).toContain("Loading...");
|
||||||
|
expect(list).not.toContain("tx-row");
|
||||||
|
expect(list).not.toContain("Failed to load transactions.");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Each balance refresh draws Home again, so the rows must not blink.
|
||||||
|
test("keeps its rows while a redraw on the same network waits for the answer", async () => {
|
||||||
|
fetchRecentTransactions.mockImplementationOnce(async () => history());
|
||||||
|
HOME.open();
|
||||||
|
await settle();
|
||||||
|
const drawn = node(HOME.listId).innerHTML;
|
||||||
|
holdHistory();
|
||||||
|
HOME.open();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(drawn).toContain("tx-row");
|
||||||
|
expect(node(HOME.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);
|
||||||
|
});
|
||||||
@@ -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).toHaveLength(1);
|
||||||
expect(balances[0].holders).toBeNull();
|
expect(balances[0].holders).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Home's "Send" and "Receive" open with no token selected. Three routes reach
|
||||||
|
// Home with a token still selected, Send reads that token ahead of its dropdown,
|
||||||
|
// and Receive warns about it
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/753). Driven through the real
|
||||||
|
// popup over a stored profile.
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
TOKEN_ADDRESS,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
|
||||||
|
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
|
||||||
|
const OTHER_WALLET_ADDRESS = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||||
|
const PRIVATE_KEY = "0x" + "11".repeat(32);
|
||||||
|
const PASSWORD = "correct horse battery staple";
|
||||||
|
const TOKEN = {
|
||||||
|
address: TOKEN_ADDRESS,
|
||||||
|
symbol: "TST",
|
||||||
|
decimals: 18,
|
||||||
|
name: "Test Token",
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// AddressToken opened from AddressDetail, as tapping the token's row does.
|
||||||
|
async function openToken(page) {
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
state.selectedToken = TOKEN_ADDRESS;
|
||||||
|
require("../src/popup/views/helpers").pushCurrentView();
|
||||||
|
require("../src/popup/views/addressToken").show();
|
||||||
|
await page.settle();
|
||||||
|
expect(page.visibleViews()).toEqual(["address-token"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The popup open on AddressToken, at an address holding 1.5 ETH and 2.5 TST.
|
||||||
|
// A second wallet is there for the route that deletes one.
|
||||||
|
async function bootOnToken() {
|
||||||
|
const profile = unversionedValidProfile({
|
||||||
|
currentView: "address",
|
||||||
|
viewStack: ["main"],
|
||||||
|
selectedWallet: 0,
|
||||||
|
selectedAddress: 0,
|
||||||
|
trackedTokens: [TOKEN],
|
||||||
|
});
|
||||||
|
profile.wallets[0].addresses[0].tokenBalances = [
|
||||||
|
{ ...TOKEN, balance: "2.5" },
|
||||||
|
];
|
||||||
|
profile.wallets.push({
|
||||||
|
type: "hd",
|
||||||
|
name: "Wallet 2",
|
||||||
|
xpub: "xpub-wallet-2",
|
||||||
|
encryptedSecret: "encrypted-secret-2",
|
||||||
|
nextIndex: 1,
|
||||||
|
addresses: [
|
||||||
|
{
|
||||||
|
address: OTHER_WALLET_ADDRESS,
|
||||||
|
balance: "0",
|
||||||
|
tokenBalances: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const page = await bootPopup(profile);
|
||||||
|
await openToken(page);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reopenAfterTokenSend() {
|
||||||
|
const closed = await bootOnToken();
|
||||||
|
await closed.click("btn-address-token-send");
|
||||||
|
cleanupPopup();
|
||||||
|
return bootPopup(undefined, { storage: closed.storage });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addWalletFromTokenSettings() {
|
||||||
|
const page = await bootOnToken();
|
||||||
|
await page.click("btn-settings");
|
||||||
|
await page.click("btn-main-add-wallet");
|
||||||
|
await page.click("tab-privkey");
|
||||||
|
page.node("import-private-key").value = PRIVATE_KEY;
|
||||||
|
page.node("add-wallet-password").value = PASSWORD;
|
||||||
|
page.node("add-wallet-password-confirm").value = PASSWORD;
|
||||||
|
await page.click("btn-add-wallet-confirm");
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletes through the lost-password screen. It asks for the wallet's name, and
|
||||||
|
// the stored wallets here have no real password.
|
||||||
|
async function deleteWalletFromTokenSettings() {
|
||||||
|
const page = await bootOnToken();
|
||||||
|
await page.click("btn-settings");
|
||||||
|
// What the second wallet's [x] button in Settings does.
|
||||||
|
require("../src/popup/views/helpers").pushCurrentView();
|
||||||
|
require("../src/popup/views/deleteWallet").show(1);
|
||||||
|
await page.click("btn-delete-wallet-lost-password");
|
||||||
|
page.node("delete-wallet-lost-name-input").value = "Wallet 2";
|
||||||
|
await page.click("btn-delete-wallet-lost-confirm");
|
||||||
|
await page.click("btn-settings-back");
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTES = [
|
||||||
|
["reopening the popup after AddressToken's Send", reopenAfterTokenSend],
|
||||||
|
[
|
||||||
|
"the Settings gear on AddressToken, then adding a wallet",
|
||||||
|
addWalletFromTokenSettings,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"the Settings gear on AddressToken, then deleting another wallet, then Back",
|
||||||
|
deleteWalletFromTokenSettings,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// A route that stopped reaching Home with the token selected would prove nothing
|
||||||
|
// in the tests below, so it fails here.
|
||||||
|
async function arriveOnHome(route) {
|
||||||
|
const page = await route();
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
expect(page.visibleViews()).toEqual(["main"]);
|
||||||
|
expect(state.selectedToken).toBe(TOKEN_ADDRESS);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ETH chosen in Send's dropdown, as the user does.
|
||||||
|
async function chooseEth(page) {
|
||||||
|
const dropdown = page.node("send-token");
|
||||||
|
dropdown.value = "ETH";
|
||||||
|
for (const fn of dropdown.listeners.change) await fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Review for 1 of the token Send has, to RECIPIENT.
|
||||||
|
async function review(page) {
|
||||||
|
page.node("send-to").value = RECIPIENT;
|
||||||
|
page.node("send-amount").value = "1";
|
||||||
|
await page.click("btn-send-review");
|
||||||
|
expect(page.visibleViews()).toEqual(["confirm-tx"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.each(ROUTES)("after %s", (_name, route) => {
|
||||||
|
test("choosing ETH on Home's Send reviews ETH", async () => {
|
||||||
|
const page = await arriveOnHome(route);
|
||||||
|
|
||||||
|
await page.click("btn-main-send");
|
||||||
|
await chooseEth(page);
|
||||||
|
await review(page);
|
||||||
|
|
||||||
|
expect(page.text("confirm-type")).toBe("Native ETH transfer");
|
||||||
|
expect(page.storage.read("autistmask").selectedToken).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Home's Send starts on ETH", async () => {
|
||||||
|
const page = await arriveOnHome(route);
|
||||||
|
|
||||||
|
await page.click("btn-main-send");
|
||||||
|
|
||||||
|
expect(page.text("send-balance")).toBe("Current balance: 1.5000 ETH");
|
||||||
|
expect(page.value("send-token")).toBe("ETH");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Home's Receive shows no token warning", async () => {
|
||||||
|
const page = await arriveOnHome(route);
|
||||||
|
|
||||||
|
await page.click("btn-main-receive");
|
||||||
|
|
||||||
|
expect(page.visibleViews()).toEqual(["receive"]);
|
||||||
|
expect(page.text("receive-erc20-warning")).toBe("");
|
||||||
|
expect(page.storage.read("autistmask").selectedToken).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("AddressToken's Send still sends the token after Home's Send has opened", async () => {
|
||||||
|
const page = await arriveOnHome(deleteWalletFromTokenSettings);
|
||||||
|
await page.click("btn-main-send");
|
||||||
|
await page.click("btn-send-back");
|
||||||
|
await openToken(page);
|
||||||
|
|
||||||
|
await page.click("btn-address-token-send");
|
||||||
|
|
||||||
|
expect(page.text("send-balance")).toBe("Current balance: 2.5000 TST");
|
||||||
|
await review(page);
|
||||||
|
expect(page.text("confirm-type")).toBe("ERC-20 token transfer (TST)");
|
||||||
|
});
|
||||||
@@ -165,9 +165,9 @@ describe("declared icons", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// README, Installing on Firefox: the extension does not run in private windows.
|
||||||
// Firefox gives a page in a private window locks of its own, so that page's
|
// Firefox gives a page in a private window locks of its own, so that page's
|
||||||
// save could land between the background's read and its write (README,
|
// save could land between the background's read and its write.
|
||||||
// "Stored state and its version").
|
|
||||||
describe("private windows", () => {
|
describe("private windows", () => {
|
||||||
test("a page in a firefox private window cannot read or write extension storage", () => {
|
test("a page in a firefox private window cannot read or write extension storage", () => {
|
||||||
expect(readManifest("firefox").incognito).toBe("not_allowed");
|
expect(readManifest("firefox").incognito).toBe("not_allowed");
|
||||||
|
|||||||
@@ -416,20 +416,6 @@ const CONTRACT = [
|
|||||||
views: ["success-tx"],
|
views: ["success-tx"],
|
||||||
restored: false,
|
restored: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } },
|
|
||||||
views: ["success-tx"],
|
|
||||||
restored: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: {
|
|
||||||
hash: "0x1",
|
|
||||||
to: ADDRESS,
|
|
||||||
decoded: { details: [{ address: 42 }] },
|
|
||||||
},
|
|
||||||
views: ["success-tx"],
|
|
||||||
restored: false,
|
|
||||||
},
|
|
||||||
// error-tx passes on `data.message`, same dereference.
|
// error-tx passes on `data.message`, same dereference.
|
||||||
{
|
{
|
||||||
value: { message: "boom" },
|
value: { message: "boom" },
|
||||||
@@ -1082,3 +1068,57 @@ describe("a restore-only hostile value onto", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A saved decoded record comes back from storage, in whatever shape it is in.
|
||||||
|
// SuccessTx reopens as itself and shows only the lines it can read.
|
||||||
|
describe("a popup reopened on a SuccessTx with a decoded record in another shape", () => {
|
||||||
|
function savedSuccess(decoded) {
|
||||||
|
return restoringOnto("success-tx", {
|
||||||
|
viewData: {
|
||||||
|
amount: "1.0000",
|
||||||
|
symbol: "ETH",
|
||||||
|
to: ADDRESS,
|
||||||
|
hash: "0x1",
|
||||||
|
chainId: "0x1",
|
||||||
|
decoded,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
7,
|
||||||
|
"text",
|
||||||
|
[1, 2],
|
||||||
|
{ details: 7 },
|
||||||
|
{ details: [null] },
|
||||||
|
{ details: [7] },
|
||||||
|
{ details: [{ address: 42 }] },
|
||||||
|
{ details: [{ label: "Steps", value: "text", address: 42 }] },
|
||||||
|
])("%j shows the amount and the recipient", async (decoded) => {
|
||||||
|
const page = await bootPopup(savedSuccess(decoded));
|
||||||
|
expect(page.pageErrors).toEqual([]);
|
||||||
|
expect(page.visibleViews()).toEqual(["success-tx"]);
|
||||||
|
expect(page.hidden("success-tx-decoded")).toBe(true);
|
||||||
|
expect(page.text("success-tx-summary")).toBe("1.0000 ETH");
|
||||||
|
expect(page.node("success-tx-to").innerHTML).toContain(ADDRESS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a line it can read is shown, with the amount and the recipient", async () => {
|
||||||
|
const page = await bootPopup(
|
||||||
|
savedSuccess({
|
||||||
|
details: [null, { label: "Steps", value: "V3_SWAP_EXACT_IN" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(page.pageErrors).toEqual([]);
|
||||||
|
expect(page.visibleViews()).toEqual(["success-tx"]);
|
||||||
|
expect(page.node("success-tx-decoded").innerHTML).toContain(
|
||||||
|
"V3_SWAP_EXACT_IN",
|
||||||
|
);
|
||||||
|
const summary = page.node("success-tx-summary");
|
||||||
|
expect(summary.parentElement.classList.contains("hidden")).toBe(false);
|
||||||
|
expect(summary.textContent).toBe("1.0000 ETH");
|
||||||
|
const to = page.node("success-tx-to");
|
||||||
|
expect(to.parentElement.classList.contains("hidden")).toBe(false);
|
||||||
|
expect(to.innerHTML).toContain(ADDRESS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -134,9 +134,63 @@ test.each([
|
|||||||
await backgroundSwitch(page, "sepolia");
|
await backgroundSwitch(page, "sepolia");
|
||||||
|
|
||||||
expect(page.visibleViews()).toEqual(["main"]);
|
expect(page.visibleViews()).toEqual(["main"]);
|
||||||
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
|
expect(page.text("total-value")).toBe("unknown (SepoliaETH)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A request on Ethereum Mainnet fails, so Home's list holds the failure message.
|
||||||
|
// The list shows it neither before nor after Sepolia's answer lands.
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
how: "saved by the background",
|
||||||
|
switchToSepolia: (page) => backgroundSwitch(page, "sepolia"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
how: "made in Settings",
|
||||||
|
switchToSepolia: async (page) => {
|
||||||
|
await page.click("btn-settings");
|
||||||
|
const select = page.node("settings-network");
|
||||||
|
select.value = "sepolia";
|
||||||
|
for (const fn of select.listeners.change) await fn();
|
||||||
|
await page.settle();
|
||||||
|
await page.click("btn-settings-back");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"Home's list drops the previous network's message after a switch $how",
|
||||||
|
async ({ switchToSepolia }) => {
|
||||||
|
const page = await bootPopup(
|
||||||
|
unversionedValidProfile({ useBlockscout: true }),
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
fetchRecentTransactions,
|
||||||
|
} = require("../src/shared/transactions");
|
||||||
|
fetchRecentTransactions.mockRejectedValueOnce(
|
||||||
|
new Error("Blockscout is down"),
|
||||||
|
);
|
||||||
|
await page.tick();
|
||||||
|
expect(page.node("home-tx-list").innerHTML).toContain(
|
||||||
|
"Failed to load transactions.",
|
||||||
|
);
|
||||||
|
|
||||||
|
let land;
|
||||||
|
fetchRecentTransactions.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
land = () => resolve([]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await switchToSepolia(page);
|
||||||
|
expect(page.visibleViews()).toEqual(["main"]);
|
||||||
|
expect(page.node("home-tx-list").innerHTML).toContain("Loading...");
|
||||||
|
|
||||||
|
land();
|
||||||
|
await page.settle();
|
||||||
|
expect(page.node("home-tx-list").innerHTML).toContain(
|
||||||
|
"No transactions found.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// The report of the switch's own save names the network the page already has.
|
// The report of the switch's own save names the network the page already has.
|
||||||
test("a switch in Settings is not taken a second time when its save is reported", async () => {
|
test("a switch in Settings is not taken a second time when its save is reported", async () => {
|
||||||
const page = await bootOnAddress();
|
const page = await bootOnAddress();
|
||||||
@@ -245,7 +299,9 @@ test("a switch back in Settings after a follow whose write failed is stored", as
|
|||||||
const stored = page.storage.read("autistmask");
|
const stored = page.storage.read("autistmask");
|
||||||
expect(stored.networkId).toBe("mainnet");
|
expect(stored.networkId).toBe("mainnet");
|
||||||
expect(stored.rpcUrl).toBe(NETWORKS.mainnet.defaultRpcUrl);
|
expect(stored.rpcUrl).toBe(NETWORKS.mainnet.defaultRpcUrl);
|
||||||
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
|
expect(stored.trackedTokens).toEqual([
|
||||||
|
{ ...MAINNET_TOKEN, balanceNotFetched: true },
|
||||||
|
]);
|
||||||
expect(page.text("flash-msg")).toBe("Switched to Ethereum Mainnet.");
|
expect(page.text("flash-msg")).toBe("Switched to Ethereum Mainnet.");
|
||||||
const told = globalThis.chrome.runtime.sendMessage.mock.calls
|
const told = globalThis.chrome.runtime.sendMessage.mock.calls
|
||||||
.map(([message]) => message)
|
.map(([message]) => message)
|
||||||
@@ -253,6 +309,271 @@ test("a switch back in Settings after a follow whose write failed is stored", as
|
|||||||
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
|
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The user saves an endpoint URL in Settings on Ethereum Mainnet. Before the
|
||||||
|
// URL is stored, the background saves a switch to Sepolia. Another write holds
|
||||||
|
// the lock, so the popup's follow of that switch and the URL's save both wait
|
||||||
|
// for the lock. Either may take it first. The URL is stored for neither
|
||||||
|
// network. The flash message says that the network changed.
|
||||||
|
test.each([
|
||||||
|
{ button: "btn-save-rpc", field: "settings-rpc", first: "follow" },
|
||||||
|
{ button: "btn-save-rpc", field: "settings-rpc", first: "save" },
|
||||||
|
{
|
||||||
|
button: "btn-save-blockscout",
|
||||||
|
field: "settings-blockscout",
|
||||||
|
first: "follow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
button: "btn-save-blockscout",
|
||||||
|
field: "settings-blockscout",
|
||||||
|
first: "save",
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"$button saves nothing when the network changes before the URL is stored ($first first)",
|
||||||
|
async ({ button, field, first }) => {
|
||||||
|
const page = await bootOnAddress();
|
||||||
|
await page.click("btn-settings");
|
||||||
|
// This answer passes both endpoint checks. The RPC check reads
|
||||||
|
// Ethereum Mainnet's chain id from it. The Blockscout check reads `ok`.
|
||||||
|
globalThis.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||||
|
});
|
||||||
|
const url = "https://own-node.example.invalid";
|
||||||
|
|
||||||
|
let releaseLock;
|
||||||
|
const lockHeld = navigator.locks.request(
|
||||||
|
"autistmask",
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
releaseLock = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// The click handler runs until its save waits for the lock.
|
||||||
|
const tapSave = async () => {
|
||||||
|
page.node(field).value = url;
|
||||||
|
const saving = page.node(button).listeners.click.map((fn) => fn());
|
||||||
|
await page.settle();
|
||||||
|
return saving;
|
||||||
|
};
|
||||||
|
let saving;
|
||||||
|
if (first === "save") saving = await tapSave();
|
||||||
|
await backgroundSwitch(page, "sepolia");
|
||||||
|
if (first === "follow") saving = await tapSave();
|
||||||
|
releaseLock();
|
||||||
|
await lockHeld;
|
||||||
|
await Promise.all(saving);
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
expect(state.networkId).toBe("sepolia");
|
||||||
|
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
expect(page.text("flash-msg")).toBe(
|
||||||
|
"The URL was not saved because the network changed.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The popup's follow of the background's switch to Sepolia has set `state` to
|
||||||
|
// Sepolia. The follow's write has not returned yet, so Settings still shows
|
||||||
|
// Ethereum Mainnet. The user taps "Save" there. The URL is for Ethereum
|
||||||
|
// Mainnet, so the save stores the URL for neither network.
|
||||||
|
test.each([
|
||||||
|
{ button: "btn-save-rpc", field: "settings-rpc" },
|
||||||
|
{ button: "btn-save-blockscout", field: "settings-blockscout" },
|
||||||
|
])(
|
||||||
|
"$button saves nothing when tapped while the follow's write waits",
|
||||||
|
async ({ button, field }) => {
|
||||||
|
const page = await bootOnAddress();
|
||||||
|
await page.click("btn-settings");
|
||||||
|
// This answer passes both endpoint checks on Ethereum Mainnet.
|
||||||
|
globalThis.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||||
|
});
|
||||||
|
const url = "https://own-node.example.invalid";
|
||||||
|
|
||||||
|
const { set } = page.storage.local;
|
||||||
|
const write = set.getMockImplementation();
|
||||||
|
let releaseWrite;
|
||||||
|
const writeHeld = new Promise((resolve) => {
|
||||||
|
releaseWrite = resolve;
|
||||||
|
});
|
||||||
|
// The background's write lands; the next, the follow's, waits.
|
||||||
|
set.mockImplementationOnce(write).mockImplementationOnce(
|
||||||
|
async (items) => {
|
||||||
|
await writeHeld;
|
||||||
|
await write(items);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await backgroundSwitch(page, "sepolia");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
expect(state.networkId).toBe("sepolia");
|
||||||
|
expect(page.value("settings-network")).toBe("mainnet");
|
||||||
|
|
||||||
|
page.node(field).value = url;
|
||||||
|
const saving = page.node(button).listeners.click.map((fn) => fn());
|
||||||
|
await page.settle();
|
||||||
|
releaseWrite();
|
||||||
|
await Promise.all(saving);
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
expect(page.text("flash-msg")).toBe(
|
||||||
|
"The URL was not saved because the network changed.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The user taps the RPC "Save" on Ethereum Mainnet. While the check waits for
|
||||||
|
// the node's answer, the popup follows the background's switch to Sepolia. The
|
||||||
|
// node answers with Sepolia's chain id. So the check refuses the node for
|
||||||
|
// Ethereum Mainnet, and nothing is stored.
|
||||||
|
test("the RPC check compares the answer with the network at the tap", async () => {
|
||||||
|
const page = await bootOnAddress();
|
||||||
|
await page.click("btn-settings");
|
||||||
|
let answer;
|
||||||
|
globalThis.fetch.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const url = "https://sepolia-node.example.invalid";
|
||||||
|
|
||||||
|
page.node("settings-rpc").value = url;
|
||||||
|
const saving = page.node("btn-save-rpc").listeners.click.map((fn) => fn());
|
||||||
|
await page.settle();
|
||||||
|
await backgroundSwitch(page, "sepolia");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
expect(state.networkId).toBe("sepolia");
|
||||||
|
answer({ json: async () => ({ result: NETWORKS.sepolia.chainId }) });
|
||||||
|
await Promise.all(saving);
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(url);
|
||||||
|
expect(page.text("flash-msg")).toBe(
|
||||||
|
"Wrong network: expected Ethereum Mainnet.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The user taps the Blockscout "Save" on Ethereum Mainnet. While the check
|
||||||
|
// waits for Blockscout's answer, the popup follows the background's switch to
|
||||||
|
// Sepolia. The URL is for Ethereum Mainnet, so nothing is stored.
|
||||||
|
test("the Blockscout save is for the network at the tap", async () => {
|
||||||
|
const page = await bootOnAddress();
|
||||||
|
await page.click("btn-settings");
|
||||||
|
let answer;
|
||||||
|
globalThis.fetch.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const url = "https://own-explorer.example.invalid/api/v2";
|
||||||
|
|
||||||
|
page.node("settings-blockscout").value = url;
|
||||||
|
const saving = page
|
||||||
|
.node("btn-save-blockscout")
|
||||||
|
.listeners.click.map((fn) => fn());
|
||||||
|
await page.settle();
|
||||||
|
await backgroundSwitch(page, "sepolia");
|
||||||
|
expect(page.value("settings-network")).toBe("sepolia");
|
||||||
|
answer({ ok: true });
|
||||||
|
await Promise.all(saving);
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(url);
|
||||||
|
expect(page.text("flash-msg")).toBe(
|
||||||
|
"The URL was not saved because the network changed.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settings is open when the network changes to Sepolia. The popup follows the
|
||||||
|
// background's switch, or the user picks Sepolia in the network selector. Each
|
||||||
|
// "Save" after the switch stores its URL for Sepolia.
|
||||||
|
test.each([
|
||||||
|
["a follow", (page) => backgroundSwitch(page, "sepolia")],
|
||||||
|
[
|
||||||
|
"the network selector",
|
||||||
|
async (page) => {
|
||||||
|
const select = page.node("settings-network");
|
||||||
|
select.value = "sepolia";
|
||||||
|
for (const fn of select.listeners.change) await fn();
|
||||||
|
await page.settle();
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
"after a switch to Sepolia by %s, Settings saves each URL for Sepolia",
|
||||||
|
async (_how, switchToSepolia) => {
|
||||||
|
const page = await bootOnAddress();
|
||||||
|
await page.click("btn-settings");
|
||||||
|
await switchToSepolia(page);
|
||||||
|
// This answer passes both endpoint checks on Sepolia. The RPC check
|
||||||
|
// reads Sepolia's chain id from it. The Blockscout check reads `ok`.
|
||||||
|
globalThis.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ result: NETWORKS.sepolia.chainId }),
|
||||||
|
});
|
||||||
|
const rpcUrl = "https://own-node.example.invalid";
|
||||||
|
const blockscoutUrl = "https://own-explorer.example.invalid/api/v2";
|
||||||
|
|
||||||
|
page.node("settings-rpc").value = rpcUrl;
|
||||||
|
await page.click("btn-save-rpc");
|
||||||
|
expect(page.text("flash-msg")).toBe("Saved.");
|
||||||
|
page.node("settings-blockscout").value = blockscoutUrl;
|
||||||
|
await page.click("btn-save-blockscout");
|
||||||
|
expect(page.text("flash-msg")).toBe("Saved.");
|
||||||
|
|
||||||
|
const stored = page.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.rpcUrl).toBe(rpcUrl);
|
||||||
|
expect(stored.blockscoutUrl).toBe(blockscoutUrl);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The user picks Sepolia in Settings, but the switch's write fails. The page is
|
||||||
|
// then on Sepolia, while Settings was last drawn for Ethereum Mainnet. A "Save"
|
||||||
|
// after the failed switch stores nothing for either network.
|
||||||
|
test.each([
|
||||||
|
{ button: "btn-save-rpc", field: "settings-rpc" },
|
||||||
|
{ button: "btn-save-blockscout", field: "settings-blockscout" },
|
||||||
|
])(
|
||||||
|
"$button saves nothing after a switch in Settings whose write failed",
|
||||||
|
async ({ button, field }) => {
|
||||||
|
const page = await bootOnAddress();
|
||||||
|
await page.click("btn-settings");
|
||||||
|
page.storage.local.set.mockRejectedValueOnce(
|
||||||
|
new Error("quota exceeded"),
|
||||||
|
);
|
||||||
|
const select = page.node("settings-network");
|
||||||
|
select.value = "sepolia";
|
||||||
|
for (const fn of select.listeners.change) {
|
||||||
|
await expect(fn()).rejects.toThrow("quota exceeded");
|
||||||
|
}
|
||||||
|
await page.settle();
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
expect(state.networkId).toBe("sepolia");
|
||||||
|
// This answer passes both endpoint checks on Ethereum Mainnet.
|
||||||
|
globalThis.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||||
|
});
|
||||||
|
const url = "https://own-node.example.invalid";
|
||||||
|
|
||||||
|
page.node(field).value = url;
|
||||||
|
await page.click(button);
|
||||||
|
|
||||||
|
expect(JSON.stringify(page.storage.read("autistmask"))).not.toContain(
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
expect(page.text("flash-msg")).toBe(
|
||||||
|
"The URL was not saved because the network changed.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Such as one a newer build wrote, which this build cannot read. Nothing reads
|
// Such as one a newer build wrote, which this build cannot read. Nothing reads
|
||||||
// or writes the stored record for it: the popup's next save finds it, as
|
// or writes the stored record for it: the popup's next save finds it, as
|
||||||
// before.
|
// before.
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
// USD prices are read from Chainlink price feeds on mainnet, through the RPC
|
||||||
|
// endpoint (https://git.eeqj.de/sneak/AutistMask/issues/525).
|
||||||
|
//
|
||||||
|
// The real refreshPrices() runs against a fake JSON-RPC node, answered through
|
||||||
|
// ethers' own request hook. The hook also records the body of every HTTP
|
||||||
|
// request, so a test can count them.
|
||||||
|
|
||||||
|
// prices.js pulls in state.js, which reads chrome.storage.local at load.
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { AbiCoder, FetchRequest } = require("ethers");
|
||||||
|
const {
|
||||||
|
prices,
|
||||||
|
refreshPrices,
|
||||||
|
clearPrices,
|
||||||
|
getPrice,
|
||||||
|
getAddressValue,
|
||||||
|
} = require("../src/shared/prices");
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
const {
|
||||||
|
TOKENS,
|
||||||
|
ETH_USD_FEED,
|
||||||
|
USD_PRICE_FEEDS,
|
||||||
|
PRICE_FEED_BY_ADDRESS,
|
||||||
|
} = require("../src/shared/tokenList");
|
||||||
|
|
||||||
|
const RPC_URL = "https://rpc.example.invalid";
|
||||||
|
const NOW = Date.UTC(2026, 9, 27, 12, 0, 0);
|
||||||
|
const NOW_SECONDS = NOW / 1000;
|
||||||
|
|
||||||
|
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
||||||
|
const LINK = "0x514910771af9ca656af840dff83e8264ecf986ca";
|
||||||
|
const DAI = "0x6b175474e89094c44da98b954eedeac495271d0f";
|
||||||
|
// A token at an address of its own that calls itself USDC.
|
||||||
|
const FAKE_USDC = "0x9999999999999999999999999999999999999999";
|
||||||
|
|
||||||
|
const ETH_FEED = ETH_USD_FEED.address.toLowerCase();
|
||||||
|
const USDC_FEED = PRICE_FEED_BY_ADDRESS.get(USDC).address.toLowerCase();
|
||||||
|
const LINK_FEED = PRICE_FEED_BY_ADDRESS.get(LINK).address.toLowerCase();
|
||||||
|
const DAI_FEED = PRICE_FEED_BY_ADDRESS.get(DAI).address.toLowerCase();
|
||||||
|
|
||||||
|
// What each feed answers, by its lowercased address: the price in the feed's
|
||||||
|
// own units and the time it was written. A feed that is not here makes the
|
||||||
|
// call fail.
|
||||||
|
let rounds;
|
||||||
|
// The parsed body of every HTTP request the node received.
|
||||||
|
let requests;
|
||||||
|
let logged;
|
||||||
|
|
||||||
|
function round(answer, updatedAt = NOW_SECONDS) {
|
||||||
|
return { answer, updatedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
// One JSON-RPC answer, to a latestRoundData() call.
|
||||||
|
function reply(req) {
|
||||||
|
const envelope = { jsonrpc: "2.0", id: req.id };
|
||||||
|
const answer = rounds[req.params[0].to.toLowerCase()];
|
||||||
|
if (!answer) {
|
||||||
|
return {
|
||||||
|
...envelope,
|
||||||
|
error: { code: -32000, message: "fixture: read failed" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...envelope,
|
||||||
|
result: AbiCoder.defaultAbiCoder().encode(
|
||||||
|
["uint80", "int256", "uint256", "uint256", "uint80"],
|
||||||
|
[1, answer.answer, answer.updatedAt, answer.updatedAt, 1],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The feed addresses one HTTP request read, sorted.
|
||||||
|
function feedsRead(body) {
|
||||||
|
return [body]
|
||||||
|
.flat()
|
||||||
|
.map((req) => req.params[0].to.toLowerCase())
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// One address that holds `held`, a list of [contract address, symbol].
|
||||||
|
function holdTokens(held) {
|
||||||
|
state.wallets = [
|
||||||
|
{
|
||||||
|
addresses: [
|
||||||
|
{
|
||||||
|
address: "0x" + "a".repeat(40),
|
||||||
|
balance: "1.0",
|
||||||
|
tokenBalances: held.map(([address, symbol]) => ({
|
||||||
|
address,
|
||||||
|
symbol,
|
||||||
|
balance: "10.0",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearPrices();
|
||||||
|
state.networkId = "mainnet";
|
||||||
|
state.rpcUrl = RPC_URL;
|
||||||
|
state.wallets = [];
|
||||||
|
state.trackedTokens = [];
|
||||||
|
rounds = { [ETH_FEED]: round(2000n * 10n ** 8n) };
|
||||||
|
requests = [];
|
||||||
|
logged = [];
|
||||||
|
jest.spyOn(Date, "now").mockReturnValue(NOW);
|
||||||
|
for (const level of ["error", "warn"]) {
|
||||||
|
jest.spyOn(console, level).mockImplementation((...args) => {
|
||||||
|
logged.push(args.map(String).join(" "));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
FetchRequest.registerGetUrl(async (req) => {
|
||||||
|
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
|
||||||
|
requests.push(body);
|
||||||
|
const answer = Array.isArray(body) ? body.map(reply) : reply(body);
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
statusMessage: "OK",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: Buffer.from(JSON.stringify(answer)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
clearPrices();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("refreshPrices", () => {
|
||||||
|
test("reads ETH and each held or tracked token that has a feed in one HTTP request", async () => {
|
||||||
|
holdTokens([
|
||||||
|
[USDC, "USDC"],
|
||||||
|
[LINK, "LINK"],
|
||||||
|
]);
|
||||||
|
state.trackedTokens = [{ address: DAI, symbol: "DAI", decimals: 18 }];
|
||||||
|
rounds[USDC_FEED] = round(10n ** 8n);
|
||||||
|
rounds[LINK_FEED] = round(1150000000n);
|
||||||
|
rounds[DAI_FEED] = round(99990000n);
|
||||||
|
|
||||||
|
await refreshPrices();
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(feedsRead(requests[0])).toEqual(
|
||||||
|
[ETH_FEED, USDC_FEED, LINK_FEED, DAI_FEED].sort(),
|
||||||
|
);
|
||||||
|
expect(getPrice("ETH")).toBe(2000);
|
||||||
|
expect(getPrice(USDC)).toBe(1);
|
||||||
|
expect(getPrice(LINK)).toBe(11.5);
|
||||||
|
expect(getPrice(DAI)).toBe(0.9999);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads again only once 5 minutes have passed", async () => {
|
||||||
|
await refreshPrices();
|
||||||
|
Date.now.mockReturnValue(NOW + 299999);
|
||||||
|
await refreshPrices();
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
|
||||||
|
Date.now.mockReturnValue(NOW + 300000);
|
||||||
|
rounds[ETH_FEED] = round(2100n * 10n ** 8n, NOW_SECONDS + 300);
|
||||||
|
await refreshPrices();
|
||||||
|
expect(requests).toHaveLength(2);
|
||||||
|
expect(getPrice("ETH")).toBe(2100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("on Sepolia, reads nothing and keeps no price", async () => {
|
||||||
|
await refreshPrices();
|
||||||
|
expect(getPrice("ETH")).toBe(2000);
|
||||||
|
|
||||||
|
state.networkId = "sepolia";
|
||||||
|
await refreshPrices();
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(Object.keys(prices)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pairing by contract address", () => {
|
||||||
|
test("a token that copies a bundled token's symbol gets no price", async () => {
|
||||||
|
holdTokens([
|
||||||
|
[USDC, "USDC"],
|
||||||
|
[FAKE_USDC, "USDC"],
|
||||||
|
]);
|
||||||
|
rounds[USDC_FEED] = round(10n ** 8n);
|
||||||
|
|
||||||
|
await refreshPrices();
|
||||||
|
|
||||||
|
expect(feedsRead(requests[0])).toEqual([ETH_FEED, USDC_FEED].sort());
|
||||||
|
expect(getPrice(USDC)).toBe(1);
|
||||||
|
expect(getPrice(FAKE_USDC)).toBeNull();
|
||||||
|
// 1 ETH and 10 real USDC are priced; the 10 look-alikes are not.
|
||||||
|
expect(getAddressValue(state.wallets[0].addresses[0])).toEqual({
|
||||||
|
usd: 2010,
|
||||||
|
partial: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("freshness", () => {
|
||||||
|
test("a reading older than its feed's heartbeat plus 30 minutes is no price", async () => {
|
||||||
|
const heartbeatAgo =
|
||||||
|
NOW_SECONDS - PRICE_FEED_BY_ADDRESS.get(USDC).heartbeat;
|
||||||
|
holdTokens([[USDC, "USDC"]]);
|
||||||
|
|
||||||
|
rounds[USDC_FEED] = round(10n ** 8n, heartbeatAgo - 1800 - 1);
|
||||||
|
await refreshPrices();
|
||||||
|
expect(getPrice(USDC)).toBeNull();
|
||||||
|
|
||||||
|
clearPrices();
|
||||||
|
rounds[USDC_FEED] = round(10n ** 8n, heartbeatAgo - 1800);
|
||||||
|
await refreshPrices();
|
||||||
|
expect(getPrice(USDC)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([0n, -1n])("an answer of %s is no price", async (answer) => {
|
||||||
|
rounds[ETH_FEED] = round(answer);
|
||||||
|
await refreshPrices();
|
||||||
|
expect(getPrice("ETH")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a failed read", () => {
|
||||||
|
test("leaves the token with no price and is logged with its error", async () => {
|
||||||
|
holdTokens([[USDC, "USDC"]]);
|
||||||
|
rounds[USDC_FEED] = round(10n ** 8n);
|
||||||
|
await refreshPrices();
|
||||||
|
expect(getPrice(USDC)).toBe(1);
|
||||||
|
|
||||||
|
Date.now.mockReturnValue(NOW + 300000);
|
||||||
|
delete rounds[USDC_FEED];
|
||||||
|
await refreshPrices();
|
||||||
|
|
||||||
|
expect(getPrice(USDC)).toBeNull();
|
||||||
|
expect(getPrice("ETH")).toBe(2000);
|
||||||
|
expect(
|
||||||
|
logged.some(
|
||||||
|
(line) =>
|
||||||
|
line.includes("price feed read failed") &&
|
||||||
|
line.includes(USDC),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an HTTP error is logged with its status", async () => {
|
||||||
|
FetchRequest.registerGetUrl(async () => ({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: "Unauthorized",
|
||||||
|
headers: { "content-type": "text/plain" },
|
||||||
|
body: Buffer.from("API key required"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await refreshPrices();
|
||||||
|
|
||||||
|
expect(getPrice("ETH")).toBeNull();
|
||||||
|
expect(logged.some((line) => line.includes("401"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the feed table", () => {
|
||||||
|
test("names each token by its address as the bundled list spells it", () => {
|
||||||
|
const listed = new Set(TOKENS.map((t) => t.address));
|
||||||
|
const unlisted = Object.keys(USD_PRICE_FEEDS).filter(
|
||||||
|
(address) => !listed.has(address),
|
||||||
|
);
|
||||||
|
expect(unlisted).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// While a refresh runs, a request through ctx is not dropped. One more refresh
|
||||||
|
// runs once the running one ends, however many were asked for in that time
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/581). A tick of the ten-second
|
||||||
|
// timer that finds a refresh running is dropped. Each test boots the real popup
|
||||||
|
// over a stored profile and holds the ten-second refresh open. `readChain()`
|
||||||
|
// sets each address's balance from `CHAIN`, as the real `refreshBalances()`
|
||||||
|
// sets it from the node.
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
ADDRESS,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
const {
|
||||||
|
hdWalletFromMnemonic,
|
||||||
|
deriveAddressFromXpub,
|
||||||
|
} = require("../src/shared/wallet");
|
||||||
|
|
||||||
|
const PHRASE =
|
||||||
|
"abandon abandon abandon abandon abandon abandon " +
|
||||||
|
"abandon abandon abandon abandon abandon about";
|
||||||
|
const PASSWORD = "correct horse battery staple";
|
||||||
|
|
||||||
|
// The first two addresses of PHRASE, both used on the chain.
|
||||||
|
const { xpub, firstAddress } = hdWalletFromMnemonic(PHRASE);
|
||||||
|
const SECOND_ADDRESS = deriveAddressFromXpub(xpub, 1);
|
||||||
|
|
||||||
|
// The ETH balance the chain holds for each address, in ether.
|
||||||
|
const CHAIN = {
|
||||||
|
[ADDRESS]: "1.5",
|
||||||
|
[firstAddress]: "2.0",
|
||||||
|
[SECOND_ADDRESS]: "3.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The balance stand-in for a refresh that returns.
|
||||||
|
async function readChain(wallets) {
|
||||||
|
for (const wallet of wallets) {
|
||||||
|
for (const addr of wallet.addresses) {
|
||||||
|
addr.balance = CHAIN[addr.address];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boots the popup over a profile with one wallet. From then on,
|
||||||
|
// `refreshBalances()` never returns unless the test answers it. So an unwanted
|
||||||
|
// refresh is counted once and then waits, instead of starting more.
|
||||||
|
async function boot() {
|
||||||
|
const page = await bootPopup(unversionedValidProfile());
|
||||||
|
expect(page.pageErrors).toEqual([]);
|
||||||
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
|
refreshBalances.mockImplementation(() => new Promise(() => {}));
|
||||||
|
return { page, refreshBalances };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts the ten-second refresh and holds it open until finish() is called.
|
||||||
|
// The held refresh then writes the chain's balances. finish() resolves once
|
||||||
|
// the held refresh has saved them and drawn Home. The one refresh after the
|
||||||
|
// held one also returns.
|
||||||
|
async function holdRefresh(page) {
|
||||||
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
|
let answer;
|
||||||
|
refreshBalances
|
||||||
|
.mockImplementationOnce(async (wallets) => {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
});
|
||||||
|
await readChain(wallets);
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(readChain);
|
||||||
|
const ticking = page.tick();
|
||||||
|
await page.settle();
|
||||||
|
return {
|
||||||
|
finish: async () => {
|
||||||
|
answer();
|
||||||
|
await ticking;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imports PHRASE on AddWallet. Its address scan finds the phrase's first two
|
||||||
|
// addresses.
|
||||||
|
async function importPhrase(page) {
|
||||||
|
const { scanForAddresses } = require("../src/shared/balances");
|
||||||
|
scanForAddresses.mockImplementationOnce(async () => ({
|
||||||
|
addresses: [
|
||||||
|
{ address: firstAddress, index: 0 },
|
||||||
|
{ address: SECOND_ADDRESS, index: 1 },
|
||||||
|
],
|
||||||
|
nextIndex: 2,
|
||||||
|
}));
|
||||||
|
await page.click("btn-add-wallet-bottom");
|
||||||
|
await page.click("tab-mnemonic");
|
||||||
|
page.node("wallet-mnemonic").value = PHRASE;
|
||||||
|
page.node("add-wallet-password").value = PASSWORD;
|
||||||
|
page.node("add-wallet-password-confirm").value = PASSWORD;
|
||||||
|
await page.click("btn-add-wallet-confirm");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imports the private key `key` on AddWallet.
|
||||||
|
async function importKey(page, key) {
|
||||||
|
await page.click("btn-add-wallet-bottom");
|
||||||
|
await page.click("tab-privkey");
|
||||||
|
page.node("import-private-key").value = key;
|
||||||
|
page.node("add-wallet-password").value = PASSWORD;
|
||||||
|
page.node("add-wallet-password-confirm").value = PASSWORD;
|
||||||
|
await page.click("btn-add-wallet-confirm");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("addresses that an import finds during a refresh show their balances once one more refresh returns", async () => {
|
||||||
|
const { page } = await boot();
|
||||||
|
const refresh = await holdRefresh(page);
|
||||||
|
|
||||||
|
await importPhrase(page);
|
||||||
|
expect(page.visibleViews()).toEqual(["main"]);
|
||||||
|
expect(page.text("flash-msg")).toBe("Found 2 addresses.");
|
||||||
|
|
||||||
|
await refresh.finish();
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
const list = page.node("wallet-list").innerHTML;
|
||||||
|
expect(list).toContain("<span>2.0000</span>");
|
||||||
|
expect(list).toContain("<span>3.0000</span>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("several requests during a refresh lead to exactly one more refresh", async () => {
|
||||||
|
const { page, refreshBalances } = await boot();
|
||||||
|
const refresh = await holdRefresh(page);
|
||||||
|
const calls = refreshBalances.mock.calls.length;
|
||||||
|
|
||||||
|
await importKey(page, "0x" + "11".repeat(32));
|
||||||
|
await importKey(page, "0x" + "22".repeat(32));
|
||||||
|
await importPhrase(page);
|
||||||
|
expect(refreshBalances).toHaveBeenCalledTimes(calls);
|
||||||
|
|
||||||
|
await refresh.finish();
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
expect(refreshBalances).toHaveBeenCalledTimes(calls + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tick of the ten-second timer during a refresh starts no other refresh", async () => {
|
||||||
|
const { page, refreshBalances } = await boot();
|
||||||
|
const refresh = await holdRefresh(page);
|
||||||
|
const calls = refreshBalances.mock.calls.length;
|
||||||
|
|
||||||
|
await page.tick();
|
||||||
|
|
||||||
|
await refresh.finish();
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
expect(refreshBalances).toHaveBeenCalledTimes(calls);
|
||||||
|
});
|
||||||
@@ -67,13 +67,15 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveTokenSymbol", () => {
|
describe("resolveTokenSymbol", () => {
|
||||||
|
// The bundled list is read only for a token on Ethereum Mainnet.
|
||||||
test("reads the bundled list", () => {
|
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", () => {
|
test("prefers the bundled list over a tracked entry", () => {
|
||||||
state.trackedTokens = [{ address: USDC, symbol: "NOTUSDC" }];
|
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", () => {
|
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", () => {
|
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.
|
// NOVEL_TOKEN is not the real USDC contract, so it may not wear USDC.
|
||||||
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "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", () => {
|
test("rejects an explorer entry claiming the native ETH ticker", () => {
|
||||||
|
|||||||
@@ -131,10 +131,14 @@ async function review(amount, token) {
|
|||||||
async function confirm(amount, token) {
|
async function confirm(amount, token) {
|
||||||
const txInfo = await review("0.5", token);
|
const txInfo = await review("0.5", token);
|
||||||
confirmTx.show({ ...txInfo, amount });
|
confirmTx.show({ ...txInfo, amount });
|
||||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// showFlash() starts a timer that clears the message two seconds later. Each
|
||||||
|
// test here runs on fake timers and drops them in afterEach, so that timer
|
||||||
|
// never outlives the test.
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
elements.clear();
|
elements.clear();
|
||||||
state.wallets = [
|
state.wallets = [
|
||||||
{
|
{
|
||||||
@@ -167,6 +171,10 @@ beforeEach(() => {
|
|||||||
state.currentView = null;
|
state.currentView = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
describe.each([
|
describe.each([
|
||||||
["1e-3", "ETH"],
|
["1e-3", "ETH"],
|
||||||
["1e-3", "TOK"],
|
["1e-3", "TOK"],
|
||||||
|
|||||||
+30
-1
@@ -277,14 +277,18 @@ async function review() {
|
|||||||
|
|
||||||
// show() starts the fee estimate without awaiting it; this lets it settle.
|
// show() starts the fee estimate without awaiting it; this lets it settle.
|
||||||
async function settle() {
|
async function settle() {
|
||||||
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function canSend() {
|
function canSend() {
|
||||||
return !el("btn-confirm-send").disabled;
|
return !el("btn-confirm-send").disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// showFlash() starts a timer that clears the message two seconds later. Each
|
||||||
|
// test here runs on fake timers and drops them in afterEach, so that timer
|
||||||
|
// never outlives the test.
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
elements.clear();
|
elements.clear();
|
||||||
log.errorf.mockClear();
|
log.errorf.mockClear();
|
||||||
pageClosed = new AbortController();
|
pageClosed = new AbortController();
|
||||||
@@ -297,6 +301,10 @@ beforeEach(() => {
|
|||||||
mockNode.broadcast = [];
|
mockNode.broadcast = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
describe("Max on an ETH send", () => {
|
describe("Max on an ETH send", () => {
|
||||||
test("fills in the exact balance minus the fee reserve", async () => {
|
test("fills in the exact balance minus the fee reserve", async () => {
|
||||||
await refreshWith(BALANCE_WEI);
|
await refreshWith(BALANCE_WEI);
|
||||||
@@ -424,6 +432,27 @@ describe("Max on an ETH send", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test("says so when no refresh has read the ETH balance", async () => {
|
||||||
|
await refreshWith(BALANCE_WEI);
|
||||||
|
state.wallets[0].addresses[0].balance = null;
|
||||||
|
openSend();
|
||||||
|
await pressMax();
|
||||||
|
expect(el("send-amount").value).toBe("");
|
||||||
|
expect(text("flash-msg")).toBe("This token's balance is unknown.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fills nothing in and says nothing when a network switch cleared the balance while the fee was estimated", async () => {
|
||||||
|
await refreshWith(BALANCE_WEI);
|
||||||
|
openSend();
|
||||||
|
const release = holdFeeEstimate();
|
||||||
|
const pressed = pressMax();
|
||||||
|
state.wallets[0].addresses[0].balance = null;
|
||||||
|
release();
|
||||||
|
await pressed;
|
||||||
|
expect(el("send-amount").value).toBe("");
|
||||||
|
expect(text("flash-msg")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
test("fills nothing in and says nothing once Send was left while the fee was estimated", async () => {
|
test("fills nothing in and says nothing once Send was left while the fee was estimated", async () => {
|
||||||
// 0.0001 ETH, which does not cover the fee: a result that landed
|
// 0.0001 ETH, which does not cover the fee: a result that landed
|
||||||
// would say so on whichever screen is shown.
|
// would say so on whichever screen is shown.
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
// Another page stores a different RPC or Blockscout URL for the same network.
|
||||||
|
// Then the user taps "Save" in Settings on this page, on the URL that this page
|
||||||
|
// loaded. The URL on screen is stored, and the flash message reads "Saved."
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/688). Driven through the real
|
||||||
|
// popup over a stored profile on Ethereum Mainnet.
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
const { NETWORKS } = require("../src/shared/networks");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
||||||
|
{
|
||||||
|
button: "btn-save-blockscout",
|
||||||
|
input: "settings-blockscout",
|
||||||
|
field: "blockscoutUrl",
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"$button stores the URL on screen after another page stored a different one",
|
||||||
|
async ({ button, input, field }) => {
|
||||||
|
const page = await bootPopup(unversionedValidProfile());
|
||||||
|
await page.click("btn-settings");
|
||||||
|
const url = page.value(input);
|
||||||
|
// This write sends this page no storage change event.
|
||||||
|
page.storage.write("autistmask", {
|
||||||
|
...page.storage.read("autistmask"),
|
||||||
|
[field]: "https://other-page.example.invalid",
|
||||||
|
});
|
||||||
|
// This answer passes both endpoint checks on Ethereum Mainnet. The RPC
|
||||||
|
// check reads Ethereum Mainnet's chain id from it. The Blockscout check
|
||||||
|
// reads `ok`.
|
||||||
|
globalThis.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.click(button);
|
||||||
|
|
||||||
|
expect(page.storage.read("autistmask")[field]).toBe(url);
|
||||||
|
expect(page.text("flash-msg")).toBe("Saved.");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The user types a URL in Settings and taps "Save". While the check waits for
|
||||||
|
// the endpoint's answer, another page turns "Use Blockscout" on. This page
|
||||||
|
// follows that change and draws Settings again, with the stored URL in the
|
||||||
|
// input. The check then passes. The typed URL is not stored. The flash message
|
||||||
|
// says that the URL was changed during the check
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/695).
|
||||||
|
test.each([
|
||||||
|
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
||||||
|
{
|
||||||
|
button: "btn-save-blockscout",
|
||||||
|
input: "settings-blockscout",
|
||||||
|
field: "blockscoutUrl",
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"$button stores nothing when Settings is drawn again during the check",
|
||||||
|
async ({ button, input, field }) => {
|
||||||
|
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 saving = page.node(button).listeners.click.map((fn) => fn());
|
||||||
|
await page.settle();
|
||||||
|
// The browser announces this write to this page.
|
||||||
|
await page.storage.local.set({
|
||||||
|
autistmask: {
|
||||||
|
...page.storage.read("autistmask"),
|
||||||
|
useBlockscout: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await page.settle();
|
||||||
|
expect(page.node("settings-use-blockscout").checked).toBe(true);
|
||||||
|
// This answer passes both endpoint checks on Ethereum Mainnet.
|
||||||
|
answer({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||||
|
});
|
||||||
|
await Promise.all(saving);
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
|
||||||
|
expect(page.value(input)).toBe(storedUrl);
|
||||||
|
expect(page.text("flash-msg")).toBe(
|
||||||
|
"The URL was not saved because it was changed during the check.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// As above, but another write holds the lock. So this page's follow of the
|
||||||
|
// change to "Use Blockscout" waits for the lock. The endpoint answers while the
|
||||||
|
// input still shows the typed URL. The save then asks for the lock, after the
|
||||||
|
// follow did. So the follow draws Settings again before the save compares the
|
||||||
|
// input.
|
||||||
|
test.each([
|
||||||
|
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
|
||||||
|
{
|
||||||
|
button: "btn-save-blockscout",
|
||||||
|
input: "settings-blockscout",
|
||||||
|
field: "blockscoutUrl",
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
"$button stores nothing when Settings is drawn again while the save waits for the lock",
|
||||||
|
async ({ button, input, field }) => {
|
||||||
|
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;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const typedUrl = "https://typed.example.invalid";
|
||||||
|
|
||||||
|
page.node(input).value = typedUrl;
|
||||||
|
const saving = page.node(button).listeners.click.map((fn) => fn());
|
||||||
|
await page.settle();
|
||||||
|
let releaseLock;
|
||||||
|
const lockHeld = navigator.locks.request(
|
||||||
|
"autistmask",
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
releaseLock = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// The browser announces this write to this page.
|
||||||
|
await page.storage.local.set({
|
||||||
|
autistmask: {
|
||||||
|
...page.storage.read("autistmask"),
|
||||||
|
useBlockscout: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await page.settle();
|
||||||
|
// This answer passes both endpoint checks on Ethereum Mainnet.
|
||||||
|
answer({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ result: NETWORKS.mainnet.chainId }),
|
||||||
|
});
|
||||||
|
await page.settle();
|
||||||
|
expect(page.value(input)).toBe(typedUrl);
|
||||||
|
releaseLock();
|
||||||
|
await lockHeld;
|
||||||
|
await Promise.all(saving);
|
||||||
|
await page.settle();
|
||||||
|
|
||||||
|
expect(page.node("settings-use-blockscout").checked).toBe(true);
|
||||||
|
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
|
||||||
|
expect(page.value(input)).toBe(storedUrl);
|
||||||
|
expect(page.text("flash-msg")).toBe(
|
||||||
|
"The URL was not saved because it was changed during the check.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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.");
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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()}"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
// version field, and showing those users a wipe prompt would be a worse defect
|
// version field, and showing those users a wipe prompt would be a worse defect
|
||||||
// than the one being fixed. It is migrated in place and keeps working.
|
// than the one being fixed. It is migrated in place and keeps working.
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
bootPopup,
|
bootPopup,
|
||||||
cleanupPopup,
|
cleanupPopup,
|
||||||
@@ -148,6 +151,29 @@ describe("the destructive reset on the recovery screen", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A key or xprv wallet has no recovery phrase, so the erase warning names all
|
||||||
|
// three backups (https://git.eeqj.de/sneak/AutistMask/issues/593). The boot
|
||||||
|
// harness holds no static text, so this reads the markup itself.
|
||||||
|
describe("the erase warning on the recovery screen", () => {
|
||||||
|
test("names the recovery phrase, the private key and the extended private key", () => {
|
||||||
|
const html = fs.readFileSync(
|
||||||
|
path.join(__dirname, "..", "src", "popup", "index.html"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const start = html.indexOf('id="view-state-recovery"');
|
||||||
|
const end = html.indexOf('id="btn-state-recovery-reset"', start);
|
||||||
|
const screenText = html
|
||||||
|
.slice(start, end)
|
||||||
|
.replace(/<[^>]*>/g, " ")
|
||||||
|
.replace(/\s+/g, " ");
|
||||||
|
|
||||||
|
expect(screenText).toContain(
|
||||||
|
"If you kept a copy of a wallet's recovery phrase, private key or " +
|
||||||
|
"extended private key, you can add that wallet again.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("a popup already open when the stored profile becomes unreadable", () => {
|
describe("a popup already open when the stored profile becomes unreadable", () => {
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/373. The popup used to stay
|
// https://git.eeqj.de/sneak/AutistMask/issues/373. The popup used to stay
|
||||||
// on the wallet list with the last good balances, and only a reopen
|
// on the wallet list with the last good balances, and only a reopen
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// browser's own does for the one call the extension makes,
|
// browser's own does for the one call the extension makes,
|
||||||
// `navigator.locks.request(name, callback)`. Every module a test file loads
|
// `navigator.locks.request(name, callback)`. Every module a test file loads
|
||||||
// shares it, as the extension's background and its pages share the browser's
|
// shares it, as the extension's background and its pages share the browser's
|
||||||
// (README, "Stored state and its version").
|
// (README, "Stored data").
|
||||||
//
|
//
|
||||||
// Run before each test file by `setupFiles` in package.json.
|
// Run before each test file by `setupFiles` in package.json.
|
||||||
|
|
||||||
|
|||||||
@@ -279,10 +279,11 @@ async function bootPopup(stored, options) {
|
|||||||
getAddressValue: () => ({ usd: null, partial: false }),
|
getAddressValue: () => ({ usd: null, partial: false }),
|
||||||
}));
|
}));
|
||||||
// scanForAddresses() answers in the real one's shape, a scan that found
|
// 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", () => ({
|
jest.doMock("../../src/shared/balances", () => ({
|
||||||
fetchTokenBalances: jest.fn(async () => []),
|
fetchTokenBalances: jest.fn(async () => []),
|
||||||
refreshBalances: jest.fn(async () => {}),
|
refreshBalances: jest.fn(async () => []),
|
||||||
dropUntrackedTokens: jest.requireActual("../../src/shared/balances")
|
dropUntrackedTokens: jest.requireActual("../../src/shared/balances")
|
||||||
.dropUntrackedTokens,
|
.dropUntrackedTokens,
|
||||||
lookupTokenInfo: jest.fn(async () => null),
|
lookupTokenInfo: jest.fn(async () => null),
|
||||||
|
|||||||
+184
-11
@@ -231,6 +231,15 @@ async function approveSwap(data, response, value = 0n) {
|
|||||||
await click("btn-approve-tx");
|
await click("btn-approve-tx");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A later popup resumes the wait, and the receipt is there.
|
||||||
|
async function receiptArrives() {
|
||||||
|
mockReceipt = { blockNumber: 1 };
|
||||||
|
txStatus.restoreWait();
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
txStatus.endWait();
|
txStatus.endWait();
|
||||||
mockReceipt = null;
|
mockReceipt = null;
|
||||||
@@ -287,20 +296,27 @@ describe.each([
|
|||||||
expect(node("error-tx-summary").textContent).toBe(expected);
|
expect(node("error-tx-summary").textContent).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The success screen shows the approval screen's decoded lines in place
|
// The success screen has an Amount line of its own only when the approval
|
||||||
// of the amount, so the ETH value is not on it either.
|
// screen's decoded lines have none. The ETH value is on neither.
|
||||||
test("the success screen has no amount line of its own", async () => {
|
test("the success screen's amount", async () => {
|
||||||
await approveSwap(v4SwapData(actions), { txHash: TX_HASH });
|
await approveSwap(v4SwapData(actions), { txHash: TX_HASH });
|
||||||
mockReceipt = { blockNumber: 1 };
|
await receiptArrives();
|
||||||
// A later popup resumes the wait, and the receipt is there.
|
const summary = node("success-tx-summary");
|
||||||
txStatus.restoreWait();
|
expect(summary.parentElement.classList.contains("hidden")).toBe(
|
||||||
for (let i = 0; i < 10; i++) {
|
!expected.startsWith("Unknown"),
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
);
|
||||||
}
|
expect(summary.textContent).toBe(expected);
|
||||||
const summary = node("success-tx-summary").parentElement;
|
|
||||||
expect(summary.classList.contains("hidden")).toBe(true);
|
|
||||||
expect(node("success-tx-decoded").innerHTML).not.toContain("0.0000");
|
expect(node("success-tx-decoded").innerHTML).not.toContain("0.0000");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The Protocol line of the decoded lines shows the router.
|
||||||
|
test("the success screen has no To line of its own", async () => {
|
||||||
|
await approveSwap(v4SwapData(actions), { txHash: TX_HASH });
|
||||||
|
await receiptArrives();
|
||||||
|
const to = node("success-tx-to").parentElement;
|
||||||
|
expect(to.classList.contains("hidden")).toBe(true);
|
||||||
|
expect(node("success-tx-decoded").innerHTML).toContain(ROUTER);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// A swap paid in native ETH has a Token In of "ETH (native)" and no Amount
|
// A swap paid in native ETH has a Token In of "ETH (native)" and no Amount
|
||||||
@@ -339,4 +355,161 @@ describe.each([
|
|||||||
);
|
);
|
||||||
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
|
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the success screen shows the ETH the transaction sends", async () => {
|
||||||
|
await approveSwap(v4SwapData(actions), { txHash: TX_HASH }, ONE_ETH);
|
||||||
|
await receiptArrives();
|
||||||
|
const summary = node("success-tx-summary");
|
||||||
|
expect(summary.parentElement.classList.contains("hidden")).toBe(false);
|
||||||
|
expect(summary.textContent).toBe("1.0000 ETH");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/722: a WRAP_ETH of 1 ETH into the
|
||||||
|
// router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH the user
|
||||||
|
// settles. The Amount line reads 1.0000 ETH, though up to 2 ETH can leave. The
|
||||||
|
// rest is on the swap's own Moved from the sender line.
|
||||||
|
describe("a swap with a Moved from the sender line of its own", () => {
|
||||||
|
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
|
||||||
|
const TWO_ETH = 2n * ONE_ETH;
|
||||||
|
const SEND_FAILS = {
|
||||||
|
error: "The transaction could not be sent.",
|
||||||
|
retryable: false,
|
||||||
|
};
|
||||||
|
const data = routerIface.encodeFunctionData("execute", [
|
||||||
|
"0x0b0c10",
|
||||||
|
[
|
||||||
|
// WRAP_ETH and UNWRAP_WETH have the same layout.
|
||||||
|
coder.encode(["address", "uint256"], [ADDRESS_THIS, ONE_ETH]),
|
||||||
|
coder.encode(["address", "uint256"], [ADDRESS_THIS, 0n]),
|
||||||
|
coder.encode(
|
||||||
|
["bytes", "bytes[]"],
|
||||||
|
[
|
||||||
|
new Uint8Array([SWAP_EXACT_IN_SINGLE, SETTLE, TAKE_ALL]),
|
||||||
|
[
|
||||||
|
coder.encode(
|
||||||
|
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
|
||||||
|
[[ETH_USDC_POOL, true, ONE_ETH, 990000n, "0x"]],
|
||||||
|
),
|
||||||
|
// The user pays this one, not the router.
|
||||||
|
coder.encode(
|
||||||
|
["address", "uint256", "bool"],
|
||||||
|
[ZERO, 0n, true],
|
||||||
|
),
|
||||||
|
takeAllUsdc,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
9999999999n,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function expectMovedLine(id) {
|
||||||
|
const html = node(id).innerHTML;
|
||||||
|
expect(html).toContain("Moved from the sender");
|
||||||
|
expect(html).toContain("Up to 1.0000 ETH");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the wait screen shows that line's amount under the Amount figure", async () => {
|
||||||
|
await approveSwap(data, { txHash: TX_HASH }, TWO_ETH);
|
||||||
|
expect(node("wait-tx-summary").textContent).toBe("1.0000 ETH");
|
||||||
|
expectMovedLine("wait-tx-token-note");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the error screen shows that line's amount under the Amount figure", async () => {
|
||||||
|
await approveSwap(data, SEND_FAILS, TWO_ETH);
|
||||||
|
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
|
||||||
|
expectMovedLine("error-tx-token-note");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a reopened popup's wait screen still shows it", async () => {
|
||||||
|
await approveSwap(data, { txHash: TX_HASH }, TWO_ETH);
|
||||||
|
node("wait-tx-token-note").innerHTML = "";
|
||||||
|
txStatus.restoreWait();
|
||||||
|
expectMovedLine("wait-tx-token-note");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a reopened popup's error screen still shows it", async () => {
|
||||||
|
await approveSwap(data, SEND_FAILS, TWO_ETH);
|
||||||
|
node("error-tx-token-note").innerHTML = "";
|
||||||
|
txStatus.renderError();
|
||||||
|
expectMovedLine("error-tx-token-note");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/733: a PERMIT2_PERMIT that gives
|
||||||
|
// another spender an unlimited USDC allowance, then a V4 swap of 1 ETH. The
|
||||||
|
// Amount line reads 1.0000 ETH and says nothing of the allowance.
|
||||||
|
describe("a swap that gives another spender an allowance", () => {
|
||||||
|
const OTHER_SPENDER = "0x1111111111111111111111111111111111111111";
|
||||||
|
const MAX_UINT160 = (1n << 160n) - 1n;
|
||||||
|
const SEND_FAILS = {
|
||||||
|
error: "The transaction could not be sent.",
|
||||||
|
retryable: false,
|
||||||
|
};
|
||||||
|
const data = routerIface.encodeFunctionData("execute", [
|
||||||
|
"0x0a10",
|
||||||
|
[
|
||||||
|
coder.encode(
|
||||||
|
[
|
||||||
|
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
|
||||||
|
"bytes",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[[USDC, MAX_UINT160, 0, 0], OTHER_SPENDER, 9999999999n],
|
||||||
|
"0x1234",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
coder.encode(
|
||||||
|
["bytes", "bytes[]"],
|
||||||
|
[
|
||||||
|
new Uint8Array([SWAP_EXACT_IN_SINGLE, SETTLE, TAKE]),
|
||||||
|
[
|
||||||
|
coder.encode(
|
||||||
|
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
|
||||||
|
[[ETH_USDC_POOL, true, ONE_ETH, 990000n, "0x"]],
|
||||||
|
),
|
||||||
|
settleEth,
|
||||||
|
takeUsdc,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
9999999999n,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function expectAllowanceLine(id) {
|
||||||
|
const html = node(id).innerHTML;
|
||||||
|
expect(html).toContain("Permit2 allowance");
|
||||||
|
expect(html).toContain("Unlimited");
|
||||||
|
expect(html).toContain("Allowance token");
|
||||||
|
expect(html).toContain('<div class="font-bold break-words">USDC</div>');
|
||||||
|
expect(html).toContain(`data-copy="${USDC}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the wait screen shows the allowance under the Amount figure", async () => {
|
||||||
|
await approveSwap(data, { txHash: TX_HASH }, ONE_ETH);
|
||||||
|
expect(node("wait-tx-summary").textContent).toBe("1.0000 ETH");
|
||||||
|
expectAllowanceLine("wait-tx-token-note");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the error screen shows the allowance under the Amount figure", async () => {
|
||||||
|
await approveSwap(data, SEND_FAILS, ONE_ETH);
|
||||||
|
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
|
||||||
|
expectAllowanceLine("error-tx-token-note");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a reopened popup's wait screen still shows it", async () => {
|
||||||
|
await approveSwap(data, { txHash: TX_HASH }, ONE_ETH);
|
||||||
|
node("wait-tx-token-note").innerHTML = "";
|
||||||
|
txStatus.restoreWait();
|
||||||
|
expectAllowanceLine("wait-tx-token-note");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a reopened popup's error screen still shows it", async () => {
|
||||||
|
await approveSwap(data, SEND_FAILS, ONE_ETH);
|
||||||
|
node("error-tx-token-note").innerHTML = "";
|
||||||
|
txStatus.renderError();
|
||||||
|
expectAllowanceLine("error-tx-token-note");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+110
-16
@@ -72,6 +72,11 @@ const {
|
|||||||
const { renderSendTokenSelect } = require("../src/popup/views/send");
|
const { renderSendTokenSelect } = require("../src/popup/views/send");
|
||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
const { debugFetch } = require("../src/shared/log");
|
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
|
// 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
|
// 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", () => {
|
test("a known symbol from its own contract is not a spoof", () => {
|
||||||
expect(isSpoofedSymbol("USDC", USDC_CONTRACT)).toBe(false);
|
expect(isSpoofedSymbol("USDC", USDC_CONTRACT, MAINNET)).toBe(false);
|
||||||
expect(isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase())).toBe(
|
expect(
|
||||||
false,
|
isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase(), MAINNET),
|
||||||
);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a known symbol from another contract is a spoof", () => {
|
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", () => {
|
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", () => {
|
test("ASCII padding does not buy a pass", () => {
|
||||||
expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true);
|
expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true);
|
||||||
expect(isSpoofedSymbol("\tETH\n", 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", () => {
|
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", () => {
|
test("compatibility forms fold onto the symbol they imitate", () => {
|
||||||
expect(isSpoofedSymbol(FULLWIDTH_ETH, FAKE_ETH_CONTRACT)).toBe(true);
|
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
|
// 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", () => {
|
test("a genuine contract still bears its own padded symbol", () => {
|
||||||
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT)).toBe(false);
|
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT, MAINNET)).toBe(false);
|
||||||
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT)).toBe(false);
|
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT, MAINNET)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Normalization must not invent a match. Interior ASCII whitespace is
|
// 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(/^[!-~]+$/);
|
expect(symbol).toMatch(/^[!-~]+$/);
|
||||||
if (addresses === null) continue;
|
if (addresses === null) continue;
|
||||||
for (const address of addresses) {
|
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", () => {
|
describe("the shipped token list", () => {
|
||||||
test("no bundled token is filtered at its own address", () => {
|
test("no bundled token is filtered at its own address", () => {
|
||||||
const filtered = TOKENS.filter((t) =>
|
const filtered = TOKENS.filter((t) =>
|
||||||
isSpoofedSymbol(t.symbol, t.address),
|
isSpoofedSymbol(t.symbol, t.address, MAINNET),
|
||||||
).map((t) => t.symbol + " @ " + t.address);
|
).map((t) => t.symbol + " @ " + t.address);
|
||||||
expect(filtered).toEqual([]);
|
expect(filtered).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -360,7 +386,9 @@ describe("the shipped token list", () => {
|
|||||||
expect(shared.length).toBeGreaterThan(0);
|
expect(shared.length).toBeGreaterThan(0);
|
||||||
for (const [, list] of shared) {
|
for (const [, list] of shared) {
|
||||||
for (const t of list) {
|
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(),
|
[...addresses].sort(),
|
||||||
);
|
);
|
||||||
for (const address of addresses) {
|
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) {
|
for (const [symbol, list] of bySymbol) {
|
||||||
if (list.length < 2) continue;
|
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(() => {
|
beforeEach(() => {
|
||||||
|
state.networkId = "mainnet";
|
||||||
state.fraudContracts = [];
|
state.fraudContracts = [];
|
||||||
state.hideLowHolderTokens = true;
|
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", () => {
|
test("a fake ETH token is not selectable", () => {
|
||||||
render([
|
render([
|
||||||
{
|
{
|
||||||
@@ -627,7 +687,12 @@ describe("surface 3: the balance list", () => {
|
|||||||
decimals: "6",
|
decimals: "6",
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
const balances = await fetchTokenBalances(
|
||||||
|
HOLDER,
|
||||||
|
BLOCKSCOUT,
|
||||||
|
[],
|
||||||
|
MAINNET,
|
||||||
|
);
|
||||||
expect(balances).toHaveLength(1);
|
expect(balances).toHaveLength(1);
|
||||||
expect(balances[0].symbol).toBe(" USDC ");
|
expect(balances[0].symbol).toBe(" USDC ");
|
||||||
});
|
});
|
||||||
@@ -641,11 +706,40 @@ describe("surface 3: the balance list", () => {
|
|||||||
decimals: "6",
|
decimals: "6",
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
const balances = await fetchTokenBalances(
|
||||||
|
HOLDER,
|
||||||
|
BLOCKSCOUT,
|
||||||
|
[],
|
||||||
|
MAINNET,
|
||||||
|
);
|
||||||
expect(balances).toHaveLength(1);
|
expect(balances).toHaveLength(1);
|
||||||
expect(balances[0].symbol).toBe("USDC");
|
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
|
// 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
|
// 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
|
// this loop at all. An explorer row that is not an ERC-20 is dropped
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// A transaction's time is written by isoDate() and timeAgo() in
|
// A transaction's time is written by isoDate() and timeAgo() in
|
||||||
// src/popup/views/helpers.js on every screen that shows one (README, Display
|
// src/popup/views/helpers.js on every screen that shows one (README, Amounts;
|
||||||
// Consistency; https://git.eeqj.de/sneak/AutistMask/issues/168). AddressDetail
|
// https://git.eeqj.de/sneak/AutistMask/issues/168). AddressDetail and
|
||||||
// and AddressToken used to define their own copies, so a fix to the shared pair
|
// AddressToken used to define their own copies, so a fix to the shared pair
|
||||||
// would not have reached them.
|
// would not have reached them.
|
||||||
//
|
//
|
||||||
// The pair is replaced before the views are loaded, because a view takes it
|
// The pair is replaced before the views are loaded, because a view takes it
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
// calls. Driven against a minimal DOM stub in the shape
|
// calls. Driven against a minimal DOM stub in the shape
|
||||||
// tests/contractCreation.test.js uses.
|
// tests/contractCreation.test.js uses.
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
const { Interface } = require("ethers");
|
const { Interface } = require("ethers");
|
||||||
|
|
||||||
jest.mock("../src/shared/log", () => ({
|
jest.mock("../src/shared/log", () => ({
|
||||||
@@ -51,6 +53,7 @@ globalThis.chrome = {
|
|||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
const { NETWORKS } = require("../src/shared/networks");
|
const { NETWORKS } = require("../src/shared/networks");
|
||||||
const { fetchRecentTransactions } = require("../src/shared/transactions");
|
const { fetchRecentTransactions } = require("../src/shared/transactions");
|
||||||
|
const { displaySymbol } = require("../src/shared/symbolDisplay");
|
||||||
const transactionDetail = require("../src/popup/views/transactionDetail");
|
const transactionDetail = require("../src/popup/views/transactionDetail");
|
||||||
|
|
||||||
const EXPLORER = "https://explorer.example/api/v2";
|
const EXPLORER = "https://explorer.example/api/v2";
|
||||||
@@ -197,15 +200,63 @@ test("a token transfer's decoded details name the token contract and the amount
|
|||||||
"Token Transfer",
|
"Token Transfer",
|
||||||
);
|
);
|
||||||
const token = decodedLine("Token");
|
const token = decodedLine("Token");
|
||||||
expect(token).toContain('<div class="font-bold">TDOL</div>');
|
expect(token).toContain('<div class="font-bold break-words">TDOL</div>');
|
||||||
expect(token).toContain(TOKEN);
|
expect(token).toContain(TOKEN);
|
||||||
expect(token).not.toContain(RECIPIENT);
|
expect(token).not.toContain(RECIPIENT);
|
||||||
expect(decodedLine("Recipient")).toContain(RECIPIENT);
|
expect(decodedLine("Recipient")).toContain(RECIPIENT);
|
||||||
expect(decodedLine("Amount")).toContain(
|
expect(decodedLine("Amount")).toContain(
|
||||||
'<div class="font-bold">1234.5000 TDOL</div>',
|
'<div class="font-bold break-words">1234.5000 TDOL</div>',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nothing limits the length of a symbol the explorer reports. Without the
|
||||||
|
// break-words class a long one runs past the right edge of the screen and is
|
||||||
|
// cut off.
|
||||||
|
describe("a long token symbol", () => {
|
||||||
|
const LONG = "L".repeat(60);
|
||||||
|
|
||||||
|
test("wraps on the amount, native quantity and decoded lines", async () => {
|
||||||
|
state.trackedTokens = [{ address: TOKEN, symbol: LONG, decimals: 6 }];
|
||||||
|
const [row] = await fetchRecentTransactions(
|
||||||
|
HOLDER,
|
||||||
|
EXPLORER,
|
||||||
|
NETWORKS.mainnet.chainId,
|
||||||
|
);
|
||||||
|
transactionDetail.show({
|
||||||
|
...row,
|
||||||
|
symbol: LONG,
|
||||||
|
rawUnit: LONG + " base units (10^-6)",
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(node("tx-detail-value").innerHTML).toContain(
|
||||||
|
`font-bold break-words" data-copy="1234.5 ${displaySymbol(LONG)}"`,
|
||||||
|
);
|
||||||
|
expect(node("tx-detail-native").innerHTML).toContain(
|
||||||
|
`break-words" data-copy="1234500000 ${LONG} base units (10^-6)"`,
|
||||||
|
);
|
||||||
|
expect(node("tx-detail-calldata-details").innerHTML).toContain(
|
||||||
|
`<div class="mb-2 break-words">Transfer ${LONG}</div>`,
|
||||||
|
);
|
||||||
|
expect(decodedLine("Token")).toContain(
|
||||||
|
`<div class="font-bold break-words">${displaySymbol(LONG)}</div>`,
|
||||||
|
);
|
||||||
|
expect(decodedLine("Amount")).toContain(
|
||||||
|
`<div class="font-bold break-words">1234.5000 ${LONG}</div>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wraps on the Action line", () => {
|
||||||
|
const html = fs.readFileSync(
|
||||||
|
path.join(__dirname, "..", "src", "popup", "index.html"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(html).toMatch(
|
||||||
|
/id="tx-detail-calldata-action"\s+class="text-xs font-bold mb-2 break-words"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Mainnet is active, and its Blockscout knows nothing of the transaction. The
|
// Mainnet is active, and its Blockscout knows nothing of the transaction. The
|
||||||
// row was read from the Blockscout Settings remembers for Sepolia, and the
|
// row was read from the Blockscout Settings remembers for Sepolia, and the
|
||||||
// screen reads the transaction from there too, so every link on it is drawn.
|
// screen reads the transaction from there too, so every link on it is drawn.
|
||||||
@@ -278,9 +329,11 @@ test("with mainnet active, a Sepolia token transfer's decoded details name the t
|
|||||||
};
|
};
|
||||||
await showSepoliaTransferWithMainnetActive();
|
await showSepoliaTransferWithMainnetActive();
|
||||||
|
|
||||||
expect(decodedLine("Token")).toContain('<div class="font-bold">TDOL</div>');
|
expect(decodedLine("Token")).toContain(
|
||||||
|
'<div class="font-bold break-words">TDOL</div>',
|
||||||
|
);
|
||||||
expect(decodedLine("Amount")).toContain(
|
expect(decodedLine("Amount")).toContain(
|
||||||
'<div class="font-bold">1234.5000 TDOL</div>',
|
'<div class="font-bold break-words">1234.5000 TDOL</div>',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -295,6 +348,6 @@ test("with mainnet active, a Sepolia token transfer's decoded details take nothi
|
|||||||
|
|
||||||
expect(decodedLine("Token")).not.toContain("MDOL");
|
expect(decodedLine("Token")).not.toContain("MDOL");
|
||||||
expect(decodedLine("Amount")).toContain(
|
expect(decodedLine("Amount")).toContain(
|
||||||
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
|
'<div class="font-bold break-words">1234500000 base units (decimals unknown)</div>',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
await refreshBalances(
|
||||||
[{ addresses }],
|
[{ addresses }],
|
||||||
RPC_URL,
|
RPC_URL,
|
||||||
EXPLORER_URL,
|
EXPLORER_URL,
|
||||||
trackedTokens,
|
trackedTokens,
|
||||||
"mainnet",
|
networkId,
|
||||||
new AbortController().signal,
|
new AbortController().signal,
|
||||||
);
|
);
|
||||||
return addresses;
|
return addresses;
|
||||||
@@ -307,3 +307,104 @@ test("a tracked token wearing a known symbol from another contract is not listed
|
|||||||
);
|
);
|
||||||
expect(first.tokenBalances).toEqual([]);
|
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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
// a contract lookup that answers after one. Driven through the real popup over
|
// a contract lookup that answers after one. Driven through the real popup over
|
||||||
// a stored profile; the same switch made by the background is in
|
// a stored profile; the same switch made by the background is in
|
||||||
// tests/coldWorkerChainSwitch.test.js.
|
// tests/coldWorkerChainSwitch.test.js.
|
||||||
|
//
|
||||||
|
// The tests of what another page stored before a switch in Settings cover a
|
||||||
|
// fraud contract too (https://git.eeqj.de/sneak/AutistMask/issues/692).
|
||||||
|
|
||||||
const {
|
const {
|
||||||
bootPopup,
|
bootPopup,
|
||||||
@@ -114,6 +117,17 @@ async function chooseNetwork(boot, networkId) {
|
|||||||
await boot.settle();
|
await boot.settle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adds NEW_TOKEN_ADDRESS by contract address on SettingsAddToken, as the user
|
||||||
|
// does. Returns the record the add tracks.
|
||||||
|
async function addNewToken(boot) {
|
||||||
|
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||||
|
const info = { symbol: "NEW", decimals: 18, name: "New Token" };
|
||||||
|
lookupTokenInfo.mockResolvedValueOnce(info);
|
||||||
|
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
|
||||||
|
await boot.click("btn-settings-addtoken-manual");
|
||||||
|
return { address: NEW_TOKEN_ADDRESS, ...info, balanceNotFetched: true };
|
||||||
|
}
|
||||||
|
|
||||||
// The Settings gear, a switch to Sepolia, and Back.
|
// The Settings gear, a switch to Sepolia, and Back.
|
||||||
async function gearSwitchBack(boot) {
|
async function gearSwitchBack(boot) {
|
||||||
await boot.click("btn-settings");
|
await boot.click("btn-settings");
|
||||||
@@ -159,24 +173,700 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
|
|||||||
"sepolia",
|
"sepolia",
|
||||||
expect.anything(),
|
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 boot.click("btn-settings");
|
||||||
await chooseNetwork(boot, "mainnet");
|
await chooseNetwork(boot, "mainnet");
|
||||||
expect(stored().networkId).toBe("mainnet");
|
expect(stored().networkId).toBe("mainnet");
|
||||||
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
|
// No refresh has read a balance on this network since the switch.
|
||||||
|
expect(stored().trackedTokens).toEqual([
|
||||||
|
{ ...MAINNET_TOKEN, balanceNotFetched: true },
|
||||||
|
]);
|
||||||
const onMainnet = boot.node("settings-tracked-tokens").innerHTML;
|
const onMainnet = boot.node("settings-tracked-tokens").innerHTML;
|
||||||
expect(onMainnet).toContain("DAI");
|
expect(onMainnet).toContain("DAI");
|
||||||
expect(onMainnet).not.toContain("TST");
|
expect(onMainnet).not.toContain("TST");
|
||||||
|
|
||||||
await chooseNetwork(boot, "sepolia");
|
await chooseNetwork(boot, "sepolia");
|
||||||
expect(stored().networkId).toBe("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;
|
const onSepolia = boot.node("settings-tracked-tokens").innerHTML;
|
||||||
expect(onSepolia).toContain("TST");
|
expect(onSepolia).toContain("TST");
|
||||||
expect(onSepolia).not.toContain("DAI");
|
expect(onSepolia).not.toContain("DAI");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Another popup page tracks DAI on Ethereum Mainnet after this page's last
|
||||||
|
// save. The switch to Sepolia puts DAI away under Ethereum Mainnet.
|
||||||
|
test("a token another page tracked before a switch in Settings stays tracked on Ethereum Mainnet", async () => {
|
||||||
|
const boot = await bootPopup(unversionedValidProfile());
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
trackedTokens: [MAINNET_TOKEN],
|
||||||
|
});
|
||||||
|
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.trackedTokens).toEqual([]);
|
||||||
|
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page stops tracking DAI on Ethereum Mainnet after this page's
|
||||||
|
// last save. This page still holds DAI. The switch to Sepolia does not store
|
||||||
|
// DAI under Ethereum Mainnet again.
|
||||||
|
test("a token another page removed before a switch in Settings stays removed", async () => {
|
||||||
|
const boot = await bootPopup(
|
||||||
|
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
|
||||||
|
);
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
trackedTokens: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.trackedTokens).toEqual([]);
|
||||||
|
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page removes DAI after this page's last save. That page then
|
||||||
|
// adds DAI again, typing the contract address in lower case. The two pages'
|
||||||
|
// entries for DAI match, because contract addresses are compared ignoring
|
||||||
|
// letter case. The switch to Sepolia keeps this page's entry under Ethereum
|
||||||
|
// Mainnet.
|
||||||
|
test("a token another page added again with a lower-case address keeps this page's entry", async () => {
|
||||||
|
const boot = await bootPopup(
|
||||||
|
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
|
||||||
|
);
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
trackedTokens: [
|
||||||
|
{ ...MAINNET_TOKEN, address: MAINNET_TOKEN.address.toLowerCase() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page saves a switch to Sepolia after this page's last save.
|
||||||
|
// TST is tracked on Sepolia. This page has not taken that switch yet. A switch
|
||||||
|
// to Sepolia in this page's Settings keeps TST off Ethereum Mainnet's list.
|
||||||
|
test("a token tracked on Sepolia when another page switched there is not 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: [SEPOLIA_TOKEN],
|
||||||
|
});
|
||||||
|
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.trackedTokens).toEqual([
|
||||||
|
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
|
||||||
|
]);
|
||||||
|
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, balanceNotFetched: true },
|
||||||
|
{ ...newToken, balanceNotFetched: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// This page switches to Sepolia, where TST is tracked. Another popup page has
|
||||||
|
// not taken that switch, and its save tracks DAI on Ethereum Mainnet. This page
|
||||||
|
// then adds a token on Sepolia. Its save changes only `trackedTokens`, so DAI
|
||||||
|
// stays on Ethereum Mainnet's list.
|
||||||
|
test("a token another page added on Ethereum Mainnet stays when this page adds a token on Sepolia", async () => {
|
||||||
|
const boot = await bootOnAddToken("mainnet", {
|
||||||
|
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
|
||||||
|
});
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
await gearSwitchBack(boot);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
networkTrackedTokens: {
|
||||||
|
mainnet: [MAINNET_TOKEN],
|
||||||
|
sepolia: [SEPOLIA_TOKEN],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const added = await addNewToken(boot);
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
||||||
|
expect(stored.networkTrackedTokens.sepolia).toEqual([SEPOLIA_TOKEN]);
|
||||||
|
expect(stored.trackedTokens).toEqual([
|
||||||
|
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
|
||||||
|
added,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// This page tracks DAI on Ethereum Mainnet and switches to Sepolia, where TST is
|
||||||
|
// tracked. Another popup page has not taken that switch, and its save removes
|
||||||
|
// DAI from Ethereum Mainnet's list. This page then adds a token on Sepolia. Its
|
||||||
|
// save changes only `trackedTokens`, so DAI does not come back.
|
||||||
|
test("a token another page removed on Ethereum Mainnet stays removed when this page adds a token on Sepolia", async () => {
|
||||||
|
const boot = await bootOnAddToken("mainnet", {
|
||||||
|
trackedTokens: [MAINNET_TOKEN],
|
||||||
|
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
|
||||||
|
});
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
await gearSwitchBack(boot);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
networkTrackedTokens: { mainnet: [], sepolia: [SEPOLIA_TOKEN] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const added = await addNewToken(boot);
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
||||||
|
expect(stored.networkTrackedTokens.sepolia).toEqual([SEPOLIA_TOKEN]);
|
||||||
|
expect(stored.trackedTokens).toEqual([
|
||||||
|
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
|
||||||
|
added,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
test("a fraud contract another page recorded before a switch in Settings is not kept on Sepolia", async () => {
|
||||||
|
const boot = await bootPopup(unversionedValidProfile());
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
fraudContracts: [NEW_TOKEN_ADDRESS],
|
||||||
|
});
|
||||||
|
|
||||||
|
await boot.click("btn-settings");
|
||||||
|
await chooseNetwork(boot, "sepolia");
|
||||||
|
|
||||||
|
const stored = boot.storage.read("autistmask");
|
||||||
|
expect(stored.networkId).toBe("sepolia");
|
||||||
|
expect(stored.fraudContracts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the
|
||||||
|
// switch to Sepolia waits for the lock. The page adds the token to Ethereum
|
||||||
|
// Mainnet's list. The switch then keeps that list for Ethereum Mainnet.
|
||||||
|
test("a token added while a switch in Settings waits for the lock 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");
|
||||||
|
|
||||||
|
// Another write holds the lock, so the switch's save waits for it.
|
||||||
|
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();
|
||||||
|
|
||||||
|
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([
|
||||||
|
{
|
||||||
|
address: NEW_TOKEN_ADDRESS,
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page tracks DAI after this page's last save. This page then
|
||||||
|
// adds a token. Its save keeps DAI.
|
||||||
|
test("a token another page added stays when this page adds a token", async () => {
|
||||||
|
const boot = await bootOnAddToken("mainnet");
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
trackedTokens: [MAINNET_TOKEN],
|
||||||
|
});
|
||||||
|
|
||||||
|
const added = await addNewToken(boot);
|
||||||
|
|
||||||
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
|
||||||
|
MAINNET_TOKEN,
|
||||||
|
added,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page tracks a new token after this page's last save. This page
|
||||||
|
// then removes DAI, as the `[x]` on a tracked token in Settings does. Its save
|
||||||
|
// keeps the new token.
|
||||||
|
test("a token another page added stays when this page removes a token", async () => {
|
||||||
|
const boot = await bootPopup(
|
||||||
|
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
|
||||||
|
);
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
const otherPageToken = {
|
||||||
|
address: NEW_TOKEN_ADDRESS,
|
||||||
|
symbol: "NEW",
|
||||||
|
decimals: 18,
|
||||||
|
name: "New Token",
|
||||||
|
};
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
trackedTokens: [MAINNET_TOKEN, otherPageToken],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { state, saveState } = require("../src/shared/state");
|
||||||
|
state.trackedTokens.splice(0, 1);
|
||||||
|
await saveState();
|
||||||
|
|
||||||
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
|
||||||
|
otherPageToken,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page stops tracking DAI after this page's last save. This page
|
||||||
|
// still holds DAI and adds a token. Its save does not store DAI again.
|
||||||
|
test("a token another page removed stays removed when this page adds a token", async () => {
|
||||||
|
const boot = await bootOnAddToken("mainnet", {
|
||||||
|
trackedTokens: [MAINNET_TOKEN],
|
||||||
|
});
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
trackedTokens: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const added = await addNewToken(boot);
|
||||||
|
|
||||||
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([added]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another popup page tracks DAI and the new token after this page's last save,
|
||||||
|
// and its refresh has read the new token's balance. This page adds the new token
|
||||||
|
// too, so the two pages hold different records for it. The save stores one
|
||||||
|
// record for the new token, this page's, and keeps DAI.
|
||||||
|
test("a token both pages added is stored once, with this page's record", async () => {
|
||||||
|
const boot = await bootOnAddToken("mainnet");
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
boot.storage.write("autistmask", {
|
||||||
|
...boot.storage.read("autistmask"),
|
||||||
|
trackedTokens: [
|
||||||
|
MAINNET_TOKEN,
|
||||||
|
{
|
||||||
|
address: NEW_TOKEN_ADDRESS,
|
||||||
|
symbol: "NEW",
|
||||||
|
decimals: 18,
|
||||||
|
name: "New Token",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const added = await addNewToken(boot);
|
||||||
|
|
||||||
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
|
||||||
|
MAINNET_TOKEN,
|
||||||
|
added,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// This page and the stored list each hold DAI twice. This page then adds a
|
||||||
|
// token. Its save stores DAI once.
|
||||||
|
test("a token held twice is stored once when this page adds a token", async () => {
|
||||||
|
const boot = await bootOnAddToken("mainnet", {
|
||||||
|
trackedTokens: [MAINNET_TOKEN, MAINNET_TOKEN],
|
||||||
|
});
|
||||||
|
expect(boot.pageErrors).toEqual([]);
|
||||||
|
|
||||||
|
const added = await addNewToken(boot);
|
||||||
|
|
||||||
|
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
|
||||||
|
MAINNET_TOKEN,
|
||||||
|
added,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
describe("Back after a switch from the Settings gear", () => {
|
describe("Back after a switch from the Settings gear", () => {
|
||||||
test("SettingsAddToken no longer offers the common tokens", async () => {
|
test("SettingsAddToken no longer offers the common tokens", async () => {
|
||||||
const boot = await bootOnAddToken("mainnet");
|
const boot = await bootOnAddToken("mainnet");
|
||||||
@@ -355,7 +1045,9 @@ describe("a contract lookup that answers after a switch adds nothing", () => {
|
|||||||
await addAcrossSwitch(boot, "btn-settings-addtoken-manual", "mainnet");
|
await addAcrossSwitch(boot, "btn-settings-addtoken-manual", "mainnet");
|
||||||
expect(boot.visibleViews()).toEqual(["settings"]);
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||||
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
|
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
|
||||||
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
|
expect(stored().trackedTokens).toEqual([
|
||||||
|
{ ...MAINNET_TOKEN, balanceNotFetched: true },
|
||||||
|
]);
|
||||||
expect(stored().networkTrackedTokens.sepolia).toEqual([]);
|
expect(stored().networkTrackedTokens.sepolia).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -368,7 +1060,9 @@ describe("a contract lookup that answers after a switch adds nothing", () => {
|
|||||||
await addAcrossSwitch(boot, "btn-add-token-confirm", "sepolia");
|
await addAcrossSwitch(boot, "btn-add-token-confirm", "sepolia");
|
||||||
expect(boot.visibleViews()).toEqual(["settings"]);
|
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||||
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
|
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
|
||||||
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
|
expect(stored().trackedTokens).toEqual([
|
||||||
|
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
|
||||||
|
]);
|
||||||
expect(stored().networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
expect(stored().networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// TransactionDetail shows only details that Blockscout gave for the
|
||||||
|
// transaction on screen (https://git.eeqj.de/sneak/AutistMask/issues/675).
|
||||||
|
//
|
||||||
|
// The screen asks Blockscout for its transaction and does not wait for the
|
||||||
|
// answer. So the user can press Back and open another transaction before
|
||||||
|
// Blockscout answers for the first. The first answer must then fill nothing on
|
||||||
|
// the second transaction's screen. An answer that lands while Settings is up
|
||||||
|
// must still fill TransactionDetail.
|
||||||
|
//
|
||||||
|
// Driven through the real popup over a stored profile
|
||||||
|
// (tests/support/popupBoot.js). Each transaction is opened as a history screen
|
||||||
|
// opens it: the current screen is pushed, then transactionDetail.show() draws
|
||||||
|
// the transaction.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const {
|
||||||
|
bootPopup,
|
||||||
|
cleanupPopup,
|
||||||
|
unversionedValidProfile,
|
||||||
|
ADDRESS,
|
||||||
|
} = require("./support/popupBoot");
|
||||||
|
|
||||||
|
const MAINNET_BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
|
||||||
|
const SEPOLIA_BLOCKSCOUT = "https://eth-sepolia.blockscout.com/api/v2";
|
||||||
|
const RECIPIENT = "0x2222222222222222222222222222222222222222";
|
||||||
|
|
||||||
|
// A history entry for a send of 1 `symbol` from ADDRESS to RECIPIENT, on the
|
||||||
|
// network with `chainId`.
|
||||||
|
function historyEntry(hash, chainId, symbol) {
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
from: ADDRESS,
|
||||||
|
to: RECIPIENT,
|
||||||
|
value: "1.0000",
|
||||||
|
exactValue: "1.0",
|
||||||
|
symbol,
|
||||||
|
timestamp: 1790000000,
|
||||||
|
isError: false,
|
||||||
|
direction: "sent",
|
||||||
|
directionLabel: "Sent",
|
||||||
|
contractAddress: null,
|
||||||
|
chainId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIRST = historyEntry("0x" + "aa".repeat(32), "0x1", "ETH");
|
||||||
|
const SECOND = historyEntry("0x" + "bb".repeat(32), "0x1", "ETH");
|
||||||
|
// A transaction on Sepolia with the same hash as FIRST.
|
||||||
|
const FIRST_ON_SEPOLIA = historyEntry(FIRST.hash, "0xaa36a7", "SepoliaETH");
|
||||||
|
|
||||||
|
// The URL that asks Blockscout for `entry`, on the network it was sent on.
|
||||||
|
function requestUrl(entry) {
|
||||||
|
const blockscout =
|
||||||
|
entry.chainId === "0x1" ? MAINNET_BLOCKSCOUT : SEPOLIA_BLOCKSCOUT;
|
||||||
|
return blockscout + "/transactions/" + entry.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What Blockscout reports for each transaction, by the URL that asks for it.
|
||||||
|
const ANSWERS = {
|
||||||
|
[requestUrl(FIRST)]: {
|
||||||
|
block_number: 21000001,
|
||||||
|
nonce: 4441,
|
||||||
|
fee: { value: "21000000000000" },
|
||||||
|
raw_input: "0x11111111",
|
||||||
|
method: "firstMethod",
|
||||||
|
},
|
||||||
|
[requestUrl(SECOND)]: {
|
||||||
|
block_number: 21000002,
|
||||||
|
nonce: 4442,
|
||||||
|
fee: { value: "42000000000000" },
|
||||||
|
raw_input: "0x22222222",
|
||||||
|
method: "secondMethod",
|
||||||
|
},
|
||||||
|
[requestUrl(FIRST_ON_SEPOLIA)]: {
|
||||||
|
block_number: 21000003,
|
||||||
|
nonce: 4443,
|
||||||
|
fee: { value: "63000000000000" },
|
||||||
|
raw_input: "0x33333333",
|
||||||
|
method: "sepoliaMethod",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// The text each answer puts on screen: its block, nonce, fee, method name and
|
||||||
|
// raw data. No text from one answer appears in another.
|
||||||
|
const FIRST_SHOWN = [
|
||||||
|
"21000001",
|
||||||
|
"4441",
|
||||||
|
"0.000021 ETH",
|
||||||
|
"firstMethod",
|
||||||
|
"0x11111111",
|
||||||
|
];
|
||||||
|
const SECOND_SHOWN = [
|
||||||
|
"21000002",
|
||||||
|
"4442",
|
||||||
|
"0.000042 ETH",
|
||||||
|
"secondMethod",
|
||||||
|
"0x22222222",
|
||||||
|
];
|
||||||
|
const FIRST_ON_SEPOLIA_SHOWN = [
|
||||||
|
"21000003",
|
||||||
|
"4443",
|
||||||
|
"0.000063 SepoliaETH",
|
||||||
|
"sepoliaMethod",
|
||||||
|
"0x33333333",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Everything drawn in the fields that Blockscout's answer fills.
|
||||||
|
function drawnDetails(page) {
|
||||||
|
return [
|
||||||
|
"tx-detail-block",
|
||||||
|
"tx-detail-nonce",
|
||||||
|
"tx-detail-fee",
|
||||||
|
"tx-detail-calldata-action",
|
||||||
|
"tx-detail-rawdata",
|
||||||
|
]
|
||||||
|
.map((id) => page.node(id).innerHTML + page.node(id).textContent)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens `entry` on TransactionDetail, as a history screen does.
|
||||||
|
function open(entry) {
|
||||||
|
require("../src/popup/views/helpers").pushCurrentView();
|
||||||
|
require("../src/popup/views/transactionDetail").show(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The URLs of every request to Blockscout so far, in order.
|
||||||
|
function requestedUrls() {
|
||||||
|
return globalThis.fetch.mock.calls.map(([url]) => url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boots the popup on Home with "Use Blockscout" on. Blockscout answers every
|
||||||
|
// request at once from ANSWERS, except the request for `held`. Returns the
|
||||||
|
// page and answer(), which lets the answer for `held` land.
|
||||||
|
async function bootWithHeldRequest(held) {
|
||||||
|
const page = await bootPopup(
|
||||||
|
unversionedValidProfile({ useBlockscout: true }),
|
||||||
|
);
|
||||||
|
let answer;
|
||||||
|
const heldAnswer = new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
});
|
||||||
|
globalThis.fetch = jest.fn(async (url) => {
|
||||||
|
if (url === requestUrl(held)) await heldAnswer;
|
||||||
|
return { ok: true, status: 200, json: async () => ANSWERS[url] };
|
||||||
|
});
|
||||||
|
return { page, answer: () => answer() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user opens `first`, whose request to Blockscout is held. Then the user
|
||||||
|
// presses Back and opens `second`. Then the answer for `first` lands. Returns
|
||||||
|
// the page.
|
||||||
|
async function firstAnswerLandsOnSecond(first, second) {
|
||||||
|
const { page, answer } = await bootWithHeldRequest(first);
|
||||||
|
open(first);
|
||||||
|
await page.settle();
|
||||||
|
await page.click("btn-tx-back");
|
||||||
|
expect(page.visibleViews()).toEqual(["main"]);
|
||||||
|
open(second);
|
||||||
|
await page.settle();
|
||||||
|
expect(requestedUrls()).toEqual([requestUrl(first), requestUrl(second)]);
|
||||||
|
answer();
|
||||||
|
await page.settle();
|
||||||
|
expect(page.visibleViews()).toEqual(["transaction"]);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => {
|
||||||
|
const page = await firstAnswerLandsOnSecond(FIRST, SECOND);
|
||||||
|
|
||||||
|
const drawn = drawnDetails(page);
|
||||||
|
for (const text of SECOND_SHOWN) expect(drawn).toContain(text);
|
||||||
|
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer for a hash on mainnet that lands on the same hash's screen on Sepolia fills nothing", async () => {
|
||||||
|
const page = await firstAnswerLandsOnSecond(FIRST, FIRST_ON_SEPOLIA);
|
||||||
|
|
||||||
|
const drawn = drawnDetails(page);
|
||||||
|
for (const text of FIRST_ON_SEPOLIA_SHOWN) expect(drawn).toContain(text);
|
||||||
|
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answer that lands while Settings is up still fills TransactionDetail", async () => {
|
||||||
|
const { page, answer } = await bootWithHeldRequest(FIRST);
|
||||||
|
open(FIRST);
|
||||||
|
await page.settle();
|
||||||
|
await page.click("btn-settings");
|
||||||
|
expect(page.visibleViews()).toEqual(["settings"]);
|
||||||
|
answer();
|
||||||
|
await page.settle();
|
||||||
|
await page.click("btn-settings");
|
||||||
|
|
||||||
|
expect(page.visibleViews()).toEqual(["transaction"]);
|
||||||
|
expect(requestedUrls()).toEqual([requestUrl(FIRST)]);
|
||||||
|
const drawn = drawnDetails(page);
|
||||||
|
for (const text of FIRST_SHOWN) expect(drawn).toContain(text);
|
||||||
|
});
|
||||||
+78
-13
@@ -1,15 +1,15 @@
|
|||||||
// Tests for the address-poisoning defense in src/shared/transactions.js.
|
// Tests for the address-poisoning defense in src/shared/transactions.js.
|
||||||
//
|
//
|
||||||
// README.md:730-814 describes four filters as a core security property of
|
// README, Scam protection, describes four filters as a core security property
|
||||||
// the wallet: known token symbol verification, the low-holder threshold, the
|
// of the wallet: known token symbol verification, the low-holder threshold, the
|
||||||
// fraud contract blocklist, and the dust threshold. A regression in any of
|
// fraud contract blocklist, and the dust threshold. A regression in any of
|
||||||
// them does not crash — it silently stops filtering, and poisoned look-alike
|
// them does not crash — it silently stops filtering, and poisoned look-alike
|
||||||
// addresses reappear in the user's transaction history. These tests pin the
|
// addresses reappear in the user's transaction history. These tests pin the
|
||||||
// behaviour down in both directions: the documented attacks must be filtered,
|
// behaviour down in both directions: the two attacks below must be filtered,
|
||||||
// and legitimate transactions must survive untouched.
|
// and legitimate transactions must survive untouched.
|
||||||
//
|
//
|
||||||
// Fixtures are static local objects modelled on the two real attacks cited in
|
// Fixtures are static local objects modelled on two real attacks.
|
||||||
// the README. Nothing here touches the network: global.fetch is replaced
|
// Nothing here touches the network: global.fetch is replaced
|
||||||
// with a throwing stub and the only fetch path in the module under test
|
// with a throwing stub and the only fetch path in the module under test
|
||||||
// (debugFetch, from src/shared/log) is mocked at the module boundary.
|
// (debugFetch, from src/shared/log) is mocked at the module boundary.
|
||||||
|
|
||||||
@@ -46,11 +46,12 @@ const { debugFetch } = require("../src/shared/log");
|
|||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Addresses and hashes from the two attacks documented in README.md:730-814.
|
// Addresses and hashes from two real attacks. README, Scam protection, has the
|
||||||
|
// filters that stop them.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// The fake "Ethereum" token with symbol "ETH" and zero holders
|
// The fake "Ethereum" token with symbol "ETH" and zero holders. See "Known
|
||||||
// (README.md:735-750).
|
// symbol spoofing" and "Low-holder tokens" in README, Scam protection.
|
||||||
const FAKE_ETH_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
|
const FAKE_ETH_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
|
||||||
// The fraudulent Transfer event it emitted.
|
// The fraudulent Transfer event it emitted.
|
||||||
const FAKE_ETH_TRANSFER_HASH =
|
const FAKE_ETH_TRANSFER_HASH =
|
||||||
@@ -62,7 +63,8 @@ const LEGIT_RECIPIENT = "0xc3c693ae04bad5f13c45885c1e85a9557798f37e";
|
|||||||
// The scam address the fake token transfer pointed at ("0xC3C0" vs "0xC3c6").
|
// The scam address the fake token transfer pointed at ("0xC3C0" vs "0xC3c6").
|
||||||
const TOKEN_SCAM_LOOKALIKE = "0xc3c0aea127c575b9ffd03bf11c6a878e8979c37f";
|
const TOKEN_SCAM_LOOKALIKE = "0xc3c0aea127c575b9ffd03bf11c6a878e8979c37f";
|
||||||
|
|
||||||
// The second wave: a real 1 gwei native transfer (README.md:800-808).
|
// The second wave: a real 1 gwei native transfer. See "Dust transactions" in
|
||||||
|
// README, Scam protection.
|
||||||
const DUST_TX_HASH =
|
const DUST_TX_HASH =
|
||||||
"0x2708ebddfb9b5fa3f7a89d3ea398ef9fd8771b83ed861ecb7c21cd55d18edc74";
|
"0x2708ebddfb9b5fa3f7a89d3ea398ef9fd8771b83ed861ecb7c21cd55d18edc74";
|
||||||
const DUST_SENDER_LOOKALIKE = "0xc3c6b3b4402bd78a9582ab6b00e747769344f37e";
|
const DUST_SENDER_LOOKALIKE = "0xc3c6b3b4402bd78a9582ab6b00e747769344f37e";
|
||||||
@@ -79,7 +81,8 @@ const NOVEL_SPAM_SYMBOL = "SPAMTKN";
|
|||||||
// An ordinary counterparty for legitimate-transaction fixtures.
|
// An ordinary counterparty for legitimate-transaction fixtures.
|
||||||
const ORDINARY_PEER = "0x5aa0f9f1e0a1d0e0e5c1e7ce3b7dbbe9c19f0a11";
|
const ORDINARY_PEER = "0x5aa0f9f1e0a1d0e0e5c1e7ce3b7dbbe9c19f0a11";
|
||||||
|
|
||||||
// The documented default settings (README.md:810-814, state.js:24-27).
|
// The documented default settings (README, Settings; DEFAULT_STATE in
|
||||||
|
// src/shared/persistedState.js).
|
||||||
const DEFAULT_FILTERS = {
|
const DEFAULT_FILTERS = {
|
||||||
hideSpoofedSymbols: true,
|
hideSpoofedSymbols: true,
|
||||||
hideLowHolderTokens: true,
|
hideLowHolderTokens: true,
|
||||||
@@ -144,6 +147,7 @@ function tokenTx(overrides = {}) {
|
|||||||
isError: false,
|
isError: false,
|
||||||
contractAddress: USDC_CONTRACT,
|
contractAddress: USDC_CONTRACT,
|
||||||
holders: 3500000,
|
holders: 3500000,
|
||||||
|
chainId: "0x1",
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -219,7 +223,7 @@ describe("token list assumptions the fixtures rely on", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("the real attacks documented in README.md:730-814", () => {
|
describe("the real attacks", () => {
|
||||||
test('the fake "Ethereum"/"ETH" token transfer is filtered by default', () => {
|
test('the fake "Ethereum"/"ETH" token transfer is filtered by default', () => {
|
||||||
const attack = fakeEthTokenTransfer();
|
const attack = fakeEthTokenTransfer();
|
||||||
const result = filterTransactions([attack], filters());
|
const result = filterTransactions([attack], filters());
|
||||||
@@ -259,6 +263,32 @@ describe("known-symbol spoof verification", () => {
|
|||||||
expect(result.newFraudContracts).toEqual([NOVEL_SPAM_CONTRACT]);
|
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", () => {
|
test("the genuine contract for that symbol is not spoofed", () => {
|
||||||
const genuine = tokenTx();
|
const genuine = tokenTx();
|
||||||
const result = filterTransactions([genuine], filters());
|
const result = filterTransactions([genuine], filters());
|
||||||
@@ -372,8 +402,8 @@ describe("known-symbol spoof verification", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Turning the other three filters off must not turn this one off: each
|
// Turning the other three filters off must not turn this one off: each
|
||||||
// filter is independent, and this is the one the README calls out as the
|
// filter is independent, and this is the one that stops the fake "ETH"
|
||||||
// defense against the fake "ETH" attack.
|
// attack.
|
||||||
test("the check still runs when the other three filters are off", () => {
|
test("the check still runs when the other three filters are off", () => {
|
||||||
const result = filterTransactions(
|
const result = filterTransactions(
|
||||||
[fakeEthTokenTransfer()],
|
[fakeEthTokenTransfer()],
|
||||||
@@ -1495,6 +1525,41 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
|||||||
expect(mainnet[0].chainId).toBe("0x1");
|
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 () => {
|
test("the fake token transfer survives fetching and is then filtered", async () => {
|
||||||
respondWith(
|
respondWith(
|
||||||
[],
|
[],
|
||||||
|
|||||||
+416
-4
@@ -101,6 +101,9 @@ global.document = {
|
|||||||
|
|
||||||
global.window = { location: { search: "" } };
|
global.window = { location: { search: "" } };
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
// Clones in both directions, as the real chrome.storage.local does; the stub
|
// Clones in both directions, as the real chrome.storage.local does; the stub
|
||||||
// here used to hand back the live stored object, so an in-memory mutation
|
// here used to hand back the live stored object, so an in-memory mutation
|
||||||
// looked like a write that had reached storage. See
|
// looked like a write that had reached storage. See
|
||||||
@@ -115,6 +118,7 @@ const { state } = require("../src/shared/state");
|
|||||||
const { NETWORKS } = require("../src/shared/networks");
|
const { NETWORKS } = require("../src/shared/networks");
|
||||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||||
const { log } = require("../src/shared/log");
|
const { log } = require("../src/shared/log");
|
||||||
|
const { CONTRACT_CREATION_TEXT } = require("../src/popup/views/helpers");
|
||||||
|
|
||||||
const TX_HASH =
|
const TX_HASH =
|
||||||
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
||||||
@@ -614,8 +618,8 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
|
|||||||
const decoded = {
|
const decoded = {
|
||||||
name: "Transfer",
|
name: "Transfer",
|
||||||
details: [
|
details: [
|
||||||
{ label: "Token", address: TOKEN, isToken: true },
|
{ label: "Token", value: TOKEN, address: TOKEN, isToken: true },
|
||||||
{ label: "Recipient", address: RECIPIENT },
|
{ label: "Recipient", value: RECIPIENT, address: RECIPIENT },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
|
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
|
||||||
@@ -633,7 +637,7 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
|
|||||||
test("SuccessTx's decoded action names the token from the tokens tracked on Sepolia", async () => {
|
test("SuccessTx's decoded action names the token from the tokens tracked on Sepolia", async () => {
|
||||||
function expectTokenNamed() {
|
function expectTokenNamed() {
|
||||||
expect(getElement("success-tx-decoded").innerHTML).toContain(
|
expect(getElement("success-tx-decoded").innerHTML).toContain(
|
||||||
'<div class="font-bold">TDOL</div>',
|
'<div class="font-bold break-words">TDOL</div>',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
state.networkTrackedTokens = {
|
state.networkTrackedTokens = {
|
||||||
@@ -641,7 +645,9 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
|
|||||||
};
|
};
|
||||||
const decoded = {
|
const decoded = {
|
||||||
name: "Transfer",
|
name: "Transfer",
|
||||||
details: [{ label: "Token", address: TOKEN, isToken: true }],
|
details: [
|
||||||
|
{ label: "Token", value: TOKEN, address: TOKEN, isToken: true },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
|
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
|
||||||
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
|
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
|
||||||
@@ -672,6 +678,412 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the Moved from the sender lines of a swap on WaitTx and ErrorTx", () => {
|
||||||
|
const moved = (value) => ({ label: "Moved from the sender", value });
|
||||||
|
const decoded = {
|
||||||
|
details: [
|
||||||
|
moved("Up to 1.0000 ETH"),
|
||||||
|
{ label: "Token moved", value: "ETH" },
|
||||||
|
moved("<b>400.0000</b> DAI"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function expectEveryLine(id) {
|
||||||
|
const html = getElement(id).innerHTML;
|
||||||
|
expect(html).toContain("Up to 1.0000 ETH");
|
||||||
|
expect(html).toContain("<b>400.0000</b> DAI");
|
||||||
|
expect(html).not.toContain("Token moved");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("WaitTx shows each line's amount, escaped", () => {
|
||||||
|
mockReceiptLookup.mockResolvedValue(null);
|
||||||
|
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
|
||||||
|
expectEveryLine("wait-tx-token-note");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ErrorTx shows each line's amount, escaped", () => {
|
||||||
|
txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed.");
|
||||||
|
expectEveryLine("error-tx-token-note");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a wait that ends in ErrorTx keeps them", async () => {
|
||||||
|
mockReceiptLookup.mockResolvedValue(null);
|
||||||
|
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
|
||||||
|
await jest.advanceTimersByTimeAsync(60000);
|
||||||
|
expect(state.currentView).toBe("error-tx");
|
||||||
|
expectEveryLine("error-tx-token-note");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A saved record comes back from storage, in whatever shape it is in. A
|
||||||
|
// throw here would leave the popup's restore unfinished.
|
||||||
|
test("a saved record in another shape shows only the lines it can read", () => {
|
||||||
|
mockReceiptLookup.mockResolvedValue(null);
|
||||||
|
function noteOnReopen(saved) {
|
||||||
|
state.viewData = {
|
||||||
|
pendingWait: {
|
||||||
|
hash: TX_HASH,
|
||||||
|
txInfo: { ...TX_INFO, decoded: saved },
|
||||||
|
broadcastTime: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(txStatus.restoreWait()).toBe(true);
|
||||||
|
const waitNote = getElement("wait-tx-token-note").innerHTML;
|
||||||
|
txStatus.endWait();
|
||||||
|
|
||||||
|
state.viewData = {
|
||||||
|
message: "It failed.",
|
||||||
|
to: RECIPIENT,
|
||||||
|
chainId: TX_INFO.chainId,
|
||||||
|
decoded: saved,
|
||||||
|
};
|
||||||
|
txStatus.renderError();
|
||||||
|
expect(getElement("error-tx-token-note").innerHTML).toBe(waitNote);
|
||||||
|
return waitNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const saved of [
|
||||||
|
7,
|
||||||
|
"text",
|
||||||
|
{ details: 7 },
|
||||||
|
{ details: [null] },
|
||||||
|
{ details: [{ label: "Moved from the sender" }] },
|
||||||
|
{ details: [moved(7)] },
|
||||||
|
{ details: [moved({ toString: 1 })] },
|
||||||
|
{ details: [{ label: "Permit2 allowance", value: 7 }] },
|
||||||
|
{
|
||||||
|
details: [
|
||||||
|
{ label: "Permit2 allowance", value: "text", address: 7 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
details: [
|
||||||
|
{
|
||||||
|
label: "Allowance token",
|
||||||
|
value: "text",
|
||||||
|
address: 7,
|
||||||
|
isToken: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
expect(noteOnReopen(saved)).toBe("");
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
noteOnReopen({ details: [null, moved("Up to 1.0000 ETH")] }),
|
||||||
|
).toContain("Up to 1.0000 ETH");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the Permit2 allowance lines of a swap on WaitTx and ErrorTx", () => {
|
||||||
|
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||||
|
const allowance = (value) => ({ label: "Permit2 allowance", value });
|
||||||
|
const decoded = {
|
||||||
|
details: [
|
||||||
|
allowance("Unlimited"),
|
||||||
|
{
|
||||||
|
label: "Allowance token",
|
||||||
|
value: "USDC (" + USDC + ")",
|
||||||
|
address: USDC,
|
||||||
|
isToken: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Allowance spender",
|
||||||
|
value: RECIPIENT,
|
||||||
|
address: RECIPIENT,
|
||||||
|
},
|
||||||
|
allowance("<b>400.0000</b> DAI"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function expectEveryLine(id) {
|
||||||
|
const html = getElement(id).innerHTML;
|
||||||
|
expect(html).toContain("Permit2 allowance");
|
||||||
|
expect(html).toContain("Unlimited");
|
||||||
|
expect(html).toContain("<b>400.0000</b> DAI");
|
||||||
|
expect(html).not.toContain("Allowance spender");
|
||||||
|
expect(html).not.toContain(RECIPIENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// `Unlimited` alone does not say which token it covers. The address gets
|
||||||
|
// the row of its own that every address gets, in the transaction's network.
|
||||||
|
function expectTokenLine(id) {
|
||||||
|
const html = getElement(id).innerHTML;
|
||||||
|
expect(html.indexOf("Unlimited")).toBeLessThan(
|
||||||
|
html.indexOf("Allowance token"),
|
||||||
|
);
|
||||||
|
expect(html).toContain('<div class="font-bold break-words">USDC</div>');
|
||||||
|
expect(html).toContain(`data-copy="${USDC}"`);
|
||||||
|
expect(html).toContain(`href="https://etherscan.io/address/${USDC}"`);
|
||||||
|
expect(html).toMatch(/<div class="text-xs">.*class="am-address"/);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("WaitTx says which token an allowance covers", () => {
|
||||||
|
mockReceiptLookup.mockResolvedValue(null);
|
||||||
|
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
|
||||||
|
expectTokenLine("wait-tx-token-note");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ErrorTx says which token an allowance covers", () => {
|
||||||
|
txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed.");
|
||||||
|
expectTokenLine("error-tx-token-note");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the token's address links to the transaction's network", () => {
|
||||||
|
const sepoliaTx = {
|
||||||
|
...TX_INFO,
|
||||||
|
chainId: NETWORKS.sepolia.chainId,
|
||||||
|
decoded,
|
||||||
|
};
|
||||||
|
txStatus.showError(sepoliaTx, TX_HASH, "It failed.");
|
||||||
|
expect(getElement("error-tx-token-note").innerHTML).toContain(
|
||||||
|
`href="https://sepolia.etherscan.io/address/${USDC}"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the token's address can be copied on an ErrorTx with no hash", () => {
|
||||||
|
const view = getElement("view-error-tx");
|
||||||
|
view.querySelectorAll = jest.fn(() => []);
|
||||||
|
txStatus.showError({ ...TX_INFO, decoded }, null, "It failed.");
|
||||||
|
expect(view.querySelectorAll).toHaveBeenCalledWith("[data-copy]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("WaitTx shows each line's amount, escaped", () => {
|
||||||
|
mockReceiptLookup.mockResolvedValue(null);
|
||||||
|
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
|
||||||
|
expectEveryLine("wait-tx-token-note");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ErrorTx shows each line's amount, escaped", () => {
|
||||||
|
txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed.");
|
||||||
|
expectEveryLine("error-tx-token-note");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a wait that ends in ErrorTx keeps them", async () => {
|
||||||
|
mockReceiptLookup.mockResolvedValue(null);
|
||||||
|
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
|
||||||
|
await jest.advanceTimersByTimeAsync(60000);
|
||||||
|
expect(state.currentView).toBe("error-tx");
|
||||||
|
expectEveryLine("error-tx-token-note");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// An amount of about 47 digits or more is wider than the screen. Without the
|
||||||
|
// break-words class its last digits are cut off at the edge. That class keeps
|
||||||
|
// the words of a line such as `(decimals unknown)` whole.
|
||||||
|
describe("a long amount on WaitTx, ErrorTx and SuccessTx", () => {
|
||||||
|
test("each amount under the Amount figure wraps", () => {
|
||||||
|
const decoded = {
|
||||||
|
details: [
|
||||||
|
{ label: "Moved from the sender", value: "1.0000 ETH" },
|
||||||
|
{ label: "Permit2 allowance", value: "Unlimited" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed.");
|
||||||
|
const html = getElement("error-tx-token-note").innerHTML;
|
||||||
|
expect(html).toContain(
|
||||||
|
'<div class="font-bold break-words">1.0000 ETH<',
|
||||||
|
);
|
||||||
|
expect(html).toContain('<div class="font-bold break-words">Unlimited<');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the Amount figure wraps", () => {
|
||||||
|
const html = fs.readFileSync(
|
||||||
|
path.join(__dirname, "..", "src", "popup", "index.html"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(html).toMatch(
|
||||||
|
/id="wait-tx-summary"\s+class="font-bold break-words"/,
|
||||||
|
);
|
||||||
|
expect(html).toMatch(
|
||||||
|
/id="error-tx-summary"\s+class="font-bold break-words"/,
|
||||||
|
);
|
||||||
|
expect(html).toMatch(
|
||||||
|
/id="success-tx-summary"\s+class="font-bold break-words"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each amount of SuccessTx's decoded action wraps", () => {
|
||||||
|
state.viewData = {
|
||||||
|
hash: TX_HASH,
|
||||||
|
to: RECIPIENT,
|
||||||
|
chainId: TX_INFO.chainId,
|
||||||
|
decoded: { details: [{ label: "Amount", value: "1.0000 ETH" }] },
|
||||||
|
};
|
||||||
|
txStatus.renderSuccess();
|
||||||
|
expect(getElement("success-tx-decoded").innerHTML).toContain(
|
||||||
|
'<div class="font-bold break-words">1.0000 ETH<',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the name and description of SuccessTx's decoded action wrap", () => {
|
||||||
|
state.viewData = {
|
||||||
|
hash: TX_HASH,
|
||||||
|
to: RECIPIENT,
|
||||||
|
chainId: TX_INFO.chainId,
|
||||||
|
decoded: {
|
||||||
|
name: "Transfer LONGSYMBOL",
|
||||||
|
description: "Transfer LONGSYMBOL to a recipient",
|
||||||
|
details: [{ label: "Amount", value: "1.0000 ETH" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
txStatus.renderSuccess();
|
||||||
|
const html = getElement("success-tx-decoded").innerHTML;
|
||||||
|
expect(html).toContain(
|
||||||
|
'<div class="font-bold break-words">Transfer LONGSYMBOL<',
|
||||||
|
);
|
||||||
|
expect(html).toContain(
|
||||||
|
'<div class="break-words">Transfer LONGSYMBOL to a recipient<',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A saved wait comes back from storage, in whatever shape it is in. A throw when
|
||||||
|
// its receipt arrives would leave the popup on WaitTx with the counter stopped.
|
||||||
|
describe("a saved wait in another shape when its receipt arrives", () => {
|
||||||
|
const LINE = { label: "Steps", value: "V3_SWAP_EXACT_IN" };
|
||||||
|
|
||||||
|
async function receiptForSavedWait(decoded) {
|
||||||
|
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
|
||||||
|
state.viewData = {
|
||||||
|
pendingWait: {
|
||||||
|
hash: TX_HASH,
|
||||||
|
txInfo: { ...TX_INFO, decoded },
|
||||||
|
broadcastTime: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(txStatus.restoreWait()).toBe(true);
|
||||||
|
await jest.advanceTimersByTimeAsync(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ details: 7 },
|
||||||
|
{ details: "text" },
|
||||||
|
{ details: { label: "Steps", value: "text" } },
|
||||||
|
{ details: [null] },
|
||||||
|
{ details: [7] },
|
||||||
|
{ details: ["text"] },
|
||||||
|
{ details: [{ value: "text" }] },
|
||||||
|
{ details: [{ label: 7, value: "text" }] },
|
||||||
|
{ details: [{ label: { toString: 1 }, value: "text" }] },
|
||||||
|
{ details: [{ label: "Steps" }] },
|
||||||
|
{ details: [{ label: "Steps", value: 7 }] },
|
||||||
|
{ details: [{ label: "Steps", value: { toString: 1 } }] },
|
||||||
|
{ details: [{ label: "Steps", address: RECIPIENT }] },
|
||||||
|
{ details: [{ label: "Steps", value: "text", address: 7 }] },
|
||||||
|
{
|
||||||
|
details: [
|
||||||
|
{ label: "Steps", value: "text", address: 7, isToken: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: "Swap", description: "A swap", details: [null] },
|
||||||
|
])("%j shows SuccessTx with the amount and recipient", async (decoded) => {
|
||||||
|
await receiptForSavedWait(decoded);
|
||||||
|
expect(state.currentView).toBe("success-tx");
|
||||||
|
expect(
|
||||||
|
getElement("success-tx-decoded").classList.contains("hidden"),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
getElement("success-tx-summary-parent").classList.contains(
|
||||||
|
"hidden",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(getElement("success-tx-summary").textContent).toBe("0.0050 ETH");
|
||||||
|
expect(
|
||||||
|
getElement("success-tx-to-parent").classList.contains("hidden"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(getElement("success-tx-to").innerHTML).toContain(RECIPIENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ details: [null, LINE, 7] },
|
||||||
|
{ name: 7, details: [LINE] },
|
||||||
|
{ name: { toString: 1 }, details: [LINE] },
|
||||||
|
{ description: 7, details: [LINE] },
|
||||||
|
{ description: { toString: 1 }, details: [LINE] },
|
||||||
|
])("%j shows SuccessTx with the line it can read", async (decoded) => {
|
||||||
|
await receiptForSavedWait(decoded);
|
||||||
|
expect(state.currentView).toBe("success-tx");
|
||||||
|
const html = getElement("success-tx-decoded").innerHTML;
|
||||||
|
expect(html).toContain("V3_SWAP_EXACT_IN");
|
||||||
|
expect(html).not.toContain("Action");
|
||||||
|
expect(html).not.toContain("Description");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The decoded action stands in for the Amount and To lines only where it shows
|
||||||
|
// the same thing. A saved record with unreadable lines can leave out either.
|
||||||
|
describe("SuccessTx's Amount and To lines beside a decoded action", () => {
|
||||||
|
const OTHER = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
|
||||||
|
const STEPS = { label: "Steps", value: "V3_SWAP_EXACT_IN" };
|
||||||
|
const AMOUNT = { label: "Amount", value: "0.0050 ETH" };
|
||||||
|
const RECIPIENT_LINE = {
|
||||||
|
label: "Recipient",
|
||||||
|
value: RECIPIENT,
|
||||||
|
address: RECIPIENT,
|
||||||
|
};
|
||||||
|
const SPENDER = { label: "Spender", value: OTHER, address: OTHER };
|
||||||
|
|
||||||
|
function renderSuccessWith(details, to) {
|
||||||
|
state.viewData = {
|
||||||
|
amount: "0.0050",
|
||||||
|
symbol: "ETH",
|
||||||
|
to,
|
||||||
|
hash: TX_HASH,
|
||||||
|
chainId: TX_INFO.chainId,
|
||||||
|
decoded: { details },
|
||||||
|
};
|
||||||
|
txStatus.renderSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineHidden(id) {
|
||||||
|
return getElement(id + "-parent").classList.contains("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["no line for either", [STEPS], false, false],
|
||||||
|
["a line of another address", [AMOUNT, SPENDER], true, false],
|
||||||
|
["an Amount line only", [AMOUNT], true, false],
|
||||||
|
["a Recipient line only", [RECIPIENT_LINE], false, true],
|
||||||
|
[
|
||||||
|
"an unreadable Amount line",
|
||||||
|
[{ label: "Amount" }, RECIPIENT_LINE],
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"an unreadable Recipient line",
|
||||||
|
[AMOUNT, { address: RECIPIENT }],
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
],
|
||||||
|
["a line for each", [AMOUNT, RECIPIENT_LINE], true, true],
|
||||||
|
])("%s", (_name, details, amountHidden, toHidden) => {
|
||||||
|
renderSuccessWith(details, RECIPIENT);
|
||||||
|
expect(state.currentView).toBe("success-tx");
|
||||||
|
expect(lineHidden("success-tx-summary")).toBe(amountHidden);
|
||||||
|
expect(lineHidden("success-tx-to")).toBe(toHidden);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the lines read the amount and the recipient", () => {
|
||||||
|
renderSuccessWith([STEPS], RECIPIENT);
|
||||||
|
expect(getElement("success-tx-summary").textContent).toBe("0.0050 ETH");
|
||||||
|
expect(getElement("success-tx-to").innerHTML).toContain(RECIPIENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A contract creation's data can decode as an ERC-20 call, with an empty
|
||||||
|
// token address.
|
||||||
|
test("a contract creation keeps its To line", () => {
|
||||||
|
renderSuccessWith(
|
||||||
|
[{ label: "Token", value: "", address: "", isToken: true }],
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
expect(lineHidden("success-tx-to")).toBe(false);
|
||||||
|
expect(getElement("success-tx-to").innerHTML).toBe(
|
||||||
|
CONTRACT_CREATION_TEXT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("wait-tx is a view the popup may reopen onto", () => {
|
describe("wait-tx is a view the popup may reopen onto", () => {
|
||||||
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
|
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
|
||||||
// refuses any view not in the set, so dropping "wait-tx" from it kills
|
// refuses any view not in the set, so dropping "wait-tx" from it kills
|
||||||
|
|||||||
+1531
-12
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user