Compare commits
1
Commits
5eba6b482f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23817de7d3 |
+7
-77
@@ -1,77 +1,7 @@
|
|||||||
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
# .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for
|
||||||
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
# build-info stamping and the Dockerfile runs `make build`, so excluding it
|
||||||
# `/` and an unprefixed pattern is anchored at the context root. Every
|
# would make every built extension report commitHash "unknown".
|
||||||
# depth-independent pattern therefore needs `**/`, or `config/.env` and
|
node_modules
|
||||||
# `certs/server.key` still ship while this file reads as solved. Only
|
.DS_Store
|
||||||
# genuinely root-anchored entries go unprefixed. Never transplant these
|
dist
|
||||||
# into .gitignore, where `**/` is wrong.
|
release
|
||||||
#
|
|
||||||
# Matching is case-sensitive, so secrets use character ranges rather
|
|
||||||
# than an ALL-CAPS twin, which would still miss `Server.Key`.
|
|
||||||
#
|
|
||||||
# Extend with this repo's own host-built artifacts, written anchored:
|
|
||||||
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
|
||||||
# deletes the package directory from the context.
|
|
||||||
|
|
||||||
# .git is sent without its config. Without a VERSION build argument the
|
|
||||||
# stage that compiles runs `git describe --tags --always` on .git, which
|
|
||||||
# does not need .git/config; that file can hold a credential, such as a
|
|
||||||
# password in a remote URL or the token the CI checkout step stores there.
|
|
||||||
# Each submodule keeps a config with the same exposure in its git directory
|
|
||||||
# under .git/modules/, nested again for a submodule's own submodules, or in
|
|
||||||
# its own .git directory when it keeps one.
|
|
||||||
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
|
|
||||||
# `deploy/config`, `config/lib`) loses its whole git directory, because
|
|
||||||
# `**/.git/modules/**/config` also matches that segment's directory
|
|
||||||
# under .git/modules/. Go's version stamping then fails the build;
|
|
||||||
# nothing leaks. Name such a submodule without that segment:
|
|
||||||
# `git submodule add --name`.
|
|
||||||
**/.git/config
|
|
||||||
**/.git/modules/**/config
|
|
||||||
|
|
||||||
# Agent scratch: one full checkout of the repo per in-flight agent.
|
|
||||||
# Anchored because it occurs once where agents run at the repo root.
|
|
||||||
# KNOWN GAP: a repo running agents in subdirectories still ships
|
|
||||||
# `services/api/.claude/` and must add its own anchored entry.
|
|
||||||
.claude
|
|
||||||
|
|
||||||
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
|
||||||
# convention. Re-include a committed template with a negation if the
|
|
||||||
# build needs one: `!docs/example.env`.
|
|
||||||
**/*.[eE][nN][vV]
|
|
||||||
**/.[eE][nN][vV].*
|
|
||||||
**/.[eE][nN][vV][rR][cC]
|
|
||||||
|
|
||||||
# Private keys and the bundles carrying them. Public certificates
|
|
||||||
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
|
|
||||||
**/*.[pP][eE][mM]
|
|
||||||
**/*.[kK][eE][yY]
|
|
||||||
**/*.[pP]12
|
|
||||||
**/*.[pP][fF][xX]
|
|
||||||
**/[iI][dD]_[rR][sS][aA]
|
|
||||||
**/[iI][dD]_[dD][sS][aA]
|
|
||||||
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
|
||||||
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
|
||||||
**/[iI][dD]_[eE][dD]25519
|
|
||||||
**/[iI][dD]_[eE][dD]25519_[sS][kK]
|
|
||||||
|
|
||||||
# Dependencies: restored inside the image, never copied in.
|
|
||||||
**/node_modules
|
|
||||||
|
|
||||||
# OS metadata.
|
|
||||||
**/.DS_Store
|
|
||||||
**/Thumbs.db
|
|
||||||
|
|
||||||
# Editor state: never a build input, and it churns COPY.
|
|
||||||
**/*.swp
|
|
||||||
**/*.swo
|
|
||||||
**/*~
|
|
||||||
**/*.bak
|
|
||||||
**/.idea
|
|
||||||
**/.vscode
|
|
||||||
**/*.sublime-*
|
|
||||||
|
|
||||||
# This repo's host-built artifacts: make build writes dist/ and make package
|
|
||||||
# writes release/. The image builds its own.
|
|
||||||
/dist
|
|
||||||
/release
|
|
||||||
|
|||||||
+10
-17
@@ -2,11 +2,11 @@ name: e2e
|
|||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
# The browser end-to-end suites, one job per browser, deliberately kept out
|
# The browser end-to-end suites, one job per browser, deliberately kept out
|
||||||
# of the check workflow: REPO_POLICIES.md caps make test at 60 seconds and
|
# of the check workflow: REPO_POLICIES.md caps make test at 20 seconds and
|
||||||
# script/cibuild runs script/check, so folding a browser suite into either
|
# script/cibuild is a plain `docker build .` whose Dockerfile runs
|
||||||
# would blow that cap and slow the local fast path. Before this workflow
|
# make check, so folding a browser suite into either would blow that cap
|
||||||
# every browser-level guarantee in this repo held only when a human
|
# and slow the local fast path. Before this workflow every browser-level
|
||||||
# remembered to run it.
|
# guarantee in this repo held only when a human remembered to run it.
|
||||||
#
|
#
|
||||||
# One job per browser rather than two steps in one job, so a Chrome failure
|
# One job per browser rather than two steps in one job, so a Chrome failure
|
||||||
# does not hide the Firefox result.
|
# does not hide the Firefox result.
|
||||||
@@ -22,10 +22,11 @@ on: [push]
|
|||||||
# These jobs REPORT, they do not gate. Whether a check blocks a merge is
|
# These jobs REPORT, they do not gate. Whether a check blocks a merge is
|
||||||
# Gitea branch protection, which this repo does not configure, so a failure
|
# Gitea branch protection, which this repo does not configure, so a failure
|
||||||
# here is a red mark a reviewer has to account for rather than a hard
|
# here is a red mark a reviewer has to account for rather than a hard
|
||||||
# block. Making e2e-chrome a required check is blocked while reports of the
|
# block. Making e2e-chrome a required check is blocked on the measured
|
||||||
# Chrome suite failing under load are still open; the "In CI" section of
|
# flake in the dApp signing wait -- two of six runs of unmutated code on a
|
||||||
# README.md names them. A gate that fails at random teaches people to merge
|
# loaded machine -- tracked as
|
||||||
# past red.
|
# https://git.eeqj.de/sneak/AutistMask/issues/287. 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
|
||||||
@@ -35,10 +36,6 @@ on: [push]
|
|||||||
jobs:
|
jobs:
|
||||||
e2e-chrome:
|
e2e-chrome:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Bounds the image build, a cold cache included, and both Chrome
|
|
||||||
# programs, so a hung browser frees the shared runner. README.md
|
|
||||||
# "In CI" has the measured times.
|
|
||||||
timeout-minutes: 20
|
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
@@ -46,10 +43,6 @@ jobs:
|
|||||||
|
|
||||||
e2e-firefox:
|
e2e-firefox:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Bounds the image build, a cold cache included, and both Firefox
|
|
||||||
# programs, so a hung browser frees the shared runner. README.md
|
|
||||||
# "In CI" has the measured times.
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout v4.2.2, 2026-02-22
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
|||||||
+5
-31
@@ -11,40 +11,14 @@ Thumbs.db
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.sublime-*
|
*.sublime-*
|
||||||
|
|
||||||
# Agent scratch (worktrees of this repo, created and destroyed by
|
|
||||||
# in-flight tooling). Unanchored: .gitignore patterns already match at
|
|
||||||
# every depth, so no prefix is wanted here. This is not a .dockerignore
|
|
||||||
# entry and must not be given a `**/` prefix on the way into one.
|
|
||||||
.claude/
|
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Secrets. Unanchored like every entry above, so each matches at every
|
# Environment / secrets
|
||||||
# depth. Matching is case-sensitive on Linux, so names use character
|
.env
|
||||||
# ranges rather than a lowercase form that misses `Server.Key`.
|
.env.*
|
||||||
|
*.pem
|
||||||
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
*.key
|
||||||
# convention. Only the templates `example.env` and `sample.env` are
|
|
||||||
# re-included below. A repository that commits any other template adds
|
|
||||||
# its own negation after these lines, for example `!.env.example`.
|
|
||||||
*.[eE][nN][vV]
|
|
||||||
.[eE][nN][vV].*
|
|
||||||
.[eE][nN][vV][rR][cC]
|
|
||||||
!example.env
|
|
||||||
!sample.env
|
|
||||||
|
|
||||||
# Private keys and the bundles carrying them.
|
|
||||||
*.[pP][eE][mM]
|
|
||||||
*.[kK][eE][yY]
|
|
||||||
*.[pP]12
|
|
||||||
*.[pP][fF][xX]
|
|
||||||
[iI][dD]_[rR][sS][aA]
|
|
||||||
[iI][dD]_[dD][sS][aA]
|
|
||||||
[iI][dD]_[eE][cC][dD][sS][aA]
|
|
||||||
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
|
||||||
[iI][dD]_[eE][dD]25519
|
|
||||||
[iI][dD]_[eE][dD]25519_[sS][kK]
|
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
.claude/
|
||||||
|
|||||||
+29
-67
@@ -1,80 +1,42 @@
|
|||||||
# Lint phase: ESLint, prettier --check, and script/check-censored. The tools
|
|
||||||
# are invoked directly rather than through `make lint` or `script/lint`, which
|
|
||||||
# are themselves a docker build and would recurse into a daemon that does not
|
|
||||||
# exist in a build step.
|
|
||||||
#
|
|
||||||
# node:22-slim (22.x LTS), 2026-02-24
|
# node:22-slim (22.x LTS), 2026-02-24
|
||||||
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS lint
|
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Marks "already inside the lint container" for script/lint, which otherwise
|
||||||
|
# shells out to docker to build the lint stage below. Nothing outside this
|
||||||
|
# image sets it.
|
||||||
|
ENV AUTISTMASK_LINT_NATIVE=1
|
||||||
|
|
||||||
|
# script/test's default 30s bound is the host figure, against a suite that
|
||||||
|
# runs in about 8s there. In here the same suite starts on a cold jest cache
|
||||||
|
# and shares the runner with the rest of the build, so 30s is marginal rather
|
||||||
|
# than a bound — it killed a healthy suite at 30.6s on a cold CI cache. 180s
|
||||||
|
# still catches a hang in three minutes and cannot be tripped by a suite that
|
||||||
|
# is merely running on contended hardware.
|
||||||
|
ENV AUTISTMASK_TEST_TIMEOUT=180
|
||||||
|
|
||||||
|
# script/bootstrap installs all prerequisites (make via apt here; node
|
||||||
|
# is already in the base image, yarn comes via corepack) and runs
|
||||||
|
# yarn install --frozen-lockfile. Dependency manifests are copied first
|
||||||
|
# so the bootstrap layer is cached until they change.
|
||||||
COPY script/ script/
|
COPY script/ script/
|
||||||
COPY package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
RUN script/bootstrap
|
RUN script/bootstrap
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN yarn run lint
|
# Lint stage — fail fast on static analysis and formatting, before the tests
|
||||||
RUN script/check-censored
|
# and the build. This is also the stage script/lint builds from a host, which
|
||||||
|
# is how linting stays on the pinned ESLint rather than the host's.
|
||||||
# Test phase, same shape and for the same reason: the jest suite (its worker
|
FROM base AS lint
|
||||||
# cap is in package.json), rerun verbose on failure, then
|
RUN make lint
|
||||||
# script/test-verify-build.
|
|
||||||
#
|
|
||||||
# node:22-slim (22.x LTS), 2026-02-24
|
|
||||||
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS test
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY script/ script/
|
|
||||||
COPY package.json yarn.lock ./
|
|
||||||
RUN script/bootstrap
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN timeout 90 yarn run test || \
|
|
||||||
{ echo "--- Rerunning with --verbose for details ---"; \
|
|
||||||
timeout 90 yarn run test:verbose; exit 1; }
|
|
||||||
RUN script/test-verify-build
|
|
||||||
|
|
||||||
# Development environment with the extension built, and the last stage: a
|
|
||||||
# plain `docker build .` names no target and so builds this one. Nothing is
|
|
||||||
# wanted from the two phases above; the copies are what make BuildKit build
|
|
||||||
# them first, so this image cannot be produced unless lint and test passed. A
|
|
||||||
# stage appended after this one would drop all three out of a plain build.
|
|
||||||
#
|
|
||||||
# node:22-slim (22.x LTS), 2026-02-24
|
|
||||||
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
|
# Full check and build. The COPY --from is a no-op file copy whose only job is
|
||||||
|
# to make BuildKit finish the lint stage before this one starts; without it the
|
||||||
|
# stages run in parallel and a lint failure would not fail the build early.
|
||||||
|
FROM base AS check
|
||||||
COPY --from=lint /app/package.json /dev/null
|
COPY --from=lint /app/package.json /dev/null
|
||||||
COPY --from=test /app/package.json /dev/null
|
|
||||||
|
|
||||||
# script/bootstrap installs all prerequisites, git included. Manifests are
|
RUN make check
|
||||||
# copied first so that layer stays cached until dependencies change.
|
RUN make build
|
||||||
COPY script/ script/
|
|
||||||
COPY package.json yarn.lock ./
|
|
||||||
RUN script/bootstrap
|
|
||||||
# A tar-stream context keeps the sender's file owners, which git refuses.
|
|
||||||
RUN git config --system --add safe.directory /app
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# The VERSION build arg when one is given, otherwise
|
|
||||||
# `git describe --tags --always` on the .git in the build context. With .git
|
|
||||||
# present, a version that is still empty, dev or unknown fails the build: git
|
|
||||||
# is missing or could not read the checkout, and build.js, which stamps the
|
|
||||||
# extension with the commit it was built from, would stamp "unknown".
|
|
||||||
ARG VERSION
|
|
||||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
|
||||||
if [ -e .git ]; then \
|
|
||||||
case "$VERSION" in ""|dev|unknown) \
|
|
||||||
echo "version is '$VERSION' although .git is present" >&2; \
|
|
||||||
exit 1 ;; \
|
|
||||||
esac; \
|
|
||||||
fi; \
|
|
||||||
make build
|
|
||||||
# A LABEL cannot run git, so it carries the build argument alone; a plain
|
|
||||||
# `docker build .` leaves it empty.
|
|
||||||
LABEL org.opencontainers.image.version="${VERSION}"
|
|
||||||
|
|||||||
@@ -107,14 +107,6 @@ vendor-blocklist:
|
|||||||
clean:
|
clean:
|
||||||
@rm -rf dist/ release/
|
@rm -rf dist/ release/
|
||||||
|
|
||||||
# Run the build make build runs, without the checks that follow it, then run it
|
|
||||||
# again after every change to a file under src/, manifest/ or icons/, until
|
|
||||||
# interrupted. A failed build is reported, may leave dist/ partly written, and
|
|
||||||
# watching carries on. It writes no build receipt, so nothing can verify what it
|
|
||||||
# leaves in dist/: anything handed on comes from make build. A release build
|
|
||||||
# unless AUTISTMASK_DEBUG=1 is exported. A change anywhere else, package.json
|
|
||||||
# and build.js included, starts no build, and a directory created while it runs
|
|
||||||
# is not watched; restart it after either.
|
|
||||||
dev:
|
dev:
|
||||||
@echo "Building in watch mode..."
|
@echo "Building in watch mode..."
|
||||||
@yarn run build --watch 2>&1
|
@yarn run build --watch 2>&1
|
||||||
|
|||||||
@@ -64,9 +64,7 @@ release/SHA256SUMS
|
|||||||
```
|
```
|
||||||
|
|
||||||
Nothing is published by this. Tagging, CRX packing and any upload are
|
Nothing is published by this. Tagging, CRX packing and any upload are
|
||||||
outward-facing acts and are the owner's alone. The full procedure that turns a
|
outward-facing acts and are the owner's alone.
|
||||||
green `main` into a tagged, packaged release — the order of steps, who performs
|
|
||||||
each, and how to check it worked — is in [docs/RELEASE.md](docs/RELEASE.md).
|
|
||||||
|
|
||||||
The archives are deterministic — entries sorted, timestamps fixed, compression
|
The archives are deterministic — entries sorted, timestamps fixed, compression
|
||||||
level fixed — so two builds of one commit produce byte-identical files and the
|
level fixed — so two builds of one commit produce byte-identical files and the
|
||||||
@@ -222,32 +220,31 @@ provide:
|
|||||||
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
|
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
|
||||||
git pre-commit hook
|
git pre-commit hook
|
||||||
- `script/projectname` — print the project name (used for the Docker image tag)
|
- `script/projectname` — print the project name (used for the Docker image tag)
|
||||||
- `script/test` — build the Dockerfile's `test` phase, uncached: the jest suite,
|
- `script/test` — run the test suite (jest)
|
||||||
stopped after 90 seconds and rerun verbose if it fails, then
|
|
||||||
`script/test-verify-build`
|
|
||||||
- `script/test-e2e` — run the Chrome browser end-to-end suite (docker is the
|
- `script/test-e2e` — run the Chrome browser end-to-end suite (docker is the
|
||||||
only prerequisite: it builds a pinned image that carries the repo and a fresh
|
only prerequisite: it builds a pinned image that carries the repo and a fresh
|
||||||
extension build, see [End-to-End Tests](#end-to-end-tests))
|
extension build, see [End-to-End Tests](#end-to-end-tests))
|
||||||
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (same,
|
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (same,
|
||||||
against an image with a pinned Firefox and geckodriver, see
|
against an image with a pinned Firefox and geckodriver, see
|
||||||
[End-to-End Tests](#end-to-end-tests))
|
[End-to-End Tests](#end-to-end-tests))
|
||||||
- `script/lint` — build the Dockerfile's `lint` phase, uncached: ESLint
|
- `script/lint` — run ESLint (`eslint.config.js`) and then `prettier --check`,
|
||||||
(`eslint.config.js`), `prettier --check`, then `script/check-censored`,
|
failing on either. It never writes: `--fix` is not in this path, so
|
||||||
failing on any of them. It never writes: `--fix` is not in this path, so
|
`make check` stays non-mutating. Linting runs in the container — the script
|
||||||
`make check` stays non-mutating. Linting runs only in the container, because
|
builds the Dockerfile's `lint` stage — because an ESLint result that depends
|
||||||
an ESLint result that depends on whichever ESLint the host happens to have is
|
on whichever ESLint the host happens to have is not a result. Docker is
|
||||||
not a result, so docker is required to lint.
|
therefore required to lint; inside that image `AUTISTMASK_LINT_NATIVE=1` makes
|
||||||
- `script/fmt` — format all files (writes), on the host
|
the same script lint in place instead of recursing.
|
||||||
- `script/fmt-check` — check formatting (read-only), on the host
|
- `script/fmt` — format all files (writes)
|
||||||
- `script/check` — run `script/test`, `script/lint` and `script/fmt-check`
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
|
- `script/check` — run test, test-verify-build, check-censored, lint, and
|
||||||
|
fmt-check
|
||||||
- `script/check-censored` — assert the competitor name RULES.md bars appears
|
- `script/check-censored` — assert the competitor name RULES.md bars appears
|
||||||
nowhere in the working tree or under `dist/` outside its documented
|
nowhere in the working tree or under `dist/` outside its documented
|
||||||
exceptions: the pinned source reference in `script/vendor-blocklist`, the two
|
exceptions: the pinned source reference in `script/vendor-blocklist`, the two
|
||||||
provider-shim identifiers in `src/content/inpage.js`, and one ERC-20's
|
provider-shim identifiers in `src/content/inpage.js`, and one ERC-20's
|
||||||
on-chain name in `src/shared/tokenList.js`. Each is scoped to that path and
|
on-chain name in `src/shared/tokenList.js`. Each is scoped to that path and
|
||||||
fails anywhere else. Run by the `lint` phase, so part of `make check`; it
|
fails anywhere else. Part of `make check`, which inspects `dist/` when there
|
||||||
inspects `dist/` when there is one and says loudly when there is not, and the
|
is one and says loudly when there is not; `make build` re-runs it with
|
||||||
build context of that phase never has one. `make build` re-runs it with
|
|
||||||
`--require-dist`, so a build artifact is always covered
|
`--require-dist`, so a build artifact is always covered
|
||||||
- `script/package` — produce the release artifacts: `make build` first, so the
|
- `script/package` — produce the release artifacts: `make build` first, so the
|
||||||
archives can only ever be made from a `dist/` that has been verified against
|
archives can only ever be made from a `dist/` that has been verified against
|
||||||
@@ -282,23 +279,14 @@ provide:
|
|||||||
failing and a succeeding release build step, and read the `make build` and
|
failing and a succeeding release build step, and read the `make build` and
|
||||||
`make build-debug` recipes back out of `make -n` to check that they pass the
|
`make build-debug` recipes back out of `make -n` to check that they pass the
|
||||||
mode as an argument on a scrubbed environment and wrap only the release path.
|
mode as an argument on a scrubbed environment and wrap only the release path.
|
||||||
Run by the `test` phase, so part of `make check`; it reads no build artifacts
|
Part of `make check`; it reads no build artifacts and writes nothing under
|
||||||
and writes nothing under `dist/`. The cases that depend on file permissions
|
`dist/`. The cases that depend on file permissions cannot mean anything for a
|
||||||
cannot mean anything for a process that is not subject to them, so the harness
|
process that is not subject to them, so the harness proves its runner against
|
||||||
proves its runner against a mode-000 file before counting them, dropping to an
|
a mode-000 file before counting them, dropping to an unprivileged user when
|
||||||
unprivileged user when run as root; if it cannot, it skips those cases and
|
run as root; if it cannot, it skips those cases and says so in a banner rather
|
||||||
says so in a banner rather than passing them.
|
than passing them.
|
||||||
- `script/docker` — build the Docker image, uncached and tagged via
|
- `script/docker` — build the Docker image tagged via `script/projectname`
|
||||||
`script/projectname`: the `lint` and `test` phases, then `make build` in the
|
- `script/cibuild` — CI entrypoint: plain `docker build .`
|
||||||
last stage. It passes what `git describe --tags --always --dirty` prints on
|
|
||||||
the host as the `VERSION` build argument, or `unknown` when that prints
|
|
||||||
nothing, and the last stage fails on `unknown` whenever the build context
|
|
||||||
carries `.git`. Only a build given no `VERSION` at all, such as a plain
|
|
||||||
`docker build .`, runs `git describe` on the `.git` in the build context,
|
|
||||||
which `.dockerignore` sends without its `config`, and fails if git cannot read
|
|
||||||
it
|
|
||||||
- `script/cibuild` — CI entrypoint: `script/bootstrap`, `script/check`, then the
|
|
||||||
same image build as `script/docker`
|
|
||||||
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
|
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
|
||||||
- `script/install-precommit` — install the git pre-commit hook
|
- `script/install-precommit` — install the git pre-commit hook
|
||||||
|
|
||||||
@@ -316,15 +304,7 @@ The Makefile shims to those. It also carries a few targets that have no
|
|||||||
debug build, and keeping its `dist/` on failure (see
|
debug build, and keeping its `dist/` on failure (see
|
||||||
[Debug Builds](#debug-builds))
|
[Debug Builds](#debug-builds))
|
||||||
- `make clean` — remove `dist/` and `release/`
|
- `make clean` — remove `dist/` and `release/`
|
||||||
- `make dev` — run the build `make build` runs, without the checks that follow
|
- `make dev` — build in watch mode
|
||||||
it, then run it again after every change to a file under `src/`, `manifest/`
|
|
||||||
or `icons/`, until interrupted. A failed build is reported, may leave `dist/`
|
|
||||||
partly written, and watching carries on. It writes no build receipt, so
|
|
||||||
nothing can verify what it leaves in `dist/` (see
|
|
||||||
[Build Receipts](#build-receipts)): anything handed on comes from
|
|
||||||
`make build`. A release build unless `AUTISTMASK_DEBUG=1` is exported. A
|
|
||||||
change anywhere else, `package.json` and `build.js` included, starts no build,
|
|
||||||
and a directory created while it runs is not watched; restart it after either
|
|
||||||
|
|
||||||
## End-to-End Tests
|
## End-to-End Tests
|
||||||
|
|
||||||
@@ -360,11 +340,6 @@ fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
|
|||||||
offline; unrecognised outbound requests are reported as failures rather than
|
offline; unrecognised outbound requests are reported as failures rather than
|
||||||
silently allowed.
|
silently allowed.
|
||||||
|
|
||||||
It also covers the StateRecovery screen, under the shipped CSP: a stored record
|
|
||||||
this build cannot read opens the popup on it, its export text box holds that
|
|
||||||
record exactly as stored, a near-miss confirmation phrase erases nothing, and
|
|
||||||
the exact one erases the record and reloads into Welcome.
|
|
||||||
|
|
||||||
It also covers the **Settings screen**, which holds the densest run of element
|
It also covers the **Settings screen**, which holds the densest run of element
|
||||||
id lookups in the codebase and where one wrong id leaves the whole popup blank
|
id lookups in the codebase and where one wrong id leaves the whole popup blank
|
||||||
rather than only degrading Settings: that the screen renders populated — the
|
rather than only degrading Settings: that the screen renders populated — the
|
||||||
@@ -394,12 +369,6 @@ reserve while sitting on the same side of the estimate, so swapping the two in
|
|||||||
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
|
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
|
||||||
previously correct by reading only.
|
previously correct by reading only.
|
||||||
|
|
||||||
It also covers both ways the wait for a sent transaction's receipt ends on the
|
|
||||||
error screen: lookups that still find no receipt 60 seconds after the broadcast,
|
|
||||||
and six lookups in a row that fail. Each must show its own message, and Done
|
|
||||||
must lead back to the address screen. Both wait in real time, about a minute
|
|
||||||
each.
|
|
||||||
|
|
||||||
It also covers the **dApp approval round trips** — the one place where the
|
It also covers the **dApp approval round trips** — the one place where the
|
||||||
content script, the inpage provider, the background worker and the approval
|
content script, the inpage provider, the background worker and the approval
|
||||||
popup all have to work together. A local test page is served by the route
|
popup all have to work together. A local test page is served by the route
|
||||||
@@ -410,13 +379,11 @@ handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
|
|||||||
the runner and compared against the active address, the transaction assertions
|
the runner and compared against the active address, the transaction assertions
|
||||||
run against the raw signed transaction captured at `eth_sendRawTransaction`
|
run against the raw signed transaction captured at `eth_sendRawTransaction`
|
||||||
rather than against anything the extension reported, rejecting each prompt is
|
rather than against anything the extension reported, rejecting each prompt is
|
||||||
required to return a rejection to the page rather than hang or resolve, a prompt
|
required to return a rejection to the page rather than hang or resolve, and the
|
||||||
raised while another approval window has focus is required to open a window of
|
password is required to be absent from every message the approval window sends
|
||||||
its own, and the password is required to be absent from every message the
|
to the background — with the message that would carry it required to be present,
|
||||||
approval window sends to the background — with the message that would carry it
|
so that check cannot pass by observing nothing. That last one is the standing
|
||||||
required to be present, so that check cannot pass by observing nothing. That
|
floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
|
||||||
last one is the standing floor under
|
|
||||||
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
|
|
||||||
|
|
||||||
Two limits of that coverage, neither of them papered over. The RPC is stubbed
|
Two limits of that coverage, neither of them papered over. The RPC is stubbed
|
||||||
throughout, so this is **not** a real dApp against a real network with real
|
throughout, so this is **not** a real dApp against a real network with real
|
||||||
@@ -493,11 +460,10 @@ Chrome that ever changes this fails the run instead of passing it.
|
|||||||
|
|
||||||
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
|
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
|
||||||
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
|
real Firefox**, installed as an unpacked MV2 temporary add-on via geckodriver.
|
||||||
It covers popup load, the StateRecovery screen (the same cases as the Chrome
|
It covers popup load, wallet creation through the UI, the Add Token screen, and
|
||||||
suite), wallet creation through the UI, the Add Token screen, and the four dApp
|
the four dApp round trips — `eth_requestAccounts`, `personal_sign`,
|
||||||
round trips — `eth_requestAccounts`, `personal_sign`, `eth_sendTransaction`, and
|
`eth_sendTransaction`, and a closed approval window rejecting with EIP-1193 4001
|
||||||
a closed approval window rejecting with EIP-1193 4001 — driven through the real
|
— driven through the real content script, background page and approval windows.
|
||||||
content script, background page and approval windows.
|
|
||||||
|
|
||||||
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
|
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
|
||||||
**no npm dependencies at all**: it is built on global `fetch` and
|
**no npm dependencies at all**: it is built on global `fetch` and
|
||||||
@@ -627,7 +593,7 @@ Two limits are worth knowing, both real differences from the Chrome suite:
|
|||||||
out, but it cannot report which requests were attempted.
|
out, but it cannot report which requests were attempted.
|
||||||
|
|
||||||
Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or
|
Neither `make test-e2e` nor `make test-e2e-firefox` is part of `make check` or
|
||||||
`make test`. `REPO_POLICIES.md` caps `make test` at 60 seconds and a browser
|
`make test`. `REPO_POLICIES.md` caps `make test` at 20 seconds and a browser
|
||||||
suite does not fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot
|
suite does not fit; nothing in `tests/e2e/` is named `*.test.js`, so jest cannot
|
||||||
pick it up either. Run them locally before changing anything under
|
pick it up either. Run them locally before changing anything under
|
||||||
`src/popup/views/`.
|
`src/popup/views/`.
|
||||||
@@ -636,7 +602,7 @@ pick it up either. Run them locally before changing anything under
|
|||||||
|
|
||||||
`.gitea/workflows/e2e.yml` runs both suites on every push, as two jobs —
|
`.gitea/workflows/e2e.yml` runs both suites on every push, as two jobs —
|
||||||
`e2e-chrome` and `e2e-firefox` — separate from the `check` workflow, so the
|
`e2e-chrome` and `e2e-firefox` — separate from the `check` workflow, so the
|
||||||
60-second `make test` cap and the local fast path are untouched. Each job is a
|
20-second `make test` cap and the local fast path are untouched. Each job is a
|
||||||
checkout and the matching `script/` entrypoint, nothing else.
|
checkout and the matching `script/` entrypoint, nothing else.
|
||||||
|
|
||||||
Docker is the only thing either job needs from the runner, and that is not an
|
Docker is the only thing either job needs from the runner, and that is not an
|
||||||
@@ -651,32 +617,24 @@ The jobs **report, they do not gate.** A failure is a red mark against the
|
|||||||
commit that a reviewer has to account for, not a hard block: whether a check
|
commit that a reviewer has to account for, not a hard block: whether a check
|
||||||
blocks a merge is Gitea branch protection, which this repo does not configure.
|
blocks a merge is Gitea branch protection, which this repo does not configure.
|
||||||
|
|
||||||
That is not only a statement about configuration. No report of the Chrome suite
|
That is not only a statement about configuration. The Chrome suite is
|
||||||
**failing under load** is open now, but it has failed that way before, so a red
|
**measurably flaky under load** — two of six runs of unmutated code on a busy
|
||||||
`e2e-chrome` is read before it is believed. Do not answer one with a retry
|
machine lost the approval popup out from under the dApp signing wait, always in
|
||||||
wrapper: a suite that reruns until it is green stops being evidence.
|
the `#183` section, tracked as
|
||||||
|
[#287](https://git.eeqj.de/sneak/AutistMask/issues/287). So a red `e2e-chrome`
|
||||||
|
has to be read before it is believed, and that flake is the blocker to ever
|
||||||
|
making this a required check. Do not answer it with a retry wrapper: a suite
|
||||||
|
that reruns until it is green stops being evidence.
|
||||||
|
|
||||||
Nothing in either job can pass vacuously. There is no `continue-on-error` and no
|
Nothing in either job can pass vacuously. There is no `continue-on-error` and no
|
||||||
`|| true`; both scripts exit non-zero when docker is missing, when the image
|
`|| true`; both scripts exit non-zero when docker is missing, when the image
|
||||||
build fails, and when the browser fails to start; the Chrome harness aborts the
|
build fails, and when the browser fails to start; the Chrome harness aborts the
|
||||||
suite outright if its network interception is not in effect.
|
suite outright if its network interception is not in effect.
|
||||||
|
|
||||||
Measured on this repo's runner in the green runs of early October 2026, from a
|
Measured on this repo's runner: `e2e-chrome` about 1m55s cold, almost all of it
|
||||||
warm docker cache to a cold one: `e2e-chrome` 1m44s to 4m48s, and `e2e-firefox`
|
the one-time pull of the pinned ~800MB Playwright layer, and well under a minute
|
||||||
31s to 4m07s. A cold cache adds three to four minutes to each job, spent
|
once that layer is cached. `e2e-firefox` about 1m05s cold, and it caches its
|
||||||
rebuilding its image: reinstalling dependencies and, for `e2e-firefox`,
|
Firefox and geckodriver downloads the same way.
|
||||||
installing Firefox, geckodriver and their system libraries. Both scripts now
|
|
||||||
build their image with `--no-cache`, so every run pays the cold figure. Those
|
|
||||||
`e2e-chrome` runs predate the cases that wait in real time for a receipt to end
|
|
||||||
in error. `make test-e2e` took 3m51s locally with its image cached, so with the
|
|
||||||
image rebuilt every run, an `e2e-chrome` run comes to about seven minutes.
|
|
||||||
|
|
||||||
Each e2e job has a `timeout-minutes` cap, so a hung build or browser ends the
|
|
||||||
job instead of holding the shared runner: `e2e-firefox` 15 minutes and
|
|
||||||
`e2e-chrome` 20, each over two and a half times the job's slowest cold run. A
|
|
||||||
job that reaches its cap has hung; read it as a hang, not as a slow run to
|
|
||||||
retry. The `check` job has no cap: `.gitea/workflows/check.yml` is the canonical
|
|
||||||
copy from `sneak/prompts`, kept byte-identical.
|
|
||||||
|
|
||||||
### Element id guard (part of `make check`)
|
### Element id guard (part of `make check`)
|
||||||
|
|
||||||
@@ -735,7 +693,6 @@ src/
|
|||||||
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
|
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
|
||||||
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
|
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
|
||||||
ens.js — ENS forward/reverse resolution (popup only)
|
ens.js — ENS forward/reverse resolution (popup only)
|
||||||
holders.js — holder-count parsing and the low-holder rule
|
|
||||||
prices.js — ETH/USD and token/USD via CoinDesk API
|
prices.js — ETH/USD and token/USD via CoinDesk API
|
||||||
scamlist.js — known fraud contract addresses
|
scamlist.js — known fraud contract addresses
|
||||||
state.js — persisted state (extension storage)
|
state.js — persisted state (extension storage)
|
||||||
@@ -842,15 +799,8 @@ discoverable.
|
|||||||
on critical screens and when space is available to allow users to disambiguate
|
on critical screens and when space is available to allow users to disambiguate
|
||||||
addresses visually, as a security feature.
|
addresses visually, as a security feature.
|
||||||
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
|
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
|
||||||
styling, and no `style="..."` attributes, which the
|
styling. Tailwind is configured with a minimal monochrome palette. This keeps
|
||||||
[Content Security Policy](#content-security-policy) refuses. Tailwind is
|
the styling co-located with the markup and eliminates CSS file management.
|
||||||
configured with a minimal monochrome palette. This keeps the styling
|
|
||||||
co-located with the markup and eliminates CSS file management. The handful of
|
|
||||||
classes in `styles/main.css` are not styling: `.copy-flash-*` carries the copy
|
|
||||||
feedback animation, and `.am-address` carries the rule that an address never
|
|
||||||
wraps. Both are invariants that hold in every place they appear, and spelling
|
|
||||||
either out as repeated utilities is how one of those places drifts away from
|
|
||||||
the rest.
|
|
||||||
- **Vanilla JS**: No framework (React, Vue, Svelte, etc.). The popup UI is small
|
- **Vanilla JS**: No framework (React, Vue, Svelte, etc.). The popup UI is small
|
||||||
enough that vanilla JS with simple view switching is sufficient. A framework
|
enough that vanilla JS with simple view switching is sufficient. A framework
|
||||||
would add bundle size, build complexity, and attack surface for no benefit at
|
would add bundle size, build complexity, and attack surface for no benefit at
|
||||||
@@ -884,26 +834,10 @@ something when you click it.
|
|||||||
The same data must be formatted identically everywhere it appears. Token and ETH
|
The same data must be formatted identically everywhere it appears. Token and ETH
|
||||||
amounts are displayed with exactly 4 decimal places (e.g. "1.0500 ETH", "17.1900
|
amounts are displayed with exactly 4 decimal places (e.g. "1.0500 ETH", "17.1900
|
||||||
USDT") in balance lists, transaction lists, send confirmations, and approval
|
USDT") in balance lists, transaction lists, send confirmations, and approval
|
||||||
screens. A transaction's time includes both an ISO datetime and a humanized
|
screens. Timestamps include both an ISO datetime and a humanized relative age
|
||||||
relative age, written by `isoDate()` and `timeAgo()` in
|
wherever shown. If a formatting rule applies in one place, it applies in every
|
||||||
`src/popup/views/helpers.js` on every screen that shows one; the ISO datetime is
|
place. Users should never see the same value rendered differently on two
|
||||||
in UTC when the UTC Timestamps setting is on. If a formatting rule applies in
|
screens.
|
||||||
one place, it applies in every place. Users should never see the same value
|
|
||||||
rendered differently on two screens.
|
|
||||||
|
|
||||||
The native token's label is a network's `nativeCurrency` in
|
|
||||||
`src/shared/networks.js`: `ETH` on mainnet, `SepoliaETH` on Sepolia. The
|
|
||||||
wallet's balances and the Send and confirmation screens, which send on the
|
|
||||||
active network, use the active network's. A transaction's figures use the one of
|
|
||||||
the network its chain id names, whichever network is active: the value and fee
|
|
||||||
on the approval screen, the amount on the wait, success and error screens, the
|
|
||||||
transaction history and the transaction detail screen, and the refusal of a fee
|
|
||||||
above 1 ETH. A chain id that names no network reads `ETH`. Wherever this
|
|
||||||
document shows ETH as the label of a native balance, value or fee, in a "Native
|
|
||||||
ETH transfer" type line, in the contract-recipient warning or in that refusal,
|
|
||||||
Sepolia shows `SepoliaETH`. The swap lines keep `ETH`, the router's own name for
|
|
||||||
the native currency, and the ETH/USD price line, shown on mainnet only, keeps
|
|
||||||
its fixed wording.
|
|
||||||
|
|
||||||
**Specific Exception — Truncation:** On some non-critical display locations, we
|
**Specific Exception — Truncation:** On some non-critical display locations, we
|
||||||
may truncate _a small number_ of characters from the middle of an address solely
|
may truncate _a small number_ of characters from the middle of an address solely
|
||||||
@@ -915,12 +849,6 @@ that the portions still displayed will be more than adequate for the user to
|
|||||||
verify addresses even in the case of address spoofing attacks. Clicking an
|
verify addresses even in the case of address spoofing attacks. Clicking an
|
||||||
address will always copy the full, untruncated value.
|
address will always copy the full, untruncated value.
|
||||||
|
|
||||||
As of the address-row layout change, no view invokes that exception: every
|
|
||||||
address in the popup is rendered on a row of its own, wide enough for all 42
|
|
||||||
characters, and no screen truncates one to fit. The cap is still enforced in
|
|
||||||
`truncateMiddle()` and the 32-character floor in `renderAddressHtml()`, so the
|
|
||||||
guarantee holds for any future caller; there simply are none today.
|
|
||||||
|
|
||||||
**Specific Exception — Transaction Detail view:** The transaction detail screen
|
**Specific Exception — Transaction Detail view:** The transaction detail screen
|
||||||
is the authoritative record of a specific transaction and shows the exact,
|
is the authoritative record of a specific transaction and shows the exact,
|
||||||
untruncated amount with all meaningful decimal places (e.g. "0.00498824598498216
|
untruncated amount with all meaningful decimal places (e.g. "0.00498824598498216
|
||||||
@@ -941,33 +869,19 @@ On those screens, when the truncated string would contain no digit from 1 to 9
|
|||||||
and the value does, the amount is extended to its first significant digit
|
and the value does, the amount is extended to its first significant digit
|
||||||
instead: `0.000000000000000001 DAI`, not `0.0000 DAI`. The test is on the whole
|
instead: `0.000000000000000001 DAI`, not `0.0000 DAI`. The test is on the whole
|
||||||
truncated string, integer part included, so `1.00005` still shows as `1.0000` —
|
truncated string, integer part included, so `1.00005` still shows as `1.0000` —
|
||||||
the exception only fires where the entire displayed figure would read as zero.
|
the exception only fires where the entire displayed figure would read as zero. A
|
||||||
Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. The
|
genuine zero still renders `0.0000`, and truncation stays truncation: `0.99999`
|
||||||
rule still renders a genuine zero as `0.0000`. Two lines of a swap say a zero in
|
shows as `0.9999`, never rounded up.
|
||||||
words instead: `Min. received` reads `None (no minimum guaranteed)` for a zero
|
|
||||||
minimum, and `Amount` reads `All available (V4 open delta)` when the amount it
|
|
||||||
shows is a V4 exact-in `amountIn` of zero and
|
|
||||||
`Whatever an earlier step sent to the pair (V2 already paid)` when it is a V2
|
|
||||||
exact-in `amountIn` of zero.
|
|
||||||
|
|
||||||
The rule and its exception live in `src/shared/amountDisplay.js` as
|
The rule and its exception live in `src/shared/amountDisplay.js` as
|
||||||
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
|
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
|
||||||
confirmation screens display goes through the floored one — the ERC-20 amount,
|
confirmation screens display goes through the floored one — the ERC-20 amount,
|
||||||
the ETH value and max fee (`src/popup/views/approval.js`), the swap's `Amount`
|
the ETH value and max fee (`src/popup/views/approval.js`), and the swap's
|
||||||
and `Min. received` lines (`src/shared/uniswap.js`), the Send screen's
|
`Amount` and `Min. received` lines (`src/shared/uniswap.js`). The history and
|
||||||
`Current balance` (`src/popup/views/send.js`), and the balance and network fee
|
balance lists (`src/shared/transactions.js`) use the unfloored one: the
|
||||||
on the confirmation screen for the wallet's own send
|
transaction detail view is the authoritative record and already shows exact
|
||||||
(`src/popup/views/confirmTx.js`). Both screens render a network fee through
|
precision. The 4-decimal rule is unchanged everywhere else, including for
|
||||||
`formatFee()` in `src/popup/views/helpers.js`, which prices the exact fee in USD
|
amounts at or above the floor on the approval screens.
|
||||||
rather than its truncated figure, so the same fee reads the same on both, USD
|
|
||||||
value included. Balances are stored exactly (`src/shared/balances.js`), whatever
|
|
||||||
decimals a token declares, so a balance below the floor reaches these screens as
|
|
||||||
it is. The history list (`src/shared/transactions.js`) uses the unfloored one:
|
|
||||||
the transaction detail view is the authoritative record and already shows exact
|
|
||||||
precision. The balance lists use neither: they round to four places with
|
|
||||||
`toFixed(4)` (`balanceLine()` in `src/popup/views/helpers.js`). The 4-decimal
|
|
||||||
rule is unchanged everywhere else, including for amounts at or above the floor
|
|
||||||
on the approval screens.
|
|
||||||
|
|
||||||
The floor applies only where the token's scale is known. Where it is not, the
|
The floor applies only where the token's scale is known. Where it is not, the
|
||||||
approval screen states base units instead of a quantity — see Unknown token
|
approval screen states base units instead of a quantity — see Unknown token
|
||||||
@@ -984,13 +898,9 @@ approximation but a different number — 1,000 units of a 6-decimal token
|
|||||||
formatted at 18 decimals reads `0.000000001` — on the screen whose only job is
|
formatted at 18 decimals reads `0.000000001` — on the screen whose only job is
|
||||||
to state what is being authorized. Both amount paths of that screen take this
|
to state what is being authorized. Both amount paths of that screen take this
|
||||||
rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
|
rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
|
||||||
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
|
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). An
|
||||||
token permission warning on the signature screen takes the same rule for its
|
unbounded allowance or permit needs no scale to describe and is still shown as
|
||||||
amounts. An unbounded allowance or permit needs no scale to describe and is
|
`Unlimited`.
|
||||||
still shown as `Unlimited`. A source's answer counts only if it is a whole
|
|
||||||
number from 0 to 80: `decimals()` returns a `uint8`, but `formatUnits()` cannot
|
|
||||||
format more than 80 decimal places, so a token that reports 81 to 255 is shown
|
|
||||||
as one whose scale nothing knows.
|
|
||||||
|
|
||||||
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
|
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
|
||||||
sources are read as authoritative, so a value written into one of them cannot be
|
sources are read as authoritative, so a value written into one of them cannot be
|
||||||
@@ -1013,80 +923,6 @@ and compare against. Reading the stored field directly instead answers `null`
|
|||||||
for a bundled or tracked token the explorer merely omitted, which is not a
|
for a bundled or tracked token the explorer merely omitted, which is not a
|
||||||
refusal the wallet has any reason to make.
|
refusal the wallet has any reason to make.
|
||||||
|
|
||||||
The Send screen also consults every address's explorer reports, so a contract
|
|
||||||
two addresses report different `decimals` for has no scale there, and the stored
|
|
||||||
balance, formatted at one of those scales, is withdrawn with it. The Send
|
|
||||||
screen's `Current balance` and the confirmation screen's balance line then both
|
|
||||||
read `unknown (SYMBOL)`. The balance list formats each explorer row as it is
|
|
||||||
fetched, without that cross-address check, and shows the row's figure.
|
|
||||||
|
|
||||||
**Decoded amount lines on the transaction approval screen:** the `Amount` line
|
|
||||||
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
|
|
||||||
decoded swap (see TxApproval below), do not always read as a number. They can
|
|
||||||
read:
|
|
||||||
|
|
||||||
- A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known:
|
|
||||||
truncated to four decimals, with the floor and the zero cases described above.
|
|
||||||
- `<amount> base units (decimals unknown)` when the scale is unknown: the
|
|
||||||
base-unit integer, rather than a figure at a guessed scale (see Unknown token
|
|
||||||
scale above).
|
|
||||||
- `Unlimited`: on the ERC-20 `Amount` line, an `approve` of the `uint256`
|
|
||||||
maximum, an unbounded allowance. On the swap's `Amount` line, any amount at or
|
|
||||||
above the `uint160` maximum, whichever step set the line: a `PERMIT2_PERMIT`
|
|
||||||
amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in,
|
|
||||||
V2 exact-out or `WRAP_ETH` amount that large, which is not an allowance. The
|
|
||||||
router's whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such
|
|
||||||
amount.
|
|
||||||
- `Up to <amount>`: the swap's `Amount` line, when the transaction has a V2
|
|
||||||
exact-out step, whichever step set the line, including the `WRAP_ETH` of a
|
|
||||||
swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure,
|
|
||||||
not necessarily all of it; the wait, success and error screens show it with
|
|
||||||
the same words. `Unlimited`, `All available (V4 open delta)` and
|
|
||||||
`Whatever an earlier step sent to the pair (V2 already paid)` keep their
|
|
||||||
wording. When a V2 exact-out step sets `Min. received`, that line shows its
|
|
||||||
`amountOut`, the exact amount it buys.
|
|
||||||
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
|
|
||||||
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
|
|
||||||
whole open delta", so the calldata states no quantity. The line shows the
|
|
||||||
amount of one step that names an input token or amount: the last
|
|
||||||
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
|
|
||||||
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
|
|
||||||
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
|
|
||||||
exact-in action.
|
|
||||||
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
|
|
||||||
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
|
|
||||||
The router reads that zero as "the pair already holds the input tokens": the
|
|
||||||
step pays nothing itself and swaps whatever an earlier step sent to the pair,
|
|
||||||
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
|
|
||||||
such meaning and is shown as a zero.
|
|
||||||
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
|
|
||||||
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
|
|
||||||
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
|
|
||||||
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
|
|
||||||
read `0.0000` when the token's scale was known. The router passes a balance
|
|
||||||
check whenever the balance is at least `minBalance`, so a zero `minBalance`
|
|
||||||
guarantees nothing: it sets `Token Out` and `Min. received` only when the
|
|
||||||
output side holds no minimum, not even a zero one, at the point the check is
|
|
||||||
reached, and otherwise leaves the current token and figure in place. A nonzero
|
|
||||||
`minBalance` sets both lines, as a swap step does.
|
|
||||||
|
|
||||||
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
|
|
||||||
reads `Unknown (not named in the calldata)` when the decoder found no token for
|
|
||||||
that side. An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side
|
|
||||||
is WETH, on mainnet or Sepolia, or when no step set the output side; a WETH
|
|
||||||
`Min. received` figure then reads in ETH. Otherwise `Token Out` and
|
|
||||||
`Min. received` keep the output side's own token and figure, whether the swap
|
|
||||||
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
|
|
||||||
unwraps the WETH it did not spend shows USDC. The token permission warning on
|
|
||||||
the signature screen has its own amount wording, including `Unknown`; the
|
|
||||||
SignApproval section below describes it.
|
|
||||||
|
|
||||||
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
|
|
||||||
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
|
|
||||||
UTC, so a later deadline, such as the `uint256` maximum, reads
|
|
||||||
`After 275760-09-13 00:00:00 (no deadline in practice)` rather than leaving the
|
|
||||||
whole swap undecoded.
|
|
||||||
|
|
||||||
#### Partial USD totals
|
#### Partial USD totals
|
||||||
|
|
||||||
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
||||||
@@ -1165,21 +1001,10 @@ balance is nonzero and it is in the bundled known-token list, is tracked by the
|
|||||||
user, or has 1,000 or more holders; a token claiming a symbol from the bundled
|
user, or has 1,000 or more holders; a token claiming a symbol from the bundled
|
||||||
list from any other contract address is always dropped, and so is any token
|
list from any other contract address is always dropped, and so is any token
|
||||||
claiming a symbol that belongs to the native asset and therefore has no
|
claiming a symbol that belongs to the native asset and therefore has no
|
||||||
legitimate contract at all (`"ETH"`, and every network's `nativeCurrency`, such
|
legitimate contract at all (`"ETH"`). That filter is unconditional — the "Hide
|
||||||
as `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
|
|
||||||
tokens with fewer than 1,000 holders" setting governs the transaction history
|
tokens with fewer than 1,000 holders" setting governs the transaction history
|
||||||
and the send-screen token selector, not this list. A token's holder count is
|
and the send-screen token selector, not this list. Tracked tokens with a zero
|
||||||
unknown when the explorer reports none, or reports anything other than a whole
|
balance are listed as well while "Show tracked tokens with zero balance" is on.
|
||||||
number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()`
|
|
||||||
in `src/shared/holders.js`). This list does not take an unknown count as 1,000
|
|
||||||
or more, so such a token is shown only when it is on the bundled list or
|
|
||||||
tracked. `fetchTokenBalances()` stores every nonzero holding of a token it
|
|
||||||
admits, however small, but a holding below 0.000001 is left out of the balance
|
|
||||||
lists, the send-screen token selector, the address total and the remove-address
|
|
||||||
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
|
|
||||||
confirmation screens show it when its token is the one being sent. Tracked
|
|
||||||
tokens with a zero balance are listed as well while "Show tracked tokens with
|
|
||||||
zero balance" is on.
|
|
||||||
|
|
||||||
#### Stored state and its version
|
#### Stored state and its version
|
||||||
|
|
||||||
@@ -1199,18 +1024,16 @@ because bumping for one would send every older install to StateRecovery for
|
|||||||
nothing.
|
nothing.
|
||||||
|
|
||||||
Every read of the record goes through `assertStateUsable()` first, on the raw
|
Every read of the record goes through `assertStateUsable()` first, on the raw
|
||||||
bytes, before normalization: `loadState()` and every `saveState()` for the
|
bytes, before normalization: `loadState()` for the popup and `getState()` for
|
||||||
popup, and `getState()` for the background. It refuses a record that is not an
|
the background. It refuses a record that is not an object, a `schemaVersion`
|
||||||
object, a `schemaVersion` this build does not understand (a newer one included),
|
this build does not understand (a newer one included), a `wallets` that is not a
|
||||||
a `wallets` that is not a list of wallet records with address records in them,
|
list of wallet records with address records in them, and a `networkId` that is
|
||||||
and a `networkId` that is not a network in `src/shared/networks.js`. Refusing is
|
not a network in `src/shared/networks.js`. Refusing is the whole point — a
|
||||||
the whole point — a record the wallet cannot vouch for is never normalized,
|
record the wallet cannot vouch for is never normalized, never written back, and
|
||||||
never written back, and never half-loaded. The popup shows StateRecovery,
|
never half-loaded. The popup shows StateRecovery; a dApp gets a specific error
|
||||||
whether it finds the record unreadable when it opens or at a save while it is
|
(`-32007`, an EIP-1474 server-error code the spec leaves unassigned) saying the
|
||||||
open; a dApp gets a specific error (`-32007`, an EIP-1474 server-error code the
|
saved data cannot be read and that nothing was signed or sent, rather than the
|
||||||
spec leaves unassigned) saying the saved data cannot be read and that nothing
|
generic `-32603` every request used to answer.
|
||||||
was signed or sent, rather than the generic `-32603` every request used to
|
|
||||||
answer.
|
|
||||||
|
|
||||||
Every other field of the record is floored in `normalizePersisted()` rather than
|
Every other field of the record is floored in `normalizePersisted()` rather than
|
||||||
gated, and the floor is not the same for every field. Some are type-checked as a
|
gated, and the floor is not the same for every field. Some are type-checked as a
|
||||||
@@ -1247,16 +1070,15 @@ each caught only by a reviewer re-deriving thirty fields by hand.
|
|||||||
The `allowedSites` case is why the entry check is not optional. A stored
|
The `allowedSites` case is why the entry check is not optional. A stored
|
||||||
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
|
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
|
||||||
passed the gate, rendered a completely healthy popup, and then threw inside
|
passed the gate, rendered a completely healthy popup, and then threw inside
|
||||||
`saveState()`'s per-origin merge, so every save from that moment on failed and
|
`saveState()`'s per-hostname merge, so every save from that moment on failed and
|
||||||
the user went on operating a wallet that was persisting nothing
|
the user went on operating a wallet that was persisting nothing
|
||||||
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). A save that fails is
|
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). A save that fails is
|
||||||
now also reported rather than swallowed: `onSaveFailure()` in
|
now also reported rather than swallowed: `onSaveFailure()` in
|
||||||
`src/shared/state.js` is called for every failed save, awaited or not, and the
|
`src/shared/state.js` is called for every failed save, awaited or not, and the
|
||||||
popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in
|
popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in
|
||||||
`src/popup/views/helpers.js`). Storage can still fail for reasons no floor
|
`src/popup/views/helpers.js`). Storage can still fail for reasons no floor
|
||||||
covers — a quota, a revoked permission — and the wallet must never look healthy
|
covers — a quota, a revoked permission, a record a newer build wrote — and the
|
||||||
while that is true. A save that fails because the stored record fails the gate,
|
wallet must never look healthy while that is true.
|
||||||
such as one a newer build wrote, gets StateRecovery instead of the banner.
|
|
||||||
|
|
||||||
The `networkId` check is not cosmetic: that value is an object KEY into
|
The `networkId` check is not cosmetic: that value is an object KEY into
|
||||||
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
|
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
|
||||||
@@ -1360,20 +1182,13 @@ view would leave a wallet one click from deletion.
|
|||||||
- Send / Receive quick-action buttons, both acting on the active address
|
- Send / Receive quick-action buttons, both acting on the active address
|
||||||
- ETH/USD price display
|
- ETH/USD price display
|
||||||
- Wallet list: each wallet shows its name (tap to rename inline) and a "+"
|
- Wallet list: each wallet shows its name (tap to rename inline) and a "+"
|
||||||
button for HD and xprv wallets, then one block per address. The block
|
button for HD and xprv wallets, then one block per address with "Address
|
||||||
opens with a row carrying the colour dot, "Address N" (bold when active),
|
N" (bold when active), the ENS name if resolved, the full address, an
|
||||||
an `[info]` button and an `[x]` button (only on HD and xprv wallets
|
`[info]` button, an `[x]` button (only on HD and xprv wallets holding more
|
||||||
holding more than one address); the ENS name, if resolved, is below it;
|
than one address), the address USD total, and a balance line for ETH and
|
||||||
then the full address on a row of its own, followed by the address USD
|
for each token shown for that address
|
||||||
total and a balance line for ETH and for each token shown for that address
|
|
||||||
- "Recent Transactions": up to 25 transactions merged across every address
|
- "Recent Transactions": up to 25 transactions merged across every address
|
||||||
of every wallet, deduplicated by hash and filtered. Each row is three
|
of every wallet, deduplicated by hash and filtered
|
||||||
lines: age and direction, then the counterparty's colour dot (with our own
|
|
||||||
name for it, where it is one of our addresses) and the amount, then the
|
|
||||||
counterparty's full address on a row of its own. A contract creation has
|
|
||||||
no counterparty: its second line is the amount alone and its third line
|
|
||||||
says "This transaction creates a new contract. It has no recipient." The
|
|
||||||
transaction lists on AddressDetail and AddressToken draw the same rows
|
|
||||||
- "Add additional wallet..." link at bottom
|
- "Add additional wallet..." link at bottom
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- Tap address row → sets the active address and broadcasts
|
- Tap address row → sets the active address and broadcasts
|
||||||
@@ -1488,9 +1303,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- Send / Receive buttons
|
- Send / Receive buttons
|
||||||
- Token contract well (ERC-20 only): full contract address (tap to copy,
|
- Token contract well (ERC-20 only): full contract address (tap to copy,
|
||||||
etherscan link) plus name, symbol, decimals, holder count and project
|
etherscan link) plus name, symbol, decimals, holder count and project
|
||||||
website where known. The "Holders:" row is left out, not shown as 0, when
|
website where known
|
||||||
the token's balance-list entry has no holder count: the explorer did not
|
|
||||||
report a readable one, or the token is not in the balance list
|
|
||||||
- Token-filtered transaction list (only this token's transfers)
|
- Token-filtered transaction list (only this token's transfers)
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Send" → **Send** (token locked: the dropdown is replaced by a static
|
- "Send" → **Send** (token locked: the dropdown is replaced by a static
|
||||||
@@ -1509,20 +1322,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- What to send: token dropdown (or static display with contract address when
|
- What to send: token dropdown (or static display with contract address when
|
||||||
locked from AddressToken)
|
locked from AddressToken)
|
||||||
- To: address or ENS name input, with an inline validation message
|
- To: address or ENS name input, with an inline validation message
|
||||||
- Amount input with current balance display, which reads
|
- Amount input with current balance display
|
||||||
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
|
|
||||||
ConfirmTx's balance line does (see Unknown token scale)
|
|
||||||
- "Max" button beside the amount input, always in place. It fills in a
|
|
||||||
token's balance, cut down to the 18 decimal places ConfirmTx accepts for a
|
|
||||||
token that has more, or for ETH the exact balance minus the network fee
|
|
||||||
reserve that ConfirmTx's balance check gates on, never the rounded balance
|
|
||||||
shown above it. The ETH fee is estimated for the recipient entered, so it
|
|
||||||
asks for a recipient first; an estimate that finishes after the screen was
|
|
||||||
left or the address, holding or recipient changed fills nothing in. Where
|
|
||||||
there is nothing to fill in, a flash message says why: the balance does
|
|
||||||
not cover the fee, the fee could not be estimated, or the token's balance
|
|
||||||
is unknown or zero. Typing in the amount makes it an ordinary amount;
|
|
||||||
changing what to send clears an amount Max filled in
|
|
||||||
- "Review" button, disabled until the recipient validates
|
- "Review" button, disabled until the recipient validates
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
||||||
@@ -1539,16 +1339,8 @@ view would leave a wallet one click from deletion.
|
|||||||
- Token contract: full address + etherscan link (ERC-20 only)
|
- Token contract: full address + etherscan link (ERC-20 only)
|
||||||
- From: blockie + color dot + full address + etherscan link + wallet title
|
- From: blockie + color dot + full address + etherscan link + wallet title
|
||||||
- To: blockie + color dot + full address + etherscan link + ENS name
|
- To: blockie + color dot + full address + etherscan link + ENS name
|
||||||
- Amount: value + symbol (USD in parentheses). An ETH amount Send's "Max"
|
- Amount: value + symbol (USD in parentheses)
|
||||||
filled in is worked out again from this screen's own fee estimate when it
|
- Your balance: value + symbol (USD in parentheses)
|
||||||
arrives, as the balance minus the reserve, and the transaction is signed
|
|
||||||
with that estimate's fee fields, so a fee fetched again at signing cannot
|
|
||||||
exceed what the amount leaves behind. The address keeps whatever part of
|
|
||||||
the reserve the transaction does not use. If the balance no longer covers
|
|
||||||
the fee, the amount is left as it was and the amount-plus-fee error below
|
|
||||||
blocks the send
|
|
||||||
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
|
|
||||||
for a token whose scale is unknown
|
|
||||||
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
||||||
fetched async. The first line is what the transfer is expected to cost,
|
fetched async. The first line is what the transfer is expected to cost,
|
||||||
`gasLimit * gasPrice` (USD in parentheses); the second is the
|
`gasLimit * gasPrice` (USD in parentheses); the second is the
|
||||||
@@ -1564,11 +1356,7 @@ view would leave a wallet one click from deletion.
|
|||||||
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
|
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
|
||||||
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
|
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
|
||||||
estimated. The first two are mutually exclusive per transfer type, so only
|
estimated. The first two are mutually exclusive per transfer type, so only
|
||||||
the applicable one holds space. The last names its cause: for a token
|
the applicable one holds space
|
||||||
whose scale is unknown the fee can never be estimated, and it says the
|
|
||||||
wallet does not know how many decimal places the token uses and that the
|
|
||||||
transaction cannot be sent; for any other failure it asks the user to go
|
|
||||||
back and try again
|
|
||||||
- Password: an inline field on this screen, not a modal, with its own error
|
- Password: an inline field on this screen, not a modal, with its own error
|
||||||
line
|
line
|
||||||
- "Sign & Send" button (disabled if errors, and while the network fee
|
- "Sign & Send" button (disabled if errors, and while the network fee
|
||||||
@@ -1593,9 +1381,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Transaction Broadcast" heading (no back button — tx is irreversible)
|
- "Transaction Broadcast" heading (no back button — tx is irreversible)
|
||||||
- Amount + symbol
|
- Amount + symbol
|
||||||
- To: color dot + full address + etherscan link; for a contract creation,
|
- To: color dot + full address + etherscan link
|
||||||
which has no recipient, "This transaction creates a new contract. It has
|
|
||||||
no recipient." instead
|
|
||||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||||
- Count-up timer: "Waiting for confirmation... Ns"
|
- Count-up timer: "Waiting for confirmation... Ns"
|
||||||
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
|
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
|
||||||
@@ -1624,8 +1410,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- Decoded action well (shown when the transaction carried recognized
|
- Decoded action well (shown when the transaction carried recognized
|
||||||
calldata; the top-level Amount and To are hidden in that case)
|
calldata; the top-level Amount and To are hidden in that case)
|
||||||
- Amount + symbol
|
- Amount + symbol
|
||||||
- To: color dot + full address + etherscan link, or for a contract creation
|
- To: color dot + full address + etherscan link
|
||||||
the same sentence as on WaitTx
|
|
||||||
- Block number
|
- Block number
|
||||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||||
- "Done" button
|
- "Done" button
|
||||||
@@ -1640,8 +1425,7 @@ view would leave a wallet one click from deletion.
|
|||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Transaction Failed" heading
|
- "Transaction Failed" heading
|
||||||
- Amount + symbol
|
- Amount + symbol
|
||||||
- To: color dot + full address + etherscan link, or for a contract creation
|
- To: color dot + full address + etherscan link
|
||||||
the same sentence as on WaitTx
|
|
||||||
- Error message (dashed border box)
|
- Error message (dashed border box)
|
||||||
- Transaction hash section (hidden if broadcast failed before getting hash):
|
- Transaction hash section (hidden if broadcast failed before getting hash):
|
||||||
full hash (tap to copy) + etherscan link
|
full hash (tap to copy) + etherscan link
|
||||||
@@ -1674,14 +1458,12 @@ view would leave a wallet one click from deletion.
|
|||||||
- "Transaction" heading, "Back" button
|
- "Transaction" heading, "Back" button
|
||||||
- Transaction hash: full hash (tap to copy) + etherscan link
|
- Transaction hash: full hash (tap to copy) + etherscan link
|
||||||
- Type: transaction classification — one of: Native ETH Transfer, ERC-20
|
- Type: transaction classification — one of: Native ETH Transfer, ERC-20
|
||||||
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation. A
|
Token Transfer, Swap, Token Approval, Contract Call, Contract Creation
|
||||||
transfer with a token contract is an ERC-20 Token Transfer whatever symbol
|
|
||||||
the token reports.
|
|
||||||
- Status: "Success" or "Failed"
|
- Status: "Success" or "Failed"
|
||||||
- From: blockie + color dot + full address (tap to copy) + etherscan link;
|
- From: blockie + color dot + full address (tap to copy) + etherscan link;
|
||||||
ENS name if available
|
ENS name if available
|
||||||
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
|
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
|
||||||
name if available. For a contract creation, the same sentence as on WaitTx
|
name if available
|
||||||
- Time: ISO datetime + relative age in parentheses
|
- Time: ISO datetime + relative age in parentheses
|
||||||
- Block: block number (tap to copy) + etherscan block link
|
- Block: block number (tap to copy) + etherscan block link
|
||||||
- Amount: value + symbol (bold)
|
- Amount: value + symbol (bold)
|
||||||
@@ -1744,14 +1526,8 @@ view would leave a wallet one click from deletion.
|
|||||||
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
|
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
|
||||||
is refused with a flash message and the field snaps back to the stored
|
is refused with a flash message and the field snaps back to the stored
|
||||||
threshold, so a number the user did not type is never stored.
|
threshold, so a number the user did not type is never stored.
|
||||||
- Allowed Sites: the origins (scheme, host and port) remembered as allowed,
|
- Allowed Sites: list with remove buttons
|
||||||
under any address, with remove buttons
|
- Denied Sites: list with remove buttons
|
||||||
- Connected Sites: the origins of the sites allowed without "Remember my
|
|
||||||
choice" that are still connected, with remove buttons. Only the background
|
|
||||||
holds these, in memory, and Settings asks it for them with
|
|
||||||
`AUTISTMASK_GET_CONNECTED_SITES`
|
|
||||||
- Denied Sites: the origins remembered as denied, under any address, with
|
|
||||||
remove buttons
|
|
||||||
- About: project link, license, author, version, release date, and the
|
- About: project link, license, author, version, release date, and the
|
||||||
commit, which links to the commit in the repository
|
commit, which links to the commit in the repository
|
||||||
- Debug: hidden until revealed, then an "Enable debug mode" checkbox that
|
- Debug: hidden until revealed, then an "Enable debug mode" checkbox that
|
||||||
@@ -1762,16 +1538,8 @@ view would leave a wallet one click from deletion.
|
|||||||
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
|
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
|
||||||
- `[x]` on a wallet → **DeleteWallet**
|
- `[x]` on a wallet → **DeleteWallet**
|
||||||
- Tap wallet name → inline rename field (no screen change)
|
- Tap wallet name → inline rename field (no screen change)
|
||||||
- `[x]` on a tracked token → removes it in place (no screen change)
|
- `[x]` on a tracked token or a site → removes it in place (no screen
|
||||||
- `[x]` on an allowed or connected site → disconnects that site, in place:
|
change)
|
||||||
its origin is dropped from Allowed Sites under every address, and
|
|
||||||
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
|
|
||||||
without "Remember" from that origin, under any address, and send
|
|
||||||
`accountsChanged` with an empty list to the open tabs of that origin. The
|
|
||||||
same host under another scheme or port is another site and is left alone.
|
|
||||||
Only the extension's own pages may send either message
|
|
||||||
- `[x]` on a denied site → forgets the refusal, in place; it connects
|
|
||||||
nothing and tells the background nothing
|
|
||||||
- Ten clicks on the version → reveals the Debug well (no screen change)
|
- Ten clicks on the version → reveals the Debug well (no screen change)
|
||||||
- "Back" (or Settings gear again) → previous screen (Home)
|
- "Back" (or Settings gear again) → previous screen (Home)
|
||||||
|
|
||||||
@@ -1822,10 +1590,6 @@ view would leave a wallet one click from deletion.
|
|||||||
- Either way, the active address moves only if it belonged to the deleted
|
- Either way, the active address moves only if it belonged to the deleted
|
||||||
wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
|
wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
|
||||||
(`src/shared/walletDelete.js`)
|
(`src/shared/walletDelete.js`)
|
||||||
- Either way, every address the wallet held loses its site permissions of
|
|
||||||
both kinds: the remembered ones in storage, and the connections approved
|
|
||||||
without "Remember", which only the background holds, in memory, and drops
|
|
||||||
on `AUTISTMASK_ADDRESSES_REMOVED`
|
|
||||||
- "Confirm Delete" (wrong password) → "That password is incorrect. Please
|
- "Confirm Delete" (wrong password) → "That password is incorrect. Please
|
||||||
try again." on the error line, nothing deleted
|
try again." on the error line, nothing deleted
|
||||||
- "I have lost my password" → **DeleteWalletLostPassword**
|
- "I have lost my password" → **DeleteWalletLostPassword**
|
||||||
@@ -1850,10 +1614,7 @@ view would leave a wallet one click from deletion.
|
|||||||
new password — and, in bold, that without that phrase written down the
|
new password — and, in bold, that without that phrase written down the
|
||||||
deletion loses everything the wallet holds, forever
|
deletion loses everything the wallet holds, forever
|
||||||
- That the other wallets are not touched
|
- That the other wallets are not touched
|
||||||
- The wallet's name, and a text input asking for it to be typed back. A name
|
- The wallet's name, and a text input asking for it to be typed back
|
||||||
that shows nothing at all (only spaces, or only characters that paint
|
|
||||||
nothing) is shown as "Wallet N", its position in the list, and that is
|
|
||||||
what is typed back.
|
|
||||||
- Error line
|
- Error line
|
||||||
- "Delete This Wallet Forever" button
|
- "Delete This Wallet Forever" button
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
@@ -1861,9 +1622,9 @@ view would leave a wallet one click from deletion.
|
|||||||
outcomes as "Confirm Delete" above, through the same `finishDelete()`, so
|
outcomes as "Confirm Delete" above, through the same `finishDelete()`, so
|
||||||
the selection repair, permission cleanup and `AUTISTMASK_ACTIVE_CHANGED`
|
the selection repair, permission cleanup and `AUTISTMASK_ACTIVE_CHANGED`
|
||||||
broadcast are identical on both routes
|
broadcast are identical on both routes
|
||||||
- "Delete This Wallet Forever" (name does not match, or the field is empty)
|
- "Delete This Wallet Forever" (name does not match) → "That is not the name
|
||||||
→ "That is not the name of this wallet. Type <name> to confirm." on
|
of this wallet. Type <name> to confirm." on the error line, nothing
|
||||||
the error line, nothing deleted
|
deleted
|
||||||
- "Back" → **DeleteWallet**, re-entered through its `show()` so the wallet
|
- "Back" → **DeleteWallet**, re-entered through its `show()` so the wallet
|
||||||
selection comes back with it. The two delete screens are siblings rather
|
selection comes back with it. The two delete screens are siblings rather
|
||||||
than parent and child: nothing is pushed on the way here, so both have
|
than parent and child: nothing is pushed on the way here, so both have
|
||||||
@@ -1872,13 +1633,8 @@ view would leave a wallet one click from deletion.
|
|||||||
secret protects nobody: an attacker at the popup who wants the wallet gone can
|
secret protects nobody: an attacker at the popup who wants the wallet gone can
|
||||||
uninstall the extension, so the only person such a gate stops is the owner who
|
uninstall the extension, so the only person such a gate stops is the owner who
|
||||||
forgot it. The typed name is a check that the user knows which wallet they are
|
forgot it. The typed name is a check that the user knows which wallet they are
|
||||||
on, not a secret, so it is matched as the user can see it: letter case,
|
on, not a secret, so it is matched with surrounding spaces and letter case
|
||||||
surrounding spaces and repeated inner spaces are ignored, and characters that
|
ignored.
|
||||||
paint nothing (format characters such as the zero-width space,
|
|
||||||
default-ignorable characters, and DELETE — the same set
|
|
||||||
`src/shared/symbolSpoof.js` strips) are removed from both sides before
|
|
||||||
comparing. An empty field, or one holding only spaces or such characters, is
|
|
||||||
refused whatever the wallet is called.
|
|
||||||
- Not in `RESTORABLE_VIEWS`, alongside `delete-wallet-confirm`: a popup reopened
|
- Not in `RESTORABLE_VIEWS`, alongside `delete-wallet-confirm`: a popup reopened
|
||||||
by accident must not land on a screen whose button erases key material.
|
by accident must not land on a screen whose button erases key material.
|
||||||
|
|
||||||
@@ -1930,10 +1686,6 @@ view would leave a wallet one click from deletion.
|
|||||||
so a connected site stops being told about an address the user removed
|
so a connected site stops being told about an address the user removed
|
||||||
(`src/shared/walletDelete.js`). A selection in any other wallet is left alone;
|
(`src/shared/walletDelete.js`). A selection in any other wallet is left alone;
|
||||||
one in this wallet follows the splice.
|
one in this wallet follows the splice.
|
||||||
- The address loses its site permissions of both kinds, whether or not it was
|
|
||||||
the active one: the remembered ones in storage, and any connection approved
|
|
||||||
without "Remember", which only the background holds, in memory, and drops on
|
|
||||||
`AUTISTMASK_ADDRESSES_REMOVED`.
|
|
||||||
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
|
- The wallet's derivation counter (`nextIndex`) is not rewound, so "+" derives a
|
||||||
fresh address rather than handing back the one just removed.
|
fresh address rather than handing back the one just removed.
|
||||||
|
|
||||||
@@ -1960,30 +1712,21 @@ view would leave a wallet one click from deletion.
|
|||||||
|
|
||||||
- **When**: A website requests wallet access via `eth_requestAccounts` or
|
- **When**: A website requests wallet access via `eth_requestAccounts` or
|
||||||
`wallet_requestPermissions` and is on neither the allowed nor the denied list.
|
`wallet_requestPermissions` and is on neither the allowed nor the denied list.
|
||||||
A site is its full origin, `scheme://host[:port]`, on both lists and for a
|
The background script prefers the toolbar popup (`action.openPopup()`) and
|
||||||
connection allowed without "Remember": a choice for `https://dapp.example`
|
falls back to a separate popup window (`src/background/index.js`,
|
||||||
says nothing about `http://dapp.example` or another port of that host. The
|
`requestApproval()`).
|
||||||
background script prefers the toolbar popup (`action.openPopup()`) and falls
|
|
||||||
back to a separate popup window (`src/background/index.js`,
|
|
||||||
`requestApproval()`). Only one exists per site at a time: a further connection
|
|
||||||
request from a site whose prompt is still unanswered is refused with EIP-1193
|
|
||||||
code `-32002` and opens no new prompt. If that prompt was in a toolbar popup
|
|
||||||
that closed before it connected, and the toolbar popup has since been set to
|
|
||||||
open something else, the refused request shows that prompt again.
|
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Connection Request" heading
|
- "Connection Request" heading
|
||||||
- Phishing warning banner (shown when the hostname is on the phishing
|
- Phishing warning banner (shown when the hostname is on the phishing
|
||||||
blocklist)
|
blocklist)
|
||||||
- Site origin (bold, scheme and port included) + "wants to connect to your
|
- Site hostname (bold) + "wants to connect to your wallet"
|
||||||
wallet"
|
|
||||||
- Address that will be shared (color dot + full address + etherscan link)
|
- Address that will be shared (color dot + full address + etherscan link)
|
||||||
- "Remember my choice for this site" checkbox
|
- "Remember my choice for this site" checkbox
|
||||||
- "Allow" / "Deny" buttons
|
- "Allow" / "Deny" buttons
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- "Allow" / "Deny" → closes popup (returns result to background script; the
|
- "Allow" / "Deny" → closes popup (returns result to background script; the
|
||||||
choice is persisted to the allowed or denied list when "Remember" is
|
choice is persisted to the allowed or denied list when "Remember" is
|
||||||
checked, and an "Allow" without it is listed under Connected Sites in
|
checked)
|
||||||
**Settings**)
|
|
||||||
- Popup closed without answering → treated as a denial
|
- Popup closed without answering → treated as a denial
|
||||||
|
|
||||||
#### TxApproval (`approve-tx`)
|
#### TxApproval (`approve-tx`)
|
||||||
@@ -1994,29 +1737,23 @@ view would leave a wallet one click from deletion.
|
|||||||
programmatically rather than by a user gesture. The background populates the
|
programmatically rather than by a user gesture. The background populates the
|
||||||
transaction (nonce, gas limit, fees, chain id) against the RPC node _before_
|
transaction (nonce, gas limit, fees, chain id) against the RPC node _before_
|
||||||
opening the window, so the screen shows a complete transaction and the signed
|
opening the window, so the screen shows a complete transaction and the signed
|
||||||
artifact can be compared with it field for field. A nonce the site supplies is
|
artifact can be compared with it field for field. A request that cannot be
|
||||||
ignored: the nonce is always the account's next nonce from the node, so a site
|
populated — unreachable node, reverting gas estimate — opens no window and is
|
||||||
cannot replace one of the user's pending transactions or leave this one stuck
|
failed back to the site. Only one transaction approval exists at a time:
|
||||||
behind a gap. A request that cannot be populated — unreachable node, reverting
|
populating fixes the nonce, so a second `eth_sendTransaction` arriving while
|
||||||
gas estimate — opens no window and is failed back to the site. Only one
|
one is unanswered is refused with EIP-1193 code `-32002` rather than being
|
||||||
transaction approval exists at a time: populating fixes the nonce, so a second
|
populated at the same nonce. It opens no window and takes no nonce, and the
|
||||||
`eth_sendTransaction` arriving while one is unanswered is refused with
|
site can send it again once the pending one is answered.
|
||||||
EIP-1193 code `-32002` rather than being populated at the same nonce. It opens
|
|
||||||
no window and takes no nonce, and the site can send it again once the pending
|
|
||||||
one is answered. The window is centred on the browser window the user was last
|
|
||||||
in; if that was another approval window, or the browser refuses the centred
|
|
||||||
position, the browser picks the position.
|
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Transaction Request" heading
|
- "Transaction Request" heading
|
||||||
- Phishing warning banner (shown when the hostname is on the phishing
|
- Phishing warning banner (shown when the hostname is on the phishing
|
||||||
blocklist)
|
blocklist)
|
||||||
- Site origin (bold, scheme and port included) + "wants to send a
|
- Site hostname (bold) + "wants to send a transaction"
|
||||||
transaction"
|
|
||||||
- Decoded action (if calldata is recognized): action name, token details,
|
- Decoded action (if calldata is recognized): action name, token details,
|
||||||
amounts, steps, deadline (see Transaction Decoding)
|
amounts, steps, deadline (see Transaction Decoding)
|
||||||
- From: color dot + full address + etherscan link
|
- From: color dot + full address + etherscan link
|
||||||
- Contract: color dot + full address + etherscan link, token symbol label if
|
- Contract: color dot + full address + etherscan link (or "contract
|
||||||
known; for a contract creation, the same sentence as on WaitTx
|
creation"), token symbol label if known
|
||||||
- Value: amount in ETH (4 decimal places, USD in parentheses)
|
- Value: amount in ETH (4 decimal places, USD in parentheses)
|
||||||
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
|
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
|
||||||
in parentheses), with the gas limit and the fee per gas in gwei below it
|
in parentheses), with the gas limit and the fee per gas in gwei below it
|
||||||
@@ -2037,57 +1774,20 @@ view would leave a wallet one click from deletion.
|
|||||||
|
|
||||||
- **When**: A connected website requests a message signature via
|
- **When**: A connected website requests a message signature via
|
||||||
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
|
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
|
||||||
TxApproval, in a separate popup window. Only one exists per site at a time: a
|
TxApproval, in a separate popup window.
|
||||||
further signature request, by any of these methods, from a site whose
|
|
||||||
signature request is still unanswered is refused with EIP-1193 code `-32002`
|
|
||||||
and opens no window.
|
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Signature Request" heading
|
- "Signature Request" heading
|
||||||
- Phishing warning banner (shown when the hostname is on the phishing
|
- Phishing warning banner (shown when the hostname is on the phishing
|
||||||
blocklist)
|
blocklist)
|
||||||
- Site origin (bold, scheme and port included) + "wants you to sign a
|
- Site hostname (bold) + "wants you to sign a message"
|
||||||
message"
|
|
||||||
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
|
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
|
||||||
- Type: "Personal message" or "Typed data (EIP-712)"
|
- Type: "Personal message" or "Typed data (EIP-712)"
|
||||||
- From: color dot + full address + etherscan link
|
- From: color dot + full address + etherscan link
|
||||||
- Message: for `personal_sign` and `eth_sign`, the text the message's bytes
|
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
|
||||||
decode to as UTF-8, laid out left to right in the order of the bytes that
|
message fields (EIP-712 typed data)
|
||||||
are signed, right-to-left characters included. Each control character,
|
|
||||||
each line or paragraph separator (U+2028, U+2029; left in the text, a
|
|
||||||
paragraph separator would end that layout for the text after it), and each
|
|
||||||
character that paints nothing (format characters such as zero-width and
|
|
||||||
bidirectional ones, default-ignorable characters such as variation
|
|
||||||
selectors and Hangul fillers, and DELETE), is shown as a bordered `U+XXXX`
|
|
||||||
mark instead of acting on the text; a line feed is shown as a line break.
|
|
||||||
Bytes that are not UTF-8 are shown as "This message is not text." For
|
|
||||||
typed data, formatted domain/type/message fields (EIP-712). The primary
|
|
||||||
type shown is the one ethers signs, derived from the typed data's `types`,
|
|
||||||
not the type the site states.
|
|
||||||
- Token permission warning, at the top of the message (typed data whose
|
|
||||||
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
|
|
||||||
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
|
|
||||||
tokens listed here from your address, without asking you again.", then the
|
|
||||||
spender's full address and, for each token, its symbol, full address and
|
|
||||||
amount (`Unlimited` for the largest amount the field holds). These are
|
|
||||||
read only from the fields the signed type declares, never from other keys
|
|
||||||
the site puts in the message, except a `Permit`'s token, which is the
|
|
||||||
domain's `verifyingContract`; any those fields do not give is shown as
|
|
||||||
`Unknown`, and the domain, type and message lines still follow. Only typed
|
|
||||||
data that cannot be read at all is shown as raw text.
|
|
||||||
- Raw data (`personal_sign` and `eth_sign`): the message's hex exactly as
|
|
||||||
the site sent it. The bytes it encodes are what is signed, as an EIP-191
|
|
||||||
personal message.
|
|
||||||
- Password input and an error line
|
- Password input and an error line
|
||||||
- "Sign" / "Reject" buttons
|
- "Sign" / "Reject" buttons
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- Typed data that states no primary type, or one other than the type it
|
|
||||||
would be signed as, or that cannot be read → shown with the error line
|
|
||||||
saying so and "Sign" disabled; only "Reject" remains
|
|
||||||
- A `personal_sign` or `eth_sign` message that is not hex (`0x` or `0X` and
|
|
||||||
an even number of hex digits, the form ethers' `getBytes` reads when
|
|
||||||
signing) → shown as plain text, with the error line "This message is plain
|
|
||||||
text, not hex, so it cannot be signed." and "Sign" disabled; signing takes
|
|
||||||
the bytes from the hex, so such a message has none to sign
|
|
||||||
- "Sign" (correct password) → signs locally → closes popup (returns
|
- "Sign" (correct password) → signs locally → closes popup (returns
|
||||||
signature)
|
signature)
|
||||||
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
||||||
@@ -2098,19 +1798,9 @@ view would leave a wallet one click from deletion.
|
|||||||
|
|
||||||
#### StateRecovery (`state-recovery`)
|
#### StateRecovery (`state-recovery`)
|
||||||
|
|
||||||
- **When**: the stored profile fails `assertStateUsable()`. At open, that is
|
- **When**: `loadState()` refused the stored profile, so the popup has no
|
||||||
`loadState()` refusing it, so the popup has no profile at all. While the popup
|
profile at all. It is the only screen reached without one, and the only one
|
||||||
is open, on any screen, it is a save refusing it: every `saveState()` reads
|
that never appears during ordinary use.
|
||||||
the stored record and runs the same check before writing, so the popup finds
|
|
||||||
it at the next navigation or ten-second refresh, whether or not the network
|
|
||||||
answers ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). A save that
|
|
||||||
fails for any other reason, such as a storage read or write that errors, gets
|
|
||||||
the "NOT SAVED" banner instead and leaves the screen as it is. The screen it
|
|
||||||
replaces is left as any navigation leaves it, so a revealed phrase or key, or
|
|
||||||
a typed password, is wiped. Once up, the screen stays until the popup closes
|
|
||||||
or reloads: work still running in the popup, such as a transaction wait,
|
|
||||||
cannot replace it, even after the record is erased in another window. It is
|
|
||||||
the only screen that never appears during ordinary use.
|
|
||||||
- **Why it exists**: a record the wallet cannot read used to render nothing — no
|
- **Why it exists**: a record the wallet cannot read used to render nothing — no
|
||||||
view, no message, no control — while every dApp call answered a generic
|
view, no message, no control — while every dApp call answered a generic
|
||||||
internal error, and no reset or wipe control existed anywhere in the product.
|
internal error, and no reset or wipe control existed anywhere in the product.
|
||||||
@@ -2137,20 +1827,16 @@ view would leave a wallet one click from deletion.
|
|||||||
Nothing was erased." on the error line
|
Nothing was erased." on the error line
|
||||||
- **No other control is reachable.** The Settings gear is hidden while this
|
- **No other control is reachable.** The Settings gear is hidden while this
|
||||||
screen is up, because every screen behind it renders from the profile that
|
screen is up, because every screen behind it renders from the profile that
|
||||||
could not be read. `showView()` is not used to raise it, for the same reason:
|
could not be read, and `showView()` is not used to raise it for the same
|
||||||
it reads and writes the state singleton. Under an open popup the screen is
|
reason — it reads and writes the state singleton.
|
||||||
passed to `showView()` only to run the replaced screen's cleanup; from then on
|
|
||||||
`showView()` shows nothing else in that popup.
|
|
||||||
- **Both controls are required.** An export with no reset leaves the user
|
- **Both controls are required.** An export with no reset leaves the user
|
||||||
looking at a broken profile with no way to use the wallet again; a reset with
|
looking at a broken profile with no way to use the wallet again; a reset with
|
||||||
no export destroys the only copy of a record that may hold recoverable key
|
no export destroys the only copy of a record that may hold recoverable key
|
||||||
material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
|
material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
|
||||||
and for the same reason: there is no password to gate this with, since there
|
and for the same reason: there is no password to gate this with, since there
|
||||||
is no profile to check one against.
|
is no profile to check one against.
|
||||||
- Not in `RESTORABLE_VIEWS`, and never recorded as the current view: the record
|
- Not in `RESTORABLE_VIEWS`: it is never persisted as the current view, because
|
||||||
can become readable again under an open popup, erased in another window, and
|
nothing on this path writes state at all.
|
||||||
the next save from that popup then succeeds. A popup opened after that opens
|
|
||||||
normally.
|
|
||||||
|
|
||||||
### External Services
|
### External Services
|
||||||
|
|
||||||
@@ -2218,8 +1904,8 @@ Dev dependencies (not shipped in extension):
|
|||||||
| Package | Version | License | Purpose |
|
| Package | Version | License | Purpose |
|
||||||
| ------------------ | ------- | ------- | ------------------------- |
|
| ------------------ | ------- | ------- | ------------------------- |
|
||||||
| `esbuild` | 0.27.3 | MIT | JS bundler (inlines deps) |
|
| `esbuild` | 0.27.3 | MIT | JS bundler (inlines deps) |
|
||||||
| `tailwindcss` | 4.3.1 | MIT | CSS compilation |
|
| `tailwindcss` | 4.2.1 | MIT | CSS compilation |
|
||||||
| `@tailwindcss/cli` | 4.3.1 | MIT | Tailwind CLI |
|
| `@tailwindcss/cli` | 4.2.1 | MIT | Tailwind CLI |
|
||||||
| `jest` | 30.2.0 | MIT | Test runner |
|
| `jest` | 30.2.0 | MIT | Test runner |
|
||||||
| `prettier` | 3.8.1 | MIT | Code formatter |
|
| `prettier` | 3.8.1 | MIT | Code formatter |
|
||||||
|
|
||||||
@@ -2243,7 +1929,7 @@ a bare string in `manifest/firefox.json` (MV2):
|
|||||||
|
|
||||||
```
|
```
|
||||||
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self';
|
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self';
|
||||||
style-src 'self'; img-src 'self' data:;
|
style-src 'self' 'unsafe-inline'; img-src 'self' data:;
|
||||||
connect-src 'self' https: http:; frame-src 'none'; form-action 'none';
|
connect-src 'self' https: http:; frame-src 'none'; form-action 'none';
|
||||||
base-uri 'none'
|
base-uri 'none'
|
||||||
```
|
```
|
||||||
@@ -2255,17 +1941,15 @@ wallet's own UI. Escaping is the primary fix for that (see
|
|||||||
`src/shared/html.js`); this is the second line, so an escape that does slip
|
`src/shared/html.js`); this is the second line, so an escape that does slip
|
||||||
cannot reach the network.
|
cannot reach the network.
|
||||||
|
|
||||||
`style-src 'self'` admits the stylesheet and nothing inline: both browsers
|
Four directives are looser than `'self'`, each for a reason that does not
|
||||||
refuse a `style="..."` attribute and a `<style>` block. So the popup's markup,
|
|
||||||
in `src/popup/index.html` and in the HTML the view helpers build, carries
|
|
||||||
Tailwind classes and never a `style` attribute. Script that sets `element.style`
|
|
||||||
is not affected; that is how the views show and hide their error lines. An
|
|
||||||
inline style that slips in anyway is refused with a console error, which fails
|
|
||||||
both end-to-end suites.
|
|
||||||
|
|
||||||
These directives differ from a plain `'self'`, each for a reason that does not
|
|
||||||
generalise:
|
generalise:
|
||||||
|
|
||||||
|
- `style-src 'unsafe-inline'` — `src/popup/index.html` and the view helpers set
|
||||||
|
presentation through `style="..."` attributes, which CSP blocks without this.
|
||||||
|
Chrome enforces `style-src` on attributes, not only on `<style>` blocks, and
|
||||||
|
Firefox has never implemented `style-src-attr`, so there is no narrower
|
||||||
|
spelling that works on both targets. It permits inline **style**; script stays
|
||||||
|
under `script-src`, which does not allow `'unsafe-inline'`.
|
||||||
- `img-src data:` — identicons are generated in the popup by
|
- `img-src data:` — identicons are generated in the popup by
|
||||||
`ethereum-blockies-base64` and assigned to `img.src` as `data:` PNGs.
|
`ethereum-blockies-base64` and assigned to `img.src` as `data:` PNGs.
|
||||||
- `connect-src https: http:` — the RPC endpoint is user-configurable and a local
|
- `connect-src https: http:` — the RPC endpoint is user-configurable and a local
|
||||||
@@ -2324,17 +2008,6 @@ the log level and turns the banner on, and that is all it may ever do: it feeds
|
|||||||
constant directly, so no runtime toggle in a release build can reach the
|
constant directly, so no runtime toggle in a release build can reach the
|
||||||
hardcoded test phrase.
|
hardcoded test phrase.
|
||||||
|
|
||||||
At the raised log level the console also shows the wallet's addresses with their
|
|
||||||
balances and ENS names, the token contracts looked up, and a line for each
|
|
||||||
request made through `debugFetch` in `src/shared/log.js` (the explorer, the
|
|
||||||
price feed, the RPC calls a site makes, and the endpoint checks in settings) and
|
|
||||||
for its response. A request is logged by its HTTP method, the origin of its URL
|
|
||||||
(scheme, host and port) and, for a JSON-RPC call, the method name; the balance
|
|
||||||
refresh, the token lookup and a failed endpoint check in settings name the
|
|
||||||
endpoint by its origin too. The URL's path and query string, where RPC providers
|
|
||||||
put API keys, any user name and password in it, and the request body are never
|
|
||||||
logged.
|
|
||||||
|
|
||||||
### Key Decisions
|
### Key Decisions
|
||||||
|
|
||||||
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small
|
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small
|
||||||
@@ -2461,8 +2134,7 @@ indexes it as a real token transfer.
|
|||||||
act on and what the user believes they own rather than what the history
|
act on and what the user believes they own rather than what the history
|
||||||
displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`,
|
displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`,
|
||||||
so they cannot answer the question differently. A symbol the list maps to no
|
so they cannot answer the question differently. A symbol the list maps to no
|
||||||
contract at all — the native asset's labels: `"ETH"` and every network's
|
contract at all — `"ETH"`, the native asset, is the only one — may be borne by
|
||||||
`nativeCurrency`, such as `"SepoliaETH"`, on every network — may be borne by
|
|
||||||
no contract, so every ERC-20 claiming it is a spoof on all three. The user's
|
no contract, so every ERC-20 claiming it is a spoof on all three. The user's
|
||||||
real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees
|
real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees
|
||||||
it.
|
it.
|
||||||
@@ -2471,8 +2143,7 @@ indexes it as a real token transfer.
|
|||||||
fewer than 1,000 holders are hidden from transaction history by default.
|
fewer than 1,000 holders are hidden from transaction history by default.
|
||||||
Legitimate tokens have substantial holder counts; poisoning tokens typically
|
Legitimate tokens have substantial holder counts; poisoning tokens typically
|
||||||
have zero. This catches new poisoning contracts that use novel symbols not in
|
have zero. This catches new poisoning contracts that use novel symbols not in
|
||||||
the known token list. A transfer whose token's holder count is unknown (see
|
the known token list.
|
||||||
Data Model) is kept: only a reported count below 1,000 hides it.
|
|
||||||
|
|
||||||
- **Fraud contract blocklist**: AutistMask maintains a local list of known fraud
|
- **Fraud contract blocklist**: AutistMask maintains a local list of known fraud
|
||||||
contract addresses. Token transfers involving these contracts are filtered
|
contract addresses. Token transfers involving these contracts are filtered
|
||||||
@@ -2482,9 +2153,7 @@ indexes it as a real token transfer.
|
|||||||
- **Send-side token filtering**: Tokens with fewer than 1,000 holders are
|
- **Send-side token filtering**: Tokens with fewer than 1,000 holders are
|
||||||
excluded from the token selector on the send screen. This prevents users from
|
excluded from the token selector on the send screen. This prevents users from
|
||||||
accidentally interacting with a spoofed token that appeared in their balance
|
accidentally interacting with a spoofed token that appeared in their balance
|
||||||
via a fake Transfer event. A token whose holder count is unknown is kept in
|
via a fake Transfer event.
|
||||||
the selector. The selector offers only tokens in the balance list, so such a
|
|
||||||
token is one on the bundled list or one the user tracks.
|
|
||||||
|
|
||||||
- **Dust transaction filtering**: A second wave of the same attack used real
|
- **Dust transaction filtering**: A second wave of the same attack used real
|
||||||
native ETH transfers instead of fake tokens. Transaction
|
native ETH transfers instead of fake tokens. Transaction
|
||||||
@@ -2508,9 +2177,8 @@ indexes it as a real token transfer.
|
|||||||
both cases identically to the history. The fraud contract blocklist is applied
|
both cases identically to the history. The fraud contract blocklist is applied
|
||||||
unconditionally on that selector and is not consulted by the balance list at
|
unconditionally on that selector and is not consulted by the balance list at
|
||||||
all. The low-holder setting also gates the send selector, while the balance
|
all. The low-holder setting also gates the send selector, while the balance
|
||||||
list's own 1,000-holder floor is unconditional (see Data Model). An unknown
|
list's own 1,000-holder floor is unconditional (see Data Model). The dust
|
||||||
holder count passes the history and send-selector filters but not that floor.
|
threshold applies to the transaction history alone.
|
||||||
The dust threshold applies to the transaction history alone.
|
|
||||||
|
|
||||||
#### Phishing Domain Protection
|
#### Phishing Domain Protection
|
||||||
|
|
||||||
|
|||||||
+84
-355
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-10-04
|
last_modified: 2026-07-06
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -60,28 +60,17 @@ style conventions are in separate documents:
|
|||||||
prerequisite since nvm requires bash. yarn is then pinned via
|
prerequisite since nvm requires bash. yarn is then pinned via
|
||||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||||
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
|
||||||
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
scripts are our own extensions to the standard: `script/check` runs
|
||||||
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
||||||
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
what the git pre-commit hook runs, and it calls `script/check`;
|
||||||
pristine checkout with nothing installed the run dies there, after the
|
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
||||||
containerised gates have passed. **The bootstrap alone is not enough**:
|
target shims to it); and `script/projectname` (literally that filename) simply
|
||||||
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
outputs the project's name. Scripts that need the name call
|
||||||
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
||||||
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
so those scripts stay byte-identical across all repos. Repo-type-specific
|
||||||
source nvm for the pinned node version before invoking it, exactly as
|
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
||||||
`script/bootstrap`'s own install step does. A runner carrying nothing but
|
`script/precommit`, not in the hook itself. Model scripts are at
|
||||||
docker and git then gets through `script/check`. Four further scripts are our
|
|
||||||
own extensions to the standard: `script/check` runs `script/test`,
|
|
||||||
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
|
|
||||||
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
|
|
||||||
installs the git pre-commit hook (the `make hooks` target shims to it); and
|
|
||||||
`script/projectname` (literally that filename) simply outputs the project's
|
|
||||||
name. Scripts that need the name call `script/projectname` — e.g.
|
|
||||||
`script/docker` assembles its image tag from it — so those scripts stay
|
|
||||||
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
|
|
||||||
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
|
|
||||||
the hook itself. Model scripts are at
|
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||||
must document the provided scripts in an **Entrypoints** section (see the
|
must document the provided scripts in an **Entrypoints** section (see the
|
||||||
README requirements below).
|
README requirements below).
|
||||||
@@ -100,198 +89,87 @@ style conventions are in separate documents:
|
|||||||
contributor should be able to understand the entire development workflow by
|
contributor should be able to understand the entire development workflow by
|
||||||
reading the Makefile.
|
reading the Makefile.
|
||||||
|
|
||||||
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
|
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
||||||
`lint` phase and a `test` phase, with the final stage depending on both so the
|
as a build step so the build fails if the branch is not green. For non-server
|
||||||
image cannot be built unless they pass. For non-server repos the final stage
|
repos, the Dockerfile should bring up a development environment and run
|
||||||
brings up a development environment; for server repos it is the runtime image.
|
`make check`. For server repos, `make check` should run as an early build
|
||||||
The gate phases and the build stage start from their pinned base images and
|
stage before the final image is assembled. Dockerfiles install development
|
||||||
install what those images lack either inline, as the canonical Go `Dockerfile`
|
prerequisites by running `script/bootstrap` rather than duplicating installs
|
||||||
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
inline; COPY `script/` and the dependency manifests (`package.json` +
|
||||||
repo's own `Dockerfile` does for its yarn packages. The development
|
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
||||||
environment stage installs development prerequisites by running
|
layer stays cached until dependencies change.
|
||||||
`script/bootstrap` rather than duplicating its installs inline. A stage that
|
|
||||||
runs `script/bootstrap` COPYs `script/` and the dependency manifests
|
|
||||||
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
|
|
||||||
|
|
||||||
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
||||||
no separate lint file. `script/lint` and `script/test` each build one phase
|
repos use a multistage build where linting runs in an independent stage based
|
||||||
and nothing else:
|
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
||||||
|
`make fmt-check` and `make lint` before the full build begins. The build stage
|
||||||
|
then declares an explicit dependency on the lint stage via
|
||||||
|
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
|
||||||
|
linting before proceeding to compilation and tests. This ensures lint failures
|
||||||
|
surface in seconds rather than minutes, without blocking on dependency
|
||||||
|
download or compilation in the build stage.
|
||||||
|
|
||||||
```sh
|
The standard pattern for a Go repo Dockerfile is:
|
||||||
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
|
|
||||||
docker build --no-cache --target test -t "$(script/projectname)-test" .
|
|
||||||
```
|
|
||||||
|
|
||||||
**A stage that is not the last one in the file is built only when the final
|
|
||||||
stage's chain depends on it, or when `--target` names it.** That is why the
|
|
||||||
two gates are always invoked by name here, and why the final stage carries a
|
|
||||||
`COPY --from=` of a harmless file from each of them: without that edge a
|
|
||||||
plain `docker build .` builds the last stage alone and exits 0 having linted
|
|
||||||
and tested nothing.
|
|
||||||
|
|
||||||
**Every `docker build` in `script/` is tagged**, here and in
|
|
||||||
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
|
|
||||||
image behind on every invocation, on every developer host and every CI
|
|
||||||
runner; a tagged one replaces the previous image.
|
|
||||||
|
|
||||||
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
|
|
||||||
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
|
|
||||||
themselves a `docker build` and would recurse into a daemon that does not
|
|
||||||
exist in a build step. Formatting is the exception and stays on the host:
|
|
||||||
`script/fmt` writes the working tree, and `script/fmt-check` is its
|
|
||||||
read-only twin.
|
|
||||||
|
|
||||||
**No lint verdict may come from a host invocation of the linter.** On a
|
|
||||||
shared host golangci-lint reads a result cache keyed on file content rather
|
|
||||||
than location, so a second checkout of the same content is served the first
|
|
||||||
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
|
||||||
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
|
||||||
cannot tell from real findings. Both have produced wrong verdicts in this
|
|
||||||
org, in both directions. A container has its own cache, its own `TMPDIR` and
|
|
||||||
a digest-pinned binary, so neither is reachable.
|
|
||||||
|
|
||||||
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
|
|
||||||
a `COPY` layer only when the copied content changes, so on an unchanged tree
|
|
||||||
the check `RUN` is served from cache, nothing executes, and the build still
|
|
||||||
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
|
|
||||||
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
|
|
||||||
four, and there is no fifth — `script/check` runs the two gate phases and
|
|
||||||
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
|
|
||||||
not evidence that anything ran: a sub-second build reporting success is a
|
|
||||||
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
|
|
||||||
and friends destroy a build cache shared with every other build on the host.
|
|
||||||
When a check is added or changed, prove it works by planting a defect it must
|
|
||||||
catch and watching the run fail on it, then revert the defect. A green run
|
|
||||||
alone shows neither that the check ran nor that it covers what it should.
|
|
||||||
|
|
||||||
- **The gate phases are separate stages, and the build stage depends on both.**
|
|
||||||
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
|
||||||
hash), so lint failures surface in seconds rather than after a full compile,
|
|
||||||
and the test phase is based on the Debian Go image. The canonical Go repo
|
|
||||||
`Dockerfile`:
|
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
# Lint phase
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
FROM golangci/golangci-lint@sha256:... AS lint
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN golangci-lint run --config .golangci.yml ./...
|
RUN make fmt-check
|
||||||
|
RUN make lint
|
||||||
|
|
||||||
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
# Build stage
|
||||||
# image ships and the alpine one does not.
|
|
||||||
# golang:1.x, YYYY-MM-DD
|
|
||||||
FROM golang@sha256:... AS test
|
|
||||||
WORKDIR /src
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY . .
|
|
||||||
RUN go test -timeout 90s -race -cover ./... || \
|
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
|
||||||
go test -timeout 90s -race -v ./...; exit 1; }
|
|
||||||
|
|
||||||
# Build stage. Nothing is wanted from either phase above; the copies
|
|
||||||
# are what make BuildKit build them first, so this stage cannot run
|
|
||||||
# unless lint and test passed.
|
|
||||||
# golang:1.x-alpine, YYYY-MM-DD
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
FROM golang@sha256:... AS builder
|
FROM golang@sha256:... AS builder
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
|
||||||
COPY --from=test /src/go.sum /dev/null
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
# A tar-stream context keeps the sender's file owners, which git refuses.
|
|
||||||
RUN git config --system --add safe.directory /src
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Force BuildKit to run the lint stage before proceeding
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN make test
|
||||||
|
|
||||||
# The VERSION build arg when one is given, otherwise
|
ARG VERSION=dev
|
||||||
# `git describe --tags --always` on the .git in the build context. With
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
# .git present, a version that is still empty, dev or unknown fails the
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
# build: git is missing or could not read the checkout.
|
-o /app ./cmd/app/
|
||||||
ARG VERSION
|
|
||||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
|
||||||
if [ -e .git ]; then \
|
|
||||||
case "$VERSION" in ""|dev|unknown) \
|
|
||||||
echo "version is '$VERSION' although .git is present" >&2; \
|
|
||||||
exit 1 ;; \
|
|
||||||
esac; \
|
|
||||||
fi; \
|
|
||||||
CGO_ENABLED=0 go build -trimpath \
|
|
||||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
|
||||||
-o /app ./cmd/app/
|
|
||||||
|
|
||||||
# Runtime stage, and the last one
|
# Runtime stage
|
||||||
FROM alpine@sha256:...
|
FROM alpine@sha256:...
|
||||||
COPY --from=builder /app /usr/local/bin/app
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
ENTRYPOINT ["app"]
|
ENTRYPOINT ["app"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Key points:
|
Key points:
|
||||||
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
||||||
both Go and the linter), so nothing needs installing.
|
includes both Go and the linter), so there is no need to install the
|
||||||
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
linter separately.
|
||||||
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
||||||
and a stage nothing depends on is not built at all, so without these two
|
a stage dependency. BuildKit runs stages in parallel by default; without
|
||||||
lines a red gate would not fail the build.
|
this line, the build stage would not wait for lint to finish and a lint
|
||||||
- Keep the runtime stage last, and if you add a stage after it, give it the
|
failure might not fail the overall build.
|
||||||
same two copies. A plain `docker build .` builds the last stage's chain
|
|
||||||
and nothing else.
|
|
||||||
- If the project uses `//go:embed` directives that reference build artifacts
|
- If the project uses `//go:embed` directives that reference build artifacts
|
||||||
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
||||||
create placeholder files so the embed directives resolve. Example:
|
create placeholder files so the embed directives resolve. Example:
|
||||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||||
- If the project requires CGO or system libraries for linting, install them
|
The lint stage should not depend on the actual build output — it exists to
|
||||||
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
fail fast.
|
||||||
has no `apk`, so install with `apt-get` under the Debian package name
|
- If the project requires CGO or system libraries for linting (e.g.
|
||||||
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
`vips-dev`), install them in the lint stage with `apk add`.
|
||||||
lists in the same `RUN`, so the layer does not keep them:
|
- The build stage runs `make test` after compilation setup. Tests run in the
|
||||||
|
build stage, not the lint stage, because they may require compiled
|
||||||
```dockerfile
|
artifacts or heavier dependencies.
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends libvips-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
```
|
|
||||||
|
|
||||||
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
|
||||||
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
|
|
||||||
repository's own, each submodule's under `.git/modules/`, and that of a
|
|
||||||
submodule keeping its own `.git` directory. `git describe` does not need
|
|
||||||
them, and each can hold a credential: a password in a remote URL, or the
|
|
||||||
token the CI checkout step stores there. A submodule whose name has a
|
|
||||||
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
|
|
||||||
git directory to `**/.git/modules/**/config`, and Go's version stamping
|
|
||||||
then fails the build: give it a name without that segment
|
|
||||||
(`git submodule add --name`). The stage that compiles has `git` (the
|
|
||||||
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
|
|
||||||
takes the version from the `VERSION` build argument when one is given,
|
|
||||||
otherwise from `git describe --tags --always`. That gives the tag on a
|
|
||||||
tagged commit; on a later commit, the tag, the number of commits since it
|
|
||||||
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
|
|
||||||
tag is reachable. The stage that compiles also marks its working directory
|
|
||||||
safe for git (`git config --system --add safe.directory /src`): a context
|
|
||||||
sent as a tar stream keeps the sender's file owners, and git refuses a
|
|
||||||
checkout owned by another user, so the version would come out empty.
|
|
||||||
`ARG VERSION` has no default, and the build fails if the context carries
|
|
||||||
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
|
|
||||||
`docker build .` with no build arguments must succeed; a Dockerfile that
|
|
||||||
refuses an empty build argument drops that refusal and keeps the argument.
|
|
||||||
|
|
||||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
||||||
That script bootstraps, runs the gate phases, and then builds the image, so a
|
Dockerfile already runs `make check`, a successful build implies all checks
|
||||||
successful run means every check passed; a bare `docker build .` does not
|
pass.
|
||||||
carry the same guarantee, because its gate phases may come from the cache. The
|
|
||||||
image build is uncached and so runs the gate phases a second time. That is the
|
|
||||||
price of the rule above, and it is worth paying: the image that ships is built
|
|
||||||
from a run of its own gates rather than from a cache entry. A separate
|
|
||||||
workflow limited to `main` by a `branches` list under `on: push` cannot be
|
|
||||||
checked by review: to try a change to it, add the feature branch to that list
|
|
||||||
and push, then remove the branch from the list again before merging. Keep any
|
|
||||||
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
|
|
||||||
from the feature branch publishes nothing.
|
|
||||||
|
|
||||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
@@ -311,21 +189,14 @@ style conventions are in separate documents:
|
|||||||
module under test to verify it compiles/parses. There is no excuse for
|
module under test to verify it compiles/parses. There is no excuse for
|
||||||
`make test` to be a no-op.
|
`make test` to be a no-op.
|
||||||
|
|
||||||
- `make test` must complete in under 60 seconds. That is the hard cap, and a
|
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||||
suite that exceeds it fails. Under 20 seconds is the target. A suite between
|
Makefile.
|
||||||
20 and 60 seconds is still green, but the overage must be filed as an
|
|
||||||
improvement bug against that repo. Add a 90-second timeout to the test
|
|
||||||
invocation (`go test -timeout 90s`). The backstop deliberately sits above the
|
|
||||||
hard cap so that it catches a genuinely hung test rather than a merely slow
|
|
||||||
one.
|
|
||||||
|
|
||||||
- **The test command should use the conditional verbose rerun pattern.** Run
|
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
||||||
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
||||||
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
show full output. This keeps CI logs and `docker build` output clean on
|
||||||
on success (just package/suite summaries) while providing full diagnostic
|
success (just package/suite summaries) while providing full diagnostic detail
|
||||||
detail on failure (every test case, every assertion). The command lives in the
|
on failure (every test case, every assertion). The general shell pattern:
|
||||||
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
|
|
||||||
Makefile form below is the same pattern for any repo-local invocation:
|
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@@ -338,26 +209,11 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@go test -count=1 -timeout 90s -race -cover ./... || \
|
@go test -timeout 30s -race -cover ./... || \
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
go test -timeout 30s -race -v ./...; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
`-count=1` is required on both invocations: it defeats Go's test _result_
|
|
||||||
cache, so neither run can report a stored pass in place of running the
|
|
||||||
tests. It leaves the build cache alone, so it costs the runtime of the suite
|
|
||||||
and no recompilation.
|
|
||||||
|
|
||||||
That cache is Go's own, separate from Docker's layer cache. Go stores a
|
|
||||||
passing result in its cache directory (`GOCACHE`), and when the same tests
|
|
||||||
run again on unchanged code it prints that result, marked `(cached)`,
|
|
||||||
without running them. That matters on a developer's machine, where this
|
|
||||||
target runs and the directory lasts from one run to the next. The `test`
|
|
||||||
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
|
|
||||||
result for this repo's tests and nothing before its `go test` step runs a
|
|
||||||
test, so there is nothing to replay. `--no-cache` (above) is what makes that
|
|
||||||
step run on an unchanged tree.
|
|
||||||
|
|
||||||
Python example:
|
Python example:
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
@@ -383,84 +239,10 @@ style conventions are in separate documents:
|
|||||||
must be in `.gitignore`. No exceptions.
|
must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||||
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
|
Fetch the standard `.gitignore` from
|
||||||
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
setting up a new repo. These patterns are written to `.gitignore`'s own
|
a new repo.
|
||||||
semantics, in which an unanchored pattern already matches at every depth; they
|
|
||||||
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
|
||||||
|
|
||||||
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
|
|
||||||
across unmodified leaves secrets in the build context.** Docker matches with
|
|
||||||
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
|
|
||||||
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
|
|
||||||
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
|
|
||||||
therefore excludes only the copies at the repository root, while `config/.env`
|
|
||||||
and `certs/server.key` still reach the context and can land in an image layer
|
|
||||||
— which is more dangerous than a short file with no secret patterns at all,
|
|
||||||
because it reads as solved and stops anyone looking. Give every
|
|
||||||
depth-independent pattern the `**/` prefix and leave only genuinely
|
|
||||||
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
|
|
||||||
binary, written `/myapp` and never `**/myapp`, which would also match
|
|
||||||
`cmd/myapp/` and delete the package directory from the context. Matching is
|
|
||||||
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
|
|
||||||
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
|
|
||||||
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
|
|
||||||
also catches something the build needs, re-include it with a negation
|
|
||||||
(`!docs/example.env`); deleting the pattern reopens the exposure for every
|
|
||||||
other file it covers. Fetch the standard `.dockerignore` from
|
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
|
|
||||||
it with the repo's own artifacts.
|
|
||||||
|
|
||||||
- **In-repo agent scratch belongs in both files, written to each file's own
|
|
||||||
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
|
|
||||||
additional checkout of the repo — so under `COPY . .` the build context
|
|
||||||
inflates by a multiple of the repo and another session's unreviewed work can
|
|
||||||
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
|
|
||||||
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
|
|
||||||
prefix, because the prefixed form would also delete any nested directory of
|
|
||||||
that name from the build. Anchoring carries a known gap that the canonical
|
|
||||||
`.dockerignore` states in its own comment, since consuming repos receive the
|
|
||||||
file and not the tracker: the directory is created in the agent's working
|
|
||||||
directory, so a repo running agents in subdirectories still ships
|
|
||||||
`services/api/.claude/` and must add its own anchored entry there.
|
|
||||||
|
|
||||||
- **A plain `docker build .` of a clone stamps the version that
|
|
||||||
`git describe --tags --always` gives**, derived from the `.git` in the build
|
|
||||||
context as the canonical `Dockerfile` above shows. Without its failure check,
|
|
||||||
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
|
|
||||||
and the build would still exit 0. `script/docker` and `script/cibuild` pass
|
|
||||||
the version they compute on the host; it takes precedence. They do this
|
|
||||||
byte-identically across repos:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Own line: a failing command substitution inside an argument does not
|
|
||||||
# trip `set -e`, so the inline form degrades to an empty constant.
|
|
||||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
|
||||||
[ -n "$version" ] || version="unknown"
|
|
||||||
docker build --no-cache \
|
|
||||||
--build-arg VERSION="$version" \
|
|
||||||
-t "$(script/projectname)" .
|
|
||||||
```
|
|
||||||
|
|
||||||
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
|
||||||
than failing, and the `[ -n "$version" ]` line is the single place the
|
|
||||||
fallback is applied — a live check that fires on a build from an export with
|
|
||||||
no `.git` and on a repository with no commits yet. Do not fold it into the
|
|
||||||
substitution as `|| echo unknown`, which makes the guard unreachable. The
|
|
||||||
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
|
|
||||||
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
|
|
||||||
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
|
|
||||||
the scripts stay byte-identical. One consequence for CI: the standard
|
|
||||||
checkout action clones shallow and fetches no tags, so a repo that embeds a
|
|
||||||
tag-derived version must set `fetch-depth: 0` on its checkout step.
|
|
||||||
|
|
||||||
- **Verify `.dockerignore` by enumerating the image, not by reading the
|
|
||||||
patterns.** Plant files at the root _and_ at least two directories deep, build
|
|
||||||
a probe image that does `COPY . .`, and list what actually landed
|
|
||||||
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
|
|
||||||
size is not a substitute: a nested secret is a few bytes, and BuildKit
|
|
||||||
transfers only the delta from the previous build.
|
|
||||||
|
|
||||||
- **No build artifacts in version control.** Code-derived data (compiled
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
bundles, minified output, generated assets) must never be committed to the
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
@@ -476,56 +258,9 @@ style conventions are in separate documents:
|
|||||||
- Make all changes on a feature branch. You can do whatever you want on a
|
- Make all changes on a feature branch. You can do whatever you want on a
|
||||||
feature branch.
|
feature branch.
|
||||||
|
|
||||||
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||||
_NEVER_ be modified by an agent: fetch it from
|
manually by the user. Fetch from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
||||||
byte-identical, so that no repo can quietly loosen its own linting. Linter
|
|
||||||
configuration changes are made to the canonical copy in the `prompts` repo and
|
|
||||||
reach consuming repos by re-vendoring; an agent may open a PR against
|
|
||||||
canonical, which only the user merges. One list is exempt from byte-identity,
|
|
||||||
because it cannot be written once for every repo: the `deny` list of the
|
|
||||||
`test-support` depguard rule, where a repo names its own test-support packages
|
|
||||||
by full import path. A repo adds entries there and changes nothing else, and a
|
|
||||||
re-vendor carries its entries forward. The canonical golangci-lint version is
|
|
||||||
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
|
|
||||||
image
|
|
||||||
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
|
|
||||||
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
|
|
||||||
directive must not name a newer Go minor version than the one golangci-lint
|
|
||||||
was built with, or golangci-lint refuses to lint it: this release lints
|
|
||||||
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
|
|
||||||
installs golangci-lint on the host. A repo sets the lint phase digest to the
|
|
||||||
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
|
|
||||||
the two prompted the change: the canonical copy can name linters that an older
|
|
||||||
golangci-lint rejects, and a newer golangci-lint can add linters that
|
|
||||||
`default: all` switches on until the canonical copy disables them.
|
|
||||||
|
|
||||||
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
|
||||||
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
|
||||||
`PATH` only, so on an already-provisioned machine the pin is inert and a
|
|
||||||
version bump is a silent no-op — while the Dockerfile, installing into a clean
|
|
||||||
image, gets the pinned version, so a local `make check` and `make docker` can
|
|
||||||
disagree about what the tool even is. The canonical form:
|
|
||||||
- compares the installed version against the pin over the **whole** version
|
|
||||||
token; a parser that stops at the first `-` reports `2.12.2` for a host
|
|
||||||
running `2.12.2-rc1` and skips the install;
|
|
||||||
- treats absent, non-zero, empty or unrecognised `--version` output as a
|
|
||||||
mismatch, so the failure direction is a redundant install and never a
|
|
||||||
skipped one;
|
|
||||||
- after installing, re-resolves the binary the way callers do — `hash -r`,
|
|
||||||
then through `PATH`, not through the directory the installer wrote to —
|
|
||||||
and fails naming the resolved path, since an install that a shadowing
|
|
||||||
binary hides succeeds while changing nothing any caller sees;
|
|
||||||
- is actually called, and prints the version on both success paths: a
|
|
||||||
function defined and never invoked has the same exit status and the same
|
|
||||||
empty output as one that worked.
|
|
||||||
|
|
||||||
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
|
||||||
|
|
||||||
A Go tool a repo needs on the host is installed with `go install` pinned to
|
|
||||||
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
|
|
||||||
a `go.mod` tool dependency or through a `tools.go` file, either of which
|
|
||||||
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
|
|
||||||
|
|
||||||
- When pinning images or packages by hash, add a comment above the reference
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
with the version and date (YYYY-MM-DD).
|
with the version and date (YYYY-MM-DD).
|
||||||
@@ -639,14 +374,12 @@ style conventions are in separate documents:
|
|||||||
settings.
|
settings.
|
||||||
|
|
||||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||||
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||||
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||||
and language-specific config). Everything else goes in a subdirectory.
|
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||||
Canonical subdirectory names:
|
subdirectory names:
|
||||||
- `bin/` — executable scripts and tools
|
- `bin/` — executable scripts and tools
|
||||||
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
- `cmd/` — Go command entrypoints
|
||||||
body is a single call into `internal/` or `pkg/`, no project logic in
|
|
||||||
`cmd/`
|
|
||||||
- `configs/` — configuration templates and examples
|
- `configs/` — configuration templates and examples
|
||||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||||
- `docs/` — documentation and markdown (README.md stays in root)
|
- `docs/` — documentation and markdown (README.md stays in root)
|
||||||
@@ -673,7 +406,3 @@ style conventions are in separate documents:
|
|||||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
- Python: `pyproject.toml`
|
- Python: `pyproject.toml`
|
||||||
|
|
||||||
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
|
|
||||||
is never committed under a file or directory named after one agent tool, such
|
|
||||||
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
|
|
||||||
|
|||||||
@@ -45,704 +45,6 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-06: Reloading or closing the popup no longer logs a request it cancels
|
|
||||||
as a failure in the transaction lists and ENS name lookups on the address and
|
|
||||||
token screens, the address scan after a wallet is created, the endpoint checks
|
|
||||||
in Settings, the wait screen's receipt check, the Send screen's Max fee
|
|
||||||
estimate, or the token lookup on the two add-token screens
|
|
||||||
([#475](https://git.eeqj.de/sneak/AutistMask/issues/475)). Each checks the
|
|
||||||
signal the popup aborts on `pagehide`
|
|
||||||
([#218](https://git.eeqj.de/sneak/AutistMask/issues/218)) before reporting a
|
|
||||||
failure; a real failure is still logged. `scanForAddresses()`,
|
|
||||||
`resolveEnsNames()` and `lookupTokenInfo()` take the signal. The e2e suite
|
|
||||||
reloads the popup on the address screen with Blockscout held, and during the
|
|
||||||
address scan with that scan held; jest tests cover each of these with the
|
|
||||||
popup open and closed. Left out: the transaction detail screen and the
|
|
||||||
confirmation screen (its fee estimate and its recipient checks), because they
|
|
||||||
discard the popup context that carries the signal.
|
|
||||||
|
|
||||||
- 2026-10-06: The canonical files are re-vendored from `sneak/prompts` at
|
|
||||||
`dd4027b` ([#472](https://git.eeqj.de/sneak/AutistMask/issues/472)). The
|
|
||||||
`Dockerfile` has separate `lint` and `test` phases, and its last stage depends
|
|
||||||
on both before it runs `make build`. `script/lint` and `script/test` each
|
|
||||||
build one phase, uncached. `script/check-censored` runs in the `lint` phase
|
|
||||||
and `script/test-verify-build` in the `test` phase. `script/check` runs the
|
|
||||||
two phases and `script/fmt-check`. `script/cibuild` bootstraps, runs
|
|
||||||
`script/check`, then builds the image uncached. Given no `VERSION` build
|
|
||||||
argument, as in a plain `docker build .`, the image build takes one from
|
|
||||||
`git describe` on the `.git` in the build context, which `.dockerignore` now
|
|
||||||
sends without its `config`. The vendored `check.yml` has no `timeout-minutes`,
|
|
||||||
so the `check` job's cap is gone.
|
|
||||||
|
|
||||||
- 2026-10-06: Reloading or closing the popup mid-refresh no longer logs the
|
|
||||||
requests that cancels as failures
|
|
||||||
([#218](https://git.eeqj.de/sneak/AutistMask/issues/218)). Chrome cancels a
|
|
||||||
closing page's open requests just after `pagehide`, and in the page a
|
|
||||||
cancelled `fetch()` fails with the same "Failed to fetch" as a server that
|
|
||||||
cannot be reached, so the home screen's transaction list and the balance
|
|
||||||
refresh logged an error for each, and the e2e suite failed on them. The popup
|
|
||||||
now aborts an `AbortController` on `pagehide`, and those two check its signal
|
|
||||||
before reporting a failure. New e2e tests reload the popup with Blockscout
|
|
||||||
held and require nothing logged, and fail the transaction list for real and
|
|
||||||
require the failure reported; `tests/balanceRefreshCancelled.test.js` covers
|
|
||||||
the balance refresh. The address and token screens and the address scan after
|
|
||||||
a new wallet still log a cancelled request
|
|
||||||
([#475](https://git.eeqj.de/sneak/AutistMask/issues/475)).
|
|
||||||
|
|
||||||
- 2026-10-05: `make build` no longer prints the Node `DEP0205`
|
|
||||||
`module.register()` deprecation warning
|
|
||||||
([#355](https://git.eeqj.de/sneak/AutistMask/issues/355)). The call came from
|
|
||||||
`@tailwindcss/node`, which the Tailwind CLI loads; `tailwindcss` and
|
|
||||||
`@tailwindcss/cli` are now pinned at 4.3.1, the first release that uses
|
|
||||||
`module.registerHooks()` where Node has it. The compiled CSS computes to the
|
|
||||||
same values; it drops the unused `.start` and `.end` rules and writes
|
|
||||||
`calc(var(--spacing) * 1)` as `var(--spacing)` and `calc(var(--spacing) * 0)`
|
|
||||||
as `0`.
|
|
||||||
|
|
||||||
- 2026-10-05: `make dev` watches
|
|
||||||
([#332](https://git.eeqj.de/sneak/AutistMask/issues/332)). It used to pass
|
|
||||||
`--watch` to a `build.js` that read no arguments, so it built once and exited.
|
|
||||||
`build.js --watch` now builds, then builds again after every change to a file
|
|
||||||
under `src/`, `manifest/` or `icons/`; any other argument fails. It watches
|
|
||||||
each directory rather than using Node's recursive watch, which on Linux stops
|
|
||||||
seeing a file an editor saves by renaming a new copy over it. It writes no
|
|
||||||
build receipt, so nothing can verify what it builds; `make build` remains the
|
|
||||||
way to produce a `dist/` to hand on. `tests/buildWatch.test.js` covers the
|
|
||||||
watch loop against a temp directory.
|
|
||||||
|
|
||||||
- 2026-10-05: Every CI job has a `timeout-minutes` cap
|
|
||||||
([#294](https://git.eeqj.de/sneak/AutistMask/issues/294)): `check` 10 minutes,
|
|
||||||
`e2e-firefox` 15 and `e2e-chrome` 20, each over two and a half times the job's
|
|
||||||
slowest cold-cache run. A hung build or browser now ends its job instead of
|
|
||||||
holding the shared runner for hours.
|
|
||||||
|
|
||||||
- 2026-10-05: `PROXY_METHODS` in `src/background/index.js` no longer lists
|
|
||||||
`eth_chainId` and `net_version`
|
|
||||||
([#326](https://git.eeqj.de/sneak/AutistMask/issues/326)). `handleRpc` answers
|
|
||||||
both itself before its proxy branch, so the list named two methods that are
|
|
||||||
never sent to the RPC endpoint. No other entry is answered earlier.
|
|
||||||
`tests/proxyMethods.test.js` sends every listed method from a page and fails
|
|
||||||
on any that does not reach the RPC endpoint.
|
|
||||||
|
|
||||||
- 2026-10-05: The popup's Content Security Policy no longer allows inline style
|
|
||||||
([#328](https://git.eeqj.de/sneak/AutistMask/issues/328)): `style-src` is
|
|
||||||
`'self'` in both manifests, pinned in `tests/manifest.test.js`. The 42
|
|
||||||
`style="..."` attributes in `src/popup/index.html` and in the markup the view
|
|
||||||
helpers build are now Tailwind classes, each computing to the value it
|
|
||||||
replaced. The 16 address dot colours are written out as whole classes, because
|
|
||||||
Tailwind builds only the classes it finds in the source. The Settings debug
|
|
||||||
well is shown and hidden with the `hidden` class, since clearing an inline
|
|
||||||
`display` no longer uncovers it. Script that sets `element.style` is
|
|
||||||
unaffected.
|
|
||||||
|
|
||||||
- 2026-10-05: `.prettierignore` no longer lists an AI vendor's tool directory
|
|
||||||
([#363](https://git.eeqj.de/sneak/AutistMask/issues/363)). The directory is
|
|
||||||
not tracked, so the line ignored nothing.
|
|
||||||
|
|
||||||
- 2026-10-05: The Chrome end-to-end suite drives both ways the wait for a
|
|
||||||
transaction's receipt ends on the error screen
|
|
||||||
([#315](https://git.eeqj.de/sneak/AutistMask/issues/315)): lookups that still
|
|
||||||
find no receipt 60 seconds after the broadcast end it with the timeout
|
|
||||||
message, and six lookups that fail in a row end it with the message naming the
|
|
||||||
unreachable network. Done then returns to the address screen. Both cases wait
|
|
||||||
in real time, about a minute each. Playwright's clock would apply to every
|
|
||||||
later test in the run and cannot be removed, and moving the stored broadcast
|
|
||||||
time back can be undone by the save the popup makes every ten seconds.
|
|
||||||
|
|
||||||
- 2026-10-05: The Chrome end-to-end suite covers the last of the
|
|
||||||
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
|
|
||||||
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) items
|
|
||||||
([#295](https://git.eeqj.de/sneak/AutistMask/issues/295)): a token added on
|
|
||||||
Add Token by its contract address is listed on the address screen;
|
|
||||||
TransactionDetail opened from the token screen leaves that screen on the
|
|
||||||
persisted navigation stack, and Back returns to it; and the token contract row
|
|
||||||
links to the explorer's token page, read off the link rather than followed.
|
|
||||||
The network stub answers `symbol()` and `name()` for the stub token, which
|
|
||||||
adding it reads.
|
|
||||||
|
|
||||||
- 2026-10-05: Each control that leads to a signature or to the private key has a
|
|
||||||
test that it refuses a defective wallet before asking for a password
|
|
||||||
([#254](https://git.eeqj.de/sneak/AutistMask/issues/254)): Send on the main,
|
|
||||||
address and token screens, Export Private Key, and both approval screens, as
|
|
||||||
drawn and as clicked. Send on the confirmation screen refuses it too now,
|
|
||||||
because the popup reopens onto that screen from a saved view. The comments
|
|
||||||
that said the wallet's key cannot be derived now say that
|
|
||||||
`getSignerForAddress` refuses it, and the module comment in
|
|
||||||
`src/shared/walletDefects.js` names both earlier import paths.
|
|
||||||
|
|
||||||
- 2026-10-05: The Chrome end-to-end suite drives the private key export screen
|
|
||||||
as it drives the recovery phrase screen
|
|
||||||
([#253](https://git.eeqj.de/sneak/AutistMask/issues/253)): the correct
|
|
||||||
password shows the key, leaving by the settings gear empties the screen, and
|
|
||||||
leaving while the password is still being checked never puts the key on it.
|
|
||||||
The cases use the imported key wallet rather than the HD one. Leaving drops
|
|
||||||
the address the screen was showing, and an HD wallet's key cannot be derived
|
|
||||||
without it, so on an HD wallet a late decrypt fails by itself and would never
|
|
||||||
exercise the check that discards it. The screen cannot yet be opened twice in
|
|
||||||
one popup session ([#460](https://git.eeqj.de/sneak/AutistMask/issues/460)),
|
|
||||||
so the cases reopen the popup before the second open.
|
|
||||||
|
|
||||||
- 2026-10-05: The StateRecovery screen is driven in a real browser under the
|
|
||||||
shipped CSP, in both end-to-end suites
|
|
||||||
([#361](https://git.eeqj.de/sneak/AutistMask/issues/361)). A stored record
|
|
||||||
this build cannot read opens the popup on it; its export text box holds the
|
|
||||||
record exactly as stored; a near-miss confirmation phrase erases nothing; and
|
|
||||||
the exact phrase erases the record and reloads into Welcome. The cases run
|
|
||||||
before any wallet exists: with no wallet nothing saves on a timer, so no save
|
|
||||||
can write a good record over the unreadable one, and the erase leaves the
|
|
||||||
popup on Welcome for wallet creation.
|
|
||||||
|
|
||||||
- 2026-10-05: Escaping in the popup's views follows its own rule with no
|
|
||||||
exceptions ([#329](https://git.eeqj.de/sneak/AutistMask/issues/329)). The
|
|
||||||
decimals and holder count on a token's screen, and every USD figure (the ETH
|
|
||||||
price, each total and each balance row's value), went into `innerHTML`
|
|
||||||
unescaped; they are escaped now. None could carry markup, but `formatUsd()`
|
|
||||||
writes a value under a cent as `< $0.01`. `displaySymbol()` counts a symbol in
|
|
||||||
code points rather than UTF-16 units, so a cut never splits an emoji into a
|
|
||||||
half that renders as U+FFFD. `explorerLink()`, also named in the issue, was
|
|
||||||
already removed by [#168](https://git.eeqj.de/sneak/AutistMask/issues/168).
|
|
||||||
|
|
||||||
- 2026-10-05: A Chrome end-to-end test that fails no longer takes later tests
|
|
||||||
down with it ([#318](https://git.eeqj.de/sneak/AutistMask/issues/318)). Each
|
|
||||||
test that turns a fixture switch on for itself alone (a held or failing gas
|
|
||||||
estimate, a seeded native transfer or receipt, a token's lying `decimals()` or
|
|
||||||
markup symbol) turns it off again in a `finally`, and the two tests that drive
|
|
||||||
the popup's own send end on the address screen whether they pass or not,
|
|
||||||
reopening the popup to leave a wait for a receipt. The lying-`decimals()` test
|
|
||||||
checks that nothing was broadcast as soon as the send ends, before it waits
|
|
||||||
for the failure screen, so a broadcast fails it in seconds rather than after a
|
|
||||||
60-second wait. The fixture's `decimals()` override tells 0 from no override,
|
|
||||||
so a token with no decimal places can be fixtured.
|
|
||||||
|
|
||||||
- 2026-10-05: Chrome draws the popup in its monospace font
|
|
||||||
([#418](https://git.eeqj.de/sneak/AutistMask/issues/418)), as Firefox does.
|
|
||||||
Chrome adds a stylesheet of its own to extension pages that sets the font on
|
|
||||||
`body`, and it beat Tailwind's `font-mono`: Tailwind 4 puts its classes in a
|
|
||||||
cascade layer, and a rule outside any layer wins over them. `body` now carries
|
|
||||||
`font-mono!`, which marks the class important. Both end-to-end suites check
|
|
||||||
the popup's font. The same stylesheet also makes Chrome draw the popup's text
|
|
||||||
at 12px rather than the 14px `text-sm` asks for; that is unchanged, and filed
|
|
||||||
as [#456](https://git.eeqj.de/sneak/AutistMask/issues/456).
|
|
||||||
|
|
||||||
- 2026-10-05: Dead code removed and copied view helpers shared
|
|
||||||
([#168](https://git.eeqj.de/sneak/AutistMask/issues/168)). AddressDetail and
|
|
||||||
AddressToken each defined their own `isoDate()` and `timeAgo()` in place of
|
|
||||||
the ones in `src/popup/views/helpers.js`, so a fix to the shared pair would
|
|
||||||
not have reached them. The copies were identical; every screen now uses the
|
|
||||||
shared pair. `blockieHtml()` and `tokenLabel()`, each defined twice, live in
|
|
||||||
`helpers.js` too. Removed as never called: `explorerLink()` (the views build
|
|
||||||
explorer links with `explorerUrl()`), `ETHEREUM_SEPOLIA_CHAIN_ID` (the chain
|
|
||||||
id lives in `src/shared/networks.js`), and `getWalletValue()` and
|
|
||||||
`getTotalValue()`: Home's "Total:" is the active address's total, as
|
|
||||||
`README.md` says. `addressColor()` and `etherscanAddressUrl()` are no longer
|
|
||||||
exported. Nothing the user sees changed.
|
|
||||||
|
|
||||||
- 2026-10-05: A prompt raised while another approval window has focus opens a
|
|
||||||
window of its own ([#290](https://git.eeqj.de/sneak/AutistMask/issues/290)).
|
|
||||||
The background centred each approval window on the last focused window, which
|
|
||||||
could be an earlier approval window still open; headless Chrome reports one as
|
|
||||||
1280x720, so the new window came out where the browser refused to create it,
|
|
||||||
and the request failed with no window at all. It now centres only on a browser
|
|
||||||
window, and when the browser refuses the position it asks again without one
|
|
||||||
and lets the browser place the window. In the Chrome end-to-end suite a test
|
|
||||||
could raise its prompt while the previous test's window was still closing, and
|
|
||||||
then either hit that refusal or take the closing window for its own. After a
|
|
||||||
test that passed, the runner now waits a few seconds for approval windows to
|
|
||||||
close and fails the test if one is still open; after a test that failed, it
|
|
||||||
closes them.
|
|
||||||
|
|
||||||
- 2026-10-05: The Send screen has a "Max" button
|
|
||||||
([#198](https://git.eeqj.de/sneak/AutistMask/issues/198)). Emptying an ETH
|
|
||||||
address took guessing an amount and being refused by the confirmation screen's
|
|
||||||
balance check. Max fills in a token's whole balance, cut to the 18 decimal
|
|
||||||
places the confirmation screen accepts, or for ETH the exact balance minus the
|
|
||||||
fee reserve that check gates on, never the four-decimal balance shown; a fee
|
|
||||||
estimate that finishes after the Send screen was left, or its address, holding
|
|
||||||
or recipient changed, fills nothing in. The confirmation screen works a max
|
|
||||||
ETH amount out again from its own fee estimate and signs it with that
|
|
||||||
estimate's fee fields: fetched again at signing, a fee that had risen since
|
|
||||||
would leave amount plus fee above the balance, and the node would refuse the
|
|
||||||
send. A token's maximum is still refused when ETH cannot pay the fee. Where
|
|
||||||
there is nothing to fill in, a flash message says why.
|
|
||||||
|
|
||||||
- 2026-10-05: A token scale of zero decimals is tested
|
|
||||||
([#325](https://git.eeqj.de/sneak/AutistMask/issues/325)).
|
|
||||||
`resolveTokenDecimals()` already used a scale of 0 from the bundled list or
|
|
||||||
from a tracked token, but no test said so: turning either of its `d !== null`
|
|
||||||
checks into a plain truthiness check left every test green while a
|
|
||||||
zero-decimal token fell through to the next source, or to "decimals unknown".
|
|
||||||
The approval tests now assert a scale of 0 from each of those two sources,
|
|
||||||
both where it is resolved and on the approval screen's Amount line. The second
|
|
||||||
half of the issue, one shared `toDecimals()`, had already landed with
|
|
||||||
[#349](https://git.eeqj.de/sneak/AutistMask/issues/349).
|
|
||||||
|
|
||||||
- 2026-10-05: The e2e suite waits for a save to land before it closes the popup
|
|
||||||
([#446](https://git.eeqj.de/sneak/AutistMask/issues/446)). The Settings round
|
|
||||||
trip switched the theme and the network and closed the popup at once, and a
|
|
||||||
close before the save lands loses the switch; with the network left on
|
|
||||||
Sepolia, a dozen later tests failed too. Each Settings switch and spam-filter
|
|
||||||
toggle is now waited for in storage before the close, and `reopenPopup()`
|
|
||||||
waits until the view it expects to reopen on is the saved one. The restore
|
|
||||||
half of the round trip and the second filter toggle change a setting right
|
|
||||||
after a reopen, while the reopened popup's own saves may still be running;
|
|
||||||
they rely on the fix for
|
|
||||||
[#448](https://git.eeqj.de/sneak/AutistMask/issues/448).
|
|
||||||
|
|
||||||
- 2026-10-05: A change made while an earlier save from the same page is still
|
|
||||||
running is stored ([#448](https://git.eeqj.de/sneak/AutistMask/issues/448)).
|
|
||||||
`saveStateOnce()` took its baseline from the page's state after the write, so
|
|
||||||
a change made while the save waited on storage counted as already stored and
|
|
||||||
the save queued after it wrote nothing. A setting changed during the read was
|
|
||||||
lost; so was a wallet added, a site revoked or an endpoint changed during the
|
|
||||||
write, and a wallet deleted then stayed in storage. The save now copies the
|
|
||||||
page's fields when it starts, writes from that copy, and keeps the copy as the
|
|
||||||
baseline.
|
|
||||||
|
|
||||||
- 2026-10-05: The extension no longer opens a window for a site-connection
|
|
||||||
prompt already answered
|
|
||||||
([#287](https://git.eeqj.de/sneak/AutistMask/issues/287)). When the prompt was
|
|
||||||
decided before the toolbar popup raised for it had loaded, that popup was torn
|
|
||||||
down, `chrome.action.openPopup()` rejected, and the background opened its
|
|
||||||
fallback window for the answered approval and then removed it. In the Chrome
|
|
||||||
end-to-end suite the next test could take that window for its own prompt and
|
|
||||||
lose it under its wait. `openApprovalWindow()` now opens nothing for an
|
|
||||||
approval that is no longer pending. The blocklist test's Reject, whose window
|
|
||||||
closes itself, is clicked as the other site Reject is, with the click
|
|
||||||
witnessed. Making `e2e-chrome` a required check is still blocked: other
|
|
||||||
reports of the Chrome suite failing under load are open, among them
|
|
||||||
[#290](https://git.eeqj.de/sneak/AutistMask/issues/290) and
|
|
||||||
[#446](https://git.eeqj.de/sneak/AutistMask/issues/446), as `README.md` says.
|
|
||||||
|
|
||||||
- 2026-10-05: The lost-password delete confirmation refuses an empty field and
|
|
||||||
ignores characters that paint nothing
|
|
||||||
([#336](https://git.eeqj.de/sneak/AutistMask/issues/336)). A wallet named only
|
|
||||||
with spaces compared equal to an empty field, so typing nothing would have
|
|
||||||
deleted it, and a zero-width space in a name made the name impossible to type
|
|
||||||
back. An empty field is now refused whatever the name is, the same invisible
|
|
||||||
characters `src/shared/symbolSpoof.js` strips are removed from both sides, and
|
|
||||||
a name that shows nothing is shown and typed back as "Wallet N".
|
|
||||||
|
|
||||||
- 2026-10-05: A `holders_count` that is not a whole number in plain digits is
|
|
||||||
unknown, not read in part
|
|
||||||
([#251](https://git.eeqj.de/sneak/AutistMask/issues/251)). `parseInt` read
|
|
||||||
`1,000` as 1, `0x10` as 0 and `1e3` as 1, a reported low count that hides the
|
|
||||||
token in the transaction history and the send-screen token selector. A count
|
|
||||||
above `Number.MAX_SAFE_INTEGER` is unknown too, not rounded or `Infinity`. The
|
|
||||||
balance list's `holders !== null` check, which did nothing, is dropped.
|
|
||||||
`README.md` and `docs/README.md` now say how each filter treats an unknown
|
|
||||||
count and that the token screen leaves out its "Holders:" row then, and
|
|
||||||
`README.md` lists `src/shared/holders.js`.
|
|
||||||
|
|
||||||
- 2026-10-04: A popup boot in the tests loads transactions without failing
|
|
||||||
([#429](https://git.eeqj.de/sneak/AutistMask/issues/429)). The stand-in for
|
|
||||||
`filterTransactions` in `tests/support/popupBoot.js` returned a bare list,
|
|
||||||
while the real one returns `{ transactions, newFraudContracts }`, so every
|
|
||||||
boot onto Home, AddressDetail or AddressToken failed inside its transaction
|
|
||||||
loading and logged `loadHomeTxs failed` or `loadTransactions failed`; the rest
|
|
||||||
of that code never ran. The stand-in now returns the real shape, and
|
|
||||||
`tests/persistedFieldContract.test.js` boots onto each of the three and
|
|
||||||
asserts neither message is logged. `make test` time did not change measurably.
|
|
||||||
|
|
||||||
- 2026-10-04: The native token's label follows the network
|
|
||||||
([#372](https://git.eeqj.de/sneak/AutistMask/issues/372)). `networks.js` gives
|
|
||||||
each network a `nativeCurrency` and nothing read it: every screen wrote `ETH`,
|
|
||||||
so on Sepolia the balance, the value and the fee all read `ETH`. Every native
|
|
||||||
figure now reads `nativeCurrency`, which is `ETH` on mainnet and `SepoliaETH`
|
|
||||||
on Sepolia: the balance lists, Send and confirmation screens and the
|
|
||||||
contract-recipient warning the active network's; the approval, wait, success,
|
|
||||||
error and transaction detail screens, the transaction history and the refusal
|
|
||||||
of a fee above the limit that of the network the transaction's chain id names.
|
|
||||||
A token claiming any network's `nativeCurrency` is dropped as a fake, as one
|
|
||||||
claiming `ETH` already was, and the transaction detail screen calls an entry a
|
|
||||||
token transfer when it has a token contract, not by its symbol.
|
|
||||||
- 2026-10-04: A popup that is already open when the stored profile becomes
|
|
||||||
unreadable moves to the recovery screen
|
|
||||||
([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). It used to stay on
|
|
||||||
the last good profile, with the "NOT SAVED" banner at most, until reopened.
|
|
||||||
Every save already ran the check the popup runs at open, so the popup finds
|
|
||||||
the record at the next navigation or ten-second refresh, whether or not the
|
|
||||||
network answers; a save that fails that check now raises the recovery screen
|
|
||||||
and stops the refresh. The screen it replaces is left as any navigation leaves
|
|
||||||
it, so a revealed phrase or key or a typed password is wiped. Once up, nothing
|
|
||||||
else in that popup can replace it, and a later save or a transaction wait that
|
|
||||||
ends does not clear an export or a typed confirmation. It is never saved as
|
|
||||||
the current view, so a popup opened after the record is erased in another
|
|
||||||
window opens normally. Any other failed save still gets the banner and leaves
|
|
||||||
the screen alone.
|
|
||||||
- 2026-10-04: A swap whose deadline is later than a JavaScript date can hold is
|
|
||||||
decoded ([#437](https://git.eeqj.de/sneak/AutistMask/issues/437)). A date
|
|
||||||
reaches only to 275760-09-13, so a later deadline, such as the `uint256`
|
|
||||||
maximum, made the `Deadline` line throw, and the approval screen showed the
|
|
||||||
swap as an undecoded contract call with nothing saying why. That line now
|
|
||||||
reads `After 275760-09-13 00:00:00 (no deadline in practice)`.
|
|
||||||
- 2026-10-04: The swap decoder reads two router zeros the way the router does
|
|
||||||
([#415](https://git.eeqj.de/sneak/AutistMask/issues/415)). A V2 exact-in
|
|
||||||
`amountIn` of zero means an earlier step already sent the tokens to the pair;
|
|
||||||
`Amount` showed `0.0000` for it and now reads
|
|
||||||
`Whatever an earlier step sent to the pair (V2 already paid)`. A
|
|
||||||
`BALANCE_CHECK_ERC20` with a zero `minBalance` guarantees nothing, yet it
|
|
||||||
replaced the minimum an earlier swap step stated, so `Min. received` read
|
|
||||||
`None (no minimum guaranteed)`; it now sets the output side only when that
|
|
||||||
side holds no minimum at the point the check is reached. A nonzero
|
|
||||||
`minBalance` still sets the output side.
|
|
||||||
- 2026-10-04: The signature screen shows a personal message as the bytes that
|
|
||||||
are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It
|
|
||||||
showed only the decoded text, with bidirectional and zero-width characters
|
|
||||||
acting on it, so a site could make the message read differently from what is
|
|
||||||
signed, and a message that was not hex was shown as NUL characters. The hex is
|
|
||||||
now shown as "Raw data" alongside the decoded text, the text is laid out left
|
|
||||||
to right in byte order, control characters, line and paragraph separators and
|
|
||||||
characters that paint nothing are shown as `U+XXXX` marks, and a message that
|
|
||||||
is not hex by the rule signing reads it with is shown as plain text with
|
|
||||||
"Sign" disabled, since such a message has no bytes to sign.
|
|
||||||
- 2026-10-04: A token that reports more than 80 decimal places has no known
|
|
||||||
scale ([#350](https://git.eeqj.de/sneak/AutistMask/issues/350)). The shared
|
|
||||||
scale check `toDecimals()` accepted any `uint8`, but `formatUnits()` throws
|
|
||||||
above 80, so such a token left a swap or an ERC-20 call on the approval screen
|
|
||||||
undecoded, with nothing saying why. The check now stops at 80, and both
|
|
||||||
approval paths show the base-unit amount with the scale stated as unknown. The
|
|
||||||
balance list and the history list use the same check, so the same token no
|
|
||||||
longer stops an address's token balances from refreshing or its history from
|
|
||||||
loading.
|
|
||||||
- 2026-10-04: Debug mode no longer writes RPC API keys to the console
|
|
||||||
([#410](https://git.eeqj.de/sneak/AutistMask/issues/410)). `debugFetch` logged
|
|
||||||
every request's full URL and body, so an RPC endpoint with a key in its path
|
|
||||||
or query string printed that key on every request. It now logs the HTTP
|
|
||||||
method, the URL's origin and, for a JSON-RPC call, the method name. The
|
|
||||||
balance refresh and token lookup log the RPC endpoint by its origin too. A
|
|
||||||
failed RPC call's error line prints the error's short message, which names the
|
|
||||||
HTTP status, not its full message, which carries the request URL. A failed
|
|
||||||
endpoint check in settings names the endpoint by its origin, not the `fetch`
|
|
||||||
error's message, which carries the whole URL, password included, for a URL
|
|
||||||
with a user name and password. The README's DEBUG Mode Policy says what debug
|
|
||||||
mode logs.
|
|
||||||
|
|
||||||
- 2026-10-04: A site has at most one connection prompt and one signature prompt
|
|
||||||
open at a time ([#405](https://git.eeqj.de/sneak/AutistMask/issues/405)). Each
|
|
||||||
`eth_requestAccounts` or `personal_sign` call opened another approval window,
|
|
||||||
so a page calling in a loop could cover the screen with identical prompts. A
|
|
||||||
further request of the same kind from a site whose prompt is still unanswered
|
|
||||||
is now refused with EIP-1193 `-32002`, the code a second transaction already
|
|
||||||
gets, and opens no window. Signing by `personal_sign`, `eth_sign` and
|
|
||||||
`eth_signTypedData_v4` counts as one kind. Other sites are not affected, and
|
|
||||||
the site may ask again once the user has answered. A connection prompt whose
|
|
||||||
toolbar popup closed before it connected, and which nothing shows any more, is
|
|
||||||
shown again when the site asks again.
|
|
||||||
|
|
||||||
- 2026-10-04: A nonce the site supplies with `eth_sendTransaction` is ignored
|
|
||||||
([#404](https://git.eeqj.de/sneak/AutistMask/issues/404)). It was passed on to
|
|
||||||
the transaction, so a site could replace one of the user's pending
|
|
||||||
transactions (same nonce, higher fee) or leave the new one stuck behind a gap,
|
|
||||||
and the approval screen showed it as a bare number. `nonce` is no longer one
|
|
||||||
of the fields taken from the request in `src/shared/approvalTx.js`, so the
|
|
||||||
transaction always gets the account's next nonce from the node, and that is
|
|
||||||
the nonce the approval screen shows and the popup signs.
|
|
||||||
|
|
||||||
- 2026-10-04: Remembered site permissions are held by full origin
|
|
||||||
([#402](https://git.eeqj.de/sneak/AutistMask/issues/402)). `allowedSites` and
|
|
||||||
`deniedSites` stored the hostname alone, so a grant to `https://dapp.example`
|
|
||||||
also authorised `http://dapp.example` and every port on that host, and the
|
|
||||||
prompts named only the hostname. Both lists now store and match the origin
|
|
||||||
(`scheme://host[:port]`), the key the connections approved without "Remember"
|
|
||||||
already used, in `src/background/index.js` and in Settings, whose site lists
|
|
||||||
and `AUTISTMASK_REMOVE_SITE` carry the origin too. The connection, transaction
|
|
||||||
and signature prompts show the origin. Entries saved by hostname before this
|
|
||||||
change are not migrated (pre-1.0): they match no site, and Settings lists them
|
|
||||||
until they are removed.
|
|
||||||
|
|
||||||
- 2026-10-04: A page's request is credited only to the site the browser says
|
|
||||||
sent it ([#407](https://git.eeqj.de/sneak/AutistMask/issues/407)). Where the
|
|
||||||
browser does not give the sender's origin (Firefox before 126), the background
|
|
||||||
used the tab's page, so a frame from another site would have been treated as
|
|
||||||
the site embedding it, and with no tab it used an origin the page wrote into
|
|
||||||
the message. It now uses the URL of the frame that sent the message, and
|
|
||||||
refuses the request with code 4100 when the browser gives neither. The content
|
|
||||||
script no longer writes an origin into the message.
|
|
||||||
|
|
||||||
- 2026-10-04: `make test` takes 8-13s on the shared build host, down from
|
|
||||||
17-25s, measured in alternating runs before and after the change
|
|
||||||
([#428](https://git.eeqj.de/sneak/AutistMask/issues/428)). Each popup boot in
|
|
||||||
the tests (`tests/support/popupBoot.js`) resets jest's module registry so that
|
|
||||||
everything under `src/` loads fresh, and that also reloaded `ethers`,
|
|
||||||
`libsodium-wrappers-sumo`, `qrcode` and `ethereum-blockies-base64` every time.
|
|
||||||
Those four libraries are now loaded once per test file and handed to every
|
|
||||||
boot. No test or assertion changed.
|
|
||||||
|
|
||||||
- 2026-10-04: A token whose scale is unknown reads the same on the Send screen
|
|
||||||
as on the confirmation screen
|
|
||||||
([#377](https://git.eeqj.de/sneak/AutistMask/issues/377)). When two addresses'
|
|
||||||
explorer reports disagree on a token's `decimals`, the Send screen showed the
|
|
||||||
stored figure while the confirmation screen it leads to said
|
|
||||||
`unknown (SYMBOL)`; both now say `unknown (SYMBOL)`, from one function in
|
|
||||||
`src/popup/views/send.js`. The confirmation screen's fee-unknown message names
|
|
||||||
its cause: for an unknown scale it says the wallet does not know how many
|
|
||||||
decimal places the token uses and that the transaction cannot be sent, instead
|
|
||||||
of asking the user to go back and try again, which cannot help. For any other
|
|
||||||
cause it is unchanged.
|
|
||||||
|
|
||||||
- 2026-10-04: A Uniswap V2 exact-out swap (Universal Router command `0x09`) is
|
|
||||||
decoded on the approval screen
|
|
||||||
([#283](https://git.eeqj.de/sneak/AutistMask/issues/283)). `decode()` in
|
|
||||||
`src/shared/uniswap.js` had no arm for it, so the screen named the step and
|
|
||||||
showed no token or amount. The input side is the path's first token with
|
|
||||||
`amountInMax`, the output side the last token with `amountOut`. When the
|
|
||||||
transaction has such a step, the `Amount` figure reads `Up to <amount>`,
|
|
||||||
whichever step set it, there and on the wait, success and error screens,
|
|
||||||
except where it reads `Unlimited` (an unbounded `PERMIT2_PERMIT`, or any
|
|
||||||
amount at or above the `uint160` maximum) or `All available (V4 open delta)`.
|
|
||||||
In every swap, `UNWRAP_WETH` makes `Token Out` ETH only when the output side
|
|
||||||
is WETH, on mainnet or Sepolia, or when no step set the output side; otherwise
|
|
||||||
`Token Out` and `Min. received` keep the output side's own token and figure.
|
|
||||||
V3 exact-out (`0x01`) is still not decoded.
|
|
||||||
|
|
||||||
- 2026-10-04: `make test` runs jest in three worker processes
|
|
||||||
([#426](https://git.eeqj.de/sneak/AutistMask/issues/426)). The `test` and
|
|
||||||
`test:verbose` scripts in `package.json` ran `jest --forceExit`, which starts
|
|
||||||
one worker per CPU core: about 47 processes and 7-8 GiB per run on the shared
|
|
||||||
48-core build host. They now pass `--maxWorkers=3`, and the suite takes 23-29s
|
|
||||||
there: inside the 30-second cap in `script/test`, which is unchanged, but not
|
|
||||||
by much, because `tests/persistedFieldContract.test.js` alone takes most of it
|
|
||||||
([#428](https://git.eeqj.de/sneak/AutistMask/issues/428)). One or two workers
|
|
||||||
went past the cap. `make check`, the pre-commit hook and `script/cibuild` all
|
|
||||||
run the suite through these scripts.
|
|
||||||
|
|
||||||
- 2026-10-04: The error container on each dApp approval screen keeps its height
|
|
||||||
when an error appears
|
|
||||||
([#297](https://git.eeqj.de/sneak/AutistMask/issues/297)). `#approve-tx-error`
|
|
||||||
and `#approve-sign-error` reserved 20px, but their border and padding took
|
|
||||||
10px of it, so a one-line error grew them to 26px and pushed the buttons below
|
|
||||||
down 6px. They now reserve 30px. A new test in `tests/e2e/run.js` shows each
|
|
||||||
of the six password error containers on its own screen, empty and then with an
|
|
||||||
error, and fails if one changes height or the element below it moves. Some of
|
|
||||||
the longer messages these two containers can show still take two lines.
|
|
||||||
|
|
||||||
- 2026-10-04: A transaction with no `to` says "This transaction creates a new
|
|
||||||
contract. It has no recipient." on its recipient line and in its transaction
|
|
||||||
history row ([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The
|
|
||||||
wait, success and error screens, the transaction detail view and the history
|
|
||||||
rows on Home, AddressDetail and AddressToken showed a blank address there,
|
|
||||||
with a colour dot whose colour was `undefined`; the approval screen showed
|
|
||||||
"(contract creation)". A transaction with a real `to` is unchanged.
|
|
||||||
|
|
||||||
- 2026-10-04: The Send and confirmation screens no longer show an ETH balance, a
|
|
||||||
token balance or a network fee below 0.000001 as zero
|
|
||||||
([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored balances
|
|
||||||
(`src/shared/balances.js`) and the confirmation screen's fee were each cut to
|
|
||||||
six decimal places by a rule of their own, and a token holding cut to zero was
|
|
||||||
dropped. Balances are now stored exactly, whatever decimals a token declares,
|
|
||||||
and every nonzero token holding is kept; the balance check reads a token
|
|
||||||
balance to its first 18 places, the most an amount can have. The balance
|
|
||||||
lists, the send-screen token selector, the address total and the
|
|
||||||
remove-address warning leave out a holding below 0.000001 themselves, as
|
|
||||||
before. The Send screen's `Current balance`, and the confirmation screen's
|
|
||||||
balance, fee, reserve and insufficient-balance messages, go through
|
|
||||||
`truncateAmountNeverZero()` in `src/shared/amountDisplay.js`, the helper the
|
|
||||||
approval screen already used. The confirmation and approval screens both
|
|
||||||
render the fee through `formatFee()` in `src/popup/views/helpers.js`, which
|
|
||||||
prices the exact fee in USD, so the same fee reads the same on both, USD value
|
|
||||||
included.
|
|
||||||
|
|
||||||
- 2026-10-04: The flash line keeps to the one line it reserves at any message
|
|
||||||
length ([#252](https://git.eeqj.de/sneak/AutistMask/issues/252)). A message
|
|
||||||
that wrapped pushed the whole screen below it down. `#flash-msg` no longer
|
|
||||||
wraps: text too long for the line is cut with an ellipsis, and `showFlash()`
|
|
||||||
puts the whole message in the line's title. Every message is also reworded to
|
|
||||||
at most 50 characters so none is cut; none carries a wallet name or text from
|
|
||||||
a server, and the add-token screens flash a fixed line for any error other
|
|
||||||
than a contract that is not a token. A new test in `tests/e2e/run.js` puts a
|
|
||||||
message several lines long on the line and fails if the line or the screen
|
|
||||||
below it moves. The two approval-screen error boxes are left to
|
|
||||||
[#297](https://git.eeqj.de/sneak/AutistMask/issues/297).
|
|
||||||
|
|
||||||
- 2026-10-04: A method the wallet does not implement is refused with EIP-1193
|
|
||||||
code `4200` ([#279](https://git.eeqj.de/sneak/AutistMask/issues/279)). The
|
|
||||||
background's `Unsupported method: <method>` error carried no code, so a site
|
|
||||||
probing for an optional method could not tell "not implemented" from "the call
|
|
||||||
failed". The message is unchanged; the background's other errors with no code
|
|
||||||
are untouched.
|
|
||||||
|
|
||||||
- 2026-10-04: Settings lists the sites connected without "Remember", and
|
|
||||||
removing a site there disconnects it
|
|
||||||
([#406](https://git.eeqj.de/sneak/AutistMask/issues/406)). Such a connection
|
|
||||||
lives only in the background's in-memory `connectedSites` map, so Settings
|
|
||||||
never showed it and the user could not end it; `AUTISTMASK_REMOVE_SITE`, sent
|
|
||||||
on every remove, did nothing. Settings now asks the background for those sites
|
|
||||||
(`AUTISTMASK_GET_CONNECTED_SITES`) and lists them under Connected Sites.
|
|
||||||
Removing a site from Allowed Sites or Connected Sites drops its remembered
|
|
||||||
entry under every address and sends `AUTISTMASK_REMOVE_SITE` with the
|
|
||||||
hostname; the background deletes every `connectedSites` entry for that
|
|
||||||
hostname and sends `accountsChanged` with an empty list to its open tabs. Only
|
|
||||||
the extension's own pages may send either message. Removing a denied site no
|
|
||||||
longer sends it, since forgetting a refusal ends no connection.
|
|
||||||
- 2026-10-04: Removing an address or deleting a wallet ends every site
|
|
||||||
connection approved without "Remember" for the addresses removed
|
|
||||||
([#245](https://git.eeqj.de/sneak/AutistMask/issues/245)). Such a connection
|
|
||||||
lives only in the background's in-memory `connectedSites` map. Both removal
|
|
||||||
paths dropped the remembered `allowedSites`/`deniedSites` entries, but nothing
|
|
||||||
told the background, so its entry was cleared only as a side effect: every
|
|
||||||
change of active address empties the whole map, and removing the active
|
|
||||||
address changes it. `dropSitePermissions()` in `src/shared/walletDelete.js`,
|
|
||||||
shared by both paths, now also sends `AUTISTMASK_ADDRESSES_REMOVED` with the
|
|
||||||
removed addresses, and the background deletes their `connectedSites` entries;
|
|
||||||
only the extension's own pages may send it.
|
|
||||||
- 2026-10-03: The typed-data signing screen warns for a token permission, and
|
|
||||||
names the primary type ethers signs
|
|
||||||
([#400](https://git.eeqj.de/sneak/AutistMask/issues/400)). A Permit or Permit2
|
|
||||||
signature lets its spender take tokens from the signer's address, and the
|
|
||||||
screen listed it as plain key/value lines, exactly like a sign-in message. For
|
|
||||||
typed data signed as `Permit` (EIP-2612's, DAI's older one, or any other of
|
|
||||||
that name) or as one of Permit2's six signature types,
|
|
||||||
`src/popup/views/approval.js` now shows a red warning at the top of the
|
|
||||||
message naming the spender and each token and amount, read only from the
|
|
||||||
fields the signed type declares (a `Permit`'s token is the domain's
|
|
||||||
`verifyingContract`), with `Unlimited` for the largest amount the field holds,
|
|
||||||
the existing unknown-scale wording otherwise, and `Unknown` for whatever those
|
|
||||||
fields do not give. The screen printed the page's `primaryType`, but ethers
|
|
||||||
signs the type it derives from `types`; the screen now shows the derived type,
|
|
||||||
and typed data whose stated type is missing or differs, or that cannot be
|
|
||||||
read, is shown with an error line and Sign disabled, and is refused again
|
|
||||||
where signing starts. The warning names no deadline or expiry: those fields
|
|
||||||
mean different things across the shapes, and a date could read as the
|
|
||||||
permission ending when it does not.
|
|
||||||
- 2026-09-21: The network fee a transaction can commit is bounded by the product
|
|
||||||
of the gas limit and the fee per gas, not by each field alone, and the
|
|
||||||
wallet's own send is bounded the same way
|
|
||||||
([#399](https://git.eeqj.de/sneak/AutistMask/issues/399)). The two per-field
|
|
||||||
ceilings in `src/shared/approvalVerify.js` were checked independently, so a
|
|
||||||
gas limit and a fee that were each under their own ceiling still multiplied to
|
|
||||||
thousands of ETH — a fee a gas-consuming contract really collects — while the
|
|
||||||
comment claimed the ceiling caught exactly that. `assertWithinCeilings` now
|
|
||||||
also refuses a transaction whose gas limit times its fee per gas
|
|
||||||
(`maxFeePerGas` for a type-2 transaction, `gasPrice` for a legacy or type-1
|
|
||||||
one) exceeds `MAX_TOTAL_FEE`, a new constant of 1 ETH beside the existing
|
|
||||||
ceilings, so both callers — where the dApp transaction is populated and where
|
|
||||||
the signed artifact is verified — reject it with a full sentence naming the
|
|
||||||
fee and the limit. The wallet's own send in `src/popup/views/confirmTx.js`
|
|
||||||
pinned no fee fields, so ethers filled them from whatever the configured node
|
|
||||||
answered with nothing bounding them; it now populates the transaction and runs
|
|
||||||
the same check before signing, showing the same error in the confirmation
|
|
||||||
screen's reserved errors box so nothing on screen moves. Deliberately out of
|
|
||||||
scope: comparing a supplied fee against the node's own suggested fee, which
|
|
||||||
the absolute bound already makes unnecessary for the balance-draining case. 1
|
|
||||||
ETH is a plain constant, one line to change; the owner may prefer another
|
|
||||||
figure.
|
|
||||||
- 2026-09-21: The test recovery phrase no longer survives in a release bundle,
|
|
||||||
and the committed-key guard matches by content
|
|
||||||
([#351](https://git.eeqj.de/sneak/AutistMask/issues/351)). `DEBUG_MNEMONIC` in
|
|
||||||
`src/shared/constants.js` is now behind the `__BUILD_DEBUG__` define, so a
|
|
||||||
release build folds the phrase to `null` and no emitted bundle carries it; it
|
|
||||||
used to survive as dead text because `module.exports` keeps the const alive.
|
|
||||||
`script/verify-build` now fails a release build if the phrase appears in any
|
|
||||||
emitted file, so the fold cannot silently regress. `tests/extensionId.test.js`
|
|
||||||
scans the content of every tracked file for a PEM private-key header instead
|
|
||||||
of matching filename extensions alone.
|
|
||||||
- 2026-09-21: A transaction response is honoured only for a transaction
|
|
||||||
approval, and the three remaining approval-settlement paths are pinned
|
|
||||||
([#262](https://git.eeqj.de/sneak/AutistMask/issues/262)). The liveness fix
|
|
||||||
the issue asks for — settle `4001` on release when the window it would be
|
|
||||||
retried in is gone — already landed with
|
|
||||||
[#271](https://git.eeqj.de/sneak/AutistMask/issues/271); this closes the rest.
|
|
||||||
`AUTISTMASK_TX_RESPONSE` now refuses any approval that is not a transaction
|
|
||||||
approval, so a reject no longer retires a sign or connection approval and a
|
|
||||||
signed artifact never runs the broadcast path against one, which before only
|
|
||||||
failed closed by throwing deeper in. Tests pin the site-connection port's
|
|
||||||
approve, reject and disconnect paths against a transaction approval
|
|
||||||
broadcasting behind them: each is declined and the dApp still receives its
|
|
||||||
broadcast result.
|
|
||||||
- 2026-09-21: `docs/RELEASE.md`, linked from `README.md`, states the release
|
|
||||||
procedure as a numbered list a newcomer can follow: confirm `main` is green in
|
|
||||||
CI, confirm the one version in the three files matches the intended tag,
|
|
||||||
`make package` from a clean checkout, verify `SHA256SUMS`, tag `vX.Y.Z`, then
|
|
||||||
distribute per browser. Each step names who performs it (owner-only steps
|
|
||||||
marked) and the check that it worked. The distribution step is written as
|
|
||||||
pending the owner's choice on
|
|
||||||
[#386](https://git.eeqj.de/sneak/AutistMask/issues/386), with the Firefox and
|
|
||||||
Chrome options named but none settled. Docs only
|
|
||||||
([#387](https://git.eeqj.de/sneak/AutistMask/issues/387)).
|
|
||||||
|
|
||||||
- 2026-09-21: Adding a second wallet no longer accepts a different password with
|
|
||||||
nothing saying it is a separate one
|
|
||||||
([#374](https://git.eeqj.de/sneak/AutistMask/issues/374)). Each wallet has its
|
|
||||||
own encrypted secret, so per-wallet passwords are by design; the add-wallet
|
|
||||||
screen said only "Choose a password". A note now appears on that screen when
|
|
||||||
the profile already holds a wallet, stating that each wallet has its own
|
|
||||||
password and this one need not match any already in use. It is shown only
|
|
||||||
then, since the first wallet has no other password to differ from, and it
|
|
||||||
stays consistent with the no-reset reality of
|
|
||||||
[#312](https://git.eeqj.de/sneak/AutistMask/issues/312) by promising no
|
|
||||||
recovery or reset.
|
|
||||||
|
|
||||||
- 2026-09-21: The dApp approval and transaction-status screens resolve a token's
|
|
||||||
symbol from the bundled list, then the tokens the user tracks, then the block
|
|
||||||
explorer's report — the same sources and precedence the amount line already
|
|
||||||
used for the token's scale
|
|
||||||
([#323](https://git.eeqj.de/sneak/AutistMask/issues/323), folding in
|
|
||||||
[#354](https://git.eeqj.de/sneak/AutistMask/issues/354)). A token the user
|
|
||||||
added by hand, or holds a balance of, is now named rather than labelled
|
|
||||||
`Unknown token`, and a non-bundled ERC-20 is no longer carried onto the wait
|
|
||||||
screen as `ETH`. A tracked or explorer-reported name stays subject to the
|
|
||||||
spoof rule, so resolving a symbol is not a new way to wear a known ticker.
|
|
||||||
- 2026-09-21: The debug/testnet banner no longer shows the internal view id to
|
|
||||||
the user in a release build
|
|
||||||
([#375](https://git.eeqj.de/sneak/AutistMask/issues/375)). The banner appended
|
|
||||||
the active view's id (e.g. `[TESTNET] (approve-tx)`), which is developer
|
|
||||||
vocabulary sitting directly above the approval screen's carefully worded
|
|
||||||
authorization text. The suffix is now gated on the compile-time `DEBUG`
|
|
||||||
constant rather than `isDebug()`, so it survives only in a debug build; a
|
|
||||||
testnet or the runtime debug toggle still raises the banner but without the
|
|
||||||
view id.
|
|
||||||
|
|
||||||
- 2026-09-21: The Confirm Delete button on the delete-wallet screen no longer
|
|
||||||
stays dead after a successful delete
|
|
||||||
([#335](https://git.eeqj.de/sneak/AutistMask/issues/335)). The password route
|
|
||||||
disabled the button before the decrypt and never re-enabled it, so a second
|
|
||||||
delete in the same popup session needed a reopen; the lost-password route
|
|
||||||
re-enabled its own button in its leave hook, so the two screens behaved
|
|
||||||
differently. Both now reset through the shared `finishDelete()`, the one path
|
|
||||||
both routes take, so they behave the same and the button is live for the next
|
|
||||||
delete.
|
|
||||||
|
|
||||||
- 2026-09-21: The EIP-6963 provider UUID is generated fresh on each page load
|
|
||||||
and never persisted ([#398](https://git.eeqj.de/sneak/AutistMask/issues/398)).
|
|
||||||
It was created once and stored, then announced verbatim to every page on every
|
|
||||||
load and across restarts, so any site — connected or not — could read it as a
|
|
||||||
stable cross-site, cross-session identifier for the install, contradicting the
|
|
||||||
"no tracking" promise. inpage.js now announces a per-load
|
|
||||||
`crypto.randomUUID()` and the `eip6963Uuid` storage key and the
|
|
||||||
`AUTISTMASK_PROVIDER_UUID` content-script message are gone. That key was a
|
|
||||||
standalone storage entry, never part of the versioned `autistmask` profile, so
|
|
||||||
the state schema is untouched and no existing profile is affected.
|
|
||||||
|
|
||||||
- 2026-09-21: `README.md` no longer says a genuine zero always renders `0.0000`
|
|
||||||
([#369](https://git.eeqj.de/sneak/AutistMask/issues/369)). The amount rule
|
|
||||||
still renders one as `0.0000`, but two swap lines say a zero in words instead:
|
|
||||||
`Min. received` reads `None (no minimum guaranteed)` for a zero minimum, and
|
|
||||||
`Amount` reads `All available (V4 open delta)` when the amount it shows is a
|
|
||||||
V4 exact-in `amountIn` of zero. A new list says what the decoded ERC-20 and
|
|
||||||
swap amount lines on the transaction approval screen can read: a formatted
|
|
||||||
quantity, base units with decimals unknown, `Unlimited`,
|
|
||||||
`All available (V4 open delta)` and `None (no minimum guaranteed)`, which a
|
|
||||||
zero `minBalance` on a `BALANCE_CHECK_ERC20` step now reads instead of
|
|
||||||
`0.0000` at a known scale. The README also names
|
|
||||||
`Unknown (not named in the calldata)` on the swap's token lines, and points to
|
|
||||||
SignApproval for the token permission warning's own wording. Docs only.
|
|
||||||
|
|
||||||
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
|
|
||||||
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
|
|
||||||
wallet list was the reported case: the address shared one row with the
|
|
||||||
`[info]` and `[x]` controls and folded onto a second line, which turns one
|
|
||||||
42-character string the user is meant to compare into two shorter ones — the
|
|
||||||
shape an address-poisoning attack wants. The fix is layout, not CSS: every
|
|
||||||
address in the popup now sits alone on a full-width row, with the colour dot,
|
|
||||||
the wallet title, the ENS name and the explorer link moved onto a strip above
|
|
||||||
it, and the transaction rows carry the counterparty's whole address instead of
|
|
||||||
a `truncateMiddle()`d one squeezed in beside the amount. `truncateMiddle()`
|
|
||||||
keeps its 10-character cap and its 32-character floor moved into
|
|
||||||
`renderAddressHtml()`, so the guarantee outlives having no callers. The e2e
|
|
||||||
suite measures every rendered address in a real Chromium — whole, one line
|
|
||||||
box, inside its row and inside the popup — across Home, the address, token,
|
|
||||||
receive, send and transaction detail screens, the confirmation screen and the
|
|
||||||
dApp transaction prompt.
|
|
||||||
- 2026-08-23: Both manifests declare toolbar icons, and real PNGs at
|
- 2026-08-23: Both manifests declare toolbar icons, and real PNGs at
|
||||||
16/32/48/128 ship inside both archives
|
16/32/48/128 ship inside both archives
|
||||||
([#371](https://git.eeqj.de/sneak/AutistMask/issues/371)). Neither manifest
|
([#371](https://git.eeqj.de/sneak/AutistMask/issues/371)). Neither manifest
|
||||||
|
|||||||
@@ -15,15 +15,6 @@ const DIST_CHROME = path.join(DIST, "chrome");
|
|||||||
const DIST_FIREFOX = path.join(DIST, "firefox");
|
const DIST_FIREFOX = path.join(DIST, "firefox");
|
||||||
const SRC = path.join(__dirname, "src");
|
const SRC = path.join(__dirname, "src");
|
||||||
|
|
||||||
// What `make dev` watches: the directories whose files build() bundles,
|
|
||||||
// compiles or copies. A change anywhere else, package.json and build.js
|
|
||||||
// included, starts no build; restart make dev after one.
|
|
||||||
const WATCHED_DIRS = [
|
|
||||||
SRC,
|
|
||||||
path.join(__dirname, "manifest"),
|
|
||||||
path.join(__dirname, "icons"),
|
|
||||||
];
|
|
||||||
|
|
||||||
// The module whose compiled DEBUG state script/verify-build asserts. Which
|
// The module whose compiled DEBUG state script/verify-build asserts. Which
|
||||||
// bundles contain it is derived from esbuild's own dependency graph rather
|
// bundles contain it is derived from esbuild's own dependency graph rather
|
||||||
// than from a hardcoded list, so it tracks the bundle layout instead of
|
// than from a hardcoded list, so it tracks the bundle layout instead of
|
||||||
@@ -40,7 +31,7 @@ const AUDITED_MODULE = "src/shared/constants.js";
|
|||||||
// the build, whether or not a text matcher would have recognized it. A
|
// the build, whether or not a text matcher would have recognized it. A
|
||||||
// background entry point the table does not name fails as well, so a second
|
// background entry point the table does not name fails as well, so a second
|
||||||
// worker is protected by default rather than by someone remembering this file.
|
// worker is protected by default rather than by someone remembering this file.
|
||||||
// The Dockerfile's last stage runs `make build`, so it is enforced in CI.
|
// Dockerfile:42 runs `make build`, so it is enforced in CI.
|
||||||
|
|
||||||
// The build receipt: every file this build emits, with its sha256 and whether
|
// The build receipt: every file this build emits, with its sha256 and whether
|
||||||
// it is one of the audited bundles. script/verify-build is handed this and
|
// it is one of the audited bundles. script/verify-build is handed this and
|
||||||
@@ -450,10 +441,6 @@ function getBuildInfo() {
|
|||||||
async function build() {
|
async function build() {
|
||||||
console.log("Building AutistMask extension...");
|
console.log("Building AutistMask extension...");
|
||||||
|
|
||||||
// Under make dev this runs once per rebuild in the same process, and each
|
|
||||||
// build accounts only for what it emits itself.
|
|
||||||
emittedFiles.length = 0;
|
|
||||||
|
|
||||||
const receiptPath = receiptTarget();
|
const receiptPath = receiptTarget();
|
||||||
if (!receiptPath) {
|
if (!receiptPath) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -610,94 +597,27 @@ async function build() {
|
|||||||
console.log("Build complete: dist/chrome/ and dist/firefox/");
|
console.log("Build complete: dist/chrome/ and dist/firefox/");
|
||||||
}
|
}
|
||||||
|
|
||||||
// make dev: build, then build again after every change under `dirs`, until
|
|
||||||
// interrupted. A failed build is reported and watching carries on, so a
|
|
||||||
// half-finished edit does not end it. A change that arrives while a build is
|
|
||||||
// running is not lost: it causes one more build as soon as that one finishes.
|
|
||||||
// A directory created after this starts is not watched until a restart.
|
|
||||||
//
|
|
||||||
// make dev sets no AUTISTMASK_BUILD_RECEIPT, so these builds write no receipt
|
|
||||||
// and nothing can verify what they leave in dist/.
|
|
||||||
//
|
|
||||||
// `rebuild` is build() everywhere but tests/buildWatch.test.js, which also
|
|
||||||
// closes the returned watchers.
|
|
||||||
function watch(dirs, rebuild) {
|
|
||||||
let building = false;
|
|
||||||
let changedAgain = false;
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
if (building) {
|
|
||||||
changedAgain = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
building = true;
|
|
||||||
do {
|
|
||||||
// Let the rest of one save's events arrive first, so that one
|
|
||||||
// save is one build.
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
changedAgain = false;
|
|
||||||
try {
|
|
||||||
await rebuild();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
`Build failed: ${err && err.message ? err.message : err}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} while (changedAgain);
|
|
||||||
building = false;
|
|
||||||
console.log("Watching for changes (Ctrl-C to stop)...");
|
|
||||||
}
|
|
||||||
|
|
||||||
// One watcher per directory rather than fs.watch's recursive option: on
|
|
||||||
// Linux, Node's recursive watch watches each file, and stops seeing one
|
|
||||||
// that an editor saves by renaming a new copy over it. A directory's
|
|
||||||
// watcher reports every change to the files in it, however they are saved.
|
|
||||||
const subdirectories = dirs.flatMap((dir) =>
|
|
||||||
fs
|
|
||||||
.readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
||||||
.filter((entry) => entry.isDirectory())
|
|
||||||
.map((entry) => path.join(entry.parentPath, entry.name)),
|
|
||||||
);
|
|
||||||
const watchers = [...dirs, ...subdirectories].map((dir) =>
|
|
||||||
fs.watch(dir, run),
|
|
||||||
);
|
|
||||||
run();
|
|
||||||
return watchers;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run only as a program. Required as a module — which is how
|
// Run only as a program. Required as a module — which is how
|
||||||
// tests/buildForbiddenInputs.test.js and tests/buildWatch.test.js reach the
|
// tests/buildForbiddenInputs.test.js reaches the checks below — this file
|
||||||
// functions below — this file builds nothing and writes nothing.
|
// builds nothing and writes nothing.
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
const args = process.argv.slice(2);
|
build().catch((err) => {
|
||||||
// An argument this file does not know fails rather than being ignored.
|
console.error(
|
||||||
if (args.length > 1 || (args.length === 1 && args[0] !== "--watch")) {
|
`Build failed: ${err && err.message ? err.message : err}`,
|
||||||
console.error("usage: node build.js [--watch]");
|
);
|
||||||
process.exit(2);
|
process.exit(1);
|
||||||
}
|
});
|
||||||
if (args[0] === "--watch") {
|
|
||||||
watch(WATCHED_DIRS, build);
|
|
||||||
} else {
|
|
||||||
build().catch((err) => {
|
|
||||||
console.error(
|
|
||||||
`Build failed: ${err && err.message ? err.message : err}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exported for tests only: watch() for tests/buildWatch.test.js, the rest for
|
// Exported for tests/buildForbiddenInputs.test.js only. The prohibition these
|
||||||
// tests/buildForbiddenInputs.test.js. The prohibition those enforce is the
|
// three functions enforce is the guarantee behind
|
||||||
// guarantee behind https://git.eeqj.de/sneak/AutistMask/issues/324, and
|
// https://git.eeqj.de/sneak/AutistMask/issues/324, and `make check` does not
|
||||||
// `make check` does not run `make build` — so they are unit tested against
|
// run `make build` — so they are unit tested against synthetic metafiles
|
||||||
// synthetic metafiles rather than being exercised only by CI, where "it ran"
|
// rather than being exercised only by CI, where "it ran" is not "it works".
|
||||||
// is not "it works".
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
importChain,
|
importChain,
|
||||||
newForbiddenRecord,
|
newForbiddenRecord,
|
||||||
recordBundledInputs,
|
recordBundledInputs,
|
||||||
assertNoForbiddenInputs,
|
assertNoForbiddenInputs,
|
||||||
assertForbiddenTableCovered,
|
assertForbiddenTableCovered,
|
||||||
watch,
|
|
||||||
};
|
};
|
||||||
|
|||||||
+5
-15
@@ -240,9 +240,7 @@ screen. Tokens can also be added from Settings, under "Tracked Tokens".
|
|||||||
2. Select what to send (ETH, or any ERC-20 token with a balance on this address
|
2. Select what to send (ETH, or any ERC-20 token with a balance on this address
|
||||||
that survives the spam filters).
|
that survives the spam filters).
|
||||||
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
|
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
|
||||||
4. Enter the amount, or click "Max" to fill it in: a token's balance, cut to 18
|
4. Enter the amount.
|
||||||
decimal places, or your ETH balance minus the amount reserved for the network
|
|
||||||
fee.
|
|
||||||
5. Click "Review" to see the confirmation screen.
|
5. Click "Review" to see the confirmation screen.
|
||||||
|
|
||||||
The confirmation screen shows:
|
The confirmation screen shows:
|
||||||
@@ -287,13 +285,11 @@ not appear and may be permanently lost.
|
|||||||
AutistMask injects a standard `window.ethereum` provider (EIP-1193) into web
|
AutistMask injects a standard `window.ethereum` provider (EIP-1193) into web
|
||||||
pages. When a site requests access to your wallet:
|
pages. When a site requests access to your wallet:
|
||||||
|
|
||||||
1. A popup appears showing the site's origin (its scheme, host and port, for
|
1. A popup appears showing the site's hostname and the address that will be
|
||||||
example `https://app.example`) and the address that will be shared.
|
shared.
|
||||||
2. Click "Allow" to connect or "Deny" to reject.
|
2. Click "Allow" to connect or "Deny" to reject.
|
||||||
3. Optionally check "Remember my choice for this site" to skip the prompt next
|
3. Optionally check "Remember my choice for this site" to skip the prompt next
|
||||||
time. The choice applies to that exact origin only: a choice remembered for
|
time.
|
||||||
`https://app.example` does not cover `http://app.example` or another port of
|
|
||||||
the same host, which ask again.
|
|
||||||
|
|
||||||
When a connected site requests a transaction, a separate approval popup appears
|
When a connected site requests a transaction, a separate approval popup appears
|
||||||
showing the transaction details (from, to, value, data, network fee, network and
|
showing the transaction details (from, to, value, data, network fee, network and
|
||||||
@@ -335,13 +331,7 @@ it is hidden from your transaction history and from the send token list.
|
|||||||
from transaction history and the send token list, and are left out of your
|
from transaction history and the send token list, and are left out of your
|
||||||
balances unless they are on the bundled known-token list or you added them
|
balances unless they are on the bundled known-token list or you added them
|
||||||
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
|
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
|
||||||
for address poisoning typically have zero. When the explorer reports no holder
|
for address poisoning typically have zero.
|
||||||
count for a token, or reports something other than a whole number in plain
|
|
||||||
digits (such as "1,000"), the count is unknown. An unknown count does not hide a
|
|
||||||
token from your transaction history or the send token list, and it does not get
|
|
||||||
a token into your balances either: such a token is listed only if it is on the
|
|
||||||
bundled known-token list or you added it yourself. The screen you reach by
|
|
||||||
clicking a token balance shows a "Holders:" line only when the count is known.
|
|
||||||
|
|
||||||
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
|
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
|
||||||
adds the contract address to a local blocklist. Future transactions from that
|
adds the contract address to a local blocklist. Future transactions from that
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
# Releasing AutistMask
|
|
||||||
|
|
||||||
This is the procedure that turns a green `main` into a tagged, packaged release.
|
|
||||||
It gathers into one place what is otherwise spread across the `Makefile` and
|
|
||||||
three `README.md` sections, so the person cutting a release does not have to
|
|
||||||
reconstruct the order from them.
|
|
||||||
|
|
||||||
There is one version, declared in three files (`package.json`,
|
|
||||||
`manifest/chrome.json`, `manifest/firefox.json`), and `make package` builds and
|
|
||||||
packages but publishes nothing. `make build` and `make package` can be run by
|
|
||||||
anyone; tagging, signing, packing a CRX and any upload need credentials only the
|
|
||||||
owner ([@sneak](https://sneak.berlin)) holds and are marked **owner-only**
|
|
||||||
below. Releases are tagged from `main` (see the Workflow section of `TODO.md`),
|
|
||||||
so the "release commit" throughout is the `main` commit the milestone PR merged.
|
|
||||||
|
|
||||||
## Procedure
|
|
||||||
|
|
||||||
1. **Confirm `main` is green in CI.** The `check` workflow
|
|
||||||
(`.gitea/workflows/check.yml`) runs `script/cibuild`, which runs
|
|
||||||
`script/check` and then builds the image uncached, so a green `check` run is
|
|
||||||
a green `make check`. Find the run for the exact release commit on the
|
|
||||||
tracker's Actions view. _Check:_ that commit's `check` run succeeded; running
|
|
||||||
`make check` on a clean checkout of the commit reproduces it and exits 0.
|
|
||||||
|
|
||||||
2. **Confirm the version matches the intended tag.** `package.json`,
|
|
||||||
`manifest/chrome.json` and `manifest/firefox.json` must all declare the same
|
|
||||||
`X.Y.Z`. `make build` fails when they disagree, but nothing checks that they
|
|
||||||
equal the tag you mean to create — that is this manual step. _Check:_ all
|
|
||||||
three files read the same `X.Y.Z`, and it is the version you intend to tag
|
|
||||||
`vX.Y.Z`.
|
|
||||||
|
|
||||||
3. **Build and package from a clean checkout of that commit.** From a fresh
|
|
||||||
clone, or a working tree with no local modifications (`git status` clean),
|
|
||||||
checked out at the release commit: run `make setup`, then `make package`.
|
|
||||||
`make package` runs `make build` first, so the archives can only be made from
|
|
||||||
a `dist/` verified against that build's own receipt as a release (not debug)
|
|
||||||
build. It writes three files into `release/`:
|
|
||||||
`autistmask-chrome-<version>.zip`, `autistmask-firefox-<version>.xpi`, and
|
|
||||||
`SHA256SUMS`. _Check:_ those three files exist and `<version>` in the archive
|
|
||||||
names is the version confirmed in step 2. The Firefox `.xpi` is **unsigned**
|
|
||||||
(see step 6 and "Installing on Firefox" in `README.md`).
|
|
||||||
|
|
||||||
4. **Verify `SHA256SUMS`.** The archives are deterministic — sorted entries,
|
|
||||||
fixed timestamps, fixed compression — so a second `make package` from another
|
|
||||||
clean checkout of the same commit produces byte-identical files. Verify the
|
|
||||||
recorded digests against the files with `sha256sum -c SHA256SUMS`, run from
|
|
||||||
`release/`. To confirm reproducibility, run `make package` again on a
|
|
||||||
separate clean checkout and compare the digests. _Check:_ `sha256sum -c`
|
|
||||||
reports `OK` for every file, and an independent build's digests match.
|
|
||||||
|
|
||||||
5. **Tag the release commit.** _(owner-only)_ Create an annotated tag `vX.Y.Z`
|
|
||||||
on the release commit and push it: `git tag -a vX.Y.Z` (with a message), then
|
|
||||||
`git push origin vX.Y.Z`. _Check:_ `git tag` lists `vX.Y.Z`, and
|
|
||||||
`git rev-parse vX.Y.Z^{commit}` resolves to the release commit.
|
|
||||||
|
|
||||||
6. **Distribute per browser.** _(owner-only; pending the owner's choice on
|
|
||||||
https://git.eeqj.de/sneak/AutistMask/issues/386)_ How 1.0.0 is distributed on
|
|
||||||
each browser is not yet decided; it is the open question on that issue, and
|
|
||||||
the concrete steps cannot be written until the owner records a choice there.
|
|
||||||
These steps need credentials only the owner holds. The options under
|
|
||||||
consideration are:
|
|
||||||
- **Firefox** — the packaged `.xpi` is unsigned, and release Firefox and ESR
|
|
||||||
refuse an unsigned add-on:
|
|
||||||
- (a) AMO self-distribution signing (unlisted): submit the `.xpi` to AMO
|
|
||||||
with the owner's credentials; AMO returns a signed `.xpi` installable
|
|
||||||
on every Firefox, with nothing listed publicly.
|
|
||||||
- (b) AMO listed: as (a), plus a public AMO listing and review.
|
|
||||||
- (c) Ship the unsigned `.xpi` and state that Firefox support means
|
|
||||||
Developer Edition, Nightly, or an Unbranded build with
|
|
||||||
`xpinstall.signatures.required` set to `false`.
|
|
||||||
- **Chrome** — the repo packs no CRX and publishes nothing; the extension id
|
|
||||||
is fixed by the `key` in `manifest/chrome.json`:
|
|
||||||
- (a) Chrome Web Store (unlisted): upload the `.zip` with the owner's
|
|
||||||
developer account; the store delivers installs and updates.
|
|
||||||
- (b) Self-hosted CRX signed with the private key the owner holds
|
|
||||||
(`chrome --pack-extension=dist/chrome --pack-extension-key=<path to the .pem>`),
|
|
||||||
installable only via enterprise policy on Windows and macOS, so
|
|
||||||
realistically Linux-only.
|
|
||||||
- (c) "Load unpacked" from `dist/chrome/` only, as today.
|
|
||||||
|
|
||||||
Once the owner decides, the chosen steps — including which credentials they
|
|
||||||
need and who holds them — are written into this section and `README.md`'s
|
|
||||||
installation sections are updated to match, which is part of the definition
|
|
||||||
of done of https://git.eeqj.de/sneak/AutistMask/issues/386. _Check:_ for a
|
|
||||||
store or AMO route, the artifact installs from the store or AMO on a clean
|
|
||||||
browser profile; for the CRX or unpacked route, the documented load succeeds
|
|
||||||
and Chrome reports the extension id `gipbhkogfopeahplcjhipkgpcimdpkip`.
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"permissions": ["storage", "activeTab", "alarms"],
|
"permissions": ["storage", "activeTab", "alarms"],
|
||||||
"host_permissions": ["<all_urls>"],
|
"host_permissions": ["<all_urls>"],
|
||||||
"content_security_policy": {
|
"content_security_policy": {
|
||||||
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'"
|
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'"
|
||||||
},
|
},
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon16.png",
|
"16": "icons/icon16.png",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Minimal Ethereum wallet for Firefox",
|
"description": "Minimal Ethereum wallet for Firefox",
|
||||||
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
||||||
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
|
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon16.png",
|
"16": "icons/icon16.png",
|
||||||
"32": "icons/icon32.png",
|
"32": "icons/icon32.png",
|
||||||
|
|||||||
+4
-4
@@ -6,8 +6,8 @@
|
|||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest --forceExit --maxWorkers=3",
|
"test": "jest --forceExit",
|
||||||
"test:verbose": "jest --forceExit --maxWorkers=3 --verbose",
|
"test:verbose": "jest --forceExit --verbose",
|
||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
"lint": "eslint . && prettier --check .",
|
"lint": "eslint . && prettier --check .",
|
||||||
"fmt": "prettier --write .",
|
"fmt": "prettier --write .",
|
||||||
@@ -15,14 +15,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@tailwindcss/cli": "4.3.1",
|
"@tailwindcss/cli": "^4.2.1",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"eslint": "10.8.1",
|
"eslint": "10.8.1",
|
||||||
"globals": "17.11.0",
|
"globals": "17.11.0",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"playwright-core": "1.56.0",
|
"playwright-core": "1.56.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "4.3.1"
|
"tailwindcss": "^4.2.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ethereum-blockies-base64": "^1.0.2",
|
"ethereum-blockies-base64": "^1.0.2",
|
||||||
|
|||||||
+4
-4
@@ -1,14 +1,14 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
# script/check: run all checks (test, test-verify-build, lint, fmt-check).
|
||||||
# extension to scripts-to-rule-them-all. test and lint are Docker
|
# Our own extension to scripts-to-rule-them-all. Must not modify any files.
|
||||||
# phases; fmt-check is native, because a formatter writes the working
|
|
||||||
# tree. Must not modify any files.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
"$SCRIPT_DIR/test"
|
"$SCRIPT_DIR/test"
|
||||||
|
"$SCRIPT_DIR/test-verify-build"
|
||||||
|
"$SCRIPT_DIR/check-censored"
|
||||||
"$SCRIPT_DIR/lint"
|
"$SCRIPT_DIR/lint"
|
||||||
"$SCRIPT_DIR/fmt-check"
|
"$SCRIPT_DIR/fmt-check"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/check-censored: assert that the competitor name RULES.md bars appears
|
# script/check-censored: assert that the competitor name RULES.md bars appears
|
||||||
# nowhere in this repo, and nowhere in the built extension, except where it is
|
# nowhere in this repo, and nowhere in the built extension, except where it is
|
||||||
# deliberate. Our own extension to scripts-to-rule-them-all, run by the
|
# deliberate. Our own extension to scripts-to-rule-them-all, run from
|
||||||
# Dockerfile's lint phase and by make build.
|
# script/check and from make build.
|
||||||
#
|
#
|
||||||
# Where the name is allowed, and why each one is not negotiable away:
|
# Where the name is allowed, and why each one is not negotiable away:
|
||||||
#
|
#
|
||||||
|
|||||||
+4
-19
@@ -1,28 +1,13 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/cibuild: run the CI build. It bootstraps first: a CI runner
|
# script/cibuild: run the CI build. The Dockerfile runs make check, so
|
||||||
# checks out and runs this and nothing else, and script/fmt-check runs
|
# a successful build implies all checks pass.
|
||||||
# the formatter on the host, which a pristine checkout cannot do.
|
|
||||||
# --no-cache for the same reason as script/docker: the gate phases the
|
|
||||||
# final stage depends on are RUN steps, and a cached one is a check that
|
|
||||||
# did not run.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
"$SCRIPT_DIR/bootstrap"
|
docker build .
|
||||||
"$SCRIPT_DIR/check"
|
|
||||||
# Own line: a failing command substitution inside an argument does
|
|
||||||
# not trip `set -e`, so the inline form degrades silently to an
|
|
||||||
# empty constant. The VERSION build argument takes precedence over
|
|
||||||
# the version a build stage derives from the .git in the context.
|
|
||||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
|
||||||
[ -n "$version" ] || version="unknown"
|
|
||||||
docker build --no-cache \
|
|
||||||
--build-arg VERSION="$version" \
|
|
||||||
-t "$("$SCRIPT_DIR/projectname")" .
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+1
-11
@@ -1,8 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/docker: build the Docker image tagged with the project name.
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
# Identical in all repos; the tag comes from script/projectname.
|
# Identical in all repos; the tag comes from script/projectname.
|
||||||
# --no-cache because the gate phases the final stage depends on are RUN
|
|
||||||
# steps, and a cached one is a check that did not run.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -10,15 +8,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
# Own line: a failing command substitution inside an argument does
|
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
||||||
# not trip `set -e`, so the inline form degrades silently to an
|
|
||||||
# empty constant. The VERSION build argument takes precedence over
|
|
||||||
# the version a build stage derives from the .git in the context.
|
|
||||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
|
||||||
[ -n "$version" ] || version="unknown"
|
|
||||||
docker build --no-cache \
|
|
||||||
--build-arg VERSION="$version" \
|
|
||||||
-t "$("$SCRIPT_DIR/projectname")" .
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+1
-20
@@ -4,29 +4,10 @@ set -eu
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
# Must match the pin in script/bootstrap.
|
|
||||||
NODE_VERSION="22.17.0"
|
|
||||||
|
|
||||||
# script/bootstrap installs node and yarn under nvm and leaves neither
|
|
||||||
# on the PATH of the shell that called it, so resolve the pinned
|
|
||||||
# toolchain here the way bootstrap's own install step does. nvm is a
|
|
||||||
# bash script, hence the subshell.
|
|
||||||
run_yarn() {
|
|
||||||
if command -v yarn >/dev/null 2>&1; then
|
|
||||||
exec yarn "$@"
|
|
||||||
fi
|
|
||||||
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
|
||||||
echo "fmt: no yarn; run script/bootstrap first" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
|
|
||||||
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
echo "Formatting..."
|
echo "Formatting..."
|
||||||
run_yarn run fmt
|
yarn run fmt 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+1
-20
@@ -5,29 +5,10 @@ set -eu
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
# Must match the pin in script/bootstrap.
|
|
||||||
NODE_VERSION="22.17.0"
|
|
||||||
|
|
||||||
# script/bootstrap installs node and yarn under nvm and leaves neither
|
|
||||||
# on the PATH of the shell that called it, so resolve the pinned
|
|
||||||
# toolchain here the way bootstrap's own install step does. nvm is a
|
|
||||||
# bash script, hence the subshell.
|
|
||||||
run_yarn() {
|
|
||||||
if command -v yarn >/dev/null 2>&1; then
|
|
||||||
exec yarn "$@"
|
|
||||||
fi
|
|
||||||
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
|
|
||||||
echo "fmt-check: no yarn; run script/bootstrap first" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
|
|
||||||
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
echo "Checking formatting..."
|
echo "Checking formatting..."
|
||||||
run_yarn run fmt-check
|
yarn run fmt-check 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
// the build when esbuild's own metafile reports src/shared/state.js as an input
|
// the build when esbuild's own metafile reports src/shared/state.js as an input
|
||||||
// of a background bundle. That consults the resolution esbuild actually
|
// of a background bundle. That consults the resolution esbuild actually
|
||||||
// performed, so no specifier syntax and no resolution rule can slip past it,
|
// performed, so no specifier syntax and no resolution rule can slip past it,
|
||||||
// and the Dockerfile's last stage runs `make build` in CI.
|
// and Dockerfile:42 runs `make build` in CI.
|
||||||
//
|
//
|
||||||
// What this rule is: fast local feedback, in the editor and in `make lint`,
|
// What this rule is: fast local feedback, in the editor and in `make lint`,
|
||||||
// before a full bundle. It reads sources from disk and matches import
|
// before a full bundle. It reads sources from disk and matches import
|
||||||
|
|||||||
+41
-13
@@ -1,23 +1,51 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/lint: run the linter. Linting is a phase of the Dockerfile and
|
# script/lint: run the linter (eslint, then prettier --check).
|
||||||
# this builds that phase alone; the linter is never installed or run on
|
|
||||||
# a developer host, where a shared result cache and a host-global lock
|
|
||||||
# make its answer untrustworthy.
|
|
||||||
#
|
#
|
||||||
# The phase is not the last stage in the file, so it is built only when
|
# Linting is containerized. ESLint results depend on the ESLint version, and
|
||||||
# --target names it. --no-cache because a cached lint layer is a lint
|
# the pinned one is the one in the image; a host's own install must not be
|
||||||
# that did not run. The tag makes each build replace the previous image
|
# able to decide whether this repo is green. From a host this therefore builds
|
||||||
# instead of leaving a dangling one behind.
|
# the Dockerfile's `lint` stage, which runs this same script inside the image.
|
||||||
|
#
|
||||||
|
# AUTISTMASK_LINT_NATIVE is set only in that image (see the Dockerfile) and is
|
||||||
|
# what stops the recursion, so `make check` inside the CI build lints in place
|
||||||
|
# instead of trying to reach a docker daemon it does not have.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build --no-cache \
|
|
||||||
--target lint \
|
case "${AUTISTMASK_LINT_NATIVE:-}" in
|
||||||
-t "$("$SCRIPT_DIR/projectname")-lint" .
|
1)
|
||||||
|
echo "Linting..."
|
||||||
|
yarn run lint 2>&1
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
"") ;;
|
||||||
|
*)
|
||||||
|
# Set but not recognized: say so rather than silently taking the
|
||||||
|
# docker path, which would look like the variable had no effect.
|
||||||
|
echo "lint: AUTISTMASK_LINT_NATIVE is set to" \
|
||||||
|
"'${AUTISTMASK_LINT_NATIVE}'; the only recognized value is 1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "lint: docker is required; linting does not run on the host" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Linting in the pinned container..."
|
||||||
|
# --progress=plain: the default progress renderer collapses the lint
|
||||||
|
# output on success, and a lint run whose output cannot be seen is not
|
||||||
|
# evidence that it ran.
|
||||||
|
#
|
||||||
|
# --output=type=cacheonly: the exit status is the whole result; exporting
|
||||||
|
# an image afterwards costs about ten times the lint itself.
|
||||||
|
docker build --progress=plain --target lint \
|
||||||
|
--output=type=cacheonly . 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+40
-10
@@ -1,19 +1,49 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/test: run the test suite. Testing is a phase of the Dockerfile
|
# script/test: run the test suite.
|
||||||
# and this builds that phase alone, on the same terms as script/lint:
|
#
|
||||||
# --target because a phase that is not the last stage is built only when
|
# The timeout bounds a hung suite; it is not a performance budget. On a
|
||||||
# named, --no-cache because a cached test layer is a test that did not
|
# developer host the suite finishes in about 8s and REPO_POLICIES' 30s cap is
|
||||||
# run, and a tag so each build replaces the previous image.
|
# the bound. Inside the image the same suite also pays a cold jest cache and
|
||||||
|
# shares the runner with the rest of the build, which is not what that budget
|
||||||
|
# describes, so the Dockerfile raises the bound through
|
||||||
|
# AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a cold cache
|
||||||
|
# produces a red that means nothing, and teaches "just run it again".
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
TIMEOUT="${AUTISTMASK_TEST_TIMEOUT:-30}"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build --no-cache \
|
echo "Running tests (timeout ${TIMEOUT}s)..."
|
||||||
--target test \
|
|
||||||
-t "$("$SCRIPT_DIR/projectname")-test" .
|
status=0
|
||||||
|
timeout "$TIMEOUT" yarn run test 2>&1 || status=$?
|
||||||
|
[ "$status" -eq 0 ] && return 0
|
||||||
|
|
||||||
|
# 124 is timeout(1) killing the suite. Say so: a kill is not a failed
|
||||||
|
# assertion, and the verbose rerun would only spend the same wall clock
|
||||||
|
# to be killed again.
|
||||||
|
if [ "$status" -eq 124 ]; then
|
||||||
|
echo "tests: TIMED OUT after ${TIMEOUT}s (no assertion failed)" >&2
|
||||||
|
echo "tests: raise AUTISTMASK_TEST_TIMEOUT if the suite is healthy" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 125 is timeout(1) itself failing, which here means AUTISTMASK_TEST_TIMEOUT
|
||||||
|
# is not a duration it accepts. The suite never ran, so it neither timed out
|
||||||
|
# nor failed, and the verbose rerun would only reprint the same complaint.
|
||||||
|
if [ "$status" -eq 125 ]; then
|
||||||
|
echo "tests: DID NOT RUN: timeout(1) rejected AUTISTMASK_TEST_TIMEOUT=\"${TIMEOUT}\"" >&2
|
||||||
|
echo "tests: set it to a duration such as 30 or 180 (see timeout(1))" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- Rerunning with --verbose for details ---"
|
||||||
|
timeout "$TIMEOUT" yarn run test:verbose 2>&1 || true
|
||||||
|
# Always fail: the first run already proved the tests are broken, so a
|
||||||
|
# flaky pass on the rerun must not turn the build green.
|
||||||
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+2
-5
@@ -4,7 +4,7 @@
|
|||||||
# scripts-to-rule-them-all.
|
# scripts-to-rule-them-all.
|
||||||
#
|
#
|
||||||
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md
|
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md
|
||||||
# caps make test at 60 seconds and a browser suite does not fit. Run it
|
# caps make test at 20 seconds and a browser suite does not fit. Run it
|
||||||
# yourself before touching popup views. ESLint's no-undef now catches a
|
# yourself before touching popup views. ESLint's no-undef now catches a
|
||||||
# used-but-not-imported identifier in make check, but only this suite sees
|
# used-but-not-imported identifier in make check, but only this suite sees
|
||||||
# what a view actually does when it runs.
|
# what a view actually does when it runs.
|
||||||
@@ -45,10 +45,7 @@ main() {
|
|||||||
trap 'cleanup; exit 130' INT TERM
|
trap 'cleanup; exit 130' INT TERM
|
||||||
|
|
||||||
echo "Building the Chrome e2e image (extension included)..."
|
echo "Building the Chrome e2e image (extension included)..."
|
||||||
# --no-cache: the image build runs make build and its checks, and a
|
docker build --iidfile "$IIDFILE" -t "$IMAGE" -f tests/e2e/Dockerfile .
|
||||||
# cached layer is a check that did not run.
|
|
||||||
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
|
|
||||||
-f tests/e2e/Dockerfile .
|
|
||||||
|
|
||||||
echo "Running e2e suite in the pinned Playwright container..."
|
echo "Running e2e suite in the pinned Playwright container..."
|
||||||
# The image is run by ID, not by tag: where two clones of this repo run
|
# The image is run by ID, not by tag: where two clones of this repo run
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# script/test-e2e. Our own extension to scripts-to-rule-them-all.
|
# script/test-e2e. Our own extension to scripts-to-rule-them-all.
|
||||||
#
|
#
|
||||||
# Deliberately NOT called by script/check or script/test, for the same
|
# Deliberately NOT called by script/check or script/test, for the same
|
||||||
# reason as the Chrome suite: REPO_POLICIES.md caps make test at 60 seconds
|
# reason as the Chrome suite: REPO_POLICIES.md caps make test at 20 seconds
|
||||||
# and a browser suite does not fit. .gitea/workflows/e2e.yml also runs it
|
# and a browser suite does not fit. .gitea/workflows/e2e.yml also runs it
|
||||||
# on every push, in a job separate from check.
|
# on every push, in a job separate from check.
|
||||||
#
|
#
|
||||||
@@ -44,9 +44,7 @@ main() {
|
|||||||
trap 'cleanup; exit 130' INT TERM
|
trap 'cleanup; exit 130' INT TERM
|
||||||
|
|
||||||
echo "Building the pinned Firefox e2e image (extension included)..."
|
echo "Building the pinned Firefox e2e image (extension included)..."
|
||||||
# --no-cache: the image build runs make build and its checks, and a
|
docker build --iidfile "$IIDFILE" -t "$IMAGE" \
|
||||||
# cached layer is a check that did not run.
|
|
||||||
docker build --no-cache --iidfile "$IIDFILE" -t "$IMAGE" \
|
|
||||||
-f tests/e2e/firefox/Dockerfile .
|
-f tests/e2e/firefox/Dockerfile .
|
||||||
|
|
||||||
echo "Running the Firefox e2e suite..."
|
echo "Running the Firefox e2e suite..."
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
# script/test-verify-build: exercise every failure mode of
|
# script/test-verify-build: exercise every failure mode of
|
||||||
# script/verify-build, and what make build does with dist/ after one of them
|
# script/verify-build, and what make build does with dist/ after one of them
|
||||||
# (script/discard-dist-on-failure). Our own extension to
|
# (script/discard-dist-on-failure). Our own extension to
|
||||||
# scripts-to-rule-them-all, run by the Dockerfile's test phase so make check
|
# scripts-to-rule-them-all, run from script/check so make check covers it.
|
||||||
# covers it.
|
|
||||||
#
|
#
|
||||||
# Why this exists: verify-build is the build-integrity guard, and four separate
|
# Why this exists: verify-build is the build-integrity guard, and four separate
|
||||||
# reviews of it each found a fresh vacuous pass — the grep exit-2 conflation,
|
# reviews of it each found a fresh vacuous pass — the grep exit-2 conflation,
|
||||||
@@ -38,10 +37,6 @@ DISCARD_DIST="$ROOT/script/discard-dist-on-failure"
|
|||||||
MARKER_ON="autistmask-build-debug=on"
|
MARKER_ON="autistmask-build-debug=on"
|
||||||
MARKER_OFF="autistmask-build-debug=off"
|
MARKER_OFF="autistmask-build-debug=off"
|
||||||
|
|
||||||
# The same test recovery phrase verify-build searches release bundles for. Held
|
|
||||||
# here too, the way the markers above are, so a case can plant it in a bundle.
|
|
||||||
TEST_MNEMONIC="cube evolve unfold result inch risk jealous skill hotel bulb night wreck"
|
|
||||||
|
|
||||||
RECEIPT_HEADER="autistmask-build-receipt v1"
|
RECEIPT_HEADER="autistmask-build-receipt v1"
|
||||||
|
|
||||||
NEWLINE='
|
NEWLINE='
|
||||||
@@ -521,24 +516,6 @@ c_debug_build() {
|
|||||||
write_receipt
|
write_receipt
|
||||||
}
|
}
|
||||||
|
|
||||||
# A release bundle that still carries the test recovery phrase — the regression
|
|
||||||
# verify-build guards against, and the reason DEBUG_MNEMONIC is behind the
|
|
||||||
# __BUILD_DEBUG__ define in src/shared/constants.js. The receipt is regenerated
|
|
||||||
# so the phrase is caught as bundle content, not incidentally as a stale digest.
|
|
||||||
c_release_bundle_with_mnemonic() {
|
|
||||||
printf '/* %s */\n' "$TEST_MNEMONIC" >>dist/chrome/src/popup/index.js
|
|
||||||
write_receipt
|
|
||||||
}
|
|
||||||
|
|
||||||
# The same phrase in a debug build is expected: make build-debug ships it on
|
|
||||||
# purpose, so the phrase check must stay quiet under --expect debug.
|
|
||||||
c_debug_bundle_with_mnemonic() {
|
|
||||||
write_bundle dist/chrome/src/popup/index.js "$MARKER_ON"
|
|
||||||
write_bundle dist/firefox/src/popup/index.js "$MARKER_ON"
|
|
||||||
printf '/* %s */\n' "$TEST_MNEMONIC" >>dist/chrome/src/popup/index.js
|
|
||||||
write_receipt
|
|
||||||
}
|
|
||||||
|
|
||||||
c_no_dist() { rm -rf dist; }
|
c_no_dist() { rm -rf dist; }
|
||||||
|
|
||||||
# --- dist discard -----------------------------------------------------------
|
# --- dist discard -----------------------------------------------------------
|
||||||
@@ -776,13 +753,6 @@ run_cases() {
|
|||||||
check_case "debug bundles under --expect debug pass" \
|
check_case "debug bundles under --expect debug pass" \
|
||||||
no debug 0 "2 bundle(s) $MARKER_ON" c_debug_build
|
no debug 0 "2 bundle(s) $MARKER_ON" c_debug_build
|
||||||
|
|
||||||
check_case "release bundle carrying the test recovery phrase fails" \
|
|
||||||
no release 1 \
|
|
||||||
"carries the BIP-39 test recovery phrase" c_release_bundle_with_mnemonic
|
|
||||||
|
|
||||||
check_case "debug bundle carrying the test recovery phrase passes" \
|
|
||||||
no debug 0 "2 bundle(s) $MARKER_ON" c_debug_bundle_with_mnemonic
|
|
||||||
|
|
||||||
check_case "no --expect argument" \
|
check_case "no --expect argument" \
|
||||||
no no-expect 1 "no --expect argument." c_control
|
no no-expect 1 "no --expect argument." c_control
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,6 @@
|
|||||||
# fallback branch; the property only exists in the emitted output, so it has to
|
# fallback branch; the property only exists in the emitted output, so it has to
|
||||||
# be asserted against the emitted output.
|
# be asserted against the emitted output.
|
||||||
#
|
#
|
||||||
# The DEBUG half also checks the phrase directly: a release build must not carry
|
|
||||||
# the test recovery phrase in any emitted file. The phrase is behind the
|
|
||||||
# __BUILD_DEBUG__ define in src/shared/constants.js and folds away in a release
|
|
||||||
# build, but the marker only proves DEBUG compiled off, not that the fold
|
|
||||||
# removed the string; the phrase grep is the assertion that it did.
|
|
||||||
#
|
|
||||||
# Which mode to expect is an ARGUMENT (--expect release|debug) and is never
|
# Which mode to expect is an ARGUMENT (--expect release|debug) and is never
|
||||||
# taken from this script's environment. It used to be read from
|
# taken from this script's environment. It used to be read from
|
||||||
# AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1
|
# AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1
|
||||||
@@ -73,15 +67,6 @@ TAB=' '
|
|||||||
MARKER_ON="autistmask-build-debug=on"
|
MARKER_ON="autistmask-build-debug=on"
|
||||||
MARKER_OFF="autistmask-build-debug=off"
|
MARKER_OFF="autistmask-build-debug=off"
|
||||||
|
|
||||||
# The 12-word BIP-39 test recovery phrase from src/shared/constants.js. It is
|
|
||||||
# behind the __BUILD_DEBUG__ define there, so a release build folds it out of
|
|
||||||
# every bundle; this is the assertion that it stayed out. The phrase is a
|
|
||||||
# publicly committed test value rather than a secret, but a BIP-39 phrase in a
|
|
||||||
# distributed wallet artifact is exactly the string a scanner or auditor has to
|
|
||||||
# stop and reason about, so a release build must not ship it. A debug build
|
|
||||||
# ships it on purpose, so this is checked only when release is expected.
|
|
||||||
TEST_MNEMONIC="cube evolve unfold result inch risk jealous skill hotel bulb night wreck"
|
|
||||||
|
|
||||||
RECEIPT_HEADER="autistmask-build-receipt v1"
|
RECEIPT_HEADER="autistmask-build-receipt v1"
|
||||||
|
|
||||||
# Set by the arguments.
|
# Set by the arguments.
|
||||||
@@ -298,18 +283,6 @@ check_entry() {
|
|||||||
receipt records $ENTRY_HASH and the file on disk is $SHA. Something wrote
|
receipt records $ENTRY_HASH and the file on disk is $SHA. Something wrote
|
||||||
to dist/ after the build, so this artifact is not the one that was built."
|
to dist/ after the build, so this artifact is not the one that was built."
|
||||||
|
|
||||||
# No emitted file of a release build may carry the test recovery phrase.
|
|
||||||
# Checked on every file, not only the audited bundles, so a copy that
|
|
||||||
# reached some other emitted file fails here too. A debug build ships the
|
|
||||||
# phrase deliberately, so this runs only when release was expected.
|
|
||||||
if [ "$EXPECT" = "$MARKER_OFF" ] && has_marker "$TEST_MNEMONIC" "$ENTRY_PATH"; then
|
|
||||||
fail "$ENTRY_PATH carries the BIP-39 test recovery phrase, which a
|
|
||||||
release build must fold out. The __BUILD_DEBUG__ define in build.js is what
|
|
||||||
drops it from src/shared/constants.js; check that DEBUG_MNEMONIC is still
|
|
||||||
behind that flag. A recovery phrase in a distributed bundle is exactly the
|
|
||||||
string an auditor or scanner has to stop on, so this is a hard failure."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$ENTRY_FLAG" = A ]; then
|
if [ "$ENTRY_FLAG" = A ]; then
|
||||||
read_marker "$ENTRY_PATH"
|
read_marker "$ENTRY_PATH"
|
||||||
[ "$MARKER" = "$EXPECT" ] ||
|
[ "$MARKER" = "$EXPECT" ] ||
|
||||||
|
|||||||
+69
-230
@@ -57,13 +57,9 @@ const windowsNs = windowsApi();
|
|||||||
const actionNs = actionApi();
|
const actionNs = actionApi();
|
||||||
|
|
||||||
// Connected sites (in-memory, non-persisted): { "origin:address": true }
|
// Connected sites (in-memory, non-persisted): { "origin:address": true }
|
||||||
//
|
|
||||||
// A site is its full origin (scheme://host[:port]), here and in the
|
|
||||||
// remembered allowedSites/deniedSites lists alike: a grant to
|
|
||||||
// https://dapp.example says nothing about http://dapp.example or another port.
|
|
||||||
const connectedSites = {};
|
const connectedSites = {};
|
||||||
|
|
||||||
// Pending approval requests: { id: { origin, resolve } }
|
// Pending approval requests: { id: { origin, hostname, resolve } }
|
||||||
const pendingApprovals = {};
|
const pendingApprovals = {};
|
||||||
|
|
||||||
// One transaction approval at a time, wallet-wide.
|
// One transaction approval at a time, wallet-wide.
|
||||||
@@ -87,8 +83,7 @@ const pendingApprovals = {};
|
|||||||
// authority on a nonce the network has not accepted, which an abandoned
|
// authority on a nonce the network has not accepted, which an abandoned
|
||||||
// approval then leaves a hole in.
|
// approval then leaves a hole in.
|
||||||
//
|
//
|
||||||
// Sign approvals do not take this slot: a signature consumes no nonce. They are
|
// Sign approvals are not gated: a signature consumes no nonce.
|
||||||
// limited per site instead; see findPendingApproval().
|
|
||||||
//
|
//
|
||||||
// The slot is null when free, and otherwise the handle of the request holding
|
// The slot is null when free, and otherwise the handle of the request holding
|
||||||
// it. Once that request has raised its approval the handle carries the
|
// it. Once that request has raised its approval the handle carries the
|
||||||
@@ -100,7 +95,7 @@ let txApprovalSlot = null;
|
|||||||
|
|
||||||
// EIP-1474 "resource unavailable": the standard code for a request that is
|
// EIP-1474 "resource unavailable": the standard code for a request that is
|
||||||
// refused because another one is already pending.
|
// refused because another one is already pending.
|
||||||
const APPROVAL_PENDING_CODE = -32002;
|
const TX_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 either being prepared
|
||||||
@@ -137,22 +132,6 @@ function releaseTxApprovalSlotFor(approvalId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One site-connection approval and one sign approval per site at a time: a
|
|
||||||
// page that asks again before the user has answered is refused with the code
|
|
||||||
// above instead of opening another window, so it cannot bury the user in
|
|
||||||
// prompts. The pending approval itself holds the place, so a caller must test
|
|
||||||
// this and raise its approval with nothing awaited in between.
|
|
||||||
function findPendingApproval(origin, type) {
|
|
||||||
return Object.values(pendingApprovals).find(
|
|
||||||
(approval) => approval.origin === origin && approval.type === type,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const APPROVAL_PENDING_MESSAGE =
|
|
||||||
"AutistMask is already waiting for your answer to a request of this kind" +
|
|
||||||
" from this site, so this one was not shown. Please answer that one," +
|
|
||||||
" then send this one again.";
|
|
||||||
|
|
||||||
// Nonces this worker has already handed to the node, per chain and address.
|
// Nonces this worker has already handed to the node, per chain and address.
|
||||||
// This is the wallet's own knowledge that a nonce is spent, and it is checked
|
// This is the wallet's own knowledge that a nonce is spent, and it is checked
|
||||||
// before a broadcast rather than after: a node's pending count can lag a
|
// before a broadcast rather than after: a node's pending count can lag a
|
||||||
@@ -305,12 +284,7 @@ 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
|
|
||||||
// it opens the wallet. Set only by resetPopupUrl() and showInToolbarPopup().
|
|
||||||
let toolbarPopupApprovalId = null;
|
|
||||||
|
|
||||||
function resetPopupUrl() {
|
function resetPopupUrl() {
|
||||||
toolbarPopupApprovalId = null;
|
|
||||||
if (actionNs && typeof actionNs.setPopup === "function") {
|
if (actionNs && typeof actionNs.setPopup === "function") {
|
||||||
actionNs.setPopup({ popup: "src/popup/index.html" });
|
actionNs.setPopup({ popup: "src/popup/index.html" });
|
||||||
}
|
}
|
||||||
@@ -413,7 +387,7 @@ function releaseApproval(approval) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open approval in a separate popup window, unless it is no longer pending.
|
// Open approval in a separate popup window.
|
||||||
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
|
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
|
||||||
// not from a user gesture) and the fallback for site-connection approvals.
|
// not from a user gesture) and the fallback for site-connection approvals.
|
||||||
// Never rejects. Its callers raise it from inside a Promise executor and drop
|
// Never rejects. Its callers raise it from inside a Promise executor and drop
|
||||||
@@ -437,13 +411,7 @@ async function openApprovalWindow(id) {
|
|||||||
width: popupWidth,
|
width: popupWidth,
|
||||||
height: popupHeight,
|
height: popupHeight,
|
||||||
};
|
};
|
||||||
// Centred on a browser window only. The last focused window can be
|
if (currentWin) {
|
||||||
// another approval window still open, and centring on one can give a
|
|
||||||
// position the browser refuses ("Bounds must be at least 50% within
|
|
||||||
// visible screen space"): headless Chrome reports this 360x600 popup as
|
|
||||||
// 1280x720. The request then failed with no window at all. Over a popup,
|
|
||||||
// the browser picks the position.
|
|
||||||
if (currentWin && currentWin.type === "normal") {
|
|
||||||
opts.left = Math.round(
|
opts.left = Math.round(
|
||||||
currentWin.left + (currentWin.width - popupWidth) / 2,
|
currentWin.left + (currentWin.width - popupWidth) / 2,
|
||||||
);
|
);
|
||||||
@@ -452,32 +420,15 @@ async function openApprovalWindow(id) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already answered: a site-connection prompt decided before the toolbar
|
|
||||||
// popup raised for it had loaded, whose openPopup() rejects only now.
|
|
||||||
if (!pendingApprovals[id]) return;
|
|
||||||
|
|
||||||
let win = null;
|
let win = null;
|
||||||
try {
|
try {
|
||||||
win = await windowsCreate(opts);
|
win = await windowsCreate(opts);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// The promise namespace reports the failure by rejecting where the
|
// The promise namespace reports the failure by rejecting where the
|
||||||
// callback namespace reported it by handing back no window; both
|
// callback namespace reported it by handing back no window; both land
|
||||||
// leave win null.
|
// on the !win branch below, which settles the approval.
|
||||||
log.errorf("could not open the approval window:", e);
|
log.errorf("could not open the approval window:", e);
|
||||||
}
|
}
|
||||||
// The browser also refuses a centred position that is too far off screen,
|
|
||||||
// as it is over a browser window near the screen edge. Asked again
|
|
||||||
// without a position, it places the window itself. If that fails too,
|
|
||||||
// the !win branch below settles the approval.
|
|
||||||
if (!win && opts.left !== undefined) {
|
|
||||||
delete opts.left;
|
|
||||||
delete opts.top;
|
|
||||||
try {
|
|
||||||
win = await windowsCreate(opts);
|
|
||||||
} catch (e) {
|
|
||||||
log.errorf("could not open the approval window:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const approval = pendingApprovals[id];
|
const approval = pendingApprovals[id];
|
||||||
if (!approval) {
|
if (!approval) {
|
||||||
@@ -508,36 +459,29 @@ async function openApprovalWindow(id) {
|
|||||||
|
|
||||||
// Open an approval popup and return a promise that resolves with the user decision.
|
// Open an approval popup and return a promise that resolves with the user decision.
|
||||||
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
|
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
|
||||||
function requestApproval(origin) {
|
function requestApproval(origin, hostname) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = { id, origin, resolve, type: "site" };
|
pendingApprovals[id] = { id, origin, hostname, resolve };
|
||||||
|
|
||||||
if (actionNs && typeof actionNs.openPopup === "function") {
|
if (actionNs && typeof actionNs.openPopup === "function") {
|
||||||
showInToolbarPopup(id);
|
actionNs.setPopup({
|
||||||
|
popup: "src/popup/index.html?approval=" + id,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = actionNs.openPopup();
|
||||||
|
if (result && typeof result.catch === "function") {
|
||||||
|
result.catch(() => openApprovalWindow(id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
openApprovalWindow(id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
openApprovalWindow(id);
|
openApprovalWindow(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show a site-connection approval in the toolbar popup, or in a separate popup
|
|
||||||
// window when the browser will not open the toolbar popup.
|
|
||||||
function showInToolbarPopup(id) {
|
|
||||||
toolbarPopupApprovalId = id;
|
|
||||||
actionNs.setPopup({
|
|
||||||
popup: "src/popup/index.html?approval=" + id,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const result = actionNs.openPopup();
|
|
||||||
if (result && typeof result.catch === "function") {
|
|
||||||
result.catch(() => openApprovalWindow(id));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
openApprovalWindow(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open a tx-approval popup and return a promise that resolves with txHash or error.
|
// Open a tx-approval popup and return a promise that resolves with txHash or error.
|
||||||
// Uses windows.create() directly because tx approvals are triggered programmatically
|
// Uses windows.create() directly because tx approvals are triggered programmatically
|
||||||
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
||||||
@@ -551,12 +495,13 @@ function showInToolbarPopup(id) {
|
|||||||
// screen never named.
|
// screen never named.
|
||||||
// `slot` is the transaction-approval slot its caller holds. Handing the
|
// `slot` is the transaction-approval slot its caller holds. Handing the
|
||||||
// approval's id to it is what makes retiring the approval free the slot.
|
// approval's id to it is what makes retiring the approval free the slot.
|
||||||
function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
|
function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = {
|
pendingApprovals[id] = {
|
||||||
id,
|
id,
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
approvedTx,
|
approvedTx,
|
||||||
approvedFrom,
|
approvedFrom,
|
||||||
resolve,
|
resolve,
|
||||||
@@ -572,12 +517,13 @@ function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
|
|||||||
// Uses windows.create() directly because sign approvals are triggered programmatically
|
// Uses windows.create() directly because sign approvals are triggered programmatically
|
||||||
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
||||||
// unreliable in this context.
|
// unreliable in this context.
|
||||||
function requestSignApproval(origin, signParams, approvedFrom) {
|
function requestSignApproval(origin, hostname, signParams, approvedFrom) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = {
|
pendingApprovals[id] = {
|
||||||
id,
|
id,
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
signParams,
|
signParams,
|
||||||
approvedFrom,
|
approvedFrom,
|
||||||
resolve,
|
resolve,
|
||||||
@@ -655,11 +601,11 @@ runtime.onConnect.addListener((port) => {
|
|||||||
// in the worker — a balance refresh in flight, another site's approval — has
|
// in the worker — a balance refresh in flight, another site's approval — has
|
||||||
// gone on running the whole time. Loading here used to replace the very
|
// gone on running the whole time. Loading here used to replace the very
|
||||||
// objects that work was holding.
|
// objects that work was holding.
|
||||||
async function rememberSiteChoice(field, address, origin) {
|
async function rememberSiteChoice(field, address, hostname) {
|
||||||
await updateState((s) => {
|
await updateState((s) => {
|
||||||
if (!s[field][address]) s[field][address] = [];
|
if (!s[field][address]) s[field][address] = [];
|
||||||
if (!s[field][address].includes(origin)) {
|
if (!s[field][address].includes(hostname)) {
|
||||||
s[field][address].push(origin);
|
s[field][address].push(hostname);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -672,11 +618,12 @@ async function handleConnectionRequest(origin) {
|
|||||||
return { error: { message: "No accounts available" } };
|
return { error: { message: "No accounts available" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
const denied = s.deniedSites[activeAddress] || [];
|
const denied = s.deniedSites[activeAddress] || [];
|
||||||
|
|
||||||
// Check denied list
|
// Check denied list
|
||||||
if (denied.includes(origin)) {
|
if (denied.includes(hostname)) {
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
code: 4001,
|
code: 4001,
|
||||||
@@ -687,50 +634,25 @@ async function handleConnectionRequest(origin) {
|
|||||||
|
|
||||||
// Check allowed list or in-memory connected
|
// Check allowed list or in-memory connected
|
||||||
if (
|
if (
|
||||||
allowed.includes(origin) ||
|
allowed.includes(hostname) ||
|
||||||
connectedSites[origin + ":" + activeAddress]
|
connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { result: [activeAddress] };
|
return { result: [activeAddress] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = findPendingApproval(origin, "site");
|
|
||||||
if (pending) {
|
|
||||||
// A toolbar popup that closed before it connected leaves its prompt
|
|
||||||
// pending, and once the toolbar popup is set to open something else
|
|
||||||
// nothing shows that prompt: the site would be refused until the
|
|
||||||
// address changed. Show it again. A prompt in a window or in a
|
|
||||||
// connected popup is settled when that closes, and one the toolbar
|
|
||||||
// popup is still set to open is a click away, so those are left alone.
|
|
||||||
if (
|
|
||||||
actionNs &&
|
|
||||||
typeof actionNs.openPopup === "function" &&
|
|
||||||
!pending.windowId &&
|
|
||||||
!pending.portConnected &&
|
|
||||||
toolbarPopupApprovalId !== pending.id
|
|
||||||
) {
|
|
||||||
showInToolbarPopup(pending.id);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
code: APPROVAL_PENDING_CODE,
|
|
||||||
message: APPROVAL_PENDING_MESSAGE,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open approval popup
|
// Open approval popup
|
||||||
const decision = await requestApproval(origin);
|
const decision = await requestApproval(origin, hostname);
|
||||||
|
|
||||||
if (decision.approved) {
|
if (decision.approved) {
|
||||||
if (decision.remember) {
|
if (decision.remember) {
|
||||||
await rememberSiteChoice("allowedSites", activeAddress, origin);
|
await rememberSiteChoice("allowedSites", activeAddress, hostname);
|
||||||
} else {
|
} else {
|
||||||
connectedSites[origin + ":" + activeAddress] = true;
|
connectedSites[origin + ":" + activeAddress] = true;
|
||||||
}
|
}
|
||||||
return { result: [activeAddress] };
|
return { result: [activeAddress] };
|
||||||
} else {
|
} else {
|
||||||
if (decision.remember) {
|
if (decision.remember) {
|
||||||
await rememberSiteChoice("deniedSites", activeAddress, origin);
|
await rememberSiteChoice("deniedSites", activeAddress, hostname);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
@@ -741,12 +663,11 @@ async function handleConnectionRequest(origin) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Methods that are safe to proxy directly to the RPC node. A method handleRpc
|
// Methods that are safe to proxy directly to the RPC node
|
||||||
// answers before its proxy branch does not belong here: it would never reach
|
|
||||||
// the node. tests/proxyMethods.test.js sends every one of these.
|
|
||||||
const PROXY_METHODS = [
|
const PROXY_METHODS = [
|
||||||
"eth_blockNumber",
|
"eth_blockNumber",
|
||||||
"eth_call",
|
"eth_call",
|
||||||
|
"eth_chainId",
|
||||||
"eth_estimateGas",
|
"eth_estimateGas",
|
||||||
"eth_gasPrice",
|
"eth_gasPrice",
|
||||||
"eth_getBalance",
|
"eth_getBalance",
|
||||||
@@ -760,6 +681,7 @@ const PROXY_METHODS = [
|
|||||||
"eth_getTransactionReceipt",
|
"eth_getTransactionReceipt",
|
||||||
"eth_maxPriorityFeePerGas",
|
"eth_maxPriorityFeePerGas",
|
||||||
"eth_sendRawTransaction",
|
"eth_sendRawTransaction",
|
||||||
|
"net_version",
|
||||||
"web3_clientVersion",
|
"web3_clientVersion",
|
||||||
"eth_feeHistory",
|
"eth_feeHistory",
|
||||||
"eth_getBlockTransactionCountByHash",
|
"eth_getBlockTransactionCountByHash",
|
||||||
@@ -776,9 +698,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
if (!activeAddress) return { result: [] };
|
if (!activeAddress) return { result: [] };
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
allowed.includes(origin) ||
|
allowed.includes(hostname) ||
|
||||||
connectedSites[origin + ":" + activeAddress]
|
connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { result: [activeAddress] };
|
return { result: [activeAddress] };
|
||||||
@@ -808,9 +731,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
// [TESTNET] banner under a user who believed they were on Sepolia.
|
// [TESTNET] banner under a user who believed they were on Sepolia.
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(hostname) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
@@ -882,9 +806,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
if (method === "wallet_getPermissions") {
|
if (method === "wallet_getPermissions") {
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
const isConnected =
|
const isConnected =
|
||||||
allowed.includes(origin) ||
|
allowed.includes(hostname) ||
|
||||||
connectedSites[origin + ":" + activeAddress];
|
connectedSites[origin + ":" + activeAddress];
|
||||||
if (!isConnected || !activeAddress) {
|
if (!isConnected || !activeAddress) {
|
||||||
return { result: [] };
|
return { result: [] };
|
||||||
@@ -910,9 +835,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
if (!activeAddress)
|
if (!activeAddress)
|
||||||
return { error: { message: "No accounts available" } };
|
return { error: { message: "No accounts available" } };
|
||||||
|
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(hostname) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
@@ -942,16 +868,9 @@ async function handleRpc(method, params, origin) {
|
|||||||
"Only proceed if you fully understand what you are signing.";
|
"Only proceed if you fully understand what you are signing.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (findPendingApproval(origin, "sign")) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
code: APPROVAL_PENDING_CODE,
|
|
||||||
message: APPROVAL_PENDING_MESSAGE,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const decision = await requestSignApproval(
|
const decision = await requestSignApproval(
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
signParams,
|
signParams,
|
||||||
activeAddress,
|
activeAddress,
|
||||||
);
|
);
|
||||||
@@ -965,9 +884,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
if (!activeAddress)
|
if (!activeAddress)
|
||||||
return { error: { message: "No accounts available" } };
|
return { error: { message: "No accounts available" } };
|
||||||
|
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(hostname) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
@@ -983,16 +903,9 @@ async function handleRpc(method, params, origin) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (findPendingApproval(origin, "sign")) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
code: APPROVAL_PENDING_CODE,
|
|
||||||
message: APPROVAL_PENDING_MESSAGE,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const decision = await requestSignApproval(
|
const decision = await requestSignApproval(
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
signParams,
|
signParams,
|
||||||
activeAddress,
|
activeAddress,
|
||||||
);
|
);
|
||||||
@@ -1019,9 +932,7 @@ async function handleRpc(method, params, origin) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EIP-1193 4200 lets a site tell "this wallet does not implement that"
|
return { error: { message: "Unsupported method: " + method } };
|
||||||
// from "that call failed", and fall back.
|
|
||||||
return { error: { code: 4200, message: "Unsupported method: " + method } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The body of eth_sendTransaction, from the connection check through to the
|
// The body of eth_sendTransaction, from the connection check through to the
|
||||||
@@ -1033,9 +944,10 @@ async function handleSendTransaction(params, origin) {
|
|||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
if (!activeAddress) return { error: { message: "No accounts available" } };
|
if (!activeAddress) return { error: { message: "No accounts available" } };
|
||||||
|
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(hostname) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
@@ -1062,7 +974,7 @@ async function handleSendTransaction(params, origin) {
|
|||||||
if (!slot) {
|
if (!slot) {
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
code: APPROVAL_PENDING_CODE,
|
code: TX_APPROVAL_PENDING_CODE,
|
||||||
message: TX_APPROVAL_PENDING_MESSAGE,
|
message: TX_APPROVAL_PENDING_MESSAGE,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1109,6 +1021,7 @@ async function handleSendTransaction(params, origin) {
|
|||||||
|
|
||||||
const decision = await requestTxApproval(
|
const decision = await requestTxApproval(
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
approvedTx,
|
approvedTx,
|
||||||
activeAddress,
|
activeAddress,
|
||||||
slot,
|
slot,
|
||||||
@@ -1182,9 +1095,10 @@ async function broadcastAccountsChanged() {
|
|||||||
}
|
}
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
const origin = tab.url ? new URL(tab.url).origin : "";
|
const origin = tab.url ? new URL(tab.url).origin : "";
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const hasPermission =
|
const hasPermission =
|
||||||
activeAddress &&
|
activeAddress &&
|
||||||
(allowed.includes(origin) ||
|
(allowed.includes(hostname) ||
|
||||||
connectedSites[origin + ":" + activeAddress]);
|
connectedSites[origin + ":" + activeAddress]);
|
||||||
// Same as chainChanged above: a tab without our content script
|
// Same as chainChanged above: a tab without our content script
|
||||||
// rejects, and that is expected rather than a fault.
|
// rejects, and that is expected rather than a fault.
|
||||||
@@ -1196,24 +1110,6 @@ async function broadcastAccountsChanged() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell every open tab of a site Settings removed that it has no account.
|
|
||||||
async function broadcastSiteRemoved(origin) {
|
|
||||||
let tabs;
|
|
||||||
try {
|
|
||||||
tabs = await tabsQuery({});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const tab of tabs) {
|
|
||||||
if (!tab.url || new URL(tab.url).origin !== origin) continue;
|
|
||||||
tabsSendMessage(tab.id, {
|
|
||||||
type: "AUTISTMASK_EVENT",
|
|
||||||
eventName: "accountsChanged",
|
|
||||||
data: [],
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background balance refresh: every 60 seconds when the popup isn't open.
|
// Background balance refresh: every 60 seconds when the popup isn't open.
|
||||||
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
|
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
|
||||||
// fresh, so this naturally skips.
|
// fresh, so this naturally skips.
|
||||||
@@ -1372,29 +1268,18 @@ if (windowsNs && windowsNs.onRemoved) {
|
|||||||
// Listen for messages from content scripts and popup
|
// Listen for messages from content scripts and popup
|
||||||
runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||||
if (msg.type === "AUTISTMASK_RPC") {
|
if (msg.type === "AUTISTMASK_RPC") {
|
||||||
// The origin is the one the browser reports for the sender, never one
|
// Derive origin from trusted sender info to prevent origin spoofing.
|
||||||
// the message carries. Firefox before 126 gives no sender.origin, so
|
// Chrome MV3 provides sender.origin; Firefox MV2 fallback uses sender.tab.url.
|
||||||
// the origin of sender.url is used: the frame that sent the message,
|
let trustedOrigin = msg.origin; // fallback only if sender info unavailable
|
||||||
// not the tab's page, which may be another site embedding that
|
if (sender.origin) {
|
||||||
// frame. With neither, the request is refused.
|
trustedOrigin = sender.origin;
|
||||||
let trustedOrigin = sender.origin;
|
} else if (sender.tab && sender.tab.url) {
|
||||||
if (!trustedOrigin && sender.url) {
|
|
||||||
try {
|
try {
|
||||||
trustedOrigin = new URL(sender.url).origin;
|
trustedOrigin = new URL(sender.tab.url).origin;
|
||||||
} catch {
|
} catch {
|
||||||
// an unparseable URL leaves the origin unknown
|
// keep fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!trustedOrigin) {
|
|
||||||
sendResponse({
|
|
||||||
error: {
|
|
||||||
code: 4100,
|
|
||||||
message:
|
|
||||||
"The wallet could not tell which site sent this request.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
handleRpc(msg.method, msg.params, trustedOrigin)
|
handleRpc(msg.method, msg.params, trustedOrigin)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
sendResponse(response);
|
sendResponse(response);
|
||||||
@@ -1420,9 +1305,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
"AUTISTMASK_GET_APPROVAL",
|
"AUTISTMASK_GET_APPROVAL",
|
||||||
"AUTISTMASK_TX_RESPONSE",
|
"AUTISTMASK_TX_RESPONSE",
|
||||||
"AUTISTMASK_SIGN_RESPONSE",
|
"AUTISTMASK_SIGN_RESPONSE",
|
||||||
"AUTISTMASK_ADDRESSES_REMOVED",
|
|
||||||
"AUTISTMASK_GET_CONNECTED_SITES",
|
|
||||||
"AUTISTMASK_REMOVE_SITE",
|
|
||||||
];
|
];
|
||||||
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
|
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
|
||||||
sendResponse({ error: "Unauthorized sender" });
|
sendResponse({ error: "Unauthorized sender" });
|
||||||
@@ -1432,7 +1314,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
||||||
const approval = pendingApprovals[msg.id];
|
const approval = pendingApprovals[msg.id];
|
||||||
if (approval) {
|
if (approval) {
|
||||||
const resp = { origin: approval.origin };
|
const resp = {
|
||||||
|
hostname: approval.hostname,
|
||||||
|
origin: approval.origin,
|
||||||
|
};
|
||||||
if (approval.type === "tx") {
|
if (approval.type === "tx") {
|
||||||
resp.type = "tx";
|
resp.type = "tx";
|
||||||
// The populated transaction, and the address it was raised
|
// The populated transaction, and the address it was raised
|
||||||
@@ -1447,9 +1332,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
resp.approvedFrom = approval.approvedFrom;
|
resp.approvedFrom = approval.approvedFrom;
|
||||||
}
|
}
|
||||||
// Flag if the requesting domain is on the phishing blocklist.
|
// Flag if the requesting domain is on the phishing blocklist.
|
||||||
resp.isPhishingDomain = isPhishingDomain(
|
resp.isPhishingDomain = isPhishingDomain(approval.hostname);
|
||||||
extractHostname(approval.origin),
|
|
||||||
);
|
|
||||||
sendResponse(resp);
|
sendResponse(resp);
|
||||||
} else {
|
} else {
|
||||||
sendResponse(null);
|
sendResponse(null);
|
||||||
@@ -1461,15 +1344,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
const approval = pendingApprovals[msg.id];
|
const approval = pendingApprovals[msg.id];
|
||||||
if (!approval) return false;
|
if (!approval) return false;
|
||||||
|
|
||||||
// This message signs and broadcasts a transaction, so it is honoured
|
|
||||||
// only for a transaction approval. A sign or connection approval
|
|
||||||
// carries no approvedTx, and reaching the broadcast path with one used
|
|
||||||
// to fail closed by throwing deeper in; refusing here keeps a future
|
|
||||||
// refactor from turning that incidental throw into a live path, and
|
|
||||||
// keeps a reject on this message from retiring an approval of another
|
|
||||||
// kind.
|
|
||||||
if (approval.type !== "tx") return false;
|
|
||||||
|
|
||||||
// A reject arriving while an attempt holds the approval is refused,
|
// A reject arriving while an attempt holds the approval is refused,
|
||||||
// not honoured: the attempt is on its way to broadcasting the
|
// not honoured: the attempt is on its way to broadcasting the
|
||||||
// transaction, and resolving 4001 here would tell the page the request
|
// transaction, and resolving 4001 here would tell the page the request
|
||||||
@@ -1764,43 +1638,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The popup removed these addresses, so no site stays connected to them.
|
|
||||||
// A connectedSites key is origin + ":" + address, and an origin can carry
|
|
||||||
// a port, so the address is what follows the last colon.
|
|
||||||
if (msg.type === "AUTISTMASK_ADDRESSES_REMOVED") {
|
|
||||||
const removed = Array.isArray(msg.addresses) ? msg.addresses : [];
|
|
||||||
for (const key of Object.keys(connectedSites)) {
|
|
||||||
const address = key.slice(key.lastIndexOf(":") + 1);
|
|
||||||
if (removed.some((a) => sameAddress(a, address))) {
|
|
||||||
delete connectedSites[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings lists the sites connected without "Remember", which only this
|
|
||||||
// worker knows. The origin is what precedes the key's last colon.
|
|
||||||
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
|
|
||||||
sendResponse(
|
|
||||||
Object.keys(connectedSites).map((key) =>
|
|
||||||
key.slice(0, key.lastIndexOf(":")),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings removed this site (msg.origin) and has already dropped its
|
|
||||||
// remembered entries. Its connections approved without "Remember" end
|
|
||||||
// here, under every address, and its open tabs are told it has no account.
|
|
||||||
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
|
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
|
||||||
for (const key of Object.keys(connectedSites)) {
|
// Popup already saved state; nothing else needed
|
||||||
if (key.slice(0, key.lastIndexOf(":")) === msg.origin) {
|
|
||||||
delete connectedSites[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
broadcastSiteRemoved(msg.origin);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = { PROXY_METHODS };
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ const {
|
|||||||
hasBrowserNamespace,
|
hasBrowserNamespace,
|
||||||
runtimeApi,
|
runtimeApi,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
storageGet,
|
||||||
|
storageSet,
|
||||||
} = require("../shared/browserApi");
|
} = require("../shared/browserApi");
|
||||||
|
|
||||||
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
|
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
|
||||||
@@ -19,6 +21,30 @@ if (hasBrowserNamespace()) {
|
|||||||
(document.head || document.documentElement).appendChild(script);
|
(document.head || document.documentElement).appendChild(script);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send the persisted EIP-6963 provider UUID to the inpage script.
|
||||||
|
// Generated once at install time and stored in extension storage.
|
||||||
|
(async function sendProviderUuid() {
|
||||||
|
let uuid = null;
|
||||||
|
try {
|
||||||
|
const items = await storageGet("eip6963Uuid");
|
||||||
|
uuid = items?.eip6963Uuid;
|
||||||
|
if (!uuid) {
|
||||||
|
uuid = crypto.randomUUID();
|
||||||
|
await storageSet({ eip6963Uuid: uuid });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Storage was unavailable or refused the write. The announcement
|
||||||
|
// still has to go out — a provider that never announces is invisible
|
||||||
|
// to every EIP-6963 dApp — so it goes under a fresh uuid that this
|
||||||
|
// page load will not outlive.
|
||||||
|
if (!uuid) uuid = crypto.randomUUID();
|
||||||
|
}
|
||||||
|
window.postMessage(
|
||||||
|
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
|
||||||
|
location.origin,
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
// Relay requests from the page to the background script
|
// Relay requests from the page to the background script
|
||||||
window.addEventListener("message", (event) => {
|
window.addEventListener("message", (event) => {
|
||||||
if (event.source !== window) return;
|
if (event.source !== window) return;
|
||||||
@@ -30,6 +56,7 @@ window.addEventListener("message", (event) => {
|
|||||||
id,
|
id,
|
||||||
method,
|
method,
|
||||||
params,
|
params,
|
||||||
|
origin: location.origin,
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
|
|||||||
+18
-15
@@ -31,12 +31,11 @@
|
|||||||
// an error instead of accepting the refusal.
|
// an error instead of accepting the refusal.
|
||||||
//
|
//
|
||||||
// Whatever code arrived is passed through verbatim rather than being
|
// Whatever code arrived is passed through verbatim rather than being
|
||||||
// matched against a list: the extension emits codes such as 4001, 4100,
|
// matched against a list: the extension emits 4001, 4100 and 4902 today,
|
||||||
// 4200 and 4902, and a code this file has never heard of is still the
|
// and a code this file has never heard of is still the truth about what
|
||||||
// truth about what happened. An error reported with no code at all stays
|
// happened. An error reported with no code at all stays a plain Error —
|
||||||
// a plain Error — a ProviderRpcError whose `code` is undefined would
|
// a ProviderRpcError whose `code` is undefined would advertise a
|
||||||
// advertise a conformance it does not have. `message` is untouched in
|
// conformance it does not have. `message` is untouched in every case.
|
||||||
// every case.
|
|
||||||
function toPageError(error) {
|
function toPageError(error) {
|
||||||
const message = (error && error.message) || "Request failed";
|
const message = (error && error.message) || "Request failed";
|
||||||
if (error && error.code !== undefined && error.code !== null) {
|
if (error && error.code !== undefined && error.code !== null) {
|
||||||
@@ -46,7 +45,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Listen for responses from the content script
|
// Listen for responses from the content script
|
||||||
window.addEventListener("message", (event) => {
|
window.addEventListener("message", function onUuid(event) {
|
||||||
if (event.source !== window) return;
|
if (event.source !== window) return;
|
||||||
if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
|
if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
|
||||||
const { id, result, error } = event.data;
|
const { id, result, error } = event.data;
|
||||||
@@ -61,7 +60,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Listen for events pushed from the extension
|
// Listen for events pushed from the extension
|
||||||
window.addEventListener("message", (event) => {
|
window.addEventListener("message", function onUuid(event) {
|
||||||
if (event.source !== window) return;
|
if (event.source !== window) return;
|
||||||
if (event.data?.type !== "AUTISTMASK_EVENT") return;
|
if (event.data?.type !== "AUTISTMASK_EVENT") return;
|
||||||
const { eventName, data } = event.data;
|
const { eventName, data } = event.data;
|
||||||
@@ -205,13 +204,7 @@
|
|||||||
"</svg>",
|
"</svg>",
|
||||||
);
|
);
|
||||||
|
|
||||||
// EIP-6963 asks for one UUIDv4 per provider for the life of the page: one
|
let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives
|
||||||
// per page load, shared by every announcement in that load. It is
|
|
||||||
// generated here and never stored: announcing one persisted value to every
|
|
||||||
// site, on every load and across restarts, turned it into a stable
|
|
||||||
// cross-site, cross-session tracking identifier any page could read
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/398).
|
|
||||||
const providerUuid = crypto.randomUUID();
|
|
||||||
|
|
||||||
function buildProviderInfo() {
|
function buildProviderInfo() {
|
||||||
return {
|
return {
|
||||||
@@ -233,6 +226,16 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Listen for the persisted UUID from the content script
|
||||||
|
function onProviderUuid(event) {
|
||||||
|
if (event.source !== window) return;
|
||||||
|
if (event.data?.type !== "AUTISTMASK_PROVIDER_UUID") return;
|
||||||
|
window.removeEventListener("message", onProviderUuid);
|
||||||
|
providerUuid = event.data.uuid;
|
||||||
|
announceProvider();
|
||||||
|
}
|
||||||
|
window.addEventListener("message", onProviderUuid);
|
||||||
|
|
||||||
window.addEventListener("eip6963:requestProvider", announceProvider);
|
window.addEventListener("eip6963:requestProvider", announceProvider);
|
||||||
announceProvider();
|
announceProvider();
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,13 @@
|
|||||||
// that the user did not type — the same silent substitution the visible
|
// that the user did not type — the same silent substitution the visible
|
||||||
// rejection message exists to end.
|
// rejection message exists to end.
|
||||||
|
|
||||||
// Must render on ONE line of #flash-msg; see showFlash() in
|
// Must render on ONE line of #flash-msg, whose reserved height
|
||||||
// src/popup/views/helpers.js for how long that is.
|
// (min-h-[1.25rem]) is exactly one line at text-xs. A string long enough to
|
||||||
const DUST_THRESHOLD_MESSAGE = "Enter a whole number of gwei, zero or greater.";
|
// wrap to two lines pushes the settings view down, which the No Layout Shift
|
||||||
|
// policy forbids. Do not lengthen this without re-running the layout test in
|
||||||
|
// tests/e2e/run.js, which measures the flash line and goes red on a shift.
|
||||||
|
const DUST_THRESHOLD_MESSAGE =
|
||||||
|
"Please enter a whole number of gwei, zero or greater.";
|
||||||
|
|
||||||
// Returns the threshold in gwei, or null if the input is not one.
|
// Returns the threshold in gwei, or null if the input is not one.
|
||||||
function parseDustThresholdGwei(raw) {
|
function parseDustThresholdGwei(raw) {
|
||||||
|
|||||||
+139
-114
@@ -6,10 +6,7 @@
|
|||||||
<title>AutistMask</title>
|
<title>AutistMask</title>
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<!-- Chrome gives extension pages a stylesheet of its own that sets the
|
<body class="bg-bg text-fg font-mono text-sm">
|
||||||
font on body, and a Tailwind class beats it only when marked
|
|
||||||
important: hence font-mono! rather than font-mono. -->
|
|
||||||
<body class="bg-bg text-fg font-mono! text-sm">
|
|
||||||
<div id="app" class="p-2 pr-5 overflow-x-hidden">
|
<div id="app" class="p-2 pr-5 overflow-x-hidden">
|
||||||
<!-- ============ GLOBAL TITLE BAR ============ -->
|
<!-- ============ GLOBAL TITLE BAR ============ -->
|
||||||
<div
|
<div
|
||||||
@@ -36,7 +33,7 @@
|
|||||||
<!-- ============ FLASH MESSAGE AREA ============ -->
|
<!-- ============ FLASH MESSAGE AREA ============ -->
|
||||||
<div
|
<div
|
||||||
id="flash-msg"
|
id="flash-msg"
|
||||||
class="text-xs text-muted min-h-[1.25rem] mb-1 truncate"
|
class="text-xs text-muted min-h-[1.25rem] mb-1"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- ============ WELCOME / FIRST USE ============ -->
|
<!-- ============ WELCOME / FIRST USE ============ -->
|
||||||
@@ -110,7 +107,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="add-wallet-phrase-warning"
|
id="add-wallet-phrase-warning"
|
||||||
class="text-xs mb-2 border border-border border-dashed p-2 invisible"
|
class="text-xs mb-2 border border-border border-dashed p-2"
|
||||||
|
style="visibility: hidden"
|
||||||
>
|
>
|
||||||
Write these words down and keep them safe. Anyone with
|
Write these words down and keep them safe. Anyone with
|
||||||
them can take your funds; if you lose them, your wallet
|
them can take your funds; if you lose them, your wallet
|
||||||
@@ -154,21 +152,6 @@
|
|||||||
|
|
||||||
<!-- Shared password fields -->
|
<!-- Shared password fields -->
|
||||||
<div class="mb-2" id="add-wallet-password-section">
|
<div class="mb-2" id="add-wallet-password-section">
|
||||||
<!-- Shown only when the profile already holds a wallet:
|
|
||||||
each wallet has its own password (its own
|
|
||||||
encryptedSecret), so a second wallet does not reuse
|
|
||||||
the first one's. addWallet.js toggles this on screen
|
|
||||||
entry from state.wallets.length, so it is constant
|
|
||||||
while the screen is up and moves nothing. -->
|
|
||||||
<p
|
|
||||||
class="text-xs mb-2 border border-border border-dashed p-2 hidden"
|
|
||||||
id="add-wallet-separate-password-note"
|
|
||||||
>
|
|
||||||
You already have a wallet. Each wallet has its own
|
|
||||||
password: the one you choose here is only for this new
|
|
||||||
wallet, and it need not match any password you already
|
|
||||||
use.
|
|
||||||
</p>
|
|
||||||
<label class="block mb-1">Choose a password</label>
|
<label class="block mb-1">Choose a password</label>
|
||||||
<!-- The hint is swapped in place when the import tab
|
<!-- The hint is swapped in place when the import tab
|
||||||
changes, and it sits directly above the password
|
changes, and it sits directly above the password
|
||||||
@@ -230,7 +213,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- active address display -->
|
<!-- active address display -->
|
||||||
<div id="active-address-display" class="text-xs mb-3"></div>
|
<div
|
||||||
|
id="active-address-display"
|
||||||
|
class="text-xs break-all mb-3"
|
||||||
|
></div>
|
||||||
|
|
||||||
<!-- quick actions for active address -->
|
<!-- quick actions for active address -->
|
||||||
<div class="flex gap-2 mb-2">
|
<div class="flex gap-2 mb-2">
|
||||||
@@ -261,7 +247,10 @@
|
|||||||
|
|
||||||
<!-- recent transactions across all addresses -->
|
<!-- recent transactions across all addresses -->
|
||||||
<div>
|
<div>
|
||||||
<div class="font-bold bg-section py-1 px-2 -mx-2">
|
<div
|
||||||
|
class="font-bold bg-section py-1 px-2"
|
||||||
|
style="margin-left: -0.5rem; margin-right: -0.5rem"
|
||||||
|
>
|
||||||
Recent Transactions
|
Recent Transactions
|
||||||
</div>
|
</div>
|
||||||
<div id="home-tx-list">
|
<div id="home-tx-list">
|
||||||
@@ -269,7 +258,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="py-1 -mx-2"> </div>
|
<div class="py-1" style="margin: 0 -0.5rem"> </div>
|
||||||
|
|
||||||
<div class="text-xs text-muted">
|
<div class="text-xs text-muted">
|
||||||
<span
|
<span
|
||||||
@@ -303,7 +292,7 @@
|
|||||||
class="font-bold mb-1 hidden flex items-center"
|
class="font-bold mb-1 hidden flex items-center"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="text-xs mb-1 cursor-pointer"
|
class="text-xs mb-1 cursor-pointer break-all"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
id="address-line"
|
id="address-line"
|
||||||
>
|
>
|
||||||
@@ -391,21 +380,22 @@
|
|||||||
></div>
|
></div>
|
||||||
<h2 class="font-bold mb-1">Export Private Key</h2>
|
<h2 class="font-bold mb-1">Export Private Key</h2>
|
||||||
<p class="text-xs mb-1" id="export-privkey-title"></p>
|
<p class="text-xs mb-1" id="export-privkey-title"></p>
|
||||||
<div class="text-xs mb-3">
|
<p class="text-xs mb-3">
|
||||||
<span id="export-privkey-dot"></span>
|
<span id="export-privkey-dot"></span>
|
||||||
<span
|
<span
|
||||||
id="export-privkey-address"
|
id="export-privkey-address"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</p>
|
||||||
<p class="text-xs mb-3 text-muted">
|
<p class="text-xs mb-3 text-muted">
|
||||||
Warning: anyone with this private key can access and
|
Warning: anyone with this private key can access and
|
||||||
transfer all funds from this address. Never share it.
|
transfer all funds from this address. Never share it.
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
id="export-privkey-flash"
|
id="export-privkey-flash"
|
||||||
class="text-xs mb-2 min-h-[1.25rem] invisible"
|
class="text-xs mb-2 min-h-[1.25rem]"
|
||||||
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<div id="export-privkey-password-section" class="mb-2">
|
<div id="export-privkey-password-section" class="mb-2">
|
||||||
<label class="block mb-1">Password</label>
|
<label class="block mb-1">Password</label>
|
||||||
@@ -450,7 +440,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="text-xs mb-1 cursor-pointer"
|
class="text-xs mb-1 cursor-pointer break-all"
|
||||||
title="Click to copy"
|
title="Click to copy"
|
||||||
id="address-token-line"
|
id="address-token-line"
|
||||||
>
|
>
|
||||||
@@ -537,7 +527,8 @@
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
id="send-to-error"
|
id="send-to-error"
|
||||||
class="text-xs min-h-[1.25rem] text-[#cc0000]"
|
class="text-xs"
|
||||||
|
style="min-height: 1.25rem; color: #cc0000"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@@ -548,20 +539,12 @@
|
|||||||
class="text-xs text-muted"
|
class="text-xs text-muted"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1">
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
id="send-amount"
|
||||||
id="send-amount"
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
class="border border-border p-1 flex-1 min-w-0 font-mono text-sm bg-bg text-fg"
|
placeholder="0.0"
|
||||||
placeholder="0.0"
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
id="btn-send-max"
|
|
||||||
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
|
||||||
>
|
|
||||||
Max
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
id="btn-send-review"
|
id="btn-send-review"
|
||||||
@@ -590,16 +573,19 @@
|
|||||||
<!-- ERC-20 token contract (hidden for ETH) -->
|
<!-- ERC-20 token contract (hidden for ETH) -->
|
||||||
<div id="confirm-token-section" class="mb-3 hidden">
|
<div id="confirm-token-section" class="mb-3 hidden">
|
||||||
<div class="text-xs text-muted mb-1">Token contract</div>
|
<div class="text-xs text-muted mb-1">Token contract</div>
|
||||||
<div id="confirm-token-contract" class="text-xs"></div>
|
<div
|
||||||
|
id="confirm-token-contract"
|
||||||
|
class="text-xs break-all"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">From</div>
|
<div class="text-xs text-muted mb-1">From</div>
|
||||||
<div id="confirm-from" class="text-xs"></div>
|
<div id="confirm-from" class="text-xs break-all"></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>
|
||||||
<div id="confirm-to" class="text-xs"></div>
|
<div id="confirm-to" class="text-xs break-all"></div>
|
||||||
<div
|
<div
|
||||||
id="confirm-to-ens"
|
id="confirm-to-ens"
|
||||||
class="text-xs text-muted hidden"
|
class="text-xs text-muted hidden"
|
||||||
@@ -613,7 +599,7 @@
|
|||||||
<div class="text-xs text-muted mb-1">Your balance</div>
|
<div class="text-xs text-muted mb-1">Your balance</div>
|
||||||
<div id="confirm-balance" class="text-xs"></div>
|
<div id="confirm-balance" class="text-xs"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="confirm-fee" class="mb-3 invisible">
|
<div id="confirm-fee" class="mb-3" style="visibility: hidden">
|
||||||
<div class="text-xs text-muted mb-1">Network fee</div>
|
<div class="text-xs text-muted mb-1">Network fee</div>
|
||||||
<div id="confirm-fee-amount" class="text-xs"></div>
|
<div id="confirm-fee-amount" class="text-xs"></div>
|
||||||
<!-- Holds its one line of space from the first paint, so
|
<!-- Holds its one line of space from the first paint, so
|
||||||
@@ -621,13 +607,22 @@
|
|||||||
nothing. The placeholder is never seen. -->
|
nothing. The placeholder is never seen. -->
|
||||||
<div
|
<div
|
||||||
id="confirm-fee-reserve"
|
id="confirm-fee-reserve"
|
||||||
class="text-xs text-muted invisible"
|
class="text-xs text-muted"
|
||||||
|
style="visibility: hidden"
|
||||||
>
|
>
|
||||||
reserve pending
|
reserve pending
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="confirm-warnings" class="mb-2 invisible"></div>
|
<div
|
||||||
<div id="confirm-recipient-warning" class="mb-2 invisible">
|
id="confirm-warnings"
|
||||||
|
class="mb-2"
|
||||||
|
style="visibility: hidden"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
id="confirm-recipient-warning"
|
||||||
|
class="mb-2"
|
||||||
|
style="visibility: hidden"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||||
>
|
>
|
||||||
@@ -636,13 +631,24 @@
|
|||||||
Double-check the address before sending.
|
Double-check the address before sending.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Its sentence names the network's native token, so show()
|
|
||||||
in confirmTx.js sets it. -->
|
|
||||||
<div
|
<div
|
||||||
id="confirm-contract-warning"
|
id="confirm-contract-warning"
|
||||||
class="mb-2 border border-red-500 border-dashed p-2 text-xs font-bold text-red-500 invisible"
|
class="mb-2"
|
||||||
></div>
|
style="visibility: hidden"
|
||||||
<div id="confirm-burn-warning" class="mb-2 invisible">
|
>
|
||||||
|
<div
|
||||||
|
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||||
|
>
|
||||||
|
WARNING: The recipient is a smart contract. Sending ETH
|
||||||
|
or tokens directly to a contract may result in permanent
|
||||||
|
loss of funds.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="confirm-burn-warning"
|
||||||
|
class="mb-2"
|
||||||
|
style="visibility: hidden"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||||
>
|
>
|
||||||
@@ -650,7 +656,11 @@
|
|||||||
here are permanently destroyed and cannot be recovered.
|
here are permanently destroyed and cannot be recovered.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="confirm-etherscan-warning" class="mb-2 invisible">
|
<div
|
||||||
|
id="confirm-etherscan-warning"
|
||||||
|
class="mb-2"
|
||||||
|
style="visibility: hidden"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
class="border border-red-500 border-dashed p-2 text-xs font-bold text-red-500"
|
||||||
>
|
>
|
||||||
@@ -660,27 +670,34 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="confirm-errors"
|
id="confirm-errors"
|
||||||
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
|
class="mb-2 border border-border border-dashed p-2"
|
||||||
|
style="visibility: hidden; min-height: 1.25rem"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
id="confirm-amount-fee-error"
|
id="confirm-amount-fee-error"
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||||
|
style="visibility: hidden"
|
||||||
>
|
>
|
||||||
Your balance does not cover this amount plus the network
|
Your balance does not cover this amount plus the network
|
||||||
fee. Please go back and send a smaller amount.
|
fee. Please go back and send a smaller amount.
|
||||||
</div>
|
</div>
|
||||||
<!-- Its sentence names the network's native token, so show()
|
|
||||||
in confirmTx.js sets it. -->
|
|
||||||
<div
|
<div
|
||||||
id="confirm-gas-error"
|
id="confirm-gas-error"
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||||
></div>
|
style="visibility: hidden"
|
||||||
<!-- Its sentence names why the fee could not be estimated,
|
>
|
||||||
so show() in confirmTx.js sets it. -->
|
You do not have enough ETH to pay the network fee for this
|
||||||
|
transfer. Please add ETH to this address and try again.
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
id="confirm-fee-unknown-error"
|
id="confirm-fee-unknown-error"
|
||||||
class="mb-2 border border-border border-dashed p-2 text-xs invisible"
|
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||||
></div>
|
style="visibility: hidden"
|
||||||
|
>
|
||||||
|
The network fee could not be estimated, so this transaction
|
||||||
|
cannot be checked against your balance. Please go back and
|
||||||
|
try again.
|
||||||
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs">Password</label>
|
<label class="block mb-1 text-xs">Password</label>
|
||||||
<input
|
<input
|
||||||
@@ -691,7 +708,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="confirm-tx-password-error"
|
id="confirm-tx-password-error"
|
||||||
class="text-xs mb-2 min-h-[1.25rem] invisible"
|
class="text-xs mb-2 min-h-[1.25rem]"
|
||||||
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<button
|
<button
|
||||||
id="btn-confirm-send"
|
id="btn-confirm-send"
|
||||||
@@ -710,7 +728,7 @@
|
|||||||
</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>
|
||||||
<div id="wait-tx-to" class="text-xs"></div>
|
<div id="wait-tx-to" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Transaction hash</div>
|
<div class="text-xs text-muted mb-1">Transaction hash</div>
|
||||||
@@ -729,7 +747,7 @@
|
|||||||
</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>
|
||||||
<div id="success-tx-to" class="text-xs"></div>
|
<div id="success-tx-to" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Block</div>
|
<div class="text-xs text-muted mb-1">Block</div>
|
||||||
@@ -756,7 +774,7 @@
|
|||||||
</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>
|
||||||
<div id="error-tx-to" class="text-xs"></div>
|
<div id="error-tx-to" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div
|
<div
|
||||||
@@ -793,9 +811,9 @@
|
|||||||
<canvas id="receive-qr"></canvas>
|
<canvas id="receive-qr"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="border border-border p-2 mb-3 text-xs cursor-pointer"
|
class="border border-border p-2 break-all mb-3 text-xs cursor-pointer"
|
||||||
>
|
>
|
||||||
<div id="receive-address-block" class="select-all"></div>
|
<span id="receive-address-block" class="select-all"></span>
|
||||||
<span id="receive-etherscan-link"></span>
|
<span id="receive-etherscan-link"></span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -806,7 +824,8 @@
|
|||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
id="receive-erc20-warning"
|
id="receive-erc20-warning"
|
||||||
class="text-xs border border-border border-dashed p-2 mt-3 invisible"
|
class="text-xs border border-border border-dashed p-2 mt-3"
|
||||||
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -834,7 +853,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="add-token-info"
|
id="add-token-info"
|
||||||
class="text-xs text-muted mb-2 min-h-[1.25rem] invisible"
|
class="text-xs text-muted mb-2 min-h-[1.25rem]"
|
||||||
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs text-muted"
|
<label class="block mb-1 text-xs text-muted"
|
||||||
@@ -1020,7 +1040,8 @@
|
|||||||
type="text"
|
type="text"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
id="settings-dust-threshold"
|
id="settings-dust-threshold"
|
||||||
class="border border-border p-1 text-xs bg-bg text-fg w-[10ch]"
|
class="border border-border p-1 text-xs bg-bg text-fg"
|
||||||
|
style="width: 10ch"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-muted">gwei</span>
|
<span class="text-xs text-muted">gwei</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1034,15 +1055,6 @@
|
|||||||
<div id="settings-allowed-sites"></div>
|
<div id="settings-allowed-sites"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-well p-3 mx-1 mb-3">
|
|
||||||
<h3 class="font-bold mb-1">Connected Sites</h3>
|
|
||||||
<p class="text-xs text-muted mb-2">
|
|
||||||
Sites you allowed without "Remember my choice".
|
|
||||||
Switching address disconnects them.
|
|
||||||
</p>
|
|
||||||
<div id="settings-connected-sites"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-well p-3 mx-1 mb-3">
|
<div class="bg-well p-3 mx-1 mb-3">
|
||||||
<h3 class="font-bold mb-1">Denied Sites</h3>
|
<h3 class="font-bold mb-1">Denied Sites</h3>
|
||||||
<p class="text-xs text-muted mb-2">
|
<p class="text-xs text-muted mb-2">
|
||||||
@@ -1097,7 +1109,8 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
id="settings-debug-well"
|
id="settings-debug-well"
|
||||||
class="bg-well p-3 mx-1 mb-3 hidden"
|
class="bg-well p-3 mx-1 mb-3"
|
||||||
|
style="display: none"
|
||||||
>
|
>
|
||||||
<h3 class="font-bold mb-1">Debug</h3>
|
<h3 class="font-bold mb-1">Debug</h3>
|
||||||
<label
|
<label
|
||||||
@@ -1125,7 +1138,8 @@
|
|||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
id="delete-wallet-flash"
|
id="delete-wallet-flash"
|
||||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
||||||
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1">Password</label>
|
<label class="block mb-1">Password</label>
|
||||||
@@ -1198,7 +1212,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="delete-wallet-lost-flash"
|
id="delete-wallet-lost-flash"
|
||||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
||||||
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<button
|
<button
|
||||||
id="btn-delete-wallet-lost-confirm"
|
id="btn-delete-wallet-lost-confirm"
|
||||||
@@ -1224,7 +1239,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
id="delete-address-value"
|
id="delete-address-value"
|
||||||
class="text-xs mb-2 min-h-[1rem]"
|
class="text-xs mb-2 break-all min-h-[1rem]"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="text-xs mb-2 border border-border border-dashed p-2"
|
class="text-xs mb-2 border border-border border-dashed p-2"
|
||||||
@@ -1253,7 +1268,8 @@
|
|||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
id="delete-address-flash"
|
id="delete-address-flash"
|
||||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
||||||
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<button
|
<button
|
||||||
id="btn-delete-address-confirm"
|
id="btn-delete-address-confirm"
|
||||||
@@ -1282,7 +1298,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="show-phrase-flash"
|
id="show-phrase-flash"
|
||||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
||||||
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<div id="show-phrase-password-section" class="mb-2">
|
<div id="show-phrase-password-section" class="mb-2">
|
||||||
<label class="block mb-1">Password</label>
|
<label class="block mb-1">Password</label>
|
||||||
@@ -1364,7 +1381,8 @@
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
id="settings-addtoken-info"
|
id="settings-addtoken-info"
|
||||||
class="text-xs text-muted mt-1 min-h-[1.25rem] invisible"
|
class="text-xs text-muted mt-1 min-h-[1.25rem]"
|
||||||
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<button
|
<button
|
||||||
id="btn-settings-addtoken-manual"
|
id="btn-settings-addtoken-manual"
|
||||||
@@ -1411,11 +1429,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<div class="text-xs text-muted mb-1">From</div>
|
<div class="text-xs text-muted mb-1">From</div>
|
||||||
<div id="tx-detail-from" class="text-xs"></div>
|
<div
|
||||||
|
id="tx-detail-from"
|
||||||
|
class="text-xs break-all"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<div class="text-xs text-muted mb-1">To</div>
|
<div class="text-xs text-muted mb-1">To</div>
|
||||||
<div id="tx-detail-to" class="text-xs"></div>
|
<div id="tx-detail-to" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1452,7 +1473,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="tx-detail-token-contract"
|
id="tx-detail-token-contract"
|
||||||
class="text-xs"
|
class="text-xs break-all"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1527,7 +1548,7 @@
|
|||||||
with extreme caution.
|
with extreme caution.
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-2">
|
<p class="mb-2">
|
||||||
<span id="approve-tx-origin" class="font-bold"></span>
|
<span id="approve-tx-hostname" class="font-bold"></span>
|
||||||
wants to send a transaction.
|
wants to send a transaction.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -1546,11 +1567,11 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">From</div>
|
<div class="text-xs text-muted mb-1">From</div>
|
||||||
<div id="approve-tx-from" class="text-xs"></div>
|
<div id="approve-tx-from" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Contract</div>
|
<div class="text-xs text-muted mb-1">Contract</div>
|
||||||
<div id="approve-tx-to" class="text-xs"></div>
|
<div id="approve-tx-to" class="text-xs break-all"></div>
|
||||||
</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>
|
||||||
@@ -1597,7 +1618,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="approve-tx-error"
|
id="approve-tx-error"
|
||||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem] invisible"
|
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
|
||||||
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<button
|
<button
|
||||||
@@ -1627,13 +1649,21 @@
|
|||||||
funds. Proceed with extreme caution.
|
funds. Proceed with extreme caution.
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-2">
|
<p class="mb-2">
|
||||||
<span id="approve-sign-origin" class="font-bold"></span>
|
<span id="approve-sign-hostname" class="font-bold"></span>
|
||||||
wants you to sign a message.
|
wants you to sign a message.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="approve-sign-danger-warning"
|
id="approve-sign-danger-warning"
|
||||||
class="mb-3 p-2 text-xs font-bold invisible min-h-[1.25rem] bg-[#fee2e2] text-[#991b1b] border-2 border-[#dc2626] rounded-[6px]"
|
class="mb-3 p-2 text-xs font-bold"
|
||||||
|
style="
|
||||||
|
visibility: hidden;
|
||||||
|
min-height: 1.25rem;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
border: 2px solid #dc2626;
|
||||||
|
border-radius: 6px;
|
||||||
|
"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -1643,22 +1673,15 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">From</div>
|
<div class="text-xs text-muted mb-1">From</div>
|
||||||
<div id="approve-sign-from" class="text-xs"></div>
|
<div id="approve-sign-from" class="text-xs break-all"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-xs text-muted mb-1">Message</div>
|
<div class="text-xs text-muted mb-1">Message</div>
|
||||||
<div
|
<div
|
||||||
id="approve-sign-message"
|
id="approve-sign-message"
|
||||||
class="text-xs break-all max-h-48 overflow-y-auto"
|
class="text-xs break-all"
|
||||||
></div>
|
style="max-height: 12rem; overflow-y: auto"
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="approve-sign-hex-section" class="mb-3 hidden">
|
|
||||||
<div class="text-xs text-muted mb-1">Raw data</div>
|
|
||||||
<div
|
|
||||||
id="approve-sign-hex"
|
|
||||||
class="text-xs break-all max-h-24 overflow-y-auto"
|
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1672,7 +1695,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="approve-sign-error"
|
id="approve-sign-error"
|
||||||
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem] invisible"
|
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
|
||||||
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<button
|
<button
|
||||||
@@ -1703,7 +1727,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<p class="mb-2">
|
<p class="mb-2">
|
||||||
<span id="approve-origin" class="font-bold"></span>
|
<span id="approve-hostname" class="font-bold"></span>
|
||||||
wants to connect to your wallet.
|
wants to connect to your wallet.
|
||||||
</p>
|
</p>
|
||||||
<div class="text-xs text-muted mb-1">
|
<div class="text-xs text-muted mb-1">
|
||||||
@@ -1796,7 +1820,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="state-recovery-flash"
|
id="state-recovery-flash"
|
||||||
class="text-xs text-red-500 mb-2 min-h-[1.25rem] invisible"
|
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
|
||||||
|
style="visibility: hidden"
|
||||||
></div>
|
></div>
|
||||||
<button
|
<button
|
||||||
id="btn-state-recovery-reset"
|
id="btn-state-recovery-reset"
|
||||||
|
|||||||
+2
-37
@@ -48,20 +48,8 @@ function renderWalletList() {
|
|||||||
home.render(ctx);
|
home.render(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aborted when the popup page goes away, closed or reloaded. Chrome then
|
|
||||||
// cancels the requests the page still has open, and a cancelled fetch() fails
|
|
||||||
// with the same "Failed to fetch" as a server that cannot be reached. pagehide
|
|
||||||
// fires first, so code that reports a failed request checks this and stays
|
|
||||||
// silent about one the page's own closing cancelled.
|
|
||||||
const pageClosed = new AbortController();
|
|
||||||
window.addEventListener("pagehide", () => pageClosed.abort());
|
|
||||||
|
|
||||||
let refreshInFlight = false;
|
let refreshInFlight = false;
|
||||||
|
|
||||||
// The ten-second refresh init() starts, stopped when the popup moves to the
|
|
||||||
// recovery screen: there is no profile left to refresh.
|
|
||||||
let refreshTimer = null;
|
|
||||||
|
|
||||||
async function doRefreshAndRender() {
|
async function doRefreshAndRender() {
|
||||||
if (refreshInFlight) return;
|
if (refreshInFlight) return;
|
||||||
refreshInFlight = true;
|
refreshInFlight = true;
|
||||||
@@ -74,7 +62,6 @@ async function doRefreshAndRender() {
|
|||||||
state.blockscoutUrl,
|
state.blockscoutUrl,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
pageClosed.signal,
|
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
state.lastBalanceRefresh = Date.now();
|
state.lastBalanceRefresh = Date.now();
|
||||||
@@ -96,7 +83,6 @@ async function doRefreshAndRender() {
|
|||||||
const ctx = {
|
const ctx = {
|
||||||
renderWalletList,
|
renderWalletList,
|
||||||
doRefreshAndRender,
|
doRefreshAndRender,
|
||||||
pageClosed: pageClosed.signal,
|
|
||||||
showAddWalletView: () => {
|
showAddWalletView: () => {
|
||||||
pushCurrentView();
|
pushCurrentView();
|
||||||
addWallet.show();
|
addWallet.show();
|
||||||
@@ -169,28 +155,7 @@ async function init() {
|
|||||||
// reported rather than being swallowed by the save queue
|
// reported rather than being swallowed by the save queue
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
|
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
|
||||||
// the approval-window branch below too, since that window saves as well.
|
// the approval-window branch below too, since that window saves as well.
|
||||||
//
|
onSaveFailure(showSaveFailureBanner);
|
||||||
// Every save first reads the stored record and refuses it with the same
|
|
||||||
// check loadState() runs below. So a record that becomes unreadable while
|
|
||||||
// the popup is open is found by the next save, a navigation or the
|
|
||||||
// ten-second refresh, and gets the screen it would get at open
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/373). Passing the recovery
|
|
||||||
// screen to showView() first leaves the current screen as any navigation
|
|
||||||
// does, so a phrase, key or password on it is wiped, and from then on
|
|
||||||
// showView() shows nothing else. A later save that fails the same way,
|
|
||||||
// such as a refresh already in flight, comes back here, where both calls
|
|
||||||
// see the screen already up and do nothing. Any other failed save is a
|
|
||||||
// read or write that failed, and gets the banner without changing the
|
|
||||||
// screen.
|
|
||||||
onSaveFailure((e) => {
|
|
||||||
if (e instanceof StateUnusableError) {
|
|
||||||
clearInterval(refreshTimer);
|
|
||||||
showView("state-recovery");
|
|
||||||
stateRecovery.show(e);
|
|
||||||
} else {
|
|
||||||
showSaveFailureBanner(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
await loadState();
|
await loadState();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -279,7 +244,7 @@ async function init() {
|
|||||||
renderWalletList();
|
renderWalletList();
|
||||||
restoreView();
|
restoreView();
|
||||||
doRefreshAndRender();
|
doRefreshAndRender();
|
||||||
refreshTimer = setInterval(doRefreshAndRender, 10000);
|
setInterval(doRefreshAndRender, 10000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,33 +44,3 @@ body {
|
|||||||
background-color 225ms ease-out,
|
background-color 225ms ease-out,
|
||||||
color 225ms ease-out;
|
color 225ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* An address is one atomic string, so it gets a row of its own and never
|
|
||||||
* breaks across lines. A wrapped address reads as two shorter strings, and
|
|
||||||
* two shorter strings are exactly what an address-poisoning attack needs
|
|
||||||
* the user to compare instead of the whole thing. Every view that shows an
|
|
||||||
* address puts it in one of these, alone: the colour dot, the wallet title,
|
|
||||||
* the ENS name and the explorer link all live on their own line above, so
|
|
||||||
* nothing competes with the 42 characters for width.
|
|
||||||
*
|
|
||||||
* overflow-x is the escape hatch, not the mechanism. The row is wide enough
|
|
||||||
* for a full address at every nesting depth the popup uses; if that ever
|
|
||||||
* stops being true — a font with wider glyphs, a browser zoom — the row
|
|
||||||
* scrolls and the user can still reach the last character, rather than the
|
|
||||||
* tail being clipped away by #app's overflow-x-hidden with nothing to say
|
|
||||||
* it happened. tests/e2e asserts the scroll is never actually needed. */
|
|
||||||
.am-address {
|
|
||||||
display: block;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A personal message on the signature screen is laid out left to right in
|
|
||||||
* the order of its bytes. Without this, right-to-left characters in it move
|
|
||||||
* the characters around them: `5`, U+05C3, `00` would read as `500`
|
|
||||||
* followed by U+05C3. A paragraph separator (U+2029) ends this layout for
|
|
||||||
* the text after it, so src/popup/views/approval.js shows one as a mark. */
|
|
||||||
.am-byte-order {
|
|
||||||
direction: ltr;
|
|
||||||
unicode-bidi: bidi-override;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ function init(ctx) {
|
|||||||
$("btn-add-token-confirm").addEventListener("click", async () => {
|
$("btn-add-token-confirm").addEventListener("click", async () => {
|
||||||
const contractAddr = $("add-token-address").value.trim();
|
const contractAddr = $("add-token-address").value.trim();
|
||||||
if (!contractAddr || !contractAddr.startsWith("0x")) {
|
if (!contractAddr || !contractAddr.startsWith("0x")) {
|
||||||
showFlash("Enter a valid contract address starting with 0x.");
|
showFlash(
|
||||||
|
"Please enter a valid contract address starting with 0x.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const already = state.trackedTokens.find(
|
const already = state.trackedTokens.find(
|
||||||
@@ -51,7 +53,6 @@ function init(ctx) {
|
|||||||
contractAddr,
|
contractAddr,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
|
||||||
);
|
);
|
||||||
log.infof("Adding token", info.symbol, contractAddr);
|
log.infof("Adding token", info.symbol, contractAddr);
|
||||||
state.trackedTokens.push({
|
state.trackedTokens.push({
|
||||||
@@ -69,19 +70,9 @@ function init(ctx) {
|
|||||||
}
|
}
|
||||||
require("./addressDetail").show();
|
require("./addressDetail").show();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
|
||||||
// src/popup/index.js.
|
|
||||||
if (ctx.pageClosed.aborted) return;
|
|
||||||
const detail = e.shortMessage || e.message || String(e);
|
const detail = e.shortMessage || e.message || String(e);
|
||||||
log.errorf("Adding token failed for", contractAddr, detail);
|
log.errorf("Token lookup failed for", contractAddr, detail);
|
||||||
// lookupTokenInfo() rejects a contract with a one-line message
|
showFlash(detail);
|
||||||
// starting "Not a valid ERC-20 token". Any other error, such as a
|
|
||||||
// failed save, can be far longer, so it is only logged.
|
|
||||||
showFlash(
|
|
||||||
detail.startsWith("Not a valid ERC-20 token")
|
|
||||||
? detail
|
|
||||||
: "Could not add the token.",
|
|
||||||
);
|
|
||||||
infoEl.textContent = "";
|
infoEl.textContent = "";
|
||||||
infoEl.style.visibility = "hidden";
|
infoEl.style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,24 +100,9 @@ function clear() {
|
|||||||
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each wallet has its own password (its own encryptedSecret), so adding a
|
|
||||||
// second wallet does not reuse the first one's. The note that says so is
|
|
||||||
// only meaningful once a wallet exists — on the first wallet there is no
|
|
||||||
// other password to be separate from — so it is shown only then. This is
|
|
||||||
// decided on entry and stays put while the screen is up, so it does not
|
|
||||||
// move the password fields the way a per-tab hint would.
|
|
||||||
function updateSeparatePasswordNote() {
|
|
||||||
const hasExistingWallet = state.wallets.length > 0;
|
|
||||||
$("add-wallet-separate-password-note").classList.toggle(
|
|
||||||
"hidden",
|
|
||||||
!hasExistingWallet,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
clear();
|
clear();
|
||||||
switchMode("mnemonic");
|
switchMode("mnemonic");
|
||||||
updateSeparatePasswordNote();
|
|
||||||
showView("add-wallet");
|
showView("add-wallet");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,13 +127,15 @@ function validatePassword() {
|
|||||||
async function importMnemonic(ctx) {
|
async function importMnemonic(ctx) {
|
||||||
const mnemonic = $("wallet-mnemonic").value.trim();
|
const mnemonic = $("wallet-mnemonic").value.trim();
|
||||||
if (!mnemonic) {
|
if (!mnemonic) {
|
||||||
showFlash("Enter a recovery phrase, or press the die.");
|
showFlash("Enter a recovery phrase or press the die to generate one.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const words = mnemonic.split(/\s+/);
|
const words = mnemonic.split(/\s+/);
|
||||||
if (words.length !== 12 && words.length !== 24) {
|
if (words.length !== 12 && words.length !== 24) {
|
||||||
showFlash(
|
showFlash(
|
||||||
"Recovery phrase must be 12 or 24 words, not " + words.length + ".",
|
"Recovery phrase must be 12 or 24 words. You entered " +
|
||||||
|
words.length +
|
||||||
|
".",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -161,12 +148,14 @@ async function importMnemonic(ctx) {
|
|||||||
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
|
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
|
||||||
const xpubDup = findWalletByXpub(xpub);
|
const xpubDup = findWalletByXpub(xpub);
|
||||||
if (xpubDup) {
|
if (xpubDup) {
|
||||||
showFlash("This recovery phrase is already added.");
|
showFlash(
|
||||||
|
"This recovery phrase is already added (" + xpubDup.name + ").",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const addrDup = findWalletByAddress(firstAddress);
|
const addrDup = findWalletByAddress(firstAddress);
|
||||||
if (addrDup) {
|
if (addrDup) {
|
||||||
showFlash("Address already exists in a wallet.");
|
showFlash("Address already exists in wallet (" + addrDup.name + ").");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const encrypted = await encryptWithPassword(mnemonic, pw);
|
const encrypted = await encryptWithPassword(mnemonic, pw);
|
||||||
@@ -190,12 +179,7 @@ async function importMnemonic(ctx) {
|
|||||||
|
|
||||||
// Scan for used HD addresses beyond index 0.
|
// Scan for used HD addresses beyond index 0.
|
||||||
showFlash("Scanning for addresses...", 30000);
|
showFlash("Scanning for addresses...", 30000);
|
||||||
const scan = await scanForAddresses(
|
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
|
||||||
xpub,
|
|
||||||
state.rpcUrl,
|
|
||||||
state.networkId,
|
|
||||||
ctx.pageClosed,
|
|
||||||
);
|
|
||||||
if (scan.addresses.length > 1) {
|
if (scan.addresses.length > 1) {
|
||||||
wallet.addresses = scan.addresses.map((a) => ({
|
wallet.addresses = scan.addresses.map((a) => ({
|
||||||
address: a.address,
|
address: a.address,
|
||||||
@@ -230,7 +214,9 @@ async function importPrivateKey(ctx) {
|
|||||||
if (!pw) return;
|
if (!pw) return;
|
||||||
const duplicate = findWalletByAddress(addr);
|
const duplicate = findWalletByAddress(addr);
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
showFlash("This address already exists in a wallet.");
|
showFlash(
|
||||||
|
"This address already exists in wallet (" + duplicate.name + ").",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const encrypted = await encryptWithPassword(key, pw);
|
const encrypted = await encryptWithPassword(key, pw);
|
||||||
@@ -257,29 +243,36 @@ async function importXprvKey(ctx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isValidXprv(xprv)) {
|
if (!isValidXprv(xprv)) {
|
||||||
showFlash("That extended private key is not valid.");
|
showFlash(
|
||||||
|
"That extended private key is not valid. Please check it and try again.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isMasterExtendedKey(xprv)) {
|
if (!isMasterExtendedKey(xprv)) {
|
||||||
showFlash("Please paste the master key, not a child key.");
|
showFlash(
|
||||||
|
"That is an account-level or child key, which cannot be imported. " +
|
||||||
|
"Please paste the master extended private key for the wallet.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = hdWalletFromXprv(xprv);
|
result = hdWalletFromXprv(xprv);
|
||||||
} catch {
|
} catch {
|
||||||
showFlash("That extended private key is not valid.");
|
showFlash(
|
||||||
|
"That extended private key is not valid. Please check it and try again.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { xpub, firstAddress } = result;
|
const { xpub, firstAddress } = result;
|
||||||
const xpubDup = findWalletByXpub(xpub);
|
const xpubDup = findWalletByXpub(xpub);
|
||||||
if (xpubDup) {
|
if (xpubDup) {
|
||||||
showFlash("This key is already added.");
|
showFlash("This key is already added (" + xpubDup.name + ").");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const addrDup = findWalletByAddress(firstAddress);
|
const addrDup = findWalletByAddress(firstAddress);
|
||||||
if (addrDup) {
|
if (addrDup) {
|
||||||
showFlash("Address already exists in a wallet.");
|
showFlash("Address already exists in wallet (" + addrDup.name + ").");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pw = validatePassword();
|
const pw = validatePassword();
|
||||||
@@ -305,12 +298,7 @@ async function importXprvKey(ctx) {
|
|||||||
|
|
||||||
// Scan for used HD addresses beyond index 0.
|
// Scan for used HD addresses beyond index 0.
|
||||||
showFlash("Scanning for addresses...", 30000);
|
showFlash("Scanning for addresses...", 30000);
|
||||||
const scan = await scanForAddresses(
|
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
|
||||||
xpub,
|
|
||||||
state.rpcUrl,
|
|
||||||
state.networkId,
|
|
||||||
ctx.pageClosed,
|
|
||||||
);
|
|
||||||
if (scan.addresses.length > 1) {
|
if (scan.addresses.length > 1) {
|
||||||
wallet.addresses = scan.addresses.map((a) => ({
|
wallet.addresses = scan.addresses.map((a) => ({
|
||||||
address: a.address,
|
address: a.address,
|
||||||
|
|||||||
@@ -3,18 +3,17 @@ const {
|
|||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
txCounterpartyHtml,
|
addressDotHtml,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
|
truncateMiddle,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
isoDate,
|
|
||||||
timeAgo,
|
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
||||||
const {
|
const {
|
||||||
fetchRecentTransactions,
|
fetchRecentTransactions,
|
||||||
@@ -33,8 +32,8 @@ const { walletDefect } = require("../../shared/walletDefects");
|
|||||||
|
|
||||||
// The defect of the wallet the selected address belongs to, or null. Both the
|
// The defect of the wallet the selected address belongs to, or null. Both the
|
||||||
// send and the private-key export path check it before asking for a password,
|
// send and the private-key export path check it before asking for a password,
|
||||||
// so a wallet whose key getSignerForAddress refuses says so instead of failing
|
// so a wallet that cannot derive its keys says so instead of failing after the
|
||||||
// after the user has typed one in.
|
// user has typed one in.
|
||||||
function selectedWalletDefect() {
|
function selectedWalletDefect() {
|
||||||
if (state.selectedWallet === null) return null;
|
if (state.selectedWallet === null) return null;
|
||||||
return walletDefect(state.wallets[state.selectedWallet]);
|
return walletDefect(state.wallets[state.selectedWallet]);
|
||||||
@@ -66,7 +65,7 @@ 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));
|
const usdTotal = formatAddressTotal(getAddressValue(addr));
|
||||||
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || " ";
|
$("address-usd-total").innerHTML = 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");
|
||||||
@@ -90,6 +89,62 @@ function show() {
|
|||||||
loadTransactions(addr.address);
|
loadTransactions(addr.address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isoDate(timestamp) {
|
||||||
|
const d = new Date(timestamp * 1000);
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
if (state.utcTimestamps) {
|
||||||
|
return (
|
||||||
|
d.getUTCFullYear() +
|
||||||
|
"-" +
|
||||||
|
pad(d.getUTCMonth() + 1) +
|
||||||
|
"-" +
|
||||||
|
pad(d.getUTCDate()) +
|
||||||
|
"T" +
|
||||||
|
pad(d.getUTCHours()) +
|
||||||
|
":" +
|
||||||
|
pad(d.getUTCMinutes()) +
|
||||||
|
":" +
|
||||||
|
pad(d.getUTCSeconds()) +
|
||||||
|
"Z"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const offsetMin = -d.getTimezoneOffset();
|
||||||
|
const sign = offsetMin >= 0 ? "+" : "-";
|
||||||
|
const absOff = Math.abs(offsetMin);
|
||||||
|
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
|
||||||
|
return (
|
||||||
|
d.getFullYear() +
|
||||||
|
"-" +
|
||||||
|
pad(d.getMonth() + 1) +
|
||||||
|
"-" +
|
||||||
|
pad(d.getDate()) +
|
||||||
|
"T" +
|
||||||
|
pad(d.getHours()) +
|
||||||
|
":" +
|
||||||
|
pad(d.getMinutes()) +
|
||||||
|
":" +
|
||||||
|
pad(d.getSeconds()) +
|
||||||
|
tzStr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(timestamp) {
|
||||||
|
const seconds = Math.floor(Date.now() / 1000 - timestamp);
|
||||||
|
if (seconds < 60) return seconds + " seconds ago";
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60)
|
||||||
|
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
if (months < 12)
|
||||||
|
return months + " month" + (months !== 1 ? "s" : "") + " ago";
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
return years + " year" + (years !== 1 ? "s" : "") + " ago";
|
||||||
|
}
|
||||||
|
|
||||||
let loadedTxs = [];
|
let loadedTxs = [];
|
||||||
|
|
||||||
let ensNameMap = new Map();
|
let ensNameMap = new Map();
|
||||||
@@ -99,7 +154,6 @@ async function loadTransactions(address) {
|
|||||||
const rawTxs = await fetchRecentTransactions(
|
const rawTxs = await fetchRecentTransactions(
|
||||||
address,
|
address,
|
||||||
state.blockscoutUrl,
|
state.blockscoutUrl,
|
||||||
currentNetwork().chainId,
|
|
||||||
);
|
);
|
||||||
const result = filterTransactions(rawTxs, {
|
const result = filterTransactions(rawTxs, {
|
||||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||||
@@ -135,7 +189,6 @@ async function loadTransactions(address) {
|
|||||||
counterparties,
|
counterparties,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
ensNameMap = new Map();
|
ensNameMap = new Map();
|
||||||
@@ -144,9 +197,6 @@ async function loadTransactions(address) {
|
|||||||
|
|
||||||
renderTransactions(txs);
|
renderTransactions(txs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
|
||||||
// src/popup/index.js.
|
|
||||||
if (ctx.pageClosed.aborted) return;
|
|
||||||
log.errorf("loadTransactions failed:", e.message);
|
log.errorf("loadTransactions failed:", e.message);
|
||||||
$("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>';
|
||||||
@@ -179,18 +229,18 @@ function renderTransactions(txs) {
|
|||||||
const amountStr = tx.value
|
const amountStr = tx.value
|
||||||
? escapeHtml(tx.value + " " + sym)
|
? escapeHtml(tx.value + " " + sym)
|
||||||
: escapeHtml(sym);
|
: escapeHtml(sym);
|
||||||
// The counterparty used to be squeezed in beside the amount and
|
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
||||||
// truncated to whatever was left over. It gets its own row now and
|
const displayAddr =
|
||||||
// is shown whole; the title or ENS name, where there is one, names
|
title || ensName || truncateMiddle(counterparty, maxAddr);
|
||||||
// it on the line above rather than replacing it.
|
const addrStr = escapeHtml(displayAddr);
|
||||||
const nameStr = escapeHtml(title || ensName || "");
|
const dot = addressDotHtml(counterparty);
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
const opacity = tx.isError ? " opacity-50" : "";
|
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||||
const ago = escapeHtml(timeAgo(tx.timestamp));
|
const ago = escapeHtml(timeAgo(tx.timestamp));
|
||||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||||
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
|
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
||||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||||
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
@@ -263,10 +313,9 @@ function init(_ctx) {
|
|||||||
$("btn-export-privkey").addEventListener("click", () => {
|
$("btn-export-privkey").addEventListener("click", () => {
|
||||||
moreDropdown.classList.add("hidden");
|
moreDropdown.classList.add("hidden");
|
||||||
moreBtn.classList.remove("bg-fg", "text-bg");
|
moreBtn.classList.remove("bg-fg", "text-bg");
|
||||||
// This address's private key can be derived from the stored key,
|
// There is no private key to export for an address this wallet
|
||||||
// but export goes through getSignerForAddress, which refuses a key
|
// cannot derive. Without this the export screen would take a
|
||||||
// that is not a master key. Without this the export screen would
|
// password and then report it as wrong.
|
||||||
// take a password and then report that refusal as a wrong password.
|
|
||||||
const defect = selectedWalletDefect();
|
const defect = selectedWalletDefect();
|
||||||
if (defect) {
|
if (defect) {
|
||||||
showFlash(defect.shortMessage);
|
showFlash(defect.shortMessage);
|
||||||
|
|||||||
@@ -6,21 +6,19 @@ const {
|
|||||||
showView,
|
showView,
|
||||||
showFlash,
|
showFlash,
|
||||||
flashCopyFeedback,
|
flashCopyFeedback,
|
||||||
txCounterpartyHtml,
|
addressDotHtml,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
nativeCurrency,
|
truncateMiddle,
|
||||||
balanceLine,
|
balanceLine,
|
||||||
unknownableAmount,
|
unknownableAmount,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
isoDate,
|
|
||||||
timeAgo,
|
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
|
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
|
||||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||||
const {
|
const {
|
||||||
@@ -39,6 +37,62 @@ const { walletDefect } = require("../../shared/walletDefects");
|
|||||||
|
|
||||||
let ctx;
|
let ctx;
|
||||||
|
|
||||||
|
function isoDate(timestamp) {
|
||||||
|
const d = new Date(timestamp * 1000);
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
if (state.utcTimestamps) {
|
||||||
|
return (
|
||||||
|
d.getUTCFullYear() +
|
||||||
|
"-" +
|
||||||
|
pad(d.getUTCMonth() + 1) +
|
||||||
|
"-" +
|
||||||
|
pad(d.getUTCDate()) +
|
||||||
|
"T" +
|
||||||
|
pad(d.getUTCHours()) +
|
||||||
|
":" +
|
||||||
|
pad(d.getUTCMinutes()) +
|
||||||
|
":" +
|
||||||
|
pad(d.getUTCSeconds()) +
|
||||||
|
"Z"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const offsetMin = -d.getTimezoneOffset();
|
||||||
|
const sign = offsetMin >= 0 ? "+" : "-";
|
||||||
|
const absOff = Math.abs(offsetMin);
|
||||||
|
const tzStr = sign + pad(Math.floor(absOff / 60)) + ":" + pad(absOff % 60);
|
||||||
|
return (
|
||||||
|
d.getFullYear() +
|
||||||
|
"-" +
|
||||||
|
pad(d.getMonth() + 1) +
|
||||||
|
"-" +
|
||||||
|
pad(d.getDate()) +
|
||||||
|
"T" +
|
||||||
|
pad(d.getHours()) +
|
||||||
|
":" +
|
||||||
|
pad(d.getMinutes()) +
|
||||||
|
":" +
|
||||||
|
pad(d.getSeconds()) +
|
||||||
|
tzStr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(timestamp) {
|
||||||
|
const seconds = Math.floor(Date.now() / 1000 - timestamp);
|
||||||
|
if (seconds < 60) return seconds + " seconds ago";
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60)
|
||||||
|
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
if (months < 12)
|
||||||
|
return months + " month" + (months !== 1 ? "s" : "") + " ago";
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
return years + " year" + (years !== 1 ? "s" : "") + " ago";
|
||||||
|
}
|
||||||
|
|
||||||
let loadedTxs = [];
|
let loadedTxs = [];
|
||||||
let ensNameMap = new Map();
|
let ensNameMap = new Map();
|
||||||
let currentSymbol = null;
|
let currentSymbol = null;
|
||||||
@@ -53,7 +107,7 @@ function show() {
|
|||||||
let symbol, amount, price;
|
let symbol, amount, price;
|
||||||
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
|
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
|
||||||
if (tokenId === "ETH") {
|
if (tokenId === "ETH") {
|
||||||
symbol = nativeCurrency();
|
symbol = "ETH";
|
||||||
amount = parseFloat(addr.balance || "0");
|
amount = parseFloat(addr.balance || "0");
|
||||||
price = getPrice("ETH");
|
price = getPrice("ETH");
|
||||||
} else {
|
} else {
|
||||||
@@ -103,7 +157,7 @@ function show() {
|
|||||||
// USD total for this token only
|
// USD total for this token only
|
||||||
const usdVal = price && amount !== null ? amount * price : null;
|
const usdVal = price && amount !== null ? amount * price : null;
|
||||||
const usdStr = formatUsd(usdVal);
|
const usdStr = formatUsd(usdVal);
|
||||||
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || " ";
|
$("address-token-usd-total").innerHTML = usdStr || " ";
|
||||||
|
|
||||||
// Single token balance line (no tokenId — not clickable here)
|
// Single token balance line (no tokenId — not clickable here)
|
||||||
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
|
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
|
||||||
@@ -148,9 +202,9 @@ function show() {
|
|||||||
if (tokenSymbol)
|
if (tokenSymbol)
|
||||||
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
|
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
|
||||||
if (tokenDecimals != null)
|
if (tokenDecimals != null)
|
||||||
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${escapeHtml(tokenDecimals)}</div>`;
|
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`;
|
||||||
if (tokenHolders != null)
|
if (tokenHolders != null)
|
||||||
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${escapeHtml(Number(tokenHolders).toLocaleString())}</div>`;
|
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}</div>`;
|
||||||
if (projectUrl)
|
if (projectUrl)
|
||||||
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
|
infoHtml += `<div class="mb-1"><span class="text-muted">Website:</span> <a href="${escapeHtml(projectUrl)}" target="_blank" rel="noopener" class="underline decoration-dashed">${escapeHtml(projectUrl)}</a></div>`;
|
||||||
contractInfo.innerHTML = infoHtml;
|
contractInfo.innerHTML = infoHtml;
|
||||||
@@ -173,7 +227,6 @@ async function loadTransactions(address, tokenId) {
|
|||||||
const rawTxs = await fetchRecentTransactions(
|
const rawTxs = await fetchRecentTransactions(
|
||||||
address,
|
address,
|
||||||
state.blockscoutUrl,
|
state.blockscoutUrl,
|
||||||
currentNetwork().chainId,
|
|
||||||
);
|
);
|
||||||
const result = filterTransactions(rawTxs, {
|
const result = filterTransactions(rawTxs, {
|
||||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||||
@@ -219,7 +272,6 @@ async function loadTransactions(address, tokenId) {
|
|||||||
counterparties,
|
counterparties,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
ensNameMap = new Map();
|
ensNameMap = new Map();
|
||||||
@@ -228,9 +280,6 @@ async function loadTransactions(address, tokenId) {
|
|||||||
|
|
||||||
renderTransactions(txs);
|
renderTransactions(txs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
|
||||||
// src/popup/index.js.
|
|
||||||
if (ctx.pageClosed.aborted) return;
|
|
||||||
log.errorf("loadTransactions failed:", e.message);
|
log.errorf("loadTransactions failed:", e.message);
|
||||||
$("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>';
|
||||||
@@ -256,18 +305,18 @@ function renderTransactions(txs) {
|
|||||||
const amountStr = tx.value
|
const amountStr = tx.value
|
||||||
? escapeHtml(tx.value + " " + sym)
|
? escapeHtml(tx.value + " " + sym)
|
||||||
: escapeHtml(sym);
|
: escapeHtml(sym);
|
||||||
// The counterparty used to be squeezed in beside the amount and
|
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
||||||
// truncated to whatever was left over. It gets its own row now and
|
const displayAddr =
|
||||||
// is shown whole; the title or ENS name, where there is one, names
|
title || ensName || truncateMiddle(counterparty, maxAddr);
|
||||||
// it on the line above rather than replacing it.
|
const addrStr = escapeHtml(displayAddr);
|
||||||
const nameStr = escapeHtml(title || ensName || "");
|
const dot = addressDotHtml(counterparty);
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
const opacity = tx.isError ? " opacity-50" : "";
|
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||||
const ago = escapeHtml(timeAgo(tx.timestamp));
|
const ago = escapeHtml(timeAgo(tx.timestamp));
|
||||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||||
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
|
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
||||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||||
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-373
@@ -1,7 +1,6 @@
|
|||||||
const {
|
const {
|
||||||
$,
|
$,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
CONTRACT_CREATION_TEXT,
|
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
showView,
|
showView,
|
||||||
showError,
|
showError,
|
||||||
@@ -9,31 +8,21 @@ const {
|
|||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
formatFee,
|
|
||||||
tokenLabel,
|
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState } = require("../../shared/state");
|
const { state, saveState } = require("../../shared/state");
|
||||||
const {
|
const { networkByChainId } = require("../../shared/networks");
|
||||||
networkByChainId,
|
|
||||||
nativeCurrencyByChainId,
|
|
||||||
} = require("../../shared/networks");
|
|
||||||
const {
|
const {
|
||||||
formatEther,
|
formatEther,
|
||||||
formatUnits,
|
formatUnits,
|
||||||
getAddress,
|
|
||||||
getBigInt,
|
|
||||||
getBytes,
|
getBytes,
|
||||||
Interface,
|
Interface,
|
||||||
MaxUint256,
|
|
||||||
toUtf8String,
|
toUtf8String,
|
||||||
TypedDataEncoder,
|
|
||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
const { getPrice, formatUsd } = require("../../shared/prices");
|
const { getPrice, formatUsd } = require("../../shared/prices");
|
||||||
const { ERC20_ABI } = require("../../shared/constants");
|
const { ERC20_ABI } = require("../../shared/constants");
|
||||||
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
|
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||||
const {
|
const {
|
||||||
resolveTokenDecimals,
|
resolveTokenDecimals,
|
||||||
resolveTokenSymbol,
|
|
||||||
unknownDecimalsAmount,
|
unknownDecimalsAmount,
|
||||||
} = require("../../shared/approvalAmount");
|
} = require("../../shared/approvalAmount");
|
||||||
// Four decimals, with the nonzero floor these screens hold: every amount this
|
// Four decimals, with the nonzero floor these screens hold: every amount this
|
||||||
@@ -74,6 +63,11 @@ function tokenAmountText(rawAmount, decimals, symbol) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tokenLabel(address) {
|
||||||
|
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
||||||
|
return t ? t.symbol : null;
|
||||||
|
}
|
||||||
|
|
||||||
// Try to decode calldata using known ABIs.
|
// Try to decode calldata using known ABIs.
|
||||||
// Returns { name, description, details } or null.
|
// Returns { name, description, details } or null.
|
||||||
function decodeCalldata(data, toAddress) {
|
function decodeCalldata(data, toAddress) {
|
||||||
@@ -91,7 +85,8 @@ function decodeCalldata(data, toAddress) {
|
|||||||
try {
|
try {
|
||||||
const parsed = erc20Iface.parseTransaction({ data });
|
const parsed = erc20Iface.parseTransaction({ data });
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
const tokenSymbol = resolveTokenSymbol(toAddress, decimalsSources);
|
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
|
||||||
|
const tokenSymbol = token ? token.symbol : null;
|
||||||
// null when no source knows this token's scale. It is not
|
// null when no source knows this token's scale. It is not
|
||||||
// defaulted to 18: an amount formatted with a guessed scale is
|
// defaulted to 18: an amount formatted with a guessed scale is
|
||||||
// the wrong number, and for a token with fewer decimals than the
|
// the wrong number, and for a token with fewer decimals than the
|
||||||
@@ -203,7 +198,7 @@ function showPhishingWarning(elementId, isPhishing) {
|
|||||||
// and the nonce. The background compares every one of them against the signed
|
// and the nonce. The background compares every one of them against the signed
|
||||||
// artifact, so every one of them has to be on the screen — a number that is
|
// artifact, so every one of them has to be on the screen — a number that is
|
||||||
// verified but never displayed is verified against nothing the user agreed to.
|
// verified but never displayed is verified against nothing the user agreed to.
|
||||||
function showTxFee(approvedTx) {
|
function showTxFee(approvedTx, ethPrice) {
|
||||||
const network = networkByChainId(approvedTx.chainId);
|
const network = networkByChainId(approvedTx.chainId);
|
||||||
$("approve-tx-network").textContent = network
|
$("approve-tx-network").textContent = network
|
||||||
? network.name
|
? network.name
|
||||||
@@ -211,13 +206,12 @@ function showTxFee(approvedTx) {
|
|||||||
|
|
||||||
const gasLimit = BigInt(approvedTx.gasLimit);
|
const gasLimit = BigInt(approvedTx.gasLimit);
|
||||||
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
|
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
|
||||||
// Through formatFee(), as the confirmation screen's fee is, so the same
|
const maxFeeEth = formatTxValue(formatEther(gasLimit * feePerGas));
|
||||||
// fee reads the same on both. In the native currency of the network shown
|
const usdStr = formatUsd(
|
||||||
// above, as the value is.
|
ethPrice ? parseFloat(maxFeeEth) * ethPrice : null,
|
||||||
$("approve-tx-fee").textContent = formatFee(
|
|
||||||
gasLimit * feePerGas,
|
|
||||||
nativeCurrencyByChainId(approvedTx.chainId),
|
|
||||||
);
|
);
|
||||||
|
$("approve-tx-fee").textContent =
|
||||||
|
maxFeeEth + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
|
||||||
|
|
||||||
let detail =
|
let detail =
|
||||||
gasLimit.toString() +
|
gasLimit.toString() +
|
||||||
@@ -248,11 +242,8 @@ function showTxApproval(details) {
|
|||||||
const approvedTx = details.approvedTx;
|
const approvedTx = details.approvedTx;
|
||||||
|
|
||||||
const toAddr = approvedTx.to;
|
const toAddr = approvedTx.to;
|
||||||
|
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
|
||||||
const ethValue = formatEther(approvedTx.value || "0");
|
const ethValue = formatEther(approvedTx.value || "0");
|
||||||
const sources = {
|
|
||||||
trackedTokens: state.trackedTokens,
|
|
||||||
wallets: state.wallets,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build txInfo for status screens
|
// Build txInfo for status screens
|
||||||
pendingTxDetails = {
|
pendingTxDetails = {
|
||||||
@@ -260,18 +251,14 @@ function showTxApproval(details) {
|
|||||||
to: toAddr || "",
|
to: toAddr || "",
|
||||||
amount: formatTxValue(ethValue),
|
amount: formatTxValue(ethValue),
|
||||||
token: "ETH",
|
token: "ETH",
|
||||||
tokenSymbol: null,
|
tokenSymbol: token ? token.symbol : null,
|
||||||
chainId: approvedTx.chainId,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// If this is an ERC-20 call, try to extract the real recipient and amount
|
// If this is an ERC-20 call, try to extract the real recipient and amount
|
||||||
const decoded = decodeCalldata(approvedTx.data, toAddr || "");
|
const decoded = decodeCalldata(approvedTx.data, toAddr || "");
|
||||||
if (decoded && decoded.details) {
|
if (decoded && decoded.details) {
|
||||||
// The asset the status summary is counted in: an ERC-20 call's Token
|
let decodedTokenAddr = null;
|
||||||
// contract, or a swap's input token. Its symbol is resolved from the
|
let decodedTokenSymbol = null;
|
||||||
// same sources as the approval screen, so a non-bundled token the
|
|
||||||
// wallet knows is not carried onto the wait and success screens as ETH.
|
|
||||||
let assetAddr = null;
|
|
||||||
for (const d of decoded.details) {
|
for (const d of decoded.details) {
|
||||||
if (d.label === "Recipient" && d.address) {
|
if (d.label === "Recipient" && d.address) {
|
||||||
pendingTxDetails.to = d.address;
|
pendingTxDetails.to = d.address;
|
||||||
@@ -279,20 +266,20 @@ function showTxApproval(details) {
|
|||||||
if (d.label === "Amount") {
|
if (d.label === "Amount") {
|
||||||
pendingTxDetails.amount = d.rawValue || d.value;
|
pendingTxDetails.amount = d.rawValue || d.value;
|
||||||
}
|
}
|
||||||
if (
|
if (d.label === "Token In" && d.isToken && d.address) {
|
||||||
(d.label === "Token" || d.label === "Token In") &&
|
const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase());
|
||||||
d.isToken &&
|
if (t) {
|
||||||
d.address
|
decodedTokenAddr = d.address;
|
||||||
) {
|
decodedTokenSymbol = t.symbol;
|
||||||
assetAddr = d.address;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (assetAddr) {
|
if (token) {
|
||||||
pendingTxDetails.token = assetAddr;
|
pendingTxDetails.token = toAddr;
|
||||||
pendingTxDetails.tokenSymbol = resolveTokenSymbol(
|
pendingTxDetails.tokenSymbol = token.symbol;
|
||||||
assetAddr,
|
} else if (decodedTokenAddr) {
|
||||||
sources,
|
pendingTxDetails.token = decodedTokenAddr;
|
||||||
);
|
pendingTxDetails.tokenSymbol = decodedTokenSymbol;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +292,7 @@ function showTxApproval(details) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
$("approve-tx-origin").textContent = details.origin;
|
$("approve-tx-hostname").textContent = details.hostname;
|
||||||
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
|
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
|
||||||
|
|
||||||
// Show token symbol next to contract address if known
|
// Show token symbol next to contract address if known
|
||||||
@@ -318,7 +305,7 @@ function showTxApproval(details) {
|
|||||||
toHtml += approvalAddressHtml(toAddr);
|
toHtml += approvalAddressHtml(toAddr);
|
||||||
$("approve-tx-to").innerHTML = toHtml;
|
$("approve-tx-to").innerHTML = toHtml;
|
||||||
} else {
|
} else {
|
||||||
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ethValueFormatted = formatTxValue(
|
const ethValueFormatted = formatTxValue(
|
||||||
@@ -327,17 +314,10 @@ function showTxApproval(details) {
|
|||||||
const ethPrice = getPrice("ETH");
|
const ethPrice = getPrice("ETH");
|
||||||
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
|
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
|
||||||
const usdStr = formatUsd(ethUsd);
|
const usdStr = formatUsd(ethUsd);
|
||||||
// In the native currency of the network the transaction is for, which the
|
|
||||||
// Network line names, not the active network's: a site can switch the
|
|
||||||
// active network after this transaction is prepared and back before it is
|
|
||||||
// signed.
|
|
||||||
$("approve-tx-value").textContent =
|
$("approve-tx-value").textContent =
|
||||||
ethValueFormatted +
|
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
|
||||||
" " +
|
|
||||||
nativeCurrencyByChainId(approvedTx.chainId) +
|
|
||||||
(usdStr ? " (" + usdStr + ")" : "");
|
|
||||||
|
|
||||||
showTxFee(approvedTx);
|
showTxFee(approvedTx, ethPrice);
|
||||||
|
|
||||||
// Decode calldata (reuse decoded from above)
|
// Decode calldata (reuse decoded from above)
|
||||||
const decodedEl = $("approve-tx-decoded");
|
const decodedEl = $("approve-tx-decoded");
|
||||||
@@ -386,287 +366,52 @@ function showTxApproval(details) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether a personal message is hex by the rule signing reads it with:
|
|
||||||
// signing takes getBytes(message), which throws on anything else.
|
|
||||||
function isHexMessage(message) {
|
|
||||||
try {
|
|
||||||
getBytes(message);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The text the hex message's bytes decode to as UTF-8, or null when they are
|
|
||||||
// not UTF-8. The caller has checked that the message is hex.
|
|
||||||
function decodeHexMessage(hex) {
|
function decodeHexMessage(hex) {
|
||||||
try {
|
try {
|
||||||
return toUtf8String(getBytes(hex));
|
const bytes = Uint8Array.from(
|
||||||
} catch {
|
hex
|
||||||
return null;
|
.slice(2)
|
||||||
}
|
.match(/.{1,2}/g)
|
||||||
}
|
.map((b) => parseInt(b, 16)),
|
||||||
|
|
||||||
// A character shown as a bordered U+XXXX mark.
|
|
||||||
function codePointMark(c) {
|
|
||||||
const code = c.codePointAt(0).toString(16).toUpperCase();
|
|
||||||
return `<span class="border border-border">U+${code.padStart(4, "0")}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The text as HTML, with each character that paints nothing (zero-width and
|
|
||||||
// bidirectional characters, variation selectors and Hangul fillers among
|
|
||||||
// them), each control character and each line or paragraph separator
|
|
||||||
// (U+2028, U+2029) shown as a mark. A line feed is shown as a line break.
|
|
||||||
// Left in the text, a paragraph separator would end the byte-order layout
|
|
||||||
// for everything after it. The marks are plain ASCII, so the second pass
|
|
||||||
// leaves them be.
|
|
||||||
function markInvisibleCharacters(text) {
|
|
||||||
return escapeHtml(text)
|
|
||||||
.replace(INVISIBLE_CHARACTERS, codePointMark)
|
|
||||||
.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, (c) =>
|
|
||||||
c === "\n" ? "<br>" : codePointMark(c),
|
|
||||||
);
|
);
|
||||||
}
|
return toUtf8String(bytes);
|
||||||
|
|
||||||
// The type ethers will sign typed data as. ethers does not read the page's
|
|
||||||
// `primaryType`: it takes the one struct in `types` that no other struct
|
|
||||||
// refers to. Throws when the types name no such single struct, which ethers
|
|
||||||
// would refuse to sign as well.
|
|
||||||
function signedPrimaryType(types) {
|
|
||||||
const structs = { ...types };
|
|
||||||
// ethers derives EIP712Domain itself and rejects it as an input.
|
|
||||||
delete structs.EIP712Domain;
|
|
||||||
return TypedDataEncoder.getPrimaryType(structs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Why a signature request cannot be signed, as a sentence for the error line,
|
|
||||||
// or null when it can. Only typed data is refused: the `primaryType` the page
|
|
||||||
// states has to be the type ethers will sign, or this screen would name one
|
|
||||||
// message while another is signed.
|
|
||||||
function typedDataRefusal(sp) {
|
|
||||||
if (sp.method === "personal_sign" || sp.method === "eth_sign") return null;
|
|
||||||
let data;
|
|
||||||
let signed;
|
|
||||||
try {
|
|
||||||
data = JSON.parse(sp.typedData);
|
|
||||||
signed = signedPrimaryType(data.types);
|
|
||||||
} catch {
|
|
||||||
return "This typed data cannot be read, so it cannot be signed.";
|
|
||||||
}
|
|
||||||
if (!data.primaryType) {
|
|
||||||
return "This typed data does not name its primary type, so it cannot be signed.";
|
|
||||||
}
|
|
||||||
if (data.primaryType !== signed) {
|
|
||||||
return (
|
|
||||||
"This typed data names its primary type as " +
|
|
||||||
data.primaryType +
|
|
||||||
", but it would be signed as " +
|
|
||||||
signed +
|
|
||||||
", so it cannot be signed."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats
|
|
||||||
// it as an allowance that is never used up.
|
|
||||||
const MAX_UINT160 = (1n << 160n) - 1n;
|
|
||||||
|
|
||||||
// One field of a struct as typed data signs it: the field's declared type and
|
|
||||||
// the struct's value for it, or null when the struct's type declares no field
|
|
||||||
// of that name. ethers signs only the fields a type declares and drops every
|
|
||||||
// other key, so a key the page adds beside them is never read here.
|
|
||||||
function declaredField(types, typeName, struct, name) {
|
|
||||||
const field = (types[typeName] || []).find((f) => f.name === name);
|
|
||||||
if (!field || !struct || typeof struct !== "object") return null;
|
|
||||||
return { type: field.type, value: struct[name] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// The tokens and amounts a Permit2 message grants, from its `details` or
|
|
||||||
// `permitted` field: one struct of `token` and `amount`, or a list of them,
|
|
||||||
// as the field's declared type says. When the field cannot be read the one
|
|
||||||
// grant returned has no token or amount, so the warning still lists it.
|
|
||||||
function permit2Grants(types, primaryType, message, name, max) {
|
|
||||||
const field = declaredField(types, primaryType, message, name);
|
|
||||||
if (!field) return [{ max }];
|
|
||||||
// `PermitDetails` is one grant, `PermitDetails[]` a list of them.
|
|
||||||
const itemType = field.type.replace(/\[\d*\]$/, "");
|
|
||||||
const items = itemType === field.type ? [field.value] : field.value;
|
|
||||||
if (!Array.isArray(items)) return [{ max }];
|
|
||||||
return items.map((item) => ({
|
|
||||||
token: declaredField(types, itemType, item, "token")?.value,
|
|
||||||
amount: declaredField(types, itemType, item, "amount")?.value,
|
|
||||||
max,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The address or number a permission field holds, or null when it holds
|
|
||||||
// none; the warning then shows `Unknown` rather than failing.
|
|
||||||
function addressOrNull(value) {
|
|
||||||
try {
|
|
||||||
return getAddress(value);
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function amountOrNull(value) {
|
|
||||||
try {
|
|
||||||
return getBigInt(value);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A warning for typed data that lets a spender take your tokens, naming the
|
|
||||||
// spender and each token and amount, or "" for any other typed data. These
|
|
||||||
// signatures are how most wallet drains are done, and as plain key/value
|
|
||||||
// lines they read exactly like a sign-in message. They are recognised by the
|
|
||||||
// type ethers signs, `Permit` or one of Permit2's six signature types: a
|
|
||||||
// contract checks the type's exact name, so a renamed copy of one of these
|
|
||||||
// would not be honoured. Everything named is read only from the fields that
|
|
||||||
// type declares, except a `Permit`'s token, which is the domain's
|
|
||||||
// `verifyingContract`; whatever they do not give is shown as `Unknown`.
|
|
||||||
function permitWarningHtml(types, primaryType, domain, message) {
|
|
||||||
// Each entry is a token, the amount, and the largest value its amount
|
|
||||||
// field holds, which the contract treats as unlimited.
|
|
||||||
let grants;
|
|
||||||
switch (primaryType) {
|
|
||||||
case "Permit": {
|
|
||||||
// EIP-2612 declares a `value`. DAI's older permit, signed under
|
|
||||||
// the same name, declares only `allowed`: unlimited, or nothing.
|
|
||||||
// Others, such as the permit for a Uniswap v3 position, declare
|
|
||||||
// neither, and their amount is unknown.
|
|
||||||
const value = declaredField(types, primaryType, message, "value");
|
|
||||||
const allowed = declaredField(
|
|
||||||
types,
|
|
||||||
primaryType,
|
|
||||||
message,
|
|
||||||
"allowed",
|
|
||||||
);
|
|
||||||
let amount;
|
|
||||||
if (value) amount = value.value;
|
|
||||||
else if (allowed) amount = allowed.value ? MaxUint256 : 0n;
|
|
||||||
grants = [
|
|
||||||
{ token: domain?.verifyingContract, amount, max: MaxUint256 },
|
|
||||||
];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "PermitSingle":
|
|
||||||
case "PermitBatch":
|
|
||||||
grants = permit2Grants(
|
|
||||||
types,
|
|
||||||
primaryType,
|
|
||||||
message,
|
|
||||||
"details",
|
|
||||||
MAX_UINT160,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "PermitTransferFrom":
|
|
||||||
case "PermitWitnessTransferFrom":
|
|
||||||
case "PermitBatchTransferFrom":
|
|
||||||
case "PermitBatchWitnessTransferFrom":
|
|
||||||
grants = permit2Grants(
|
|
||||||
types,
|
|
||||||
primaryType,
|
|
||||||
message,
|
|
||||||
"permitted",
|
|
||||||
MaxUint256,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const sources = {
|
|
||||||
trackedTokens: state.trackedTokens,
|
|
||||||
wallets: state.wallets,
|
|
||||||
};
|
|
||||||
const spender = addressOrNull(
|
|
||||||
declaredField(types, primaryType, message, "spender")?.value,
|
|
||||||
);
|
|
||||||
let html = `<div class="mb-2 p-2 font-bold bg-red-100 text-red-800 border-2 border-red-600 rounded-md">`;
|
|
||||||
html += `<div class="mb-2">⚠️ TOKEN PERMISSION: Signing this lets the spender below take the tokens listed here from your address, without asking you again.</div>`;
|
|
||||||
html += `<div class="mb-2"><div>Spender</div>`;
|
|
||||||
html += spender ? approvalAddressHtml(spender) : `<div>Unknown</div>`;
|
|
||||||
html += `</div>`;
|
|
||||||
for (const grant of grants) {
|
|
||||||
const token = addressOrNull(grant.token);
|
|
||||||
const amount = amountOrNull(grant.amount);
|
|
||||||
// `Unlimited` as on the ERC-20 approve line; otherwise the quantity,
|
|
||||||
// or base units when nothing knows the token's scale.
|
|
||||||
let amountText = "Unknown";
|
|
||||||
if (amount === grant.max) {
|
|
||||||
amountText = "Unlimited";
|
|
||||||
} else if (amount !== null && token === null) {
|
|
||||||
amountText = unknownDecimalsAmount(amount);
|
|
||||||
} else if (amount !== null) {
|
|
||||||
amountText = tokenAmountText(
|
|
||||||
amount,
|
|
||||||
resolveTokenDecimals(token, sources),
|
|
||||||
tokenLabel(token),
|
|
||||||
).display;
|
|
||||||
}
|
|
||||||
html += `<div class="mb-2"><div>Token</div>`;
|
|
||||||
if (token) {
|
|
||||||
html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`;
|
|
||||||
html += approvalAddressHtml(token);
|
|
||||||
} else {
|
|
||||||
html += `<div>Unknown</div>`;
|
|
||||||
}
|
|
||||||
html += `</div>`;
|
|
||||||
html += `<div class="mb-2"><div>Amount</div><div>${escapeHtml(amountText)}</div></div>`;
|
|
||||||
}
|
|
||||||
html += `</div>`;
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The typed data as the screen shows it. The primary type shown is the one
|
|
||||||
// ethers signs, never the page's word for it; typedDataRefusal() keeps the two
|
|
||||||
// from differing on anything that can be signed. Only typed data that cannot
|
|
||||||
// be read at all is shown as raw text, and typedDataRefusal() refuses it.
|
|
||||||
function formatTypedDataHtml(jsonStr) {
|
function formatTypedDataHtml(jsonStr) {
|
||||||
let data;
|
|
||||||
let primaryType;
|
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(jsonStr);
|
const data = JSON.parse(jsonStr);
|
||||||
primaryType = signedPrimaryType(data.types);
|
let html = "";
|
||||||
|
|
||||||
|
if (data.domain) {
|
||||||
|
html += `<div class="mb-2"><div class="text-muted">Domain</div>`;
|
||||||
|
for (const [key, val] of Object.entries(data.domain)) {
|
||||||
|
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> ${escapeHtml(String(val))}</div>`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.primaryType) {
|
||||||
|
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
|
||||||
|
html += `<div class="font-bold">${escapeHtml(data.primaryType)}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.message) {
|
||||||
|
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
|
||||||
|
for (const [key, val] of Object.entries(data.message)) {
|
||||||
|
const display =
|
||||||
|
typeof val === "object" ? JSON.stringify(val) : String(val);
|
||||||
|
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display)}</span></div>`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
} catch {
|
} catch {
|
||||||
return `<div class="break-all">${escapeHtml(jsonStr)}</div>`;
|
return `<div class="break-all">${escapeHtml(jsonStr)}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = permitWarningHtml(
|
|
||||||
data.types,
|
|
||||||
primaryType,
|
|
||||||
data.domain,
|
|
||||||
data.message,
|
|
||||||
);
|
|
||||||
|
|
||||||
// A value that is an object is shown as JSON: String() of it says
|
|
||||||
// nothing, and throws for some objects a page can send.
|
|
||||||
const display = (val) =>
|
|
||||||
typeof val === "object" ? JSON.stringify(val) : String(val);
|
|
||||||
|
|
||||||
if (data.domain) {
|
|
||||||
html += `<div class="mb-2"><div class="text-muted">Domain</div>`;
|
|
||||||
for (const [key, val] of Object.entries(data.domain)) {
|
|
||||||
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> ${escapeHtml(display(val))}</div>`;
|
|
||||||
}
|
|
||||||
html += `</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
|
|
||||||
html += `<div class="font-bold">${escapeHtml(primaryType)}</div></div>`;
|
|
||||||
|
|
||||||
if (data.message) {
|
|
||||||
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
|
|
||||||
for (const [key, val] of Object.entries(data.message)) {
|
|
||||||
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
|
|
||||||
}
|
|
||||||
html += `</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSignApproval(details) {
|
function showSignApproval(details) {
|
||||||
@@ -679,7 +424,7 @@ function showSignApproval(details) {
|
|||||||
pendingSignParams = sp;
|
pendingSignParams = sp;
|
||||||
pendingSignFrom = details.approvedFrom;
|
pendingSignFrom = details.approvedFrom;
|
||||||
|
|
||||||
$("approve-sign-origin").textContent = details.origin;
|
$("approve-sign-hostname").textContent = details.hostname;
|
||||||
$("approve-sign-from").innerHTML = approvalAddressHtml(
|
$("approve-sign-from").innerHTML = approvalAddressHtml(
|
||||||
details.approvedFrom,
|
details.approvedFrom,
|
||||||
);
|
);
|
||||||
@@ -691,33 +436,15 @@ function showSignApproval(details) {
|
|||||||
? "Typed data (EIP-712)"
|
? "Typed data (EIP-712)"
|
||||||
: "Personal message";
|
: "Personal message";
|
||||||
|
|
||||||
// A personal message is signed as the bytes its hex encodes, so the hex
|
|
||||||
// is shown as well as any text it decodes to, and that text is laid out
|
|
||||||
// left to right in the order of its bytes. Signing reads the bytes from
|
|
||||||
// the hex, so a message that is not hex cannot be signed: it is shown as
|
|
||||||
// the text it is, and refused.
|
|
||||||
let refusal = null;
|
|
||||||
$("approve-sign-hex-section").classList.add("hidden");
|
|
||||||
$("approve-sign-message").classList.toggle("am-byte-order", !isTyped);
|
|
||||||
if (isTyped) {
|
if (isTyped) {
|
||||||
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
|
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
|
||||||
refusal = typedDataRefusal(sp);
|
} else {
|
||||||
} else if (isHexMessage(sp.message)) {
|
|
||||||
const decoded = decodeHexMessage(sp.message);
|
const decoded = decodeHexMessage(sp.message);
|
||||||
if (decoded !== null) {
|
if (decoded !== null) {
|
||||||
$("approve-sign-message").innerHTML =
|
$("approve-sign-message").textContent = decoded;
|
||||||
markInvisibleCharacters(decoded);
|
|
||||||
} else {
|
} else {
|
||||||
$("approve-sign-message").textContent = "This message is not text.";
|
$("approve-sign-message").textContent = sp.message;
|
||||||
}
|
}
|
||||||
$("approve-sign-hex").textContent = sp.message;
|
|
||||||
$("approve-sign-hex-section").classList.remove("hidden");
|
|
||||||
} else {
|
|
||||||
$("approve-sign-message").innerHTML = markInvisibleCharacters(
|
|
||||||
sp.message,
|
|
||||||
);
|
|
||||||
refusal =
|
|
||||||
"This message is plain text, not hex, so it cannot be signed.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display danger warning for eth_sign (raw hash signing)
|
// Display danger warning for eth_sign (raw hash signing)
|
||||||
@@ -739,12 +466,6 @@ function showSignApproval(details) {
|
|||||||
|
|
||||||
showView("approve-sign");
|
showView("approve-sign");
|
||||||
attachCopyHandlers("view-approve-sign");
|
attachCopyHandlers("view-approve-sign");
|
||||||
if (refusal) {
|
|
||||||
showError("approve-sign-error", refusal);
|
|
||||||
$("btn-approve-sign").disabled = true;
|
|
||||||
$("btn-approve-sign").classList.add("text-muted");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
gateOnWalletDefect(
|
gateOnWalletDefect(
|
||||||
"approve-sign-error",
|
"approve-sign-error",
|
||||||
"btn-approve-sign",
|
"btn-approve-sign",
|
||||||
@@ -783,7 +504,7 @@ async function show(id) {
|
|||||||
"approve-site-phishing-warning",
|
"approve-site-phishing-warning",
|
||||||
details.isPhishingDomain,
|
details.isPhishingDomain,
|
||||||
);
|
);
|
||||||
$("approve-origin").textContent = details.origin;
|
$("approve-hostname").textContent = details.hostname;
|
||||||
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
|
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
|
||||||
attachCopyHandlers("view-approve-site");
|
attachCopyHandlers("view-approve-site");
|
||||||
$("approve-remember").checked = state.rememberSiteChoice;
|
$("approve-remember").checked = state.rememberSiteChoice;
|
||||||
@@ -822,10 +543,9 @@ function setSignButtonBusy(busy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Say so on the approval screen itself, and disable the approve button, when
|
// Say so on the approval screen itself, and disable the approve button, when
|
||||||
// the address the approval was raised for belongs to a wallet whose key
|
// the address the approval was raised for belongs to a wallet whose keys
|
||||||
// getSignerForAddress refuses. Without this the screen would take a password
|
// cannot be derived. Without this the screen would take a password and fail
|
||||||
// and fail after deriving it. Reject stays available; the wallet is not
|
// after deriving it. Reject stays available; the wallet is not touched.
|
||||||
// touched.
|
|
||||||
// Returns true when it gated.
|
// Returns true when it gated.
|
||||||
function gateOnWalletDefect(errorId, buttonId, address) {
|
function gateOnWalletDefect(errorId, buttonId, address) {
|
||||||
const owner = findWalletFor(address);
|
const owner = findWalletFor(address);
|
||||||
@@ -1051,16 +771,6 @@ function init(_ctx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checked again where the signing starts, not only when the screen
|
|
||||||
// was drawn, and the button stays disabled: this request can never be
|
|
||||||
// signed.
|
|
||||||
const refusal = typedDataRefusal(pendingSignParams);
|
|
||||||
if (refusal) {
|
|
||||||
password = null;
|
|
||||||
showError("approve-sign-error", refusal);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt here, in the popup. The password must never cross the
|
// Decrypt here, in the popup. The password must never cross the
|
||||||
// extension messaging boundary; only the signature does.
|
// extension messaging boundary; only the signature does.
|
||||||
let decryptedSecret;
|
let decryptedSecret;
|
||||||
@@ -1152,10 +862,4 @@ function init(_ctx) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = { init, show, decodeCalldata };
|
||||||
init,
|
|
||||||
show,
|
|
||||||
decodeCalldata,
|
|
||||||
formatTypedDataHtml,
|
|
||||||
typedDataRefusal,
|
|
||||||
};
|
|
||||||
|
|||||||
+75
-187
@@ -11,17 +11,13 @@ const {
|
|||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
nativeCurrency,
|
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
blockieHtml,
|
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
onViewLeave,
|
onViewLeave,
|
||||||
formatFee,
|
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, currentNetwork } = require("../../shared/state");
|
const { state } = require("../../shared/state");
|
||||||
const { getSignerForAddress } = require("../../shared/wallet");
|
const { getSignerForAddress } = require("../../shared/wallet");
|
||||||
const { walletDefect } = require("../../shared/walletDefects");
|
|
||||||
const { decryptWithPassword } = require("../../shared/vault");
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
@@ -34,10 +30,6 @@ const {
|
|||||||
displayedDecimals,
|
displayedDecimals,
|
||||||
transferAmountUnits,
|
transferAmountUnits,
|
||||||
} = require("../../shared/transferAmount");
|
} = require("../../shared/transferAmount");
|
||||||
const { assertWithinCeilings } = require("../../shared/approvalVerify");
|
|
||||||
// The balance lines, the fee reserve and the insufficient-balance messages go
|
|
||||||
// through it, as the approval screen's amounts do.
|
|
||||||
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
|
|
||||||
const {
|
const {
|
||||||
CODES,
|
CODES,
|
||||||
FEE_PENDING,
|
FEE_PENDING,
|
||||||
@@ -45,10 +37,10 @@ const {
|
|||||||
FEE_UNAVAILABLE,
|
FEE_UNAVAILABLE,
|
||||||
feeReserveWei,
|
feeReserveWei,
|
||||||
feeEstimateWei,
|
feeEstimateWei,
|
||||||
maxEthAmount,
|
|
||||||
validateTransfer,
|
validateTransfer,
|
||||||
} = require("../../shared/txValidation");
|
} = require("../../shared/txValidation");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
const makeBlockie = require("ethereum-blockies-base64");
|
||||||
const txStatus = require("./txStatus");
|
const txStatus = require("./txStatus");
|
||||||
|
|
||||||
let pendingTx = null;
|
let pendingTx = null;
|
||||||
@@ -56,10 +48,6 @@ let pendingTx = null;
|
|||||||
// filled in by estimateGas() when the estimate resolves or fails.
|
// filled in by estimateGas() when the estimate resolves or fails.
|
||||||
let feeStatus = FEE_PENDING;
|
let feeStatus = FEE_PENDING;
|
||||||
let feeWei = null;
|
let feeWei = null;
|
||||||
// The fee fields a max ETH send is signed with: those of the estimate its
|
|
||||||
// amount was derived from. Null for any other send, which ethers prices from
|
|
||||||
// the node at signing time.
|
|
||||||
let maxSendFees = null;
|
|
||||||
|
|
||||||
function restore() {
|
function restore() {
|
||||||
const d = state.viewData;
|
const d = state.viewData;
|
||||||
@@ -68,6 +56,11 @@ function restore() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function blockieHtml(address) {
|
||||||
|
const src = makeBlockie(address);
|
||||||
|
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
|
||||||
|
}
|
||||||
|
|
||||||
function confirmAddressHtml(address, ensName, title) {
|
function confirmAddressHtml(address, ensName, title) {
|
||||||
const blockie = blockieHtml(address);
|
const blockie = blockieHtml(address);
|
||||||
return (
|
return (
|
||||||
@@ -83,30 +76,16 @@ function valueWithUsd(text, usdAmount) {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Amount line, with its USD value. A max ETH send's line is drawn again
|
|
||||||
// once its amount is re-derived from the fee estimate.
|
|
||||||
function renderAmount(txInfo) {
|
|
||||||
const isErc20 = txInfo.token !== "ETH";
|
|
||||||
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
|
|
||||||
const price = isErc20 ? getPrice(rawSymbol) : getPrice("ETH");
|
|
||||||
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
|
||||||
$("confirm-amount").textContent = valueWithUsd(
|
|
||||||
txInfo.amount + " " + displaySymbol(rawSymbol),
|
|
||||||
amountUsd,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function show(txInfo) {
|
function show(txInfo) {
|
||||||
pendingTx = txInfo;
|
pendingTx = txInfo;
|
||||||
feeStatus = FEE_PENDING;
|
feeStatus = FEE_PENDING;
|
||||||
feeWei = null;
|
feeWei = null;
|
||||||
maxSendFees = null;
|
|
||||||
|
|
||||||
const isErc20 = txInfo.token !== "ETH";
|
const isErc20 = txInfo.token !== "ETH";
|
||||||
// The raw symbol is the price-table key; the capped one is what the
|
// The raw symbol is the price-table key; the capped one is what the
|
||||||
// screen says. Truncating before the lookup would silently drop the
|
// screen says. Truncating before the lookup would silently drop the
|
||||||
// price of any token whose symbol is long enough to be capped.
|
// price of any token whose symbol is long enough to be capped.
|
||||||
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrency();
|
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
||||||
const symbol = displaySymbol(rawSymbol);
|
const symbol = displaySymbol(rawSymbol);
|
||||||
|
|
||||||
// Transaction type
|
// Transaction type
|
||||||
@@ -114,7 +93,7 @@ function show(txInfo) {
|
|||||||
$("confirm-type").textContent =
|
$("confirm-type").textContent =
|
||||||
"ERC-20 token transfer (" + symbol + ")";
|
"ERC-20 token transfer (" + symbol + ")";
|
||||||
} else {
|
} else {
|
||||||
$("confirm-type").textContent = "Native " + symbol + " transfer";
|
$("confirm-type").textContent = "Native ETH transfer";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token contract section (ERC-20 only)
|
// Token contract section (ERC-20 only)
|
||||||
@@ -147,11 +126,18 @@ function show(txInfo) {
|
|||||||
);
|
);
|
||||||
$("confirm-to-ens").classList.add("hidden");
|
$("confirm-to-ens").classList.add("hidden");
|
||||||
|
|
||||||
renderAmount(txInfo);
|
// Amount (with inline USD)
|
||||||
|
|
||||||
// Balance (with inline USD)
|
|
||||||
const ethPrice = getPrice("ETH");
|
const ethPrice = getPrice("ETH");
|
||||||
const tokenPrice = getPrice(rawSymbol);
|
const tokenPrice = getPrice(rawSymbol);
|
||||||
|
const amountNum = parseFloat(txInfo.amount);
|
||||||
|
const price = isErc20 ? tokenPrice : ethPrice;
|
||||||
|
const amountUsd = price ? amountNum * price : null;
|
||||||
|
$("confirm-amount").textContent = valueWithUsd(
|
||||||
|
txInfo.amount + " " + symbol,
|
||||||
|
amountUsd,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Balance (with inline USD)
|
||||||
if (isErc20) {
|
if (isErc20) {
|
||||||
// null is a balance whose scale nothing knows, not a balance of zero
|
// null is a balance whose scale nothing knows, not a balance of zero
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
|
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
|
||||||
@@ -163,17 +149,11 @@ function show(txInfo) {
|
|||||||
$("confirm-balance").textContent =
|
$("confirm-balance").textContent =
|
||||||
bal == null
|
bal == null
|
||||||
? "unknown (" + symbol + ")"
|
? "unknown (" + symbol + ")"
|
||||||
: valueWithUsd(
|
: valueWithUsd(bal + " " + symbol, balUsd);
|
||||||
truncateAmountNeverZero(bal) + " " + symbol,
|
|
||||||
balUsd,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const bal = txInfo.balance || "0";
|
const bal = txInfo.balance || "0";
|
||||||
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
||||||
$("confirm-balance").textContent = valueWithUsd(
|
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
|
||||||
truncateAmountNeverZero(bal) + " " + symbol,
|
|
||||||
balUsd,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for warnings (synchronous local checks)
|
// Check for warnings (synchronous local checks)
|
||||||
@@ -206,32 +186,6 @@ function show(txInfo) {
|
|||||||
// estimate landing later never moves anything.
|
// estimate landing later never moves anything.
|
||||||
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
||||||
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
||||||
$("confirm-gas-error").textContent =
|
|
||||||
"You do not have enough " +
|
|
||||||
nativeCurrency() +
|
|
||||||
" to pay the network fee for this transfer. Please add " +
|
|
||||||
nativeCurrency() +
|
|
||||||
" to this address and try again.";
|
|
||||||
|
|
||||||
// Shown later, once checkRecipientHistory() finds a contract.
|
|
||||||
$("confirm-contract-warning").textContent =
|
|
||||||
"WARNING: The recipient is a smart contract. Sending " +
|
|
||||||
nativeCurrency() +
|
|
||||||
" or tokens directly to a contract may result in permanent loss of" +
|
|
||||||
" funds.";
|
|
||||||
|
|
||||||
// The fee-unknown message names its cause, which is also known here.
|
|
||||||
// Without the token's scale estimateGas() cannot encode the transfer, so
|
|
||||||
// the estimate fails every time and going back cannot help; any other
|
|
||||||
// failure may clear on a retry.
|
|
||||||
$("confirm-fee-unknown-error").textContent =
|
|
||||||
isErc20 && txInfo.tokenDecimals == null
|
|
||||||
? "The network fee could not be estimated, because this wallet" +
|
|
||||||
" does not know how many decimal places this token uses, so" +
|
|
||||||
" this transaction cannot be sent."
|
|
||||||
: "The network fee could not be estimated, so this transaction" +
|
|
||||||
" cannot be checked against your balance. Please go back and" +
|
|
||||||
" try again.";
|
|
||||||
|
|
||||||
renderValidation(txInfo);
|
renderValidation(txInfo);
|
||||||
|
|
||||||
@@ -267,9 +221,7 @@ function show(txInfo) {
|
|||||||
// touches already occupies its space, so re-running it never moves anything.
|
// touches already occupies its space, so re-running it never moves anything.
|
||||||
function renderValidation(txInfo) {
|
function renderValidation(txInfo) {
|
||||||
const isErc20 = txInfo.token !== "ETH";
|
const isErc20 = txInfo.token !== "ETH";
|
||||||
const symbol = isErc20
|
const symbol = isErc20 ? displaySymbol(txInfo.tokenSymbol || "?") : "ETH";
|
||||||
? displaySymbol(txInfo.tokenSymbol || "?")
|
|
||||||
: nativeCurrency();
|
|
||||||
|
|
||||||
const { canSend, codes } = validateTransfer({
|
const { canSend, codes } = validateTransfer({
|
||||||
isErc20,
|
isErc20,
|
||||||
@@ -281,8 +233,7 @@ function renderValidation(txInfo) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Messages carrying the user's own numbers are built here; the fixed
|
// Messages carrying the user's own numbers are built here; the fixed
|
||||||
// sentences live in the reserved elements in index.html, except the
|
// sentences live in the reserved elements in index.html.
|
||||||
// gas and fee-unknown ones, which show() sets.
|
|
||||||
const messages = [];
|
const messages = [];
|
||||||
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
||||||
messages.push("Please enter a valid amount to send.");
|
messages.push("Please enter a valid amount to send.");
|
||||||
@@ -297,7 +248,7 @@ function renderValidation(txInfo) {
|
|||||||
: "Insufficient " +
|
: "Insufficient " +
|
||||||
symbol +
|
symbol +
|
||||||
" balance. You have " +
|
" balance. You have " +
|
||||||
truncateAmountNeverZero(txInfo.tokenBalance) +
|
txInfo.tokenBalance +
|
||||||
" " +
|
" " +
|
||||||
symbol +
|
symbol +
|
||||||
" but are trying to send " +
|
" but are trying to send " +
|
||||||
@@ -310,14 +261,10 @@ function renderValidation(txInfo) {
|
|||||||
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
|
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
|
||||||
messages.push(
|
messages.push(
|
||||||
"Insufficient balance. You have " +
|
"Insufficient balance. You have " +
|
||||||
truncateAmountNeverZero(txInfo.balance || "0") +
|
txInfo.balance +
|
||||||
" " +
|
" ETH but are trying to send " +
|
||||||
symbol +
|
|
||||||
" but are trying to send " +
|
|
||||||
txInfo.amount +
|
txInfo.amount +
|
||||||
" " +
|
" ETH.",
|
||||||
symbol +
|
|
||||||
".",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +304,14 @@ function setVisible(id, visible) {
|
|||||||
$(id).style.visibility = visible ? "visible" : "hidden";
|
$(id).style.visibility = visible ? "visible" : "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A fee in wei as an ETH string, truncated to 6 decimal places.
|
||||||
|
function formatFeeEth(wei) {
|
||||||
|
const parts = formatEther(wei).split(".");
|
||||||
|
const dec =
|
||||||
|
parts.length > 1 ? parts[1].slice(0, 6).replace(/0+$/, "") || "0" : "0";
|
||||||
|
return parts[0] + "." + dec + " ETH";
|
||||||
|
}
|
||||||
|
|
||||||
async function estimateGas(txInfo) {
|
async function estimateGas(txInfo) {
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
@@ -388,8 +343,8 @@ async function estimateGas(txInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// What the node will require to be reserved, which is what the gate
|
// What the node will require to be reserved, which is what the gate
|
||||||
// must be: the send is broadcast as a type-2 transaction priced at
|
// must be: the send pins no fee fields, so it is broadcast as a
|
||||||
// maxFeePerGas, which only a max ETH send pins (see below).
|
// type-2 transaction priced at maxFeePerGas.
|
||||||
const gasCostWei = feeReserveWei(gasLimit, feeData);
|
const gasCostWei = feeReserveWei(gasLimit, feeData);
|
||||||
if (gasCostWei === null) {
|
if (gasCostWei === null) {
|
||||||
throw new Error("no usable gas price from the provider");
|
throw new Error("no usable gas price from the provider");
|
||||||
@@ -403,57 +358,33 @@ async function estimateGas(txInfo) {
|
|||||||
// flight; a stale fee must not reach the screen or the balance check.
|
// flight; a stale fee must not reach the screen or the balance check.
|
||||||
if (pendingTx !== txInfo) return;
|
if (pendingTx !== txInfo) return;
|
||||||
|
|
||||||
// The fee line goes through formatFee(), as the approval screen's
|
const ethPrice = getPrice("ETH");
|
||||||
// does, so the same fee reads the same on both.
|
const usd = (wei) =>
|
||||||
|
ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
|
||||||
|
|
||||||
if (estimateWei !== null && estimateWei < gasCostWei) {
|
if (estimateWei !== null && estimateWei < gasCostWei) {
|
||||||
$("confirm-fee-amount").textContent =
|
$("confirm-fee-amount").textContent = valueWithUsd(
|
||||||
"~" + formatFee(estimateWei, nativeCurrency());
|
"~" + formatFeeEth(estimateWei),
|
||||||
|
usd(estimateWei),
|
||||||
|
);
|
||||||
$("confirm-fee-reserve").textContent =
|
$("confirm-fee-reserve").textContent =
|
||||||
"up to " +
|
"up to " + formatFeeEth(gasCostWei) + " reserved";
|
||||||
truncateAmountNeverZero(formatEther(gasCostWei)) +
|
|
||||||
" " +
|
|
||||||
nativeCurrency() +
|
|
||||||
" reserved";
|
|
||||||
setVisible("confirm-fee-reserve", true);
|
setVisible("confirm-fee-reserve", true);
|
||||||
} else {
|
} else {
|
||||||
// No spread to report: either there is no estimate, or the node
|
// No spread to report: either there is no estimate, or the node
|
||||||
// quotes a gas price at or above maxFeePerGas, so the expected
|
// quotes a gas price at or above maxFeePerGas, so the expected
|
||||||
// cost is not below the reserve. Show the reserve alone.
|
// cost is not below the reserve. Show the reserve alone.
|
||||||
$("confirm-fee-amount").textContent = formatFee(
|
$("confirm-fee-amount").textContent = valueWithUsd(
|
||||||
gasCostWei,
|
formatFeeEth(gasCostWei),
|
||||||
nativeCurrency(),
|
usd(gasCostWei),
|
||||||
);
|
);
|
||||||
setVisible("confirm-fee-reserve", false);
|
setVisible("confirm-fee-reserve", false);
|
||||||
}
|
}
|
||||||
feeStatus = FEE_KNOWN;
|
feeStatus = FEE_KNOWN;
|
||||||
feeWei = gasCostWei;
|
feeWei = gasCostWei;
|
||||||
// A max ETH send is the balance minus this estimate's reserve, not the
|
|
||||||
// Send screen's, and is signed with this estimate's fee fields: fees
|
|
||||||
// fetched again at signing could exceed the reserve it leaves, and the
|
|
||||||
// node would refuse it for want of funds. Where the balance no longer
|
|
||||||
// covers the fee, the amount is left as it is and the balance check
|
|
||||||
// below says so.
|
|
||||||
if (txInfo.max && txInfo.token === "ETH") {
|
|
||||||
const amount = maxEthAmount(txInfo.balance, gasCostWei);
|
|
||||||
if (amount !== null) {
|
|
||||||
txInfo.amount = amount;
|
|
||||||
renderAmount(txInfo);
|
|
||||||
// Priced as feeReserveWei() priced the reserve: maxFeePerGas,
|
|
||||||
// or gasPrice on a network with no type-2 pricing.
|
|
||||||
if (feeData.maxFeePerGas != null) {
|
|
||||||
maxSendFees = {
|
|
||||||
gasLimit,
|
|
||||||
maxFeePerGas: feeData.maxFeePerGas,
|
|
||||||
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
maxSendFees = { gasLimit, gasPrice: feeData.gasPrice };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
renderValidation(txInfo);
|
renderValidation(txInfo);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("gas estimation failed:", e.shortMessage || e.message);
|
log.errorf("gas estimation failed:", e.message);
|
||||||
if (pendingTx !== txInfo) return;
|
if (pendingTx !== txInfo) return;
|
||||||
$("confirm-fee-amount").textContent = "Unable to estimate";
|
$("confirm-fee-amount").textContent = "Unable to estimate";
|
||||||
setVisible("confirm-fee-reserve", false);
|
setVisible("confirm-fee-reserve", false);
|
||||||
@@ -463,47 +394,6 @@ async function estimateGas(txInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate the transaction this send describes, enforce the fee bound against
|
|
||||||
// the fees that were actually filled in, then sign and broadcast it. Apart
|
|
||||||
// from a max ETH send, which passes its estimate's fee fields as `fees`, the
|
|
||||||
// send pins no fee fields, so ethers fills maxFeePerGas and the gas limit from
|
|
||||||
// what the configured RPC node answers, with nothing otherwise bounding what a
|
|
||||||
// hostile node can set — the dApp path's ceilings never reached this one.
|
|
||||||
// Populating before the check is what makes assertWithinCeilings() see the
|
|
||||||
// same numbers that would be signed; it throws an ApprovalMismatchError when
|
|
||||||
// the product gasLimit × maxFeePerGas is over the bound, which the caller
|
|
||||||
// shows in the reserved error area rather than sending.
|
|
||||||
async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
|
|
||||||
let request;
|
|
||||||
if (tx.token === "ETH") {
|
|
||||||
request = { to: tx.to, value: parseEther(tx.amount), ...fees };
|
|
||||||
} else {
|
|
||||||
const contract = new Contract(tx.token, ERC20_ABI, connectedSigner);
|
|
||||||
// The contract's decimals() is read to be COMPARED with the scale the
|
|
||||||
// screen rendered this amount at, not to encode with: encoding from it
|
|
||||||
// signs whatever the contract answers now, which is not what the user
|
|
||||||
// read. A disagreement throws. See transferAmount.js.
|
|
||||||
const amount = transferAmountUnits(
|
|
||||||
tx.amount,
|
|
||||||
tx.tokenDecimals,
|
|
||||||
await contract.decimals(),
|
|
||||||
);
|
|
||||||
request = await contract.transfer.populateTransaction(tx.to, amount);
|
|
||||||
}
|
|
||||||
const populated = await connectedSigner.populateTransaction(request);
|
|
||||||
assertWithinCeilings(populated);
|
|
||||||
return connectedSigner.sendTransaction(populated);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show a full-sentence send failure in the reserved errors box, the same
|
|
||||||
// element and markup renderValidation() uses for messages carrying the user's
|
|
||||||
// own numbers, so it never moves anything on the screen.
|
|
||||||
function showSendError(message) {
|
|
||||||
const el = $("confirm-errors");
|
|
||||||
el.innerHTML = `<div class="text-xs">${escapeHtml(message)}</div>`;
|
|
||||||
el.style.visibility = "visible";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkRecipientHistory(txInfo) {
|
async function checkRecipientHistory(txInfo) {
|
||||||
try {
|
try {
|
||||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
@@ -538,15 +428,6 @@ function init(_ctx) {
|
|||||||
onViewLeave("confirm-tx", clearPassword);
|
onViewLeave("confirm-tx", clearPassword);
|
||||||
|
|
||||||
$("btn-confirm-send").addEventListener("click", async () => {
|
$("btn-confirm-send").addEventListener("click", async () => {
|
||||||
const wallet = state.wallets[state.selectedWallet];
|
|
||||||
// Every Send button refuses a defective wallet before this screen,
|
|
||||||
// but the popup also reopens onto it from a saved view.
|
|
||||||
const defect = walletDefect(wallet);
|
|
||||||
if (defect) {
|
|
||||||
showError("confirm-tx-password-error", defect.shortMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const password = $("confirm-tx-password").value;
|
const password = $("confirm-tx-password").value;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
showError(
|
showError(
|
||||||
@@ -556,6 +437,7 @@ function init(_ctx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wallet = state.wallets[state.selectedWallet];
|
||||||
let decryptedSecret;
|
let decryptedSecret;
|
||||||
hideError("confirm-tx-password-error");
|
hideError("confirm-tx-password-error");
|
||||||
|
|
||||||
@@ -575,10 +457,6 @@ function init(_ctx) {
|
|||||||
$("btn-confirm-send").disabled = true;
|
$("btn-confirm-send").disabled = true;
|
||||||
$("btn-confirm-send").classList.add("text-muted");
|
$("btn-confirm-send").classList.add("text-muted");
|
||||||
|
|
||||||
// The network it is sent on. The wait, success and error screens
|
|
||||||
// label its amount by this, not by the network active when they draw.
|
|
||||||
pendingTx.chainId = currentNetwork().chainId;
|
|
||||||
|
|
||||||
let tx;
|
let tx;
|
||||||
try {
|
try {
|
||||||
const signer = getSignerForAddress(
|
const signer = getSignerForAddress(
|
||||||
@@ -589,11 +467,29 @@ function init(_ctx) {
|
|||||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
const connectedSigner = signer.connect(provider);
|
const connectedSigner = signer.connect(provider);
|
||||||
|
|
||||||
tx = await populateVerifyAndSend(
|
if (pendingTx.token === "ETH") {
|
||||||
connectedSigner,
|
tx = await connectedSigner.sendTransaction({
|
||||||
pendingTx,
|
to: pendingTx.to,
|
||||||
maxSendFees,
|
value: parseEther(pendingTx.amount),
|
||||||
);
|
});
|
||||||
|
} else {
|
||||||
|
const contract = new Contract(
|
||||||
|
pendingTx.token,
|
||||||
|
ERC20_ABI,
|
||||||
|
connectedSigner,
|
||||||
|
);
|
||||||
|
// The contract's decimals() is read to be COMPARED with the
|
||||||
|
// scale the screen rendered this amount at, not to encode with:
|
||||||
|
// encoding from it signs whatever the contract answers now,
|
||||||
|
// which is not what the user read. A disagreement throws and is
|
||||||
|
// reported on the error screen. See transferAmount.js.
|
||||||
|
const amount = transferAmountUnits(
|
||||||
|
pendingTx.amount,
|
||||||
|
pendingTx.tokenDecimals,
|
||||||
|
await contract.decimals(),
|
||||||
|
);
|
||||||
|
tx = await contract.transfer(pendingTx.to, amount);
|
||||||
|
}
|
||||||
|
|
||||||
// Best-effort: clear decrypted secret after use.
|
// Best-effort: clear decrypted secret after use.
|
||||||
// Note: JS strings are immutable; this nulls the reference but
|
// Note: JS strings are immutable; this nulls the reference but
|
||||||
@@ -602,14 +498,6 @@ function init(_ctx) {
|
|||||||
txStatus.showWait(pendingTx, tx.hash);
|
txStatus.showWait(pendingTx, tx.hash);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
decryptedSecret = null;
|
decryptedSecret = null;
|
||||||
// A fee over the bound is refused before anything is broadcast, so
|
|
||||||
// there is no transaction that may have reached the network to warn
|
|
||||||
// about: the message stays on the confirmation screen where the
|
|
||||||
// user can go back, rather than routing to the sent/failed screen.
|
|
||||||
if (e && e.approvalMismatch) {
|
|
||||||
showSendError(e.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hash = tx ? tx.hash : null;
|
const hash = tx ? tx.hash : null;
|
||||||
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
|
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -623,4 +511,4 @@ function init(_ctx) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, show, restore, populateVerifyAndSend };
|
module.exports = { init, show, restore };
|
||||||
|
|||||||
@@ -87,8 +87,7 @@ function recoveryPathText(wallet) {
|
|||||||
// 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 or before the first price fetch, and no figure
|
||||||
// when every holding here is one with no price, since "$0.00" directly under
|
// when every holding here is one with no price, since "$0.00" directly under
|
||||||
// "This address holds a balance." is a contradiction. A token holding below
|
// "This address holds a balance." is a contradiction.
|
||||||
// 0.000001 does not count, as the lines below leave it out.
|
|
||||||
function balanceWarningHtml(addr) {
|
function balanceWarningHtml(addr) {
|
||||||
if (!addressHoldsFunds(addr)) return " ";
|
if (!addressHoldsFunds(addr)) return " ";
|
||||||
const line = formatAddressTotal(getAddressValue(addr));
|
const line = formatAddressTotal(getAddressValue(addr));
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const {
|
|||||||
removeWalletFromState,
|
removeWalletFromState,
|
||||||
broadcastActiveChanged,
|
broadcastActiveChanged,
|
||||||
} = require("../../shared/walletDelete");
|
} = require("../../shared/walletDelete");
|
||||||
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
|
|
||||||
|
|
||||||
let deleteWalletIndex = null;
|
let deleteWalletIndex = null;
|
||||||
let lostPasswordIndex = null;
|
let lostPasswordIndex = null;
|
||||||
@@ -21,31 +20,21 @@ let ctx = null;
|
|||||||
// The name shown for a wallet, and on the lost-password screen the string
|
// The name shown for a wallet, and on the lost-password screen the string
|
||||||
// the user has to type back. One function so the two cannot disagree: a
|
// the user has to type back. One function so the two cannot disagree: a
|
||||||
// confirmation that asks for a name other than the one on screen is
|
// confirmation that asks for a name other than the one on screen is
|
||||||
// unusable. A name that shows nothing at all (only spaces, or only
|
// unusable.
|
||||||
// zero-width characters) is replaced by "Wallet N" for the same reason:
|
|
||||||
// there would be nothing on screen to type back.
|
|
||||||
function displayName(walletIdx) {
|
function displayName(walletIdx) {
|
||||||
const wallet = state.wallets[walletIdx];
|
const wallet = state.wallets[walletIdx];
|
||||||
const name = wallet && wallet.name;
|
return (wallet && wallet.name) || "Wallet " + (walletIdx + 1);
|
||||||
if (name && confirmKey(name)) return name;
|
|
||||||
return "Wallet " + (walletIdx + 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the typed confirmation and the wallet name are compared as. HTML
|
// What the typed confirmation and the wallet name are compared as. HTML
|
||||||
// collapses runs of whitespace when it renders the name, so a wallet named
|
// collapses runs of whitespace when it renders the name, so a wallet named
|
||||||
// "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot
|
// "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot
|
||||||
// see the second space and cannot type a string that matches the stored
|
// see the second space and cannot type a string that matches the stored
|
||||||
// name. Characters that paint nothing, such as a zero-width space, are
|
// name. Comparing collapsed on both sides is what keeps the confirmation
|
||||||
// invisible the same way and are removed first. Comparing this form on
|
// satisfiable, on the one screen whose whole purpose is unwedging a user
|
||||||
// both sides is what keeps the confirmation satisfiable, on the one screen
|
// who is already stuck. Case and surrounding space go the same way.
|
||||||
// whose whole purpose is unwedging a user who is already stuck. Case and
|
|
||||||
// surrounding space go the same way.
|
|
||||||
function confirmKey(name) {
|
function confirmKey(name) {
|
||||||
return name
|
return name.trim().replace(/\s+/g, " ").toLowerCase();
|
||||||
.replace(INVISIBLE_CHARACTERS, "")
|
|
||||||
.trim()
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop the password from the DOM and the wallet selection from the
|
// Drop the password from the DOM and the wallet selection from the
|
||||||
@@ -62,12 +51,16 @@ function clear() {
|
|||||||
// The lost-password screen holds no secret — a wallet name is not one —
|
// The lost-password screen holds no secret — a wallet name is not one —
|
||||||
// but it is wiped on leave for the neighbouring reason: a typed
|
// but it is wiped on leave for the neighbouring reason: a typed
|
||||||
// confirmation left standing in a hidden view is one click away from
|
// confirmation left standing in a hidden view is one click away from
|
||||||
// destroying a wallet the user has since navigated off.
|
// destroying a wallet the user has since navigated off. The button is
|
||||||
|
// re-enabled here too, so a screen left mid-delete is usable on re-entry.
|
||||||
function clearLostPassword() {
|
function clearLostPassword() {
|
||||||
lostPasswordIndex = null;
|
lostPasswordIndex = null;
|
||||||
$("delete-wallet-lost-name-input").value = "";
|
$("delete-wallet-lost-name-input").value = "";
|
||||||
$("delete-wallet-lost-flash").textContent = "";
|
$("delete-wallet-lost-flash").textContent = "";
|
||||||
$("delete-wallet-lost-flash").style.visibility = "hidden";
|
$("delete-wallet-lost-flash").style.visibility = "hidden";
|
||||||
|
const btn = $("btn-delete-wallet-lost-confirm");
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.classList.remove("text-muted");
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(walletIdx) {
|
function show(walletIdx) {
|
||||||
@@ -105,17 +98,6 @@ function showLostPassword() {
|
|||||||
// cleanup and the accountsChanged broadcast cannot drift apart between
|
// cleanup and the accountsChanged broadcast cannot drift apart between
|
||||||
// them.
|
// them.
|
||||||
async function finishDelete(walletIdx) {
|
async function finishDelete(walletIdx) {
|
||||||
// Each route's confirm button was disabled by its own click handler
|
|
||||||
// before the delete ran. Re-enable both here, on the one path they
|
|
||||||
// share, so the two routes reset the same way and a second delete in
|
|
||||||
// the same popup session finds a live button instead of a dead one.
|
|
||||||
const passwordBtn = $("btn-delete-wallet-confirm");
|
|
||||||
passwordBtn.disabled = false;
|
|
||||||
passwordBtn.classList.remove("text-muted");
|
|
||||||
const lostPasswordBtn = $("btn-delete-wallet-lost-confirm");
|
|
||||||
lostPasswordBtn.disabled = false;
|
|
||||||
lostPasswordBtn.classList.remove("text-muted");
|
|
||||||
|
|
||||||
const { activeAddressChanged } = removeWalletFromState(state, walletIdx);
|
const { activeAddressChanged } = removeWalletFromState(state, walletIdx);
|
||||||
|
|
||||||
deleteWalletIndex = null;
|
deleteWalletIndex = null;
|
||||||
@@ -185,16 +167,14 @@ function init(_ctx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case, surrounding spaces, repeated inner spaces and invisible
|
// Case, surrounding spaces and repeated inner spaces are not part
|
||||||
// characters are not part of the confirmation; see confirmKey().
|
// of the confirmation; see confirmKey(). This asks whether the
|
||||||
// This asks whether the user knows which wallet they are on; it is
|
// user knows which wallet they are on; it is not a secret, and
|
||||||
// not a secret, and refusing "wallet 2" for "Wallet 2" would only
|
// refusing "wallet 2" for "Wallet 2" would only teach the user to
|
||||||
// teach the user to distrust the control. An empty field is
|
// distrust the control.
|
||||||
// refused whatever the wallet is called, so no stored name can
|
const typed = $("delete-wallet-lost-name-input").value;
|
||||||
// ever be confirmed by typing nothing.
|
|
||||||
const typed = confirmKey($("delete-wallet-lost-name-input").value);
|
|
||||||
const expected = displayName(lostPasswordIndex);
|
const expected = displayName(lostPasswordIndex);
|
||||||
if (typed === "" || typed !== confirmKey(expected)) {
|
if (confirmKey(typed) !== confirmKey(expected)) {
|
||||||
$("delete-wallet-lost-flash").textContent =
|
$("delete-wallet-lost-flash").textContent =
|
||||||
"That is not the name of this wallet. Type " +
|
"That is not the name of this wallet. Type " +
|
||||||
expected +
|
expected +
|
||||||
@@ -207,8 +187,8 @@ function init(_ctx) {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.classList.add("text-muted");
|
btn.classList.add("text-muted");
|
||||||
|
|
||||||
// finishDelete() re-enables the button; navigating away then runs
|
// finishDelete() navigates, and the leave hook re-enables the
|
||||||
// the leave hook that wipes the typed name.
|
// button and wipes the typed name on the way out.
|
||||||
await finishDelete(lostPasswordIndex);
|
await finishDelete(lostPasswordIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+50
-181
@@ -12,14 +12,6 @@
|
|||||||
// escapeHtml lives in src/shared/html.js, where the escape and the
|
// escapeHtml lives in src/shared/html.js, where the escape and the
|
||||||
// reasoning behind it are; it is re-exported below so views keep importing
|
// reasoning behind it are; it is re-exported below so views keep importing
|
||||||
// it from here.
|
// it from here.
|
||||||
const { formatEther } = require("ethers");
|
|
||||||
const makeBlockie = require("ethereum-blockies-base64");
|
|
||||||
const {
|
|
||||||
truncateAmountNeverZero,
|
|
||||||
isBelowOneMillionth,
|
|
||||||
} = require("../../shared/amountDisplay");
|
|
||||||
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
|
|
||||||
const { DEBUG } = require("../../shared/constants");
|
|
||||||
const { escapeHtml } = require("../../shared/html");
|
const { escapeHtml } = require("../../shared/html");
|
||||||
const { isDebug } = require("../../shared/log");
|
const { isDebug } = require("../../shared/log");
|
||||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||||
@@ -54,8 +46,9 @@ const VIEWS = [
|
|||||||
"export-privkey",
|
"export-privkey",
|
||||||
"show-phrase",
|
"show-phrase",
|
||||||
// Shown by src/popup/views/stateRecovery.js when the stored profile
|
// Shown by src/popup/views/stateRecovery.js when the stored profile
|
||||||
// cannot be read, never by showView() (see there), but listed so that
|
// cannot be read. It is never reached through showView() — by then the
|
||||||
// every view-hiding loop covers it.
|
// state singleton this file writes on every navigation refuses to be read
|
||||||
|
// — but it is listed so that every view-hiding loop covers it.
|
||||||
"state-recovery",
|
"state-recovery",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -86,28 +79,12 @@ function hideError(id) {
|
|||||||
el.style.visibility = "hidden";
|
el.style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set when src/popup/index.js passes the recovery screen to showView(), and
|
|
||||||
// never cleared. Kept in memory for this popup's life, never in
|
|
||||||
// state.currentView, which is saved: a popup opened later must not inherit it.
|
|
||||||
let stateRecoveryShown = false;
|
|
||||||
|
|
||||||
function showView(name) {
|
function showView(name) {
|
||||||
// The recovery screen, once up, is never replaced: work still running
|
|
||||||
// when it went up, such as a transaction wait, must not take the user off
|
|
||||||
// it or clear what they exported or typed there
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/373).
|
|
||||||
if (stateRecoveryShown) return;
|
|
||||||
const leaving = state.currentView;
|
const leaving = state.currentView;
|
||||||
if (leaving && leaving !== name) {
|
if (leaving && leaving !== name) {
|
||||||
const onLeave = viewLeaveHandlers.get(leaving);
|
const onLeave = viewLeaveHandlers.get(leaving);
|
||||||
if (onLeave) onLeave();
|
if (onLeave) onLeave();
|
||||||
}
|
}
|
||||||
// Passed here only so the screen it replaces is left like any other;
|
|
||||||
// stateRecovery.show() raises it, and it is never the current view.
|
|
||||||
if (name === "state-recovery") {
|
|
||||||
stateRecoveryShown = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const v of VIEWS) {
|
for (const v of VIEWS) {
|
||||||
const el = document.getElementById(`view-${v}`);
|
const el = document.getElementById(`view-${v}`);
|
||||||
if (el) {
|
if (el) {
|
||||||
@@ -142,11 +119,7 @@ function updateDebugBanner(viewName) {
|
|||||||
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;";
|
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:1px 0;font-family:monospace;position:sticky;top:0;z-index:9999;";
|
||||||
document.body.prepend(banner);
|
document.body.prepend(banner);
|
||||||
}
|
}
|
||||||
// The view id is internal vocabulary; it helps while developing but
|
const suffix = viewName ? " (" + viewName + ")" : "";
|
||||||
// means nothing to a user. Only a debug build appends it, gated on the
|
|
||||||
// compile-time DEBUG constant so a release build never shows it — not
|
|
||||||
// isDebug(), which is also true for a testnet or the runtime toggle.
|
|
||||||
const suffix = DEBUG && viewName ? " (" + viewName + ")" : "";
|
|
||||||
if (debug && net.isTestnet) {
|
if (debug && net.isTestnet) {
|
||||||
banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix;
|
banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix;
|
||||||
} else if (net.isTestnet) {
|
} else if (net.isTestnet) {
|
||||||
@@ -245,19 +218,15 @@ function clearFlash() {
|
|||||||
flashTimer = null;
|
flashTimer = null;
|
||||||
}
|
}
|
||||||
$("flash-msg").textContent = "";
|
$("flash-msg").textContent = "";
|
||||||
$("flash-msg").title = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
|
||||||
// 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
|
|
||||||
// characters, so none is cut.
|
|
||||||
function showFlash(msg, duration = 2000) {
|
function showFlash(msg, duration = 2000) {
|
||||||
clearFlash();
|
clearFlash();
|
||||||
$("flash-msg").textContent = msg;
|
$("flash-msg").textContent = msg;
|
||||||
$("flash-msg").title = msg;
|
flashTimer = setTimeout(() => {
|
||||||
flashTimer = setTimeout(clearFlash, duration);
|
$("flash-msg").textContent = "";
|
||||||
|
flashTimer = null;
|
||||||
|
}, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A stored token balance as a number, or null when there is no number in it.
|
// A stored token balance as a number, or null when there is no number in it.
|
||||||
@@ -269,45 +238,6 @@ function unknownableAmount(balance) {
|
|||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The active network's native token symbol, `ETH` on mainnet and `SepoliaETH`
|
|
||||||
// on Sepolia, as src/shared/networks.js names it. The wallet's balances and
|
|
||||||
// the Send and confirmation screens, which send on the active network, label a
|
|
||||||
// native amount with this; a transaction already made or requested is labelled
|
|
||||||
// by its own chain id, through nativeCurrencyByChainId() in networks.js. The
|
|
||||||
// "ETH" that state.selectedToken and txInfo.token hold is the native token's
|
|
||||||
// id, not its label, and stays "ETH" on every network.
|
|
||||||
function nativeCurrency() {
|
|
||||||
return currentNetwork().nativeCurrency;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The symbol shown for a token line, resolved from the bundled list, the
|
|
||||||
// tokens the user tracks, and the explorer's report — the same chain the
|
|
||||||
// amount line's scale comes from. Null when no source names one, so the token
|
|
||||||
// lines keep saying `Unknown token` for a token nothing knows.
|
|
||||||
function tokenLabel(address) {
|
|
||||||
return resolveTokenSymbol(address, {
|
|
||||||
trackedTokens: state.trackedTokens,
|
|
||||||
wallets: state.wallets,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// A network fee in wei as the confirmation and approval screens both show it:
|
|
||||||
// the ETH figure through truncateAmountNeverZero() and labelled `symbol`, the
|
|
||||||
// native currency of the network the fee is paid on, then its USD value when
|
|
||||||
// the ETH price is known. The USD value is of the exact fee, not of the
|
|
||||||
// truncated figure.
|
|
||||||
function formatFee(wei, symbol) {
|
|
||||||
const eth = formatEther(wei);
|
|
||||||
const ethPrice = getPrice("ETH");
|
|
||||||
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
|
|
||||||
return (
|
|
||||||
truncateAmountNeverZero(eth) +
|
|
||||||
" " +
|
|
||||||
symbol +
|
|
||||||
(usd ? " (" + usd + ")" : "")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One row of the balance list: symbol, quantity, fiat value.
|
// One row of the balance list: symbol, quantity, fiat value.
|
||||||
//
|
//
|
||||||
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
|
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
|
||||||
@@ -325,7 +255,7 @@ 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 =
|
||||||
price && amount !== null
|
price && amount !== null
|
||||||
? escapeHtml(formatUsd(amount * price)) || " "
|
? formatUsd(amount * price) || " "
|
||||||
: " ";
|
: " ";
|
||||||
// tokenId is a contract address out of the same explorer JSON, and it
|
// tokenId is a contract address out of the same explorer JSON, and it
|
||||||
// lands inside a quoted attribute.
|
// lands inside a quoted attribute.
|
||||||
@@ -335,7 +265,7 @@ function balanceLine(symbol, amount, price, tokenId) {
|
|||||||
: "";
|
: "";
|
||||||
return (
|
return (
|
||||||
`<div class="flex text-xs${clickClass}"${tokenAttr}>` +
|
`<div class="flex text-xs${clickClass}"${tokenAttr}>` +
|
||||||
`<span class="flex justify-between w-[42ch] max-w-full">` +
|
`<span class="flex justify-between" style="width:42ch;max-width:100%">` +
|
||||||
`<span>${escapeHtml(displaySymbol(symbol))}</span>` +
|
`<span>${escapeHtml(displaySymbol(symbol))}</span>` +
|
||||||
`<span>${qty}</span>` +
|
`<span>${qty}</span>` +
|
||||||
`</span>` +
|
`</span>` +
|
||||||
@@ -346,16 +276,13 @@ function balanceLine(symbol, amount, price, tokenId) {
|
|||||||
|
|
||||||
function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||||
let html = balanceLine(
|
let html = balanceLine(
|
||||||
nativeCurrency(),
|
"ETH",
|
||||||
parseFloat(addr.balance || "0"),
|
parseFloat(addr.balance || "0"),
|
||||||
getPrice("ETH"),
|
getPrice("ETH"),
|
||||||
"ETH",
|
"ETH",
|
||||||
);
|
);
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const t of addr.tokenBalances || []) {
|
for (const t of addr.tokenBalances || []) {
|
||||||
// A holding below 0.000001 is not listed, tracked or not. A tracked
|
|
||||||
// token then gets the zero row below while showZero is on.
|
|
||||||
if (isBelowOneMillionth(t.balance)) continue;
|
|
||||||
// A null balance is a holding of an unstatable amount, not a holding
|
// A null balance is a holding of an unstatable amount, not a holding
|
||||||
// of zero, so the show-zero setting has no say over it: hiding it
|
// of zero, so the show-zero setting has no say over it: hiding it
|
||||||
// would be asserting the zero nobody established. Anything that does
|
// would be asserting the zero nobody established. Anything that does
|
||||||
@@ -386,16 +313,14 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Whether an address holds anything at all: ETH or any ERC-20 the wallet
|
// Whether an address holds anything at all: ETH or any ERC-20 the wallet
|
||||||
// knows about, except a token holding below 0.000001, which the balance list
|
// knows about. Deliberately unrounded — the rendered lines round to four
|
||||||
// under the remove-address warning leaves out too. Deliberately unrounded —
|
// decimals, so a dust balance displays as 0.0000 while still being real
|
||||||
// the rendered lines round to four decimals, so a dust balance displays as
|
// money at a real address. Callers that warn about holdings must ask this,
|
||||||
// 0.0000 while still being real money at a real address. Callers that warn
|
// 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;
|
if (parseFloat(addr.balance || "0") > 0) return true;
|
||||||
for (const t of addr.tokenBalances || []) {
|
for (const t of addr.tokenBalances || []) {
|
||||||
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 —
|
||||||
// balances.js drops a row of zero base units before the scale is
|
// balances.js drops a row of zero base units before the scale is
|
||||||
// consulted, so a row that survived with no quantity is holding
|
// consulted, so a row that survived with no quantity is holding
|
||||||
@@ -406,12 +331,6 @@ function addressHoldsFunds(addr) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The fewest characters of an address any caller may ask to display. The
|
|
||||||
// 10-character cap inside truncateMiddle() is the other half of the same
|
|
||||||
// guarantee; this is the half that used to be spelled out at each call
|
|
||||||
// site, and is now enforced once in renderAddressHtml().
|
|
||||||
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 Display Consistency in
|
||||||
@@ -430,26 +349,23 @@ function truncateMiddle(str, maxLen) {
|
|||||||
|
|
||||||
// 16 colors evenly spaced around the hue wheel (22.5° apart),
|
// 16 colors evenly spaced around the hue wheel (22.5° apart),
|
||||||
// all at HSL saturation 70%, lightness 50% for uniform vibrancy.
|
// all at HSL saturation 70%, lightness 50% for uniform vibrancy.
|
||||||
// Each is a whole Tailwind class: Tailwind builds only the classes it finds
|
|
||||||
// written out in the source, so the class name cannot be put together at
|
|
||||||
// runtime.
|
|
||||||
const ADDRESS_COLORS = [
|
const ADDRESS_COLORS = [
|
||||||
"bg-[#d92626]",
|
"#d92626",
|
||||||
"bg-[#d96926]",
|
"#d96926",
|
||||||
"bg-[#d9ac26]",
|
"#d9ac26",
|
||||||
"bg-[#c2d926]",
|
"#c2d926",
|
||||||
"bg-[#80d926]",
|
"#80d926",
|
||||||
"bg-[#3dd926]",
|
"#3dd926",
|
||||||
"bg-[#26d953]",
|
"#26d953",
|
||||||
"bg-[#26d996]",
|
"#26d996",
|
||||||
"bg-[#26d9d9]",
|
"#26d9d9",
|
||||||
"bg-[#2696d9]",
|
"#2696d9",
|
||||||
"bg-[#2653d9]",
|
"#2653d9",
|
||||||
"bg-[#3d26d9]",
|
"#3d26d9",
|
||||||
"bg-[#8026d9]",
|
"#8026d9",
|
||||||
"bg-[#c226d9]",
|
"#c226d9",
|
||||||
"bg-[#d926ac]",
|
"#d926ac",
|
||||||
"bg-[#d92669]",
|
"#d92669",
|
||||||
];
|
];
|
||||||
|
|
||||||
function addressColor(address) {
|
function addressColor(address) {
|
||||||
@@ -459,12 +375,7 @@ function addressColor(address) {
|
|||||||
|
|
||||||
function addressDotHtml(address) {
|
function addressDotHtml(address) {
|
||||||
const color = addressColor(address);
|
const color = addressColor(address);
|
||||||
return `<span class="inline-block w-[8px] h-[8px] rounded-[50%] ${color} mr-[4px] align-middle shrink-0"></span>`;
|
return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`;
|
||||||
}
|
|
||||||
|
|
||||||
function blockieHtml(address) {
|
|
||||||
const src = makeBlockie(address);
|
|
||||||
return `<img src="${escapeHtml(src)}" width="48" height="48" class="inline-block rounded-[50%] [image-rendering:pixelated]">`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up an address across all wallets and return its title
|
// Look up an address across all wallets and return its title
|
||||||
@@ -482,29 +393,6 @@ function addressTitle(address, wallets) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What every recipient line and history row says for a transaction with no
|
|
||||||
// `to`. Such a transaction creates a contract, so there is no address to show,
|
|
||||||
// and a blank line on these screens reads as a rendering fault.
|
|
||||||
const CONTRACT_CREATION_TEXT =
|
|
||||||
"This transaction creates a new contract. It has no recipient.";
|
|
||||||
|
|
||||||
// The last two lines of a transaction history row: the counterparty's colour
|
|
||||||
// dot and name beside the amount, then its full address. A contract creation
|
|
||||||
// the user sent has no counterparty (its `to` is ""), so its row has the
|
|
||||||
// amount alone and the contract creation sentence in place of the address.
|
|
||||||
function txCounterpartyHtml(address, nameHtml, amountHtml) {
|
|
||||||
if (!address) {
|
|
||||||
return (
|
|
||||||
`<div class="flex justify-between"><span></span><span>${amountHtml}</span></div>` +
|
|
||||||
`<div>${escapeHtml(CONTRACT_CREATION_TEXT)}</div>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
`<div class="flex justify-between"><span class="flex items-center">${addressDotHtml(address)}${nameHtml}</span><span>${amountHtml}</span></div>` +
|
|
||||||
`<div class="am-address">${escapeHtml(address)}</div>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render an address with color dot, optional ENS name, optional title,
|
// Render an address with color dot, optional ENS name, optional title,
|
||||||
// and optional truncation. Title and ENS are shown as bold labels above
|
// and optional truncation. Title and ENS are shown as bold labels above
|
||||||
// the full address.
|
// the full address.
|
||||||
@@ -513,9 +401,6 @@ 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
|
|
||||||
// Consistency): the ISO datetime, in UTC when the UTC Timestamps setting is
|
|
||||||
// on, and the relative age. 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");
|
||||||
@@ -574,7 +459,7 @@ function timeAgo(timestamp) {
|
|||||||
|
|
||||||
// Shared external-link icon SVG used across all views.
|
// Shared external-link icon SVG used across all views.
|
||||||
const EXT_ICON =
|
const EXT_ICON =
|
||||||
`<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` +
|
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
|
||||||
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
|
`<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">` +
|
||||||
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
|
`<path d="M4.5 1.5H2a.5.5 0 00-.5.5v8a.5.5 0 00.5.5h8a.5.5 0 00.5-.5V7.5"/>` +
|
||||||
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
||||||
@@ -633,29 +518,17 @@ function attachCopyHandlers(container) {
|
|||||||
|
|
||||||
// Unified address rendering.
|
// Unified address rendering.
|
||||||
//
|
//
|
||||||
// Two stacked rows, in this order:
|
// Produces consistent HTML for any Ethereum address:
|
||||||
// 1. Identity strip — colour dot, optional title (e.g. "Wallet 1 —
|
// • Color dot
|
||||||
// Address 2") and the explorer link icon. Optional ENS name below it.
|
// • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address
|
||||||
// 2. The address itself, alone on a full-width row that never wraps
|
// • Optional ENS name shown bold above address
|
||||||
// (see .am-address in styles/main.css).
|
// • Full address (or truncated via maxLen) with dashed-underline click-to-copy
|
||||||
//
|
// • Etherscan external link icon
|
||||||
// The split is the point. Everything used to sit on one line: dot, address
|
|
||||||
// and link together, with `break-all` to let the address fold when the line
|
|
||||||
// ran out. In the wallet list, where the row also carried [info] and [x],
|
|
||||||
// it ran out every time — the bug in #380 — and a folded address is a
|
|
||||||
// spoofing hazard, not a cosmetic one. Nothing shares the address's row
|
|
||||||
// now, so all 42 characters fit at every nesting depth the popup uses and
|
|
||||||
// nothing has to be dropped or folded to make room.
|
|
||||||
//
|
//
|
||||||
// Options object:
|
// Options object:
|
||||||
// title — wallet title string (from addressTitle)
|
// title — wallet title string (from addressTitle)
|
||||||
// ensName — ENS name string
|
// ensName — ENS name string
|
||||||
// maxLen — if set, truncate address display. Floored at 32 characters
|
// maxLen — if set, truncate address display (min 32 chars enforced)
|
||||||
// here rather than by the caller: no view passes it any more
|
|
||||||
// (every address row is wide enough for all 42 characters),
|
|
||||||
// so a floor that lived in the callers would have gone away
|
|
||||||
// with them, and the "at least 32 characters" guarantee has
|
|
||||||
// to survive having no current callers to be a guarantee.
|
|
||||||
// noLink — if true, omit etherscan link
|
// noLink — if true, omit etherscan link
|
||||||
//
|
//
|
||||||
// After inserting the returned HTML into the DOM, call
|
// After inserting the returned HTML into the DOM, call
|
||||||
@@ -663,22 +536,22 @@ function attachCopyHandlers(container) {
|
|||||||
function renderAddressHtml(address, opts) {
|
function renderAddressHtml(address, opts) {
|
||||||
const { title, ensName, maxLen, noLink } = opts || {};
|
const { title, ensName, maxLen, noLink } = opts || {};
|
||||||
const dot = addressDotHtml(address);
|
const dot = addressDotHtml(address);
|
||||||
const displayAddr = maxLen
|
const displayAddr = maxLen ? truncateMiddle(address, maxLen) : address;
|
||||||
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
|
|
||||||
: address;
|
|
||||||
const link = etherscanAddressUrl(address);
|
const link = etherscanAddressUrl(address);
|
||||||
const extLink = noLink ? "" : etherscanLinkHtml(link);
|
const extLink = noLink ? "" : etherscanLinkHtml(link);
|
||||||
|
|
||||||
let html = "";
|
let html = "";
|
||||||
html += `<div class="flex items-center">${dot}`;
|
|
||||||
if (title) {
|
if (title) {
|
||||||
html += `<span class="font-bold">${escapeHtml(title)}</span>`;
|
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
|
||||||
}
|
}
|
||||||
html += `${extLink}</div>`;
|
|
||||||
if (ensName) {
|
if (ensName) {
|
||||||
html += `<div class="font-bold">${escapeHtml(ensName)}</div>`;
|
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
|
||||||
|
}
|
||||||
|
if (title || ensName) {
|
||||||
|
html += `<div class="flex items-center">${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
|
||||||
|
} else {
|
||||||
|
html += `<div class="flex items-center">${dot}${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
|
||||||
}
|
}
|
||||||
html += `<div class="am-address">${copyableHtml(displayAddr)}</div>`;
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,20 +587,16 @@ module.exports = {
|
|||||||
balanceLinesForAddress,
|
balanceLinesForAddress,
|
||||||
addressHoldsFunds,
|
addressHoldsFunds,
|
||||||
unknownableAmount,
|
unknownableAmount,
|
||||||
nativeCurrency,
|
addressColor,
|
||||||
tokenLabel,
|
|
||||||
formatFee,
|
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
blockieHtml,
|
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
CONTRACT_CREATION_TEXT,
|
|
||||||
txCounterpartyHtml,
|
|
||||||
formatAddressHtml,
|
formatAddressHtml,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
copyableHtml,
|
copyableHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
|
etherscanAddressUrl,
|
||||||
etherscanLinkHtml,
|
etherscanLinkHtml,
|
||||||
explorerUrl,
|
explorerUrl,
|
||||||
EXT_ICON,
|
EXT_ICON,
|
||||||
|
|||||||
+23
-43
@@ -6,21 +6,15 @@ const {
|
|||||||
isoDate,
|
isoDate,
|
||||||
timeAgo,
|
timeAgo,
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
txCounterpartyHtml,
|
|
||||||
addressTitle,
|
addressTitle,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
nativeCurrency,
|
truncateMiddle,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
pushCurrentView,
|
pushCurrentView,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const {
|
const { state, saveState, currentAddress } = require("../../shared/state");
|
||||||
state,
|
|
||||||
saveState,
|
|
||||||
currentAddress,
|
|
||||||
currentNetwork,
|
|
||||||
} = require("../../shared/state");
|
|
||||||
const { notify } = require("../../shared/browserApi");
|
const { notify } = require("../../shared/browserApi");
|
||||||
const {
|
const {
|
||||||
updateSendBalance,
|
updateSendBalance,
|
||||||
@@ -63,7 +57,7 @@ function renderTotalValue() {
|
|||||||
const ethPrice = getPrice("ETH");
|
const ethPrice = getPrice("ETH");
|
||||||
if (priceEl) {
|
if (priceEl) {
|
||||||
priceEl.innerHTML = ethPrice
|
priceEl.innerHTML = ethPrice
|
||||||
? escapeHtml(formatUsd(ethPrice) + " USD/ETH")
|
? formatUsd(ethPrice) + " USD/ETH"
|
||||||
: " ";
|
: " ";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,13 +68,12 @@ function renderTotalValue() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ethBal = parseFloat(addr.balance || "0");
|
const ethBal = parseFloat(addr.balance || "0");
|
||||||
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
|
const ethStr = ethBal.toFixed(4) + " ETH";
|
||||||
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 = formatAddressTotal(getAddressValue(addr)) || " ";
|
||||||
escapeHtml(formatAddressTotal(getAddressValue(addr))) || " ";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,19 +117,18 @@ function renderHomeTxList(ctx) {
|
|||||||
const amountStr = tx.value
|
const amountStr = tx.value
|
||||||
? escapeHtml(tx.value + " " + sym)
|
? escapeHtml(tx.value + " " + sym)
|
||||||
: escapeHtml(sym);
|
: escapeHtml(sym);
|
||||||
// The counterparty used to be squeezed in beside the amount and
|
|
||||||
// truncated to whatever was left over. It gets its own row now and
|
|
||||||
// is shown whole; the title, when it is one of our own addresses,
|
|
||||||
// names it on the line above rather than replacing it.
|
|
||||||
const title = addressTitle(counterparty, state.wallets);
|
const title = addressTitle(counterparty, state.wallets);
|
||||||
const titleStr = title ? escapeHtml(title) : "";
|
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
||||||
|
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
|
||||||
|
const addrStr = escapeHtml(displayAddr);
|
||||||
|
const dot = addressDotHtml(counterparty);
|
||||||
const err = tx.isError ? " (failed)" : "";
|
const err = tx.isError ? " (failed)" : "";
|
||||||
const opacity = tx.isError ? " opacity-50" : "";
|
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||||
const ago = escapeHtml(timeAgo(tx.timestamp));
|
const ago = escapeHtml(timeAgo(tx.timestamp));
|
||||||
const iso = escapeHtml(isoDate(tx.timestamp));
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
||||||
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover${opacity}" data-tx="${i}">`;
|
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
||||||
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||||
html += txCounterpartyHtml(counterparty, titleStr, amountStr);
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
@@ -189,11 +181,7 @@ async function loadHomeTxs(ctx) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const fetches = allAddresses.map((addr) =>
|
const fetches = allAddresses.map((addr) =>
|
||||||
fetchRecentTransactions(
|
fetchRecentTransactions(addr, state.blockscoutUrl),
|
||||||
addr,
|
|
||||||
state.blockscoutUrl,
|
|
||||||
currentNetwork().chainId,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const results = await Promise.all(fetches);
|
const results = await Promise.all(fetches);
|
||||||
|
|
||||||
@@ -225,9 +213,6 @@ async function loadHomeTxs(ctx) {
|
|||||||
homeTxs = merged.slice(0, 25);
|
homeTxs = merged.slice(0, 25);
|
||||||
renderHomeTxList(ctx);
|
renderHomeTxList(ctx);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
|
||||||
// src/popup/index.js.
|
|
||||||
if (ctx.pageClosed.aborted) return;
|
|
||||||
log.errorf("loadHomeTxs failed:", e.message);
|
log.errorf("loadHomeTxs failed:", e.message);
|
||||||
const list = $("home-tx-list");
|
const list = $("home-tx-list");
|
||||||
if (list) {
|
if (list) {
|
||||||
@@ -244,7 +229,7 @@ function walletListHtml() {
|
|||||||
state.wallets.forEach((wallet, wi) => {
|
state.wallets.forEach((wallet, wi) => {
|
||||||
const defect = walletDefect(wallet);
|
const defect = walletDefect(wallet);
|
||||||
html += `<div>`;
|
html += `<div>`;
|
||||||
html += `<div class="flex justify-between items-center bg-section py-1 px-2 -mx-2">`;
|
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
|
||||||
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${escapeHtml(wallet.name)}</span>`;
|
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${escapeHtml(wallet.name)}</span>`;
|
||||||
// No "+" on a defective wallet: deriving another address from that
|
// No "+" on a defective wallet: deriving another address from that
|
||||||
// xpub would only add one more address the key does not produce
|
// xpub would only add one more address the key does not produce
|
||||||
@@ -258,33 +243,28 @@ function walletListHtml() {
|
|||||||
wallet.addresses.forEach((addr, ai) => {
|
wallet.addresses.forEach((addr, ai) => {
|
||||||
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
|
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
|
||||||
const isActive = state.activeAddress === addr.address;
|
const isActive = state.activeAddress === addr.address;
|
||||||
const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg p-0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
|
const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg" style="padding:0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
|
||||||
// Only where a wallet can spare the address: a wallet holding a
|
// Only where a wallet can spare the address: a wallet holding a
|
||||||
// single address has no remove control, because its last address
|
// single address has no remove control, because its last address
|
||||||
// is never removable.
|
// is never removable.
|
||||||
const removeBtn = canRemoveAddress(wallet)
|
const removeBtn = canRemoveAddress(wallet)
|
||||||
? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1 p-0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
|
? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1" style="padding:0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
|
||||||
: "";
|
: "";
|
||||||
const dot = addressDotHtml(addr.address);
|
const dot = addressDotHtml(addr.address);
|
||||||
const titleBold = isActive ? "font-bold" : "";
|
const titleBold = isActive ? "font-bold" : "";
|
||||||
// [info] and [x] ride on the "Address N" line, which was empty
|
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
|
||||||
// to its right, so the address below gets the row to itself.
|
|
||||||
// They used to sit beside the address and take about a third of
|
|
||||||
// the width off it, which is what made a 42-character address
|
|
||||||
// fold onto a second line here and nowhere else (#380).
|
|
||||||
html += `<div class="flex text-xs items-center justify-between">`;
|
|
||||||
html += `<span class="flex items-center ${titleBold}">${dot}Address ${ai + 1}</span>`;
|
|
||||||
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
|
|
||||||
html += `</div>`;
|
|
||||||
if (addr.ensName) {
|
if (addr.ensName) {
|
||||||
// An ENS reverse record is whatever the name owner set it
|
// An ENS reverse record is whatever the name owner set it
|
||||||
// to; renderAddressHtml() escapes its own copy of this and
|
// to; renderAddressHtml() escapes its own copy of this and
|
||||||
// this list was the one that did not.
|
// this list was the one that did not.
|
||||||
html += `<div class="text-xs font-bold">${escapeHtml(addr.ensName)}</div>`;
|
html += `<div class="text-xs font-bold flex items-center">${dot}${escapeHtml(addr.ensName)}</div>`;
|
||||||
}
|
}
|
||||||
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
|
html += `<div class="flex text-xs items-center justify-between">`;
|
||||||
|
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${escapeHtml(addr.address)}</span>`;
|
||||||
|
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
|
||||||
|
html += `</div>`;
|
||||||
const addrTotal = formatAddressTotal(getAddressValue(addr));
|
const addrTotal = formatAddressTotal(getAddressValue(addr));
|
||||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${escapeHtml(addrTotal) || " "}</div>`;
|
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || " "}</div>`;
|
||||||
html += balanceLinesForAddress(
|
html += balanceLinesForAddress(
|
||||||
addr,
|
addr,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
|
|||||||
+60
-191
@@ -5,8 +5,6 @@ const {
|
|||||||
showFlash,
|
showFlash,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
escapeHtml,
|
|
||||||
nativeCurrency,
|
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
goBack,
|
goBack,
|
||||||
@@ -18,29 +16,10 @@ const { resolveTokenDecimals } = require("../../shared/approvalAmount");
|
|||||||
const { resolveSymbol } = require("../../shared/tokenList");
|
const { resolveSymbol } = require("../../shared/tokenList");
|
||||||
const { isLowHolderCount } = require("../../shared/holders");
|
const { isLowHolderCount } = require("../../shared/holders");
|
||||||
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||||
const {
|
const { getAddress } = require("ethers");
|
||||||
truncateAmountNeverZero,
|
|
||||||
isBelowOneMillionth,
|
|
||||||
} = require("../../shared/amountDisplay");
|
|
||||||
const {
|
|
||||||
feeReserveWei,
|
|
||||||
maxEthAmount,
|
|
||||||
maxTokenAmount,
|
|
||||||
} = require("../../shared/txValidation");
|
|
||||||
const { log } = require("../../shared/log");
|
|
||||||
const { getAddress, parseEther } = require("ethers");
|
|
||||||
|
|
||||||
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
||||||
|
|
||||||
// Whether the amount field holds what Max filled in. The confirmation screen
|
|
||||||
// re-derives a max ETH amount from its own fee estimate; typing in the field
|
|
||||||
// makes it an ordinary amount again.
|
|
||||||
let amountIsMax = false;
|
|
||||||
|
|
||||||
// Counts the times the Send screen has opened, so a Max fee estimate started
|
|
||||||
// before it was last opened fills nothing in.
|
|
||||||
let sendScreenOpenings = 0;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a destination address string.
|
* Validate a destination address string.
|
||||||
* Returns { valid: true } or { valid: false, error: "..." }.
|
* Returns { valid: true } or { valid: false, error: "..." }.
|
||||||
@@ -84,13 +63,13 @@ function validateToAddress(value) {
|
|||||||
if (checksummed !== v) {
|
if (checksummed !== v) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: "Address checksum is invalid. Check the address.",
|
error: "Address checksum is invalid. Please double-check the address.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: "Address checksum is invalid. Check the address.",
|
error: "Address checksum is invalid. Please double-check the address.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,15 +120,11 @@ function updateToValidation() {
|
|||||||
|
|
||||||
function renderSendTokenSelect(addr) {
|
function renderSendTokenSelect(addr) {
|
||||||
const sel = $("send-token");
|
const sel = $("send-token");
|
||||||
sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`;
|
sel.innerHTML = '<option value="ETH">ETH</option>';
|
||||||
const fraudSet = new Set(
|
const fraudSet = new Set(
|
||||||
(state.fraudContracts || []).map((a) => a.toLowerCase()),
|
(state.fraudContracts || []).map((a) => a.toLowerCase()),
|
||||||
);
|
);
|
||||||
for (const t of addr.tokenBalances || []) {
|
for (const t of addr.tokenBalances || []) {
|
||||||
// A holding below 0.000001 is left out, as the balance lists leave it
|
|
||||||
// out. Its token's own screen can still send it: there
|
|
||||||
// state.selectedToken picks the token, not this list.
|
|
||||||
if (isBelowOneMillionth(t.balance)) continue;
|
|
||||||
if (isSpoofedSymbol(t.symbol, t.address)) continue;
|
if (isSpoofedSymbol(t.symbol, t.address)) 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:
|
||||||
@@ -163,50 +138,6 @@ function renderSendTokenSelect(addr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The token balance and scale the Send screen states and hands the
|
|
||||||
// confirmation screen, so the two screens describe the holding the same way.
|
|
||||||
//
|
|
||||||
// The scale is resolved the same way balances.js resolved the scale it
|
|
||||||
// DISPLAYED this token's balance at: bundled list, then the user's tracked
|
|
||||||
// tokens, then the explorer. The stored tokenBalances[].decimals is the
|
|
||||||
// explorer's own answer alone, so reading it raw carries a null forward for a
|
|
||||||
// token the wallet does know the scale of — and displayedDecimals() then throws
|
|
||||||
// inside estimateGas(), which the confirmation screen reports as an unestimable
|
|
||||||
// fee. Unsendable, over a scale that was never in doubt
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349). Still null when nothing
|
|
||||||
// knows: no fallback.
|
|
||||||
//
|
|
||||||
// Resolved WITH `wallets`, which balances.js does not pass: that adds
|
|
||||||
// explorerDecimals()'s cross-address check, so a contract two addresses report
|
|
||||||
// different scales for answers null rather than picking one. That check has to
|
|
||||||
// apply here, because this scale encodes the transfer — it is carried forward
|
|
||||||
// so the transfer is encoded with the number the user read rather than with
|
|
||||||
// whatever the contract answers at signing time (see
|
|
||||||
// src/shared/transferAmount.js). balances.js is formatting one explorer row at
|
|
||||||
// fetch time and cannot consult a state it is in the middle of replacing.
|
|
||||||
//
|
|
||||||
// The two resolutions can therefore differ, and where they do, the stored
|
|
||||||
// `balance` is a quantity computed at a scale this screen has just declined to
|
|
||||||
// stand behind. Stating it would leave validateTransfer() checking the amount
|
|
||||||
// against a number the wallet does not vouch for, so it is withdrawn: unknown
|
|
||||||
// scale means unknown balance. It is null rather than "0": both screens state
|
|
||||||
// an unknown balance as unknown, and validateTransfer() treats it as no balance
|
|
||||||
// to spend from, which is the fail-closed side of an amount nobody can check.
|
|
||||||
// Only a stored quantity is withdrawn: the "0" for a token that has no row at
|
|
||||||
// all is an absence of holdings, which is true at every scale.
|
|
||||||
function tokenBalanceAndDecimals(addr, token) {
|
|
||||||
const tb = (addr.tokenBalances || []).find(
|
|
||||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
|
||||||
);
|
|
||||||
const tokenDecimals = resolveTokenDecimals(token, {
|
|
||||||
trackedTokens: state.trackedTokens,
|
|
||||||
wallets: state.wallets,
|
|
||||||
});
|
|
||||||
if (!tb) return { tokenBalance: "0", tokenDecimals };
|
|
||||||
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
|
|
||||||
return { tokenBalance: tb.balance ?? null, tokenDecimals };
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSendBalance() {
|
function updateSendBalance() {
|
||||||
const addr = currentAddress();
|
const addr = currentAddress();
|
||||||
if (!addr) return;
|
if (!addr) return;
|
||||||
@@ -219,130 +150,30 @@ function updateSendBalance() {
|
|||||||
const token = state.selectedToken || $("send-token").value;
|
const token = state.selectedToken || $("send-token").value;
|
||||||
if (token === "ETH") {
|
if (token === "ETH") {
|
||||||
$("send-balance").textContent =
|
$("send-balance").textContent =
|
||||||
"Current balance: " +
|
"Current balance: " + (addr.balance || "0") + " ETH";
|
||||||
truncateAmountNeverZero(addr.balance || "0") +
|
|
||||||
" " +
|
|
||||||
nativeCurrency();
|
|
||||||
} else {
|
} else {
|
||||||
|
const tb = (addr.tokenBalances || []).find(
|
||||||
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||||
|
);
|
||||||
const symbol = resolveSymbol(
|
const symbol = resolveSymbol(
|
||||||
token,
|
token,
|
||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
);
|
);
|
||||||
// A null balance is a holding whose scale is unknown. Saying a figure
|
// A null balance is a holding whose scale nothing knows. Saying "0"
|
||||||
// for it would be a claim about the amount, so it reads as the
|
// for it would be a claim about the amount; the send itself is
|
||||||
// confirmation screen's balance line reads it; the send itself is
|
|
||||||
// refused later by transferAmountUnits() for the same missing scale.
|
// refused later by transferAmountUnits() for the same missing scale.
|
||||||
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
const bal = tb ? tb.balance : "0";
|
||||||
$("send-balance").textContent =
|
$("send-balance").textContent =
|
||||||
bal == null
|
bal == null
|
||||||
? "Current balance: unknown (" + symbol + ")"
|
? "Current balance: unknown (" + symbol + ")"
|
||||||
: "Current balance: " +
|
: "Current balance: " + bal + " " + symbol;
|
||||||
truncateAmountNeverZero(bal) +
|
|
||||||
" " +
|
|
||||||
symbol;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill the amount field with the most the selected holding can send: a
|
|
||||||
// token's whole balance (cut to 18 decimal places), or for ETH the exact
|
|
||||||
// balance minus the fee reserve the confirmation screen checks against, never
|
|
||||||
// the rounded balance the screen shows. Where there is nothing to fill in, a
|
|
||||||
// flash message says why.
|
|
||||||
async function fillMaxAmount() {
|
|
||||||
const addr = currentAddress();
|
|
||||||
if (!addr) return;
|
|
||||||
const token = state.selectedToken || $("send-token").value;
|
|
||||||
|
|
||||||
if (token !== "ETH") {
|
|
||||||
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
|
||||||
if (bal == null) {
|
|
||||||
showFlash("This token's balance is unknown.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const amount = maxTokenAmount(bal);
|
|
||||||
if (!(parseFloat(amount) > 0)) {
|
|
||||||
showFlash("This token's balance is zero.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$("send-amount").value = amount;
|
|
||||||
amountIsMax = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The fee is estimated for this recipient, as the confirmation screen
|
|
||||||
// estimates it: sending to a contract can cost more gas.
|
|
||||||
const to = $("send-to").value.trim();
|
|
||||||
if (!validateToAddress(to).valid) {
|
|
||||||
showFlash("Please enter a recipient address first.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const typed = $("send-amount").value;
|
|
||||||
const opening = sendScreenOpenings;
|
|
||||||
let feeWei = null;
|
|
||||||
try {
|
|
||||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
|
||||||
const [feeData, gasLimit] = await Promise.all([
|
|
||||||
provider.getFeeData(),
|
|
||||||
provider.estimateGas({
|
|
||||||
from: addr.address,
|
|
||||||
to,
|
|
||||||
value: parseEther(addr.balance || "0"),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
feeWei = feeReserveWei(gasLimit, feeData);
|
|
||||||
} catch (e) {
|
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
|
||||||
// src/popup/index.js.
|
|
||||||
if (ctx.pageClosed.aborted) return;
|
|
||||||
log.errorf(
|
|
||||||
"max amount fee estimate failed:",
|
|
||||||
e.shortMessage || e.message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 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
|
|
||||||
// or the recipient: what they did wins.
|
|
||||||
if (
|
|
||||||
state.currentView !== "send" ||
|
|
||||||
sendScreenOpenings !== opening ||
|
|
||||||
currentAddress()?.address !== addr.address ||
|
|
||||||
(state.selectedToken || $("send-token").value) !== token ||
|
|
||||||
$("send-to").value.trim() !== to ||
|
|
||||||
$("send-amount").value !== typed
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (feeWei === null) {
|
|
||||||
showFlash("The network fee could not be estimated.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const amount = maxEthAmount(addr.balance, feeWei);
|
|
||||||
if (amount === null) {
|
|
||||||
showFlash("Your balance does not cover the network fee.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$("send-amount").value = amount;
|
|
||||||
amountIsMax = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function init(_ctx) {
|
function init(_ctx) {
|
||||||
ctx = _ctx;
|
ctx = _ctx;
|
||||||
$("send-token").addEventListener("change", () => {
|
$("send-token").addEventListener("change", updateSendBalance);
|
||||||
// A filled-in maximum is the maximum of the holding it was filled in
|
|
||||||
// for.
|
|
||||||
if (amountIsMax) {
|
|
||||||
$("send-amount").value = "";
|
|
||||||
amountIsMax = false;
|
|
||||||
}
|
|
||||||
updateSendBalance();
|
|
||||||
});
|
|
||||||
|
|
||||||
$("btn-send-max").addEventListener("click", fillMaxAmount);
|
|
||||||
$("send-amount").addEventListener("input", () => {
|
|
||||||
amountIsMax = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial state: disable review button until address is entered
|
// Initial state: disable review button until address is entered
|
||||||
$("btn-send-review").disabled = true;
|
$("btn-send-review").disabled = true;
|
||||||
@@ -380,7 +211,7 @@ function init(_ctx) {
|
|||||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||||
const resolved = await provider.resolveName(to);
|
const resolved = await provider.resolveName(to);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
showFlash("That ENS name has no address.");
|
showFlash("Could not resolve " + to);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolvedTo = resolved;
|
resolvedTo = resolved;
|
||||||
@@ -396,17 +227,59 @@ function init(_ctx) {
|
|||||||
|
|
||||||
let tokenSymbol = null;
|
let tokenSymbol = null;
|
||||||
let tokenBalance = null;
|
let tokenBalance = null;
|
||||||
|
// The scale the amount and the balance below are rendered at, carried
|
||||||
|
// forward so the transfer is encoded with the number the user read
|
||||||
|
// rather than with whatever the contract answers at signing time. See
|
||||||
|
// src/shared/transferAmount.js.
|
||||||
let tokenDecimals = null;
|
let tokenDecimals = null;
|
||||||
if (token !== "ETH") {
|
if (token !== "ETH") {
|
||||||
|
const tb = (addr.tokenBalances || []).find(
|
||||||
|
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||||
|
);
|
||||||
tokenSymbol = resolveSymbol(
|
tokenSymbol = resolveSymbol(
|
||||||
token,
|
token,
|
||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
);
|
);
|
||||||
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
|
// null carried through rather than flattened to "0": the confirm
|
||||||
addr,
|
// screen states an unknown balance as unknown, and
|
||||||
token,
|
// validateTransfer() treats it as no balance to spend from, which
|
||||||
));
|
// is the fail-closed side of an amount nobody can check.
|
||||||
|
tokenBalance = tb ? (tb.balance ?? null) : "0";
|
||||||
|
// Resolved the same way balances.js resolved the scale it
|
||||||
|
// DISPLAYED this token's balance at: bundled list, then the user's
|
||||||
|
// tracked tokens, then the explorer. The stored
|
||||||
|
// tokenBalances[].decimals is the explorer's own answer alone, so
|
||||||
|
// reading it raw carries a null forward for a token the wallet
|
||||||
|
// does know the scale of — and displayedDecimals() then throws
|
||||||
|
// inside estimateGas(), which the confirmation screen reports as
|
||||||
|
// an unestimable fee. Unsendable, over a scale that was never in
|
||||||
|
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
||||||
|
// Still null when nothing knows: no fallback.
|
||||||
|
//
|
||||||
|
// Resolved WITH `wallets`, which balances.js does not pass: that
|
||||||
|
// adds explorerDecimals()'s cross-address check, so a contract two
|
||||||
|
// addresses report different scales for answers null rather than
|
||||||
|
// picking one. That check has to apply here, because this value
|
||||||
|
// encodes a transfer; balances.js is formatting one explorer row
|
||||||
|
// at fetch time and cannot consult a state it is in the middle of
|
||||||
|
// replacing.
|
||||||
|
tokenDecimals = resolveTokenDecimals(token, {
|
||||||
|
trackedTokens: state.trackedTokens,
|
||||||
|
wallets: state.wallets,
|
||||||
|
});
|
||||||
|
// The two resolutions can therefore differ, and where they do, the
|
||||||
|
// stored `balance` is a quantity computed at a scale this screen
|
||||||
|
// has just declined to stand behind. Stating it would leave
|
||||||
|
// validateTransfer() checking the amount against a number the
|
||||||
|
// wallet does not vouch for, and — since the unknown-balance path
|
||||||
|
// is gated on the balance, not on the scale — would leave the
|
||||||
|
// fee-estimate failure as the only thing on the confirmation
|
||||||
|
// screen, which says nothing about decimals. Unknown scale means
|
||||||
|
// unknown balance. Only a stored quantity is withdrawn: the "0"
|
||||||
|
// for a token that has no row at all is an absence of holdings,
|
||||||
|
// which is true at every scale.
|
||||||
|
if (tb && tokenDecimals === null) tokenBalance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.showConfirmTx({
|
ctx.showConfirmTx({
|
||||||
@@ -419,7 +292,6 @@ function init(_ctx) {
|
|||||||
tokenSymbol: tokenSymbol,
|
tokenSymbol: tokenSymbol,
|
||||||
tokenBalance: tokenBalance,
|
tokenBalance: tokenBalance,
|
||||||
tokenDecimals: tokenDecimals,
|
tokenDecimals: tokenDecimals,
|
||||||
max: amountIsMax,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -430,10 +302,7 @@ function init(_ctx) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called each time the Send screen opens, with its fields cleared.
|
|
||||||
function resetSendValidation() {
|
function resetSendValidation() {
|
||||||
sendScreenOpenings++;
|
|
||||||
amountIsMax = false;
|
|
||||||
const errorEl = $("send-to-error");
|
const errorEl = $("send-to-error");
|
||||||
const btn = $("btn-send-review");
|
const btn = $("btn-send-review");
|
||||||
if (errorEl) errorEl.textContent = "";
|
if (errorEl) errorEl.textContent = "";
|
||||||
|
|||||||
+45
-75
@@ -16,12 +16,7 @@ const {
|
|||||||
} = require("../dustThreshold");
|
} = require("../dustThreshold");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||||||
const {
|
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
|
||||||
log,
|
|
||||||
debugFetch,
|
|
||||||
urlOrigin,
|
|
||||||
setRuntimeDebug,
|
|
||||||
} = require("../../shared/log");
|
|
||||||
const deleteWallet = require("./deleteWallet");
|
const deleteWallet = require("./deleteWallet");
|
||||||
const showPhrase = require("./showPhrase");
|
const showPhrase = require("./showPhrase");
|
||||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||||
@@ -34,63 +29,46 @@ const {
|
|||||||
GITEA_COMMIT_URL,
|
GITEA_COMMIT_URL,
|
||||||
} = require("../../shared/buildInfo");
|
} = require("../../shared/buildInfo");
|
||||||
|
|
||||||
const { notify, sendMessage } = require("../../shared/browserApi");
|
const { notify } = require("../../shared/browserApi");
|
||||||
|
|
||||||
let versionClickCount = 0;
|
let versionClickCount = 0;
|
||||||
let versionClickTimer = null;
|
let versionClickTimer = null;
|
||||||
|
|
||||||
// One row per site origin, however many addresses it appears under, each with
|
function renderSiteList(containerId, siteMap, stateKey) {
|
||||||
// an [x] that hands it to onRemove.
|
|
||||||
function renderSiteList(containerId, origins, onRemove) {
|
|
||||||
const container = $(containerId);
|
const container = $(containerId);
|
||||||
const unique = [...new Set(origins)];
|
const hostnames = [...new Set(Object.values(siteMap).flat())];
|
||||||
if (unique.length === 0) {
|
if (hostnames.length === 0) {
|
||||||
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = "";
|
let html = "";
|
||||||
unique.forEach((origin) => {
|
hostnames.forEach((hostname) => {
|
||||||
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
||||||
// An origin the URL parser produced cannot carry a delimiter, so
|
// A hostname the URL parser produced cannot carry a delimiter, so
|
||||||
// this is escaped for the rule rather than for a known hole — the
|
// this is escaped for the rule rather than for a known hole — the
|
||||||
// rule being that nothing reaches innerHTML unescaped.
|
// rule being that nothing reaches innerHTML unescaped.
|
||||||
html += `<span>${escapeHtml(origin)}</span>`;
|
html += `<span>${escapeHtml(hostname)}</span>`;
|
||||||
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-origin="${escapeHtml(origin)}">[x]</button>`;
|
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-key="${escapeHtml(stateKey)}" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
});
|
});
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
|
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => onRemove(btn.dataset.origin));
|
btn.addEventListener("click", async () => {
|
||||||
|
const key = btn.dataset.key;
|
||||||
|
const host = btn.dataset.hostname;
|
||||||
|
for (const addr of Object.keys(state[key])) {
|
||||||
|
state[key][addr] = state[key][addr].filter((h) => h !== host);
|
||||||
|
if (state[key][addr].length === 0) {
|
||||||
|
delete state[key][addr];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveState();
|
||||||
|
notify({ type: "AUTISTMASK_REMOVE_SITE" });
|
||||||
|
renderSiteList(containerId, state[key], key);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop a site origin from a remembered site list under every address.
|
|
||||||
function forgetOrigin(siteMap, origin) {
|
|
||||||
for (const addr of Object.keys(siteMap)) {
|
|
||||||
siteMap[addr] = siteMap[addr].filter((o) => o !== origin);
|
|
||||||
if (siteMap[addr].length === 0) {
|
|
||||||
delete siteMap[addr];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
|
|
||||||
// no longer allowed under any address, and the background ends its
|
|
||||||
// connections approved without "Remember" and tells its open tabs.
|
|
||||||
async function removeAllowedSite(origin) {
|
|
||||||
forgetOrigin(state.allowedSites, origin);
|
|
||||||
await saveState();
|
|
||||||
notify({ type: "AUTISTMASK_REMOVE_SITE", origin });
|
|
||||||
await renderSiteLists();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Removing a denied site only forgets the refusal; it connects nothing.
|
|
||||||
async function removeDeniedSite(origin) {
|
|
||||||
forgetOrigin(state.deniedSites, origin);
|
|
||||||
await saveState();
|
|
||||||
await renderSiteLists();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTrackedTokens() {
|
function renderTrackedTokens() {
|
||||||
const container = $("settings-tracked-tokens");
|
const container = $("settings-tracked-tokens");
|
||||||
if (state.trackedTokens.length === 0) {
|
if (state.trackedTokens.length === 0) {
|
||||||
@@ -213,30 +191,24 @@ function show() {
|
|||||||
versionClickCount = 0;
|
versionClickCount = 0;
|
||||||
|
|
||||||
// Show debug well if debug mode is already enabled
|
// Show debug well if debug mode is already enabled
|
||||||
$("settings-debug-well").classList.toggle("hidden", !state.debugMode);
|
const debugWell = $("settings-debug-well");
|
||||||
|
if (state.debugMode) {
|
||||||
|
debugWell.style.display = "";
|
||||||
|
} else {
|
||||||
|
debugWell.style.display = "none";
|
||||||
|
}
|
||||||
$("settings-debug-mode").checked = state.debugMode;
|
$("settings-debug-mode").checked = state.debugMode;
|
||||||
|
|
||||||
showView("settings");
|
showView("settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderSiteLists() {
|
function renderSiteLists() {
|
||||||
renderSiteList(
|
renderSiteList(
|
||||||
"settings-allowed-sites",
|
"settings-allowed-sites",
|
||||||
Object.values(state.allowedSites).flat(),
|
state.allowedSites,
|
||||||
removeAllowedSite,
|
"allowedSites",
|
||||||
);
|
|
||||||
renderSiteList(
|
|
||||||
"settings-denied-sites",
|
|
||||||
Object.values(state.deniedSites).flat(),
|
|
||||||
removeDeniedSite,
|
|
||||||
);
|
|
||||||
// Sites allowed without "Remember" are held only by the background, in
|
|
||||||
// memory, so it is asked for them.
|
|
||||||
renderSiteList(
|
|
||||||
"settings-connected-sites",
|
|
||||||
await sendMessage({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
|
|
||||||
removeAllowedSite,
|
|
||||||
);
|
);
|
||||||
|
renderSiteList("settings-denied-sites", state.deniedSites, "deniedSites");
|
||||||
}
|
}
|
||||||
|
|
||||||
function init(ctx) {
|
function init(ctx) {
|
||||||
@@ -264,22 +236,22 @@ function init(ctx) {
|
|||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
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 error: " + json.error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const net = currentNetwork();
|
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 +
|
||||||
|
", got chain " +
|
||||||
|
json.result +
|
||||||
|
").",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
log.errorf("RPC validation fetch failed:", e.message);
|
||||||
// src/popup/index.js.
|
|
||||||
if (ctx.pageClosed.aborted) return;
|
|
||||||
// Not the error's message: fetch puts the whole URL, password and
|
|
||||||
// key included, in the message of the error it throws for a URL
|
|
||||||
// with a user name and password or one it cannot parse.
|
|
||||||
log.errorf("RPC validation fetch failed:", urlOrigin(url));
|
|
||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -301,10 +273,8 @@ function init(ctx) {
|
|||||||
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
if (ctx.pageClosed.aborted) return;
|
log.errorf("Blockscout validation failed:", e.message);
|
||||||
// Not the error's message, as for the RPC check above.
|
|
||||||
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
|
||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -433,7 +403,7 @@ function init(ctx) {
|
|||||||
if (versionClickCount >= 10) {
|
if (versionClickCount >= 10) {
|
||||||
versionClickCount = 0;
|
versionClickCount = 0;
|
||||||
clearTimeout(versionClickTimer);
|
clearTimeout(versionClickTimer);
|
||||||
$("settings-debug-well").classList.remove("hidden");
|
$("settings-debug-well").style.display = "";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function isTracked(address) {
|
|||||||
return state.trackedTokens.some((t) => t.address.toLowerCase() === lower);
|
return state.trackedTokens.some((t) => t.address.toLowerCase() === lower);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nameAndSymbol(t) {
|
function tokenLabel(t) {
|
||||||
return t.name ? t.name + " (" + t.symbol + ")" : t.symbol;
|
return t.name ? t.name + " (" + t.symbol + ")" : t.symbol;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ function renderDropdown() {
|
|||||||
let html = '<option value="">-- select --</option>';
|
let html = '<option value="">-- select --</option>';
|
||||||
for (const t of tokens) {
|
for (const t of tokens) {
|
||||||
const tracked = isTracked(t.address);
|
const tracked = isTracked(t.address);
|
||||||
const label = nameAndSymbol(t) + (tracked ? " (tracked)" : "");
|
const label = tokenLabel(t) + (tracked ? " (tracked)" : "");
|
||||||
html +=
|
html +=
|
||||||
`<option value="${escapeHtml(t.address)}"` +
|
`<option value="${escapeHtml(t.address)}"` +
|
||||||
` data-symbol="${escapeHtml(t.symbol)}"` +
|
` data-symbol="${escapeHtml(t.symbol)}"` +
|
||||||
@@ -115,7 +115,9 @@ function init(_ctx) {
|
|||||||
$("btn-settings-addtoken-manual").addEventListener("click", async () => {
|
$("btn-settings-addtoken-manual").addEventListener("click", async () => {
|
||||||
const addr = $("settings-addtoken-address").value.trim();
|
const addr = $("settings-addtoken-address").value.trim();
|
||||||
if (!addr || !addr.startsWith("0x")) {
|
if (!addr || !addr.startsWith("0x")) {
|
||||||
showFlash("Enter a valid contract address starting with 0x.");
|
showFlash(
|
||||||
|
"Please enter a valid contract address starting with 0x.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isTracked(addr)) {
|
if (isTracked(addr)) {
|
||||||
@@ -135,7 +137,6 @@ function init(_ctx) {
|
|||||||
addr,
|
addr,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
state.networkId,
|
state.networkId,
|
||||||
ctx.pageClosed,
|
|
||||||
);
|
);
|
||||||
log.infof("Adding token", info.symbol, addr);
|
log.infof("Adding token", info.symbol, addr);
|
||||||
state.trackedTokens.push({
|
state.trackedTokens.push({
|
||||||
@@ -153,19 +154,9 @@ function init(_ctx) {
|
|||||||
renderDropdown();
|
renderDropdown();
|
||||||
ctx.doRefreshAndRender();
|
ctx.doRefreshAndRender();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
|
||||||
// src/popup/index.js.
|
|
||||||
if (ctx.pageClosed.aborted) return;
|
|
||||||
const detail = e.shortMessage || e.message || String(e);
|
const detail = e.shortMessage || e.message || String(e);
|
||||||
log.errorf("Adding token failed for", addr, detail);
|
log.errorf("Token lookup failed for", addr, detail);
|
||||||
// lookupTokenInfo() rejects a contract with a one-line message
|
showFlash(detail);
|
||||||
// starting "Not a valid ERC-20 token". Any other error, such as a
|
|
||||||
// failed save, can be far longer, so it is only logged.
|
|
||||||
showFlash(
|
|
||||||
detail.startsWith("Not a valid ERC-20 token")
|
|
||||||
? detail
|
|
||||||
: "Could not add the token.",
|
|
||||||
);
|
|
||||||
infoEl.textContent = "";
|
infoEl.textContent = "";
|
||||||
infoEl.style.visibility = "hidden";
|
infoEl.style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,8 @@
|
|||||||
// Everything else in the popup assumes a loaded profile: showView() reads and
|
// Everything else in the popup assumes a loaded profile: showView() reads and
|
||||||
// writes the state singleton, every view renders from it, and the Settings
|
// writes the state singleton, every view renders from it, and the Settings
|
||||||
// gear leads to a screen that does both. None of that is available here — by
|
// gear leads to a screen that does both. None of that is available here — by
|
||||||
// the time this runs, the stored record has been REFUSED, deliberately: at
|
// the time this runs, loadState() has REFUSED, deliberately, and reading the
|
||||||
// open loadState() refused it and reading the singleton throws
|
// singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/311), and under an open popup
|
|
||||||
// a save refused it (https://git.eeqj.de/sneak/AutistMask/issues/373).
|
|
||||||
//
|
//
|
||||||
// So this module talks to the DOM directly and touches no state at all. It is
|
// So this module talks to the DOM directly and touches no state at all. It is
|
||||||
// the one screen that must work when nothing else can, which is also why it
|
// the one screen that must work when nothing else can, which is also why it
|
||||||
@@ -172,11 +170,6 @@ function wire() {
|
|||||||
* refused, or its sentence.
|
* refused, or its sentence.
|
||||||
*/
|
*/
|
||||||
function show(problem) {
|
function show(problem) {
|
||||||
// Already up: a later save that trips over the same record, such as a
|
|
||||||
// refresh that was in flight when the screen went up, must not clear what
|
|
||||||
// the user has exported or typed here.
|
|
||||||
if (!$("view-state-recovery").classList.contains("hidden")) return;
|
|
||||||
|
|
||||||
const sentence =
|
const sentence =
|
||||||
(problem && (problem.problem || problem.message)) || String(problem);
|
(problem && (problem.problem || problem.message)) || String(problem);
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ const {
|
|||||||
showFlash,
|
showFlash,
|
||||||
flashCopyFeedback,
|
flashCopyFeedback,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
CONTRACT_CREATION_TEXT,
|
|
||||||
addressDotHtml,
|
addressDotHtml,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
isoDate,
|
isoDate,
|
||||||
timeAgo,
|
timeAgo,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
blockieHtml,
|
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
copyableHtml,
|
copyableHtml,
|
||||||
etherscanLinkHtml,
|
etherscanLinkHtml,
|
||||||
@@ -22,8 +20,8 @@ const {
|
|||||||
goBack,
|
goBack,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state } = require("../../shared/state");
|
const { state } = require("../../shared/state");
|
||||||
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
|
||||||
const { formatEther, formatUnits } = require("ethers");
|
const { formatEther, formatUnits } = require("ethers");
|
||||||
|
const makeBlockie = require("ethereum-blockies-base64");
|
||||||
const { log, debugFetch } = require("../../shared/log");
|
const { log, debugFetch } = require("../../shared/log");
|
||||||
const { decodeCalldata } = require("./approval");
|
const { decodeCalldata } = require("./approval");
|
||||||
|
|
||||||
@@ -42,10 +40,13 @@ function getTransactionType(tx) {
|
|||||||
return "Token Approval";
|
return "Token Approval";
|
||||||
return "Contract Call";
|
return "Contract Call";
|
||||||
}
|
}
|
||||||
// By the token contract, not the symbol: a token chooses its own symbol
|
if (tx.symbol && tx.symbol !== "ETH") return "ERC-20 Token Transfer";
|
||||||
// and can report the native token's, but only a token transfer has one.
|
return "Native ETH Transfer";
|
||||||
if (tx.contractAddress) return "ERC-20 Token Transfer";
|
}
|
||||||
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
|
|
||||||
|
function blockieHtml(address) {
|
||||||
|
const src = makeBlockie(address);
|
||||||
|
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function txAddressHtml(address, ensName, title) {
|
function txAddressHtml(address, ensName, title) {
|
||||||
@@ -82,11 +83,6 @@ function show(tx) {
|
|||||||
isContractCall: tx.isContractCall || false,
|
isContractCall: tx.isContractCall || false,
|
||||||
method: tx.method || null,
|
method: tx.method || null,
|
||||||
contractAddress: tx.contractAddress || null,
|
contractAddress: tx.contractAddress || null,
|
||||||
// The network the history entry was read from. The type line and
|
|
||||||
// the fee are in its native currency, not the active network's:
|
|
||||||
// a site can switch the active network before a later popup
|
|
||||||
// shows this screen again.
|
|
||||||
chainId: tx.chainId,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
render();
|
render();
|
||||||
@@ -98,18 +94,13 @@ function render() {
|
|||||||
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
|
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
|
||||||
|
|
||||||
const fromTitle = addressTitle(tx.from, state.wallets);
|
const fromTitle = addressTitle(tx.from, state.wallets);
|
||||||
|
const toTitle = addressTitle(tx.to, state.wallets);
|
||||||
$("tx-detail-from").innerHTML = txAddressHtml(
|
$("tx-detail-from").innerHTML = txAddressHtml(
|
||||||
tx.from,
|
tx.from,
|
||||||
tx.fromEns,
|
tx.fromEns,
|
||||||
fromTitle,
|
fromTitle,
|
||||||
);
|
);
|
||||||
// A contract creation has no recipient: transactions.js gives it `to: ""`.
|
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
|
||||||
if (tx.to) {
|
|
||||||
const toTitle = addressTitle(tx.to, state.wallets);
|
|
||||||
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
|
|
||||||
} else {
|
|
||||||
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exact amount (full precision, copyable)
|
// Exact amount (full precision, copyable)
|
||||||
const detailSym = displaySymbol(tx.symbol);
|
const detailSym = displaySymbol(tx.symbol);
|
||||||
@@ -146,16 +137,10 @@ function render() {
|
|||||||
if (tx.contractAddress) {
|
if (tx.contractAddress) {
|
||||||
const dot = addressDotHtml(tx.contractAddress);
|
const dot = addressDotHtml(tx.contractAddress);
|
||||||
const link = explorerUrl("token", tx.contractAddress);
|
const link = explorerUrl("token", tx.contractAddress);
|
||||||
// Hand-rolled rather than renderAddressHtml() because the
|
|
||||||
// link goes to the explorer's /token/ page, not /address/.
|
|
||||||
// Same two-row shape though: dot and link on the strip, the
|
|
||||||
// contract address alone on the row below it.
|
|
||||||
tokenContractEl.innerHTML =
|
tokenContractEl.innerHTML =
|
||||||
`<div class="flex items-center">${dot}` +
|
`<div class="flex items-center">${dot}` +
|
||||||
|
copyableHtml(tx.contractAddress, "break-all") +
|
||||||
etherscanLinkHtml(link) +
|
etherscanLinkHtml(link) +
|
||||||
`</div>` +
|
|
||||||
`<div class="am-address">` +
|
|
||||||
copyableHtml(tx.contractAddress) +
|
|
||||||
`</div>`;
|
`</div>`;
|
||||||
tokenContractSection.classList.remove("hidden");
|
tokenContractSection.classList.remove("hidden");
|
||||||
} else {
|
} else {
|
||||||
@@ -182,7 +167,7 @@ function render() {
|
|||||||
if (el) el.classList.add("hidden");
|
if (el) el.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFullTxDetails(tx.hash, tx.to, tx.chainId);
|
loadFullTxDetails(tx.hash, tx.to);
|
||||||
|
|
||||||
const isoStr = isoDate(tx.timestamp);
|
const isoStr = isoDate(tx.timestamp);
|
||||||
$("tx-detail-time").innerHTML =
|
$("tx-detail-time").innerHTML =
|
||||||
@@ -200,7 +185,7 @@ function showDetailField(sectionId, contentId, value) {
|
|||||||
section.classList.remove("hidden");
|
section.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateOnChainDetails(txData, chainId) {
|
function populateOnChainDetails(txData) {
|
||||||
// Block number
|
// Block number
|
||||||
if (txData.block_number != null) {
|
if (txData.block_number != null) {
|
||||||
const blockLink = explorerUrl("block", String(txData.block_number));
|
const blockLink = explorerUrl("block", String(txData.block_number));
|
||||||
@@ -230,7 +215,7 @@ function populateOnChainDetails(txData, chainId) {
|
|||||||
showDetailField(
|
showDetailField(
|
||||||
"tx-detail-fee-section",
|
"tx-detail-fee-section",
|
||||||
"tx-detail-fee",
|
"tx-detail-fee",
|
||||||
feeEth + " " + nativeCurrencyByChainId(chainId),
|
feeEth + " ETH",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +275,7 @@ function populateOnChainDetails(txData, chainId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFullTxDetails(txHash, toAddress, chainId) {
|
async function loadFullTxDetails(txHash, toAddress) {
|
||||||
const section = $("tx-detail-calldata-section");
|
const section = $("tx-detail-calldata-section");
|
||||||
const actionEl = $("tx-detail-calldata-action");
|
const actionEl = $("tx-detail-calldata-action");
|
||||||
const detailsEl = $("tx-detail-calldata-details");
|
const detailsEl = $("tx-detail-calldata-details");
|
||||||
@@ -307,7 +292,7 @@ async function loadFullTxDetails(txHash, toAddress, chainId) {
|
|||||||
const txData = await resp.json();
|
const txData = await resp.json();
|
||||||
|
|
||||||
// Populate on-chain detail fields (block, nonce, gas, fee)
|
// Populate on-chain detail fields (block, nonce, gas, fee)
|
||||||
populateOnChainDetails(txData, chainId);
|
populateOnChainDetails(txData);
|
||||||
|
|
||||||
const inputData = txData.raw_input || txData.input || null;
|
const inputData = txData.raw_input || txData.input || null;
|
||||||
if (!inputData || inputData === "0x") return;
|
if (!inputData || inputData === "0x") return;
|
||||||
|
|||||||
+15
-24
@@ -4,7 +4,6 @@ const {
|
|||||||
$,
|
$,
|
||||||
showView,
|
showView,
|
||||||
addressTitle,
|
addressTitle,
|
||||||
CONTRACT_CREATION_TEXT,
|
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
renderAddressHtml,
|
renderAddressHtml,
|
||||||
attachCopyHandlers,
|
attachCopyHandlers,
|
||||||
@@ -13,10 +12,9 @@ const {
|
|||||||
explorerUrl,
|
explorerUrl,
|
||||||
displaySymbol,
|
displaySymbol,
|
||||||
clearViewStack,
|
clearViewStack,
|
||||||
tokenLabel,
|
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
|
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||||
const { state } = require("../../shared/state");
|
const { state } = require("../../shared/state");
|
||||||
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
|
||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
const { log } = require("../../shared/log");
|
const { log } = require("../../shared/log");
|
||||||
|
|
||||||
@@ -60,10 +58,7 @@ function endWait() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A contract creation reaches these screens with `to` as "" (approval.js
|
|
||||||
// writes `to: toAddr || ""`).
|
|
||||||
function toAddressHtml(address) {
|
function toAddressHtml(address) {
|
||||||
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
|
|
||||||
const title = addressTitle(address, state.wallets);
|
const title = addressTitle(address, state.wallets);
|
||||||
return renderAddressHtml(address, { title });
|
return renderAddressHtml(address, { title });
|
||||||
}
|
}
|
||||||
@@ -87,13 +82,9 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
|||||||
endWait();
|
endWait();
|
||||||
const id = waitId;
|
const id = waitId;
|
||||||
|
|
||||||
// A native amount, here and on the success and error screens, is in the
|
|
||||||
// native currency of txInfo.chainId, the network the transaction was sent
|
|
||||||
// on, not the active network's: a site can switch the active network
|
|
||||||
// while this screen is open or before a later popup resumes it.
|
|
||||||
const symbol =
|
const symbol =
|
||||||
txInfo.token === "ETH"
|
txInfo.token === "ETH"
|
||||||
? nativeCurrencyByChainId(txInfo.chainId)
|
? "ETH"
|
||||||
: displaySymbol(txInfo.tokenSymbol || "?");
|
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||||
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
|
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
|
||||||
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
|
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
|
||||||
@@ -132,16 +123,13 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
|||||||
try {
|
try {
|
||||||
receipt = await provider.getTransactionReceipt(txHash);
|
receipt = await provider.getTransactionReceipt(txHash);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
|
||||||
// src/popup/index.js.
|
|
||||||
if (ctx.pageClosed.aborted) return;
|
|
||||||
// A thrown lookup means "no answer this tick", not "no
|
// A thrown lookup means "no answer this tick", not "no
|
||||||
// receipt": the RPC failed, the chain said nothing. Declaring
|
// receipt": the RPC failed, the chain said nothing. Declaring
|
||||||
// the timeout off it would report a confirmed transaction as
|
// the timeout off it would report a confirmed transaction as
|
||||||
// failed — which matters most on a resumed wait, where the
|
// failed — which matters most on a resumed wait, where the
|
||||||
// first poll is already past the deadline.
|
// first poll is already past the deadline.
|
||||||
answered = false;
|
answered = false;
|
||||||
log.errorf("poll receipt failed:", e.shortMessage || e.message);
|
log.errorf("poll receipt failed:", e.message);
|
||||||
}
|
}
|
||||||
// The lookup is async: the wait may have ended while it was in
|
// The lookup is async: the wait may have ended while it was in
|
||||||
// flight, in which case this result must not touch the view.
|
// flight, in which case this result must not touch the view.
|
||||||
@@ -201,10 +189,9 @@ function showWait(txInfo, txHash) {
|
|||||||
// an object merely missing one of them throws a TypeError out of
|
// an object merely missing one of them throws a TypeError out of
|
||||||
// restoreView() — which init() does not guard, skipping the rest of popup
|
// restoreView() — which init() does not guard, skipping the rest of popup
|
||||||
// init and leaving wait-tx on screen with no back control. A non-numeric
|
// init and leaving wait-tx on screen with no back control. A non-numeric
|
||||||
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token,
|
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token and
|
||||||
// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
|
// txInfo.tokenSymbol are deliberately unchecked: they are compared and
|
||||||
// compared and coalesced rather than dereferenced, and tokenSymbol is null for
|
// coalesced rather than dereferenced, and tokenSymbol is null for ETH.
|
||||||
// ETH.
|
|
||||||
function restoreWait() {
|
function restoreWait() {
|
||||||
const d = state.viewData;
|
const d = state.viewData;
|
||||||
if (!d || !d.pendingWait) return false;
|
if (!d || !d.pendingWait) return false;
|
||||||
@@ -215,9 +202,8 @@ function restoreWait() {
|
|||||||
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
|
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
|
||||||
// A string is the whole requirement: the empty string is what a
|
// A string is the whole requirement: the empty string is what a
|
||||||
// contract-deployment approval persists (approval.js writes `to: toAddr
|
// contract-deployment approval persists (approval.js writes `to: toAddr
|
||||||
// || ""`), an empty `to` renders as a contract creation and an empty
|
// || ""`), and both fields render harmlessly when empty, so refusing it
|
||||||
// amount renders harmlessly, so refusing it would abandon a wait the live
|
// would abandon a wait the live path itself created.
|
||||||
// path itself created.
|
|
||||||
if (typeof info.to !== "string") return false;
|
if (typeof info.to !== "string") return false;
|
||||||
if (typeof info.amount !== "string") return false;
|
if (typeof info.amount !== "string") return false;
|
||||||
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
||||||
@@ -232,7 +218,7 @@ function showSuccess(txInfo, txHash, blockNumber) {
|
|||||||
|
|
||||||
const symbol =
|
const symbol =
|
||||||
txInfo.token === "ETH"
|
txInfo.token === "ETH"
|
||||||
? nativeCurrencyByChainId(txInfo.chainId)
|
? "ETH"
|
||||||
: displaySymbol(txInfo.tokenSymbol || "?");
|
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||||
state.viewData = {
|
state.viewData = {
|
||||||
amount: txInfo.amount,
|
amount: txInfo.amount,
|
||||||
@@ -246,6 +232,11 @@ function showSuccess(txInfo, txHash, blockNumber) {
|
|||||||
ctx.doRefreshAndRender();
|
ctx.doRefreshAndRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tokenLabel(address) {
|
||||||
|
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
||||||
|
return t ? t.symbol : null;
|
||||||
|
}
|
||||||
|
|
||||||
function decodedDetailsHtml(decoded) {
|
function decodedDetailsHtml(decoded) {
|
||||||
if (!decoded || !decoded.details) return "";
|
if (!decoded || !decoded.details) 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">`;
|
||||||
@@ -318,7 +309,7 @@ function showError(txInfo, txHash, message) {
|
|||||||
|
|
||||||
const symbol =
|
const symbol =
|
||||||
txInfo.token === "ETH"
|
txInfo.token === "ETH"
|
||||||
? nativeCurrencyByChainId(txInfo.chainId)
|
? "ETH"
|
||||||
: displaySymbol(txInfo.tokenSymbol || "?");
|
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||||
state.viewData = {
|
state.viewData = {
|
||||||
amount: txInfo.amount,
|
amount: txInfo.amount,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ async function getFullWarnings(address, provider, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("contract check failed:", e.shortMessage || e.message);
|
log.errorf("contract check failed:", e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip tx count check for contracts — they may legitimately have
|
// Skip tx count check for contracts — they may legitimately have
|
||||||
@@ -92,7 +92,7 @@ async function getFullWarnings(address, provider, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("tx count check failed:", e.shortMessage || e.message);
|
log.errorf("tx count check failed:", e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
// (`src/shared/uniswap.js`) — and a fix applied to one of them left the other
|
// (`src/shared/uniswap.js`) — and a fix applied to one of them left the other
|
||||||
// two showing a different number for the same value.
|
// two showing a different number for the same value.
|
||||||
//
|
//
|
||||||
// The two truncation functions below are the two policies, not two
|
// The two functions below are the two policies, not two implementations of
|
||||||
// implementations of one: summary lists truncate, and the screens that state
|
// one: summary lists truncate, and the screens that state what is being
|
||||||
// what is being authorized truncate with a floor. Keeping them adjacent is the
|
// authorized truncate with a floor. Keeping them adjacent is the point, so a
|
||||||
// point, so a change to the rule cannot reach one screen and miss another.
|
// change to the rule cannot reach one screen and miss another.
|
||||||
|
|
||||||
// Truncate to exactly four decimal places. Truncation, never rounding: an
|
// Truncate to exactly four decimal places. Truncation, never rounding: an
|
||||||
// amount must never be displayed as larger than it is, so 0.99999 stays
|
// amount must never be displayed as larger than it is, so 0.99999 stays
|
||||||
@@ -43,18 +43,4 @@ function truncateAmountNeverZero(val) {
|
|||||||
return parts[0] + "." + parts[1].slice(0, sig + 1);
|
return parts[0] + "." + parts[1].slice(0, sig + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether a stored token balance is a holding below 0.000001. The balance
|
module.exports = { truncateAmount, truncateAmountNeverZero };
|
||||||
// lists, the send-screen token selector, the address total and the
|
|
||||||
// remove-address warning leave such a holding out; the Send and confirmation
|
|
||||||
// screens show it when its token is the one being sent. Exact, because
|
|
||||||
// src/shared/balances.js stores plain decimal digits: below 0.000001 the
|
|
||||||
// balance reads "0.000000" and then more digits.
|
|
||||||
function isBelowOneMillionth(balance) {
|
|
||||||
return typeof balance === "string" && balance.startsWith("0.000000");
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
truncateAmount,
|
|
||||||
truncateAmountNeverZero,
|
|
||||||
isBelowOneMillionth,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -23,14 +23,13 @@
|
|||||||
// disputed is refused rather than guessed at.
|
// disputed is refused rather than guessed at.
|
||||||
|
|
||||||
// Solidity's decimals() is a uint8, and every source here is ultimately
|
// Solidity's decimals() is a uint8, and every source here is ultimately
|
||||||
// reporting that call's result. toDecimals() is that check, stopping at the 80
|
// reporting that call's result. toDecimals() is that check, shared with the
|
||||||
// places formatUnits() accepts, and shared with the send path rather than
|
// send path rather than copied: the bundled list stores numbers, the
|
||||||
// copied: the bundled list stores numbers, the explorer's copy arrives as a
|
// explorer's copy arrives as a string, and a token the user added by hand
|
||||||
// string, and a token the user added by hand carries whatever lookupTokenInfo()
|
// carries whatever lookupTokenInfo() got back, so the accepted types are
|
||||||
// got back, so the accepted types are enumerated rather than coerced.
|
// enumerated rather than coerced.
|
||||||
const { toDecimals } = require("./transferAmount");
|
const { toDecimals } = require("./transferAmount");
|
||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
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
|
||||||
// addresses whose balances have been fetched. They describe one contract, so
|
// addresses whose balances have been fetched. They describe one contract, so
|
||||||
@@ -75,59 +74,6 @@ function resolveTokenDecimals(tokenAddress, sources) {
|
|||||||
return explorerDecimals(lower, sources && sources.wallets);
|
return explorerDecimals(lower, sources && sources.wallets);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every symbol the explorer reported for this contract, across the addresses
|
|
||||||
// whose balances have been fetched. The counterpart to explorerDecimals(): one
|
|
||||||
// contract, so the reports should agree, and a set that does not agree is a
|
|
||||||
// name this screen has no way to choose between.
|
|
||||||
function explorerSymbol(lower, wallets) {
|
|
||||||
let found = null;
|
|
||||||
for (const wallet of wallets || []) {
|
|
||||||
for (const addr of wallet.addresses || []) {
|
|
||||||
for (const tb of addr.tokenBalances || []) {
|
|
||||||
if ((tb.address || "").toLowerCase() !== lower) continue;
|
|
||||||
if (!tb.symbol) continue;
|
|
||||||
if (found !== null && found !== tb.symbol) return null;
|
|
||||||
found = tb.symbol;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The symbol to label a token with, or null when no source the wallet trusts
|
|
||||||
// names one — in which case the screen keeps saying `Unknown token` rather than
|
|
||||||
// guessing. The bundled list, then the tokens the user tracks, then what the
|
|
||||||
// explorer reported: the same sources and the same precedence
|
|
||||||
// resolveTokenDecimals() uses, so a token's name and its scale are drawn from
|
|
||||||
// the same place and the two can no longer disagree about which sources they
|
|
||||||
// trust. `sources` is { trackedTokens, wallets }, shaped as on `state`.
|
|
||||||
//
|
|
||||||
// A tracked or explorer-reported symbol is attacker-influenced text, so it is
|
|
||||||
// held to the spoof rule (symbolSpoof.js): a candidate that wears a bundled or
|
|
||||||
// native ticker from a contract not entitled to it is refused and the next
|
|
||||||
// source tried, so resolving a symbol never becomes a new way to claim a known
|
|
||||||
// ticker. The bundled list is the wallet's own data and is trusted as it is.
|
|
||||||
function resolveTokenSymbol(tokenAddress, sources) {
|
|
||||||
const lower = (tokenAddress || "").toLowerCase();
|
|
||||||
if (!lower) return null;
|
|
||||||
|
|
||||||
const bundled = TOKEN_BY_ADDRESS.get(lower);
|
|
||||||
if (bundled && bundled.symbol) return bundled.symbol;
|
|
||||||
|
|
||||||
const tracked = ((sources && sources.trackedTokens) || []).find(
|
|
||||||
(t) => (t.address || "").toLowerCase() === lower,
|
|
||||||
);
|
|
||||||
const candidates = [];
|
|
||||||
if (tracked && tracked.symbol) candidates.push(tracked.symbol);
|
|
||||||
const reported = explorerSymbol(lower, sources && sources.wallets);
|
|
||||||
if (reported) candidates.push(reported);
|
|
||||||
|
|
||||||
for (const symbol of candidates) {
|
|
||||||
if (!isSpoofedSymbol(symbol, tokenAddress)) return symbol;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the amount line reads when the scale is unknown. The base units are
|
// What the amount line reads when the scale is unknown. The base units are
|
||||||
// exact and the caveat is part of the same string, so the number on the screen
|
// exact and the caveat is part of the same string, so the number on the screen
|
||||||
// cannot be mistaken for a token quantity, and it can never read as zero for a
|
// cannot be mistaken for a token quantity, and it can never read as zero for a
|
||||||
@@ -138,6 +84,5 @@ function unknownDecimalsAmount(rawAmount) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
resolveTokenDecimals,
|
resolveTokenDecimals,
|
||||||
resolveTokenSymbol,
|
|
||||||
unknownDecimalsAmount,
|
unknownDecimalsAmount,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,15 +51,11 @@ const POPULATE_TIMEOUT_MS = 20000;
|
|||||||
// passed to ethers: the object is page-controlled, and a future ethers that
|
// passed to ethers: the object is page-controlled, and a future ethers that
|
||||||
// learns to carry a new transaction field must not start picking one up out of
|
// learns to carry a new transaction field must not start picking one up out of
|
||||||
// it without this module knowing.
|
// it without this module knowing.
|
||||||
//
|
|
||||||
// The nonce is not taken from the page; it is always the account's next nonce
|
|
||||||
// from the network. A page that chose it could replace one of the user's
|
|
||||||
// pending transactions (the same nonce at a higher fee) or leave this one stuck
|
|
||||||
// behind a gap (a nonce above the next one).
|
|
||||||
const REQUEST_FIELDS = [
|
const REQUEST_FIELDS = [
|
||||||
"to",
|
"to",
|
||||||
"value",
|
"value",
|
||||||
"data",
|
"data",
|
||||||
|
"nonce",
|
||||||
"gasLimit",
|
"gasLimit",
|
||||||
"gasPrice",
|
"gasPrice",
|
||||||
"maxFeePerGas",
|
"maxFeePerGas",
|
||||||
|
|||||||
@@ -51,14 +51,11 @@
|
|||||||
const {
|
const {
|
||||||
Transaction,
|
Transaction,
|
||||||
accessListify,
|
accessListify,
|
||||||
formatEther,
|
|
||||||
getAddress,
|
getAddress,
|
||||||
getBytes,
|
getBytes,
|
||||||
toQuantity,
|
|
||||||
verifyMessage,
|
verifyMessage,
|
||||||
verifyTypedData,
|
verifyTypedData,
|
||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
const { nativeCurrencyByChainId } = require("./networks");
|
|
||||||
|
|
||||||
// The only transaction types this wallet signs: legacy, EIP-2930 and
|
// The only transaction types this wallet signs: legacy, EIP-2930 and
|
||||||
// EIP-1559. populateTransaction() produces nothing else, so nothing else can
|
// EIP-1559. populateTransaction() produces nothing else, so nothing else can
|
||||||
@@ -137,19 +134,10 @@ const FORBIDDEN_FIELDS = [
|
|||||||
const MAX_GAS_LIMIT = 100000000n;
|
const MAX_GAS_LIMIT = 100000000n;
|
||||||
|
|
||||||
// 100,000 gwei per gas: orders of magnitude above the highest fee either
|
// 100,000 gwei per gas: orders of magnitude above the highest fee either
|
||||||
// supported network has produced.
|
// supported network has produced, and low enough to catch a fee that would
|
||||||
|
// hand the validator the balance.
|
||||||
const MAX_FEE_PER_GAS = 100000000000000n;
|
const MAX_FEE_PER_GAS = 100000000000000n;
|
||||||
|
|
||||||
// The largest total fee this wallet will sign, in wei. The two ceilings above
|
|
||||||
// bound the gas limit and the price per gas each on its own, but the fee a
|
|
||||||
// validator is actually paid is their product, and a gas limit and a price
|
|
||||||
// that are each under their own ceiling still multiply to thousands of ETH —
|
|
||||||
// 30,000,000 gas at 100,000 gwei is about 3,000 ETH. Bounding the product is
|
|
||||||
// what catches a fee that would hand the validator the balance; the per-field
|
|
||||||
// ceilings alone do not. A full 30,000,000-gas block at 33 gwei reaches this,
|
|
||||||
// which no ordinary wallet transaction approaches.
|
|
||||||
const MAX_TOTAL_FEE = 1000000000000000000n; // 1 ETH
|
|
||||||
|
|
||||||
// A refusal to act on an artifact: it is not the thing that was approved, so
|
// A refusal to act on an artifact: it is not the thing that was approved, so
|
||||||
// the approval it was offered against is spent and must not be retried. Every
|
// the approval it was offered against is spent and must not be retried. Every
|
||||||
// throw in this module is one of these; the background distinguishes them from
|
// throw in this module is one of these; the background distinguishes them from
|
||||||
@@ -396,37 +384,6 @@ function assertWithinCeilings(tx) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The product: gasLimit × the most this transaction could pay per gas —
|
|
||||||
// maxFeePerGas for a type-2 transaction, gasPrice for a legacy or type-1
|
|
||||||
// one. This is the fee a gas-consuming contract can really extract, and it
|
|
||||||
// is the bound the two per-field ceilings above cannot express.
|
|
||||||
if (present(tx.gasLimit)) {
|
|
||||||
const gasLimit = normalizeQuantity(tx.gasLimit, "gas limit");
|
|
||||||
let price = null;
|
|
||||||
if (present(tx.maxFeePerGas)) {
|
|
||||||
price = normalizeQuantity(tx.maxFeePerGas, "maximum fee per gas");
|
|
||||||
} else if (present(tx.gasPrice)) {
|
|
||||||
price = normalizeQuantity(tx.gasPrice, "gas price");
|
|
||||||
}
|
|
||||||
if (price !== null && gasLimit * price > MAX_TOTAL_FEE) {
|
|
||||||
// The fee is paid in the native currency of the network the
|
|
||||||
// transaction is for. Every caller's transaction names it.
|
|
||||||
const nativeCurrency = nativeCurrencyByChainId(
|
|
||||||
present(tx.chainId) ? toQuantity(tx.chainId) : null,
|
|
||||||
);
|
|
||||||
throw refuse(
|
|
||||||
"This transaction would allow a network fee of up to " +
|
|
||||||
formatEther(gasLimit * price) +
|
|
||||||
" " +
|
|
||||||
nativeCurrency +
|
|
||||||
", which is more than the " +
|
|
||||||
formatEther(MAX_TOTAL_FEE) +
|
|
||||||
" " +
|
|
||||||
nativeCurrency +
|
|
||||||
" this wallet will sign for.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refuse a field only a transaction type this wallet does not sign can carry.
|
// Refuse a field only a transaction type this wallet does not sign can carry.
|
||||||
@@ -818,5 +775,4 @@ module.exports = {
|
|||||||
TX_STAGE_NONCE,
|
TX_STAGE_NONCE,
|
||||||
MAX_GAS_LIMIT,
|
MAX_GAS_LIMIT,
|
||||||
MAX_FEE_PER_GAS,
|
MAX_FEE_PER_GAS,
|
||||||
MAX_TOTAL_FEE,
|
|
||||||
};
|
};
|
||||||
|
|||||||
+38
-61
@@ -10,7 +10,7 @@ const {
|
|||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
const { ERC20_ABI } = require("./constants");
|
const { ERC20_ABI } = require("./constants");
|
||||||
const { NETWORKS } = require("./networks");
|
const { NETWORKS } = require("./networks");
|
||||||
const { log, debugFetch, urlOrigin } = require("./log");
|
const { log, debugFetch } = require("./log");
|
||||||
const { deriveAddressFromXpub } = require("./wallet");
|
const { deriveAddressFromXpub } = require("./wallet");
|
||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
||||||
@@ -52,16 +52,19 @@ function requireNetworkId(networkId) {
|
|||||||
return net;
|
return net;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A token balance as an exact decimal string, never cut: a cut stores a small
|
function formatBalance(wei) {
|
||||||
// nonzero holding as zero. fetchTokenBalances() stores every nonzero holding of
|
const eth = formatEther(wei);
|
||||||
// a token it admits, however small; the screens that leave out one below
|
const parts = eth.split(".");
|
||||||
// 0.000001 decide that themselves, through isBelowOneMillionth() in
|
if (parts.length === 1) return eth + ".0";
|
||||||
// src/shared/amountDisplay.js.
|
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
|
||||||
|
return parts[0] + "." + dec;
|
||||||
|
}
|
||||||
|
|
||||||
function formatTokenBalance(raw, decimals) {
|
function formatTokenBalance(raw, decimals) {
|
||||||
const val = formatUnits(raw, decimals);
|
const val = formatUnits(raw, decimals);
|
||||||
const parts = val.split(".");
|
const parts = val.split(".");
|
||||||
if (parts.length === 1) return val + ".0";
|
if (parts.length === 1) return val + ".0";
|
||||||
const dec = parts[1].replace(/0+$/, "") || "0";
|
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
|
||||||
return parts[0] + "." + dec;
|
return parts[0] + "." + dec;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,15 +90,7 @@ function rawUnits(value) {
|
|||||||
// 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
|
||||||
// at this point is indistinguishable from a real one everywhere below it.
|
// at this point is indistinguishable from a real one everywhere below it.
|
||||||
//
|
async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||||
// `signal`, passed by the popup, is aborted when the popup closes; a request
|
|
||||||
// that fails after that was cancelled by the closing and is not logged.
|
|
||||||
async function fetchTokenBalances(
|
|
||||||
address,
|
|
||||||
blockscoutUrl,
|
|
||||||
trackedTokens,
|
|
||||||
signal,
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
const resp = await debugFetch(
|
const resp = await debugFetch(
|
||||||
blockscoutUrl + "/addresses/" + address + "/token-balances",
|
blockscoutUrl + "/addresses/" + address + "/token-balances",
|
||||||
@@ -154,21 +149,25 @@ async function fetchTokenBalances(
|
|||||||
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.
|
||||||
|
// With a scale, the display filter proper applies: a balance that
|
||||||
|
// rounds to zero at six places is dust and is not listed. Without
|
||||||
|
// one there is no such judgement to make, and the row is kept.
|
||||||
const bal = scale === null ? null : formatTokenBalance(raw, scale);
|
const bal = scale === null ? null : formatTokenBalance(raw, scale);
|
||||||
// null means the explorer reported no readable count, which is
|
if (bal === "0.0") continue;
|
||||||
// not the same as a count of zero. This gate is not the
|
// null means the explorer reported no count, which is not the
|
||||||
// low-holder display filter: it has no user-facing off switch and
|
// same as a count of zero. This gate is not the low-holder
|
||||||
// governs the whole balance list, so it stays strict and admits a
|
// display filter: it has no user-facing off switch and governs
|
||||||
// token only on a reported count — an unreported one is no
|
// the whole balance list, so it stays strict and admits a token
|
||||||
// evidence, and `null >= LOW_HOLDER_THRESHOLD` is false. A
|
// only on a reported count — an unreported one is no evidence.
|
||||||
// legitimate token still reaches the list through the known
|
// A legitimate token still reaches the list through the known
|
||||||
// token list or by the user tracking it, and the null is carried
|
// token list or by the user tracking it, and the null is carried
|
||||||
// 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 = TOKEN_BY_ADDRESS.has(tokenAddr);
|
||||||
const isTracked = trackedSet.has(tokenAddr);
|
const isTracked = trackedSet.has(tokenAddr);
|
||||||
const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD;
|
const hasEnoughHolders =
|
||||||
|
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
|
||||||
|
|
||||||
// Skip spam tokens the user never asked to see
|
// Skip spam tokens the user never asked to see
|
||||||
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
|
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
|
||||||
@@ -198,24 +197,20 @@ async function fetchTokenBalances(
|
|||||||
}
|
}
|
||||||
return balances;
|
return balances;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!signal?.aborted) {
|
log.errorf("fetchTokenBalances failed:", e.message);
|
||||||
log.errorf("fetchTokenBalances failed:", e.message);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
// `signal` is as for fetchTokenBalances().
|
|
||||||
async function refreshBalances(
|
async function refreshBalances(
|
||||||
wallets,
|
wallets,
|
||||||
rpcUrl,
|
rpcUrl,
|
||||||
blockscoutUrl,
|
blockscoutUrl,
|
||||||
trackedTokens,
|
trackedTokens,
|
||||||
networkId,
|
networkId,
|
||||||
signal,
|
|
||||||
) {
|
) {
|
||||||
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
|
log.debugf("refreshBalances start, rpc:", rpcUrl);
|
||||||
const provider = getProvider(rpcUrl, networkId);
|
const provider = getProvider(rpcUrl, networkId);
|
||||||
const updates = [];
|
const updates = [];
|
||||||
|
|
||||||
@@ -226,13 +221,10 @@ async function refreshBalances(
|
|||||||
provider
|
provider
|
||||||
.getBalance(addr.address)
|
.getBalance(addr.address)
|
||||||
.then((bal) => {
|
.then((bal) => {
|
||||||
// Exact, never cut: a cut here stores a small nonzero
|
addr.balance = formatBalance(bal);
|
||||||
// balance as zero.
|
|
||||||
addr.balance = formatEther(bal);
|
|
||||||
log.debugf("ETH balance", addr.address, addr.balance);
|
log.debugf("ETH balance", addr.address, addr.balance);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (signal?.aborted) return;
|
|
||||||
log.errorf(
|
log.errorf(
|
||||||
"ETH balance failed",
|
"ETH balance failed",
|
||||||
addr.address,
|
addr.address,
|
||||||
@@ -256,11 +248,10 @@ async function refreshBalances(
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (signal?.aborted) return;
|
|
||||||
log.errorf(
|
log.errorf(
|
||||||
"ENS reverse failed",
|
"ENS reverse failed",
|
||||||
addr.address,
|
addr.address,
|
||||||
e.shortMessage || e.message,
|
e.message,
|
||||||
);
|
);
|
||||||
// Keep existing addr.ensName if we had one
|
// Keep existing addr.ensName if we had one
|
||||||
}),
|
}),
|
||||||
@@ -272,7 +263,6 @@ async function refreshBalances(
|
|||||||
addr.address,
|
addr.address,
|
||||||
blockscoutUrl,
|
blockscoutUrl,
|
||||||
trackedTokens,
|
trackedTokens,
|
||||||
signal,
|
|
||||||
).then((balances) => {
|
).then((balances) => {
|
||||||
if (balances !== null) {
|
if (balances !== null) {
|
||||||
addr.tokenBalances = balances;
|
addr.tokenBalances = balances;
|
||||||
@@ -294,9 +284,8 @@ async function refreshBalances(
|
|||||||
|
|
||||||
// Look up token metadata from its contract.
|
// Look up token metadata from its contract.
|
||||||
// Calls symbol() and decimals() to verify it implements ERC-20.
|
// Calls symbol() and decimals() to verify it implements ERC-20.
|
||||||
// `signal` is as for fetchTokenBalances().
|
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
||||||
async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) {
|
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
|
||||||
log.debugf("lookupTokenInfo", contractAddress, "rpc:", urlOrigin(rpcUrl));
|
|
||||||
const provider = getProvider(rpcUrl, networkId);
|
const provider = getProvider(rpcUrl, networkId);
|
||||||
const contract = new Contract(contractAddress, ERC20_ABI, provider);
|
const contract = new Contract(contractAddress, ERC20_ABI, provider);
|
||||||
|
|
||||||
@@ -305,9 +294,7 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) {
|
|||||||
symbol = await contract.symbol();
|
symbol = await contract.symbol();
|
||||||
log.debugf("symbol() =", symbol);
|
log.debugf("symbol() =", symbol);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!signal?.aborted) {
|
log.errorf("symbol() failed:", e.shortMessage || e.message);
|
||||||
log.errorf("symbol() failed:", e.shortMessage || e.message);
|
|
||||||
}
|
|
||||||
throw new Error("Not a valid ERC-20 token (symbol() failed).");
|
throw new Error("Not a valid ERC-20 token (symbol() failed).");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,9 +302,7 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) {
|
|||||||
decimals = await contract.decimals();
|
decimals = await contract.decimals();
|
||||||
log.debugf("decimals() =", decimals);
|
log.debugf("decimals() =", decimals);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!signal?.aborted) {
|
log.errorf("decimals() failed:", e.shortMessage || e.message);
|
||||||
log.errorf("decimals() failed:", e.shortMessage || e.message);
|
|
||||||
}
|
|
||||||
throw new Error("Not a valid ERC-20 token (decimals() failed).");
|
throw new Error("Not a valid ERC-20 token (decimals() failed).");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,12 +310,7 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) {
|
|||||||
name = await contract.name();
|
name = await contract.name();
|
||||||
log.debugf("name() =", name);
|
log.debugf("name() =", name);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!signal?.aborted) {
|
log.warnf("name() failed, using symbol as name:", e.message);
|
||||||
log.warnf(
|
|
||||||
"name() failed, using symbol as name:",
|
|
||||||
e.shortMessage || e.message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
name = symbol;
|
name = symbol;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,8 +326,7 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) {
|
|||||||
// Checks gapLimit addresses in parallel per batch. Stops when an entire
|
// Checks gapLimit addresses in parallel per batch. Stops when an entire
|
||||||
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
|
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
|
||||||
// Returns { addresses: [{ address, index }], nextIndex }.
|
// Returns { addresses: [{ address, index }], nextIndex }.
|
||||||
// `signal` is as for fetchTokenBalances().
|
async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
|
||||||
async function scanForAddresses(xpub, rpcUrl, networkId, signal, gapLimit = 5) {
|
|
||||||
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
|
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
|
||||||
const provider = getProvider(rpcUrl, networkId);
|
const provider = getProvider(rpcUrl, networkId);
|
||||||
const used = [];
|
const used = [];
|
||||||
@@ -370,13 +349,11 @@ async function scanForAddresses(xpub, rpcUrl, networkId, signal, gapLimit = 5) {
|
|||||||
]);
|
]);
|
||||||
return { addr, index, isUsed: balance > 0n || txCount > 0 };
|
return { addr, index, isUsed: balance > 0n || txCount > 0 };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!signal?.aborted) {
|
log.errorf(
|
||||||
log.errorf(
|
"scanForAddresses check failed",
|
||||||
"scanForAddresses check failed",
|
addr,
|
||||||
addr,
|
e.shortMessage || e.message,
|
||||||
e.shortMessage || e.message,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
return { addr, index, isUsed: false };
|
return { addr, index, isUsed: false };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -22,17 +22,11 @@ const BUILD_DEBUG_MARKER = DEBUG
|
|||||||
? "autistmask-build-debug=on"
|
? "autistmask-build-debug=on"
|
||||||
: "autistmask-build-debug=off";
|
: "autistmask-build-debug=off";
|
||||||
|
|
||||||
// Behind DEBUG for the same reason BUILD_DEBUG_MARKER is above: in a release
|
const DEBUG_MNEMONIC =
|
||||||
// build __BUILD_DEBUG__ is a compile-time false, esbuild drops this branch, and
|
"cube evolve unfold result inch risk jealous skill hotel bulb night wreck";
|
||||||
// the phrase never reaches a distributed bundle. The literal used to survive as
|
|
||||||
// dead text because module.exports keeps this const live even though wallet.js's
|
|
||||||
// only use of it is folded away; making the value itself fold to null removes
|
|
||||||
// it. script/verify-build fails a release build if the phrase appears anyway.
|
|
||||||
const DEBUG_MNEMONIC = DEBUG
|
|
||||||
? "cube evolve unfold result inch risk jealous skill hotel bulb night wreck"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
|
const ETHEREUM_MAINNET_CHAIN_ID = "0x1";
|
||||||
|
const ETHEREUM_SEPOLIA_CHAIN_ID = "0xaa36a7";
|
||||||
|
|
||||||
const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com";
|
const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com";
|
||||||
|
|
||||||
@@ -68,6 +62,7 @@ module.exports = {
|
|||||||
BUILD_DEBUG_MARKER,
|
BUILD_DEBUG_MARKER,
|
||||||
DEBUG_MNEMONIC,
|
DEBUG_MNEMONIC,
|
||||||
ETHEREUM_MAINNET_CHAIN_ID,
|
ETHEREUM_MAINNET_CHAIN_ID,
|
||||||
|
ETHEREUM_SEPOLIA_CHAIN_ID,
|
||||||
DEFAULT_RPC_URL,
|
DEFAULT_RPC_URL,
|
||||||
DEFAULT_BLOCKSCOUT_URL,
|
DEFAULT_BLOCKSCOUT_URL,
|
||||||
BIP44_ETH_PATH,
|
BIP44_ETH_PATH,
|
||||||
|
|||||||
+4
-14
@@ -32,8 +32,7 @@ function setCache(address, name) {
|
|||||||
localStorage.setItem(key, JSON.stringify({ name, ts: Date.now() }));
|
localStorage.setItem(key, JSON.stringify({ name, ts: Date.now() }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// `signal` is as for fetchTokenBalances() in src/shared/balances.js.
|
async function resolveEnsName(address, rpcUrl, networkId) {
|
||||||
async function resolveEnsName(address, rpcUrl, networkId, signal) {
|
|
||||||
const cached = getCached(address);
|
const cached = getCached(address);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
@@ -43,26 +42,17 @@ async function resolveEnsName(address, rpcUrl, networkId, signal) {
|
|||||||
setCache(address, name);
|
setCache(address, name);
|
||||||
return name;
|
return name;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!signal?.aborted) {
|
log.errorf("ENS reverse lookup failed", address, e.message);
|
||||||
log.errorf(
|
|
||||||
"ENS reverse lookup failed",
|
|
||||||
address,
|
|
||||||
e.shortMessage || e.message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Don't cache failures — let subsequent lookups retry
|
// Don't cache failures — let subsequent lookups retry
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveEnsNames(addresses, rpcUrl, networkId, signal) {
|
async function resolveEnsNames(addresses, rpcUrl, networkId) {
|
||||||
const results = new Map();
|
const results = new Map();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
addresses.map(async (addr) => {
|
addresses.map(async (addr) => {
|
||||||
results.set(
|
results.set(addr, await resolveEnsName(addr, rpcUrl, networkId));
|
||||||
addr,
|
|
||||||
await resolveEnsName(addr, rpcUrl, networkId, signal),
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
+6
-15
@@ -9,22 +9,13 @@
|
|||||||
|
|
||||||
const LOW_HOLDER_THRESHOLD = 1000;
|
const LOW_HOLDER_THRESHOLD = 1000;
|
||||||
|
|
||||||
// Parse an explorer-supplied holders_count into a number, or null when it is
|
// Parse an explorer-supplied holders_count into a number, or null when the
|
||||||
// not one. Only a whole number of zero or more, or a string made of nothing
|
// explorer did not report one. Anything unparseable is unknown too: a count
|
||||||
// but the digits 0-9, is a count. Anything else is null, never read in part:
|
// we cannot read is not a count of zero.
|
||||||
// "1,000", "0x10" and "1e3" are unknown, not 1, 0 and 1, because a count we
|
|
||||||
// cannot read is not a low count. A count above Number.MAX_SAFE_INTEGER is
|
|
||||||
// null too: a number cannot hold it exactly, so it would come back rounded,
|
|
||||||
// or as Infinity.
|
|
||||||
function parseHoldersCount(raw) {
|
function parseHoldersCount(raw) {
|
||||||
if (typeof raw === "number") {
|
if (raw === null || raw === undefined || raw === "") return null;
|
||||||
return Number.isSafeInteger(raw) && raw >= 0 ? raw : null;
|
const n = parseInt(raw, 10);
|
||||||
}
|
return Number.isFinite(n) ? n : null;
|
||||||
if (typeof raw === "string" && /^[0-9]+$/.test(raw)) {
|
|
||||||
const count = Number(raw);
|
|
||||||
return Number.isSafeInteger(count) ? count : null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// True only for a token the explorer reported as having fewer holders than
|
// True only for a token the explorer reported as having fewer holders than
|
||||||
|
|||||||
+5
-25
@@ -42,34 +42,14 @@ const log = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// The origin (scheme, host and port) of a URL, for logging in place of the
|
// Fetch wrapper that debug-logs every request and response.
|
||||||
// URL: RPC providers put API keys in the path or the query string, and a URL
|
|
||||||
// can carry a user name and password, which the origin leaves out. A URL that
|
|
||||||
// does not parse gives "", so logging never stops a request.
|
|
||||||
function urlOrigin(url) {
|
|
||||||
try {
|
|
||||||
return new URL(url).origin;
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch wrapper that debug-logs every request and response. It logs the
|
|
||||||
// URL's origin and, for a JSON-RPC body, the method name: never the full URL
|
|
||||||
// or body, which can carry an API key or a signed transaction.
|
|
||||||
async function debugFetch(url, opts) {
|
async function debugFetch(url, opts) {
|
||||||
const method = (opts && opts.method) || "GET";
|
const method = (opts && opts.method) || "GET";
|
||||||
const origin = urlOrigin(url);
|
const body = opts && opts.body;
|
||||||
let rpcMethod = "";
|
log.debugf("fetch →", method, url, body || "");
|
||||||
try {
|
|
||||||
rpcMethod = JSON.parse(opts.body).method || "";
|
|
||||||
} catch {
|
|
||||||
// no body, or a body that is not JSON
|
|
||||||
}
|
|
||||||
log.debugf("fetch →", method, origin, rpcMethod);
|
|
||||||
const resp = await fetch(url, opts);
|
const resp = await fetch(url, opts);
|
||||||
log.debugf("fetch ←", resp.status, origin);
|
log.debugf("fetch ←", resp.status, url);
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { log, debugFetch, urlOrigin, setRuntimeDebug, isDebug };
|
module.exports = { log, debugFetch, setRuntimeDebug, isDebug };
|
||||||
|
|||||||
@@ -76,13 +76,10 @@ function networkByChainId(chainId) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The native currency of the network with this chain id. A transaction's
|
// Build a block explorer link for the given path type and value.
|
||||||
// value and fee are labelled with the one of the chain the transaction is on,
|
// type: "address" | "tx" | "token" | "block"
|
||||||
// which need not be the active network. `ETH` when the chain id is missing or
|
function explorerLink(network, type, value) {
|
||||||
// no network here has it.
|
return `${network.explorerUrl}/${type}/${value}`;
|
||||||
function nativeCurrencyByChainId(chainId) {
|
|
||||||
const network = networkByChainId(chainId);
|
|
||||||
return network ? network.nativeCurrency : "ETH";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -92,5 +89,5 @@ module.exports = {
|
|||||||
isKnownNetworkId,
|
isKnownNetworkId,
|
||||||
networkById,
|
networkById,
|
||||||
networkByChainId,
|
networkByChainId,
|
||||||
nativeCurrencyByChainId,
|
explorerLink,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -102,11 +102,11 @@ function tokenRefs(value) {
|
|||||||
|
|
||||||
// A list of strings, for the fields whose entries are dereferenced as text:
|
// A list of strings, for the fields whose entries are dereferenced as text:
|
||||||
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
|
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
|
||||||
// src/shared/transactions.js) and each address's origin list in the site maps
|
// src/shared/transactions.js) and each address's hostname list in the site maps
|
||||||
// below (`o !== origin` filters, `list.includes(origin)` in the background).
|
// below (`h !== host` filters, `list.includes(hostname)` in the background).
|
||||||
//
|
//
|
||||||
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
|
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
|
||||||
// with a malformed entry DROPPED rather than repaired. A number in an origin
|
// with a malformed entry DROPPED rather than repaired. A number in a hostname
|
||||||
// list names no site and a number in fraudContracts names no contract, so there
|
// list names no site and a number in fraudContracts names no contract, so there
|
||||||
// is nothing to repair either to, and the empty list is a legitimate value that
|
// is nothing to repair either to, and the empty list is a legitimate value that
|
||||||
// survives. The result is a fresh array of primitives, so it shares no
|
// survives. The result is a fresh array of primitives, so it shares no
|
||||||
@@ -116,22 +116,21 @@ function textList(value) {
|
|||||||
return value.filter((entry) => typeof entry === "string");
|
return value.filter((entry) => typeof entry === "string");
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedSites / deniedSites: { [address]: [origin, ...] }, each origin the
|
// allowedSites / deniedSites: { [address]: [hostname, ...] }.
|
||||||
// full scheme://host[:port] of a site.
|
|
||||||
//
|
//
|
||||||
// The container check these had (truthy and not an array) is not the floor:
|
// The container check these had (truthy and not an array) is not the floor:
|
||||||
// `{"0xabc…": "notalist"}` IS a non-array object, and the dereference is one
|
// `{"0xabc…": "notalist"}` IS a non-array object, and the dereference is one
|
||||||
// level below it. saveState() merges these maps per key and then per origin
|
// level below it. saveState() merges these maps per key and then per hostname
|
||||||
// WITHIN each key, so a stored value that is not a list reaches `base.map()` in
|
// WITHIN each key, so a stored value that is not a list reaches `base.map()` in
|
||||||
// mergeListByIdentity() (src/shared/state.js) and throws — after the popup has
|
// mergeListByIdentity() (src/shared/state.js) and throws — after the popup has
|
||||||
// rendered, which is why every save from then on failed while the UI looked
|
// rendered, which is why every save from then on failed while the UI looked
|
||||||
// healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings
|
// healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings
|
||||||
// revoke button (`list.filter()`), and the background's
|
// revoke button (`list.filter()`), and the background's
|
||||||
// `allowed.includes(origin)` gate, dereference it the same way; on that last
|
// `allowed.includes(hostname)` gate, dereference it the same way; on that last
|
||||||
// one a stored string would also answer a SUBSTRING match, so a corrupt map
|
// one a stored string would also answer a SUBSTRING match, so a corrupt map
|
||||||
// could widen a site permission rather than merely throw.
|
// could widen a site permission rather than merely throw.
|
||||||
//
|
//
|
||||||
// An address key whose value is not a list of origins is dropped entirely: it
|
// An address key whose value is not a list of hostnames is dropped entirely: it
|
||||||
// grants and denies nothing, and dropping it fails closed. A stored own
|
// grants and denies nothing, and dropping it fails closed. A stored own
|
||||||
// "__proto__" key — which JSON can carry — is dropped for the same reason: it
|
// "__proto__" key — which JSON can carry — is dropped for the same reason: it
|
||||||
// can never be a wallet address, so it grants nothing either, and keeping it
|
// can never be a wallet address, so it grants nothing either, and keeping it
|
||||||
@@ -144,9 +143,9 @@ function siteMap(value) {
|
|||||||
if (!isRecord(value)) return out;
|
if (!isRecord(value)) return out;
|
||||||
for (const address of Object.keys(value)) {
|
for (const address of Object.keys(value)) {
|
||||||
if (address === "__proto__") continue;
|
if (address === "__proto__") continue;
|
||||||
const origins = textList(value[address]);
|
const hostnames = textList(value[address]);
|
||||||
if (origins.length === 0) continue;
|
if (hostnames.length === 0) continue;
|
||||||
defineOwn(out, address, origins);
|
defineOwn(out, address, hostnames);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-4
@@ -1,7 +1,6 @@
|
|||||||
// Price fetching with 5-minute cache, USD formatting, value aggregation.
|
// Price fetching with 5-minute cache, USD formatting, value aggregation.
|
||||||
|
|
||||||
const { getTopTokenPrices } = require("./tokenList");
|
const { getTopTokenPrices } = require("./tokenList");
|
||||||
const { isBelowOneMillionth } = require("./amountDisplay");
|
|
||||||
|
|
||||||
const PRICE_CACHE_TTL = 300000; // 5 minutes
|
const PRICE_CACHE_TTL = 300000; // 5 minutes
|
||||||
|
|
||||||
@@ -79,9 +78,6 @@ function getAddressValue(addr) {
|
|||||||
let usd = parseFloat(addr.balance || "0") * prices.ETH;
|
let usd = parseFloat(addr.balance || "0") * 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
|
|
||||||
// out, so the total never counts a holding the list does not show.
|
|
||||||
if (isBelowOneMillionth(token.balance)) continue;
|
|
||||||
// A null balance is a holding whose scale nothing knows, so it has no
|
// A null balance is a holding whose scale nothing knows, so it has no
|
||||||
// quantity to price — but it is still a holding, and a total that
|
// quantity to price — but it is still a holding, and a total that
|
||||||
// silently omits it would read as complete. That is exactly what
|
// silently omits it would read as complete. That is exactly what
|
||||||
@@ -104,6 +100,27 @@ function getAddressValue(addr) {
|
|||||||
return { usd, partial };
|
return { usd, partial };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The same pair for a whole wallet, and for every wallet at once. One
|
||||||
|
// unpriced holding anywhere makes the sum a floor, so partial carries up.
|
||||||
|
function getWalletValue(wallet) {
|
||||||
|
return sumValues(wallet.addresses.map(getAddressValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotalValue(wallets) {
|
||||||
|
return sumValues(wallets.map(getWalletValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumValues(values) {
|
||||||
|
let usd = null;
|
||||||
|
let partial = false;
|
||||||
|
for (const value of values) {
|
||||||
|
if (value.usd === null) continue;
|
||||||
|
usd = (usd === null ? 0 : usd) + value.usd;
|
||||||
|
partial = partial || value.partial;
|
||||||
|
}
|
||||||
|
return { usd, partial };
|
||||||
|
}
|
||||||
|
|
||||||
// The one rendering of an address total, so no screen says it differently.
|
// The one rendering of an address total, so no screen says it differently.
|
||||||
//
|
//
|
||||||
// A partial total is shown and named as partial: the figure is the ETH and
|
// A partial total is shown and named as partial: the figure is the ETH and
|
||||||
@@ -128,4 +145,6 @@ module.exports = {
|
|||||||
formatUsd,
|
formatUsd,
|
||||||
formatAddressTotal,
|
formatAddressTotal,
|
||||||
getAddressValue,
|
getAddressValue,
|
||||||
|
getWalletValue,
|
||||||
|
getTotalValue,
|
||||||
};
|
};
|
||||||
|
|||||||
+15
-22
@@ -122,9 +122,9 @@ function currentNetwork() {
|
|||||||
return networkById(state.networkId);
|
return networkById(state.networkId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The persisted fields as this page held them when its last loadState()
|
// The persisted fields as they stood at the end of this page's last
|
||||||
// finished, or when its last successful saveState() began. saveState() diffs
|
// loadState() or saveState(). saveState() diffs the live state against this
|
||||||
// the live state against this to find only the fields THIS page changed since.
|
// to find only the fields THIS page actually changed.
|
||||||
//
|
//
|
||||||
// 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
|
||||||
@@ -304,7 +304,7 @@ function mergeAddress(base, ours, theirs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Merge a plain object keyed by string (allowedSites/deniedSites: address ->
|
// Merge a plain object keyed by string (allowedSites/deniedSites: address ->
|
||||||
// origin list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
|
// hostname list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
|
||||||
// same way mergeListByIdentity() merges an array — by key, not by whole-
|
// same way mergeListByIdentity() merges an array — by key, not by whole-
|
||||||
// object diff — so a key one page added or removed applies independently of
|
// object diff — so a key one page added or removed applies independently of
|
||||||
// a key another page edited. Unlike an array's identity function, an object
|
// a key another page edited. Unlike an array's identity function, an object
|
||||||
@@ -353,25 +353,25 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedSites/deniedSites: { [address]: [origin, ...] }. The origin
|
// allowedSites/deniedSites: { [address]: [hostname, ...] }. The hostname
|
||||||
// list is itself membership, not a leaf — the background appends a newly
|
// list is itself membership, not a leaf — the background appends a newly
|
||||||
// approved/denied origin to it, and the Settings "revoke" button
|
// approved/denied hostname to it, and the Settings "revoke" button
|
||||||
// (src/popup/views/settings.js) filters an origin out of it in place, from a
|
// (src/popup/views/settings.js) filters a hostname out of it in place, from a
|
||||||
// different page. Merge it the same way wallets are merged: identity is the
|
// different page. Merge it the same way wallets are merged: identity is the
|
||||||
// origin itself, so a merged pair is always equal and mergeItem is a no-op
|
// hostname itself, so a merged pair is always equal and mergeItem is a no-op
|
||||||
// pick.
|
// pick.
|
||||||
function mergeOriginList(base, ours, theirs) {
|
function mergeHostnameList(base, ours, theirs) {
|
||||||
return mergeListByIdentity(
|
return mergeListByIdentity(
|
||||||
base,
|
base,
|
||||||
ours,
|
ours,
|
||||||
theirs,
|
theirs,
|
||||||
(origin) => origin,
|
(hostname) => hostname,
|
||||||
(b, o, t) => t,
|
(b, o, t) => t,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeSiteMap(base, ours, theirs) {
|
function mergeSiteMap(base, ours, theirs) {
|
||||||
return mergeMapByKey(base, ours, theirs, mergeOriginList);
|
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
|
||||||
}
|
}
|
||||||
|
|
||||||
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
|
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
|
||||||
@@ -422,8 +422,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
// address) apply independently instead of colliding as the same field.
|
// address) apply independently instead of colliding as the same field.
|
||||||
//
|
//
|
||||||
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
|
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
|
||||||
// by address key and then by origin within each address's list), for the
|
// by address key and then by hostname within each address's list), for the
|
||||||
// identical reason: the background appends a newly approved/denied origin
|
// identical reason: the background appends a newly approved/denied hostname
|
||||||
// to them, and the Settings "revoke" button (src/popup/views/settings.js)
|
// to them, and the Settings "revoke" button (src/popup/views/settings.js)
|
||||||
// filters one out in place, from a different page. A whole-field diff here
|
// filters one out in place, from a different page. A whole-field diff here
|
||||||
// doesn't just lose data, it is a security defect — a stale page's save can
|
// doesn't just lose data, it is a security defect — a stale page's save can
|
||||||
@@ -464,10 +464,7 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
// does not own goes on being whatever its last loadState() saw, same as
|
// does not own goes on being whatever its last loadState() saw, same as
|
||||||
// before this fix; only the persisted record is guaranteed current.
|
// before this fix; only the persisted record is guaranteed current.
|
||||||
async function saveStateOnce() {
|
async function saveStateOnce() {
|
||||||
// A copy, so what this save compares and writes is the page's state as it
|
const current = snapshotPersisted();
|
||||||
// stood when the save began. A change made while it waits on storage is
|
|
||||||
// left for the next save, which compares against this copy.
|
|
||||||
const current = structuredClone(snapshotPersisted());
|
|
||||||
const result = await storageGet("autistmask");
|
const result = await storageGet("autistmask");
|
||||||
// The record in storage right now is about to be merged into and written
|
// The record in storage right now is about to be merged into and written
|
||||||
// back, so it is validated exactly like a load validates it. Without this,
|
// back, so it is validated exactly like a load validates it. Without this,
|
||||||
@@ -524,11 +521,7 @@ async function saveStateOnce() {
|
|||||||
// exactly as it stood; see the note above.
|
// exactly as it stood; see the note above.
|
||||||
rawState.hasWallet = rawState.wallets.length > 0;
|
rawState.hasWallet = rawState.wallets.length > 0;
|
||||||
|
|
||||||
// What this save compared and wrote, not the page's state now: a change
|
baseline = structuredClone(snapshotPersisted());
|
||||||
// made during the save must still differ from the baseline, or the save
|
|
||||||
// queued after it finds nothing to store
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/448).
|
|
||||||
baseline = current;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// showView() calls saveState() on every navigation without awaiting it, so
|
// showView() calls saveState() on every navigation without awaiting it, so
|
||||||
|
|||||||
@@ -20,10 +20,6 @@
|
|||||||
// (MSYRUPUSDP), so nothing the wallet ships as a real token is ever
|
// (MSYRUPUSDP), so nothing the wallet ships as a real token is ever
|
||||||
// truncated. The ellipsis is what tells the user the name they are looking
|
// truncated. The ellipsis is what tells the user the name they are looking
|
||||||
// at is not the whole name — worth knowing before they send to it.
|
// at is not the whole name — worth knowing before they send to it.
|
||||||
//
|
|
||||||
// Characters are counted as code points, not UTF-16 units, so an emoji is
|
|
||||||
// one character and the cut never falls between the two halves of one: a
|
|
||||||
// half on its own renders as U+FFFD.
|
|
||||||
|
|
||||||
const MAX_SYMBOL_LENGTH = 12;
|
const MAX_SYMBOL_LENGTH = 12;
|
||||||
|
|
||||||
@@ -36,9 +32,8 @@ const UNKNOWN_SYMBOL = "???";
|
|||||||
function displaySymbol(symbol) {
|
function displaySymbol(symbol) {
|
||||||
const s = symbol === null || symbol === undefined ? "" : String(symbol);
|
const s = symbol === null || symbol === undefined ? "" : String(symbol);
|
||||||
if (s.length === 0) return UNKNOWN_SYMBOL;
|
if (s.length === 0) return UNKNOWN_SYMBOL;
|
||||||
const chars = Array.from(s);
|
if (s.length <= MAX_SYMBOL_LENGTH) return s;
|
||||||
if (chars.length <= MAX_SYMBOL_LENGTH) return s;
|
return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…";
|
||||||
return chars.slice(0, MAX_SYMBOL_LENGTH - 1).join("") + "…";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
|
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
|
||||||
// that may bear it, or to null. Null means the symbol belongs to the native
|
// that may bear it, or to null. Null means the symbol belongs to the native
|
||||||
// asset, which has no contract at all, so no contract may bear it and every
|
// asset, which has no contract at all, so no contract may bear it and every
|
||||||
// one that does is a spoof. "ETH" is one such entry, and every network's
|
// one that does is a spoof. "ETH" is the only such entry today; the rule is
|
||||||
// `nativeCurrency` in networks.js (`SepoliaETH`) is another, on every network.
|
// written so that a second one needs no change here or at any call site.
|
||||||
//
|
//
|
||||||
// The value is a set because a ticker is not unique: seven symbols in the
|
// The value is a set because a ticker is not unique: seven symbols in the
|
||||||
// bundled list belong to two real contracts each, and answering with one of
|
// bundled list belong to two real contracts each, and answering with one of
|
||||||
@@ -34,11 +34,6 @@ function normalizeAddress(addr) {
|
|||||||
return (addr || "").toLowerCase();
|
return (addr || "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The characters that paint nothing; normalizeSymbol below says which they
|
|
||||||
// are. The signature screen marks them in a personal message
|
|
||||||
// (src/popup/views/approval.js).
|
|
||||||
const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu;
|
|
||||||
|
|
||||||
// Fold a symbol onto what a user actually sees, and no further:
|
// Fold a symbol onto what a user actually sees, and no further:
|
||||||
//
|
//
|
||||||
// NFKC collapses compatibility variants that render as the ASCII
|
// NFKC collapses compatibility variants that render as the ASCII
|
||||||
@@ -87,7 +82,7 @@ const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu;
|
|||||||
function normalizeSymbol(symbol) {
|
function normalizeSymbol(symbol) {
|
||||||
return String(symbol || "")
|
return String(symbol || "")
|
||||||
.normalize("NFKC")
|
.normalize("NFKC")
|
||||||
.replace(INVISIBLE_CHARACTERS, "")
|
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
|
||||||
.trim()
|
.trim()
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -109,6 +104,5 @@ function isSpoofedSymbol(symbol, contractAddress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
INVISIBLE_CHARACTERS,
|
|
||||||
isSpoofedSymbol,
|
isSpoofedSymbol,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
// 511 tokens.
|
// 511 tokens.
|
||||||
|
|
||||||
const { debugFetch } = require("./log");
|
const { debugFetch } = require("./log");
|
||||||
const { NETWORKS } = require("./networks");
|
|
||||||
|
|
||||||
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
|
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
|
||||||
|
|
||||||
@@ -3611,9 +3610,7 @@ for (const t of TOKENS) {
|
|||||||
// 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
|
||||||
// bear its symbol. So does every network's `nativeCurrency` in networks.js
|
// bear its symbol.
|
||||||
// (`SepoliaETH`), on every network, since that is the label the wallet shows
|
|
||||||
// its native asset under on that network.
|
|
||||||
//
|
//
|
||||||
// The value is a set and not a single address because tickers are not unique
|
// The value is a set and not a single address because tickers are not unique
|
||||||
// and the list above proves it: seven of these 512 tokens share a symbol with
|
// and the list above proves it: seven of these 512 tokens share a symbol with
|
||||||
@@ -3627,9 +3624,6 @@ for (const t of TOKENS) {
|
|||||||
// loosen the rule, because a contract outside the set is still a spoof.
|
// loosen the rule, because a contract outside the set is still a spoof.
|
||||||
const KNOWN_SYMBOLS = new Map();
|
const KNOWN_SYMBOLS = new Map();
|
||||||
KNOWN_SYMBOLS.set("ETH", null);
|
KNOWN_SYMBOLS.set("ETH", null);
|
||||||
for (const network of Object.values(NETWORKS)) {
|
|
||||||
KNOWN_SYMBOLS.set(network.nativeCurrency.toUpperCase(), null);
|
|
||||||
}
|
|
||||||
for (const t of TOKENS) {
|
for (const t of TOKENS) {
|
||||||
const upper = t.symbol.toUpperCase();
|
const upper = t.symbol.toUpperCase();
|
||||||
if (!KNOWN_SYMBOLS.has(upper)) {
|
if (!KNOWN_SYMBOLS.has(upper)) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const { log, debugFetch } = require("./log");
|
|||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
||||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||||
const { nativeCurrencyByChainId } = require("./networks");
|
|
||||||
// The uint8 test every scale in this wallet goes through. Shared, not copied:
|
// The uint8 test every scale in this wallet goes through. Shared, not copied:
|
||||||
// a scale is either reported or it is unknown, and "unknown" must mean the
|
// a scale is either reported or it is unknown, and "unknown" must mean the
|
||||||
// same thing here as it does on the screens that refuse to format one.
|
// same thing here as it does on the screens that refuse to format one.
|
||||||
@@ -29,7 +28,7 @@ function normalizeAddress(addr) {
|
|||||||
return (addr || "").toLowerCase();
|
return (addr || "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTx(tx, addrLower, chainId) {
|
function parseTx(tx, addrLower) {
|
||||||
const from = tx.from?.hash || "";
|
const from = tx.from?.hash || "";
|
||||||
const to = tx.to?.hash || "";
|
const to = tx.to?.hash || "";
|
||||||
const rawWei = tx.value || "0";
|
const rawWei = tx.value || "0";
|
||||||
@@ -37,7 +36,7 @@ function parseTx(tx, addrLower, chainId) {
|
|||||||
const method = tx.method || null;
|
const method = tx.method || null;
|
||||||
|
|
||||||
// For contract calls, produce a meaningful label instead of "0.0000 ETH"
|
// For contract calls, produce a meaningful label instead of "0.0000 ETH"
|
||||||
let symbol = nativeCurrencyByChainId(chainId);
|
let symbol = "ETH";
|
||||||
let value = formatTxValue(formatEther(rawWei));
|
let value = formatTxValue(formatEther(rawWei));
|
||||||
let exactValue = formatEther(rawWei);
|
let exactValue = formatEther(rawWei);
|
||||||
let rawAmount = rawWei;
|
let rawAmount = rawWei;
|
||||||
@@ -91,11 +90,10 @@ function parseTx(tx, addrLower, chainId) {
|
|||||||
holders: null,
|
holders: null,
|
||||||
isContractCall: toIsContract,
|
isContractCall: toIsContract,
|
||||||
method: method,
|
method: method,
|
||||||
chainId: chainId,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTokenTransfer(tt, addrLower, chainId) {
|
function parseTokenTransfer(tt, addrLower) {
|
||||||
const from = tt.from?.hash || "";
|
const from = tt.from?.hash || "";
|
||||||
const to = tt.to?.hash || "";
|
const to = tt.to?.hash || "";
|
||||||
// The explorer's own answer, or null. Never a default: a transfer of
|
// The explorer's own answer, or null. Never a default: a transfer of
|
||||||
@@ -137,12 +135,10 @@ function parseTokenTransfer(tt, addrLower, chainId) {
|
|||||||
contractAddress: normalizeAddress(
|
contractAddress: normalizeAddress(
|
||||||
tt.token?.address_hash || tt.token?.address || "",
|
tt.token?.address_hash || tt.token?.address || "",
|
||||||
),
|
),
|
||||||
// null when the explorer reported no readable count: unknown, not
|
// null when the explorer reported no count: unknown, not zero. The
|
||||||
// zero. The low-holder filter declines to judge a null, so a
|
// low-holder filter declines to judge a null, so a legitimate token
|
||||||
// legitimate token is not hidden because a field went missing
|
// is not hidden because a field went missing upstream.
|
||||||
// upstream.
|
|
||||||
holders: parseHoldersCount(tt.token?.holders_count),
|
holders: parseHoldersCount(tt.token?.holders_count),
|
||||||
chainId: chainId,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,15 +221,7 @@ function mergeTransactions(txs, tokenTransfers) {
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry
|
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||||
// carries it, and a native entry is labelled with that network's
|
|
||||||
// `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`).
|
|
||||||
async function fetchRecentTransactions(
|
|
||||||
address,
|
|
||||||
blockscoutUrl,
|
|
||||||
chainId,
|
|
||||||
count = 25,
|
|
||||||
) {
|
|
||||||
log.debugf("fetchRecentTransactions", address);
|
log.debugf("fetchRecentTransactions", address);
|
||||||
const addrLower = normalizeAddress(address);
|
const addrLower = normalizeAddress(address);
|
||||||
|
|
||||||
@@ -266,10 +254,8 @@ async function fetchRecentTransactions(
|
|||||||
const ttJson = ttResp.ok ? await ttResp.json() : {};
|
const ttJson = ttResp.ok ? await ttResp.json() : {};
|
||||||
|
|
||||||
const txs = mergeTransactions(
|
const txs = mergeTransactions(
|
||||||
(txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)),
|
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
|
||||||
(ttJson.items || []).map((tt) =>
|
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
|
||||||
parseTokenTransfer(tt, addrLower, chainId),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = txs.slice(0, count);
|
const result = txs.slice(0, count);
|
||||||
|
|||||||
@@ -27,11 +27,9 @@
|
|||||||
|
|
||||||
const { parseUnits } = require("ethers");
|
const { parseUnits } = require("ethers");
|
||||||
|
|
||||||
// Solidity's decimals() returns a uint8, but ethers' formatUnits() and
|
// Solidity's decimals() returns a uint8, so anything outside that range is not
|
||||||
// parseUnits() refuse more than 80 decimal places ("invalid FixedNumber
|
// an answer this wallet can use.
|
||||||
// decimals (too large)"). A scale of 81 to 255 can be neither displayed nor
|
const MAX_DECIMALS = 255;
|
||||||
// encoded, so it is not an answer this wallet can use, the same as no answer.
|
|
||||||
const MAX_DECIMALS = 80;
|
|
||||||
|
|
||||||
const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE =
|
const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE =
|
||||||
"The transfer was not sent, because the number of decimal places this" +
|
"The transfer was not sent, because the number of decimal places this" +
|
||||||
@@ -57,7 +55,7 @@ function mismatchMessage(displayed, onChain) {
|
|||||||
// A decimals value from any source as a number, or null if it is not one.
|
// A decimals value from any source as a number, or null if it is not one.
|
||||||
// decimals() comes back from ethers as a bigint and the explorer's copy arrives
|
// decimals() comes back from ethers as a bigint and the explorer's copy arrives
|
||||||
// as a string, so both of those are accepted alongside a plain number; anything
|
// as a string, so both of those are accepted alongside a plain number; anything
|
||||||
// fractional, negative, above MAX_DECIMALS, or of any other type at all is not.
|
// fractional, negative, out of uint8 range, or of any other type at all is not.
|
||||||
//
|
//
|
||||||
// The types are enumerated rather than coerced because Number() is far too
|
// The types are enumerated rather than coerced because Number() is far too
|
||||||
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
|
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Balance arithmetic for the Send and transaction confirmation screens.
|
// Balance arithmetic for the transaction confirmation screen.
|
||||||
//
|
//
|
||||||
// Pure: no DOM, no network, no state. Everything is exact integer math on
|
// Pure: no DOM, no network, no state. Everything is exact integer math on
|
||||||
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
|
// 18-decimal fixed point (wei for ETH), so it can be unit tested directly
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
// the token balance arrive as human decimal strings, so comparing them at a
|
// the token balance arrive as human decimal strings, so comparing them at a
|
||||||
// common scale is exact.
|
// common scale is exact.
|
||||||
|
|
||||||
const { parseUnits, formatEther } = require("ethers");
|
const { parseUnits } = require("ethers");
|
||||||
|
|
||||||
const SCALE_DECIMALS = 18;
|
const SCALE_DECIMALS = 18;
|
||||||
|
|
||||||
@@ -87,28 +87,6 @@ function toFixedPoint(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The most ETH a send can carry: the exact balance minus the fee reserve from
|
|
||||||
// feeReserveWei(), as a decimal string, so validateTransfer() passes it with
|
|
||||||
// exactly that reserve left behind. `ethBalance` is the exact decimal string
|
|
||||||
// balances.js stores, never a rounded one. Null when the balance does not
|
|
||||||
// leave anything to send once the fee is paid, or when either input is
|
|
||||||
// unusable.
|
|
||||||
function maxEthAmount(ethBalance, feeWei) {
|
|
||||||
const balanceWei = toFixedPoint(ethBalance);
|
|
||||||
if (balanceWei === null) return null;
|
|
||||||
if (typeof feeWei !== "bigint" || feeWei < 0n) return null;
|
|
||||||
const amountWei = balanceWei - feeWei;
|
|
||||||
if (amountWei <= 0n) return null;
|
|
||||||
return formatEther(amountWei);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The most of a token a send can carry: its balance cut down, never rounded
|
|
||||||
// up, to the 18 places (SCALE_DECIMALS) an amount may have. A token can
|
|
||||||
// declare more than 18 decimals, and its balance is stored with all of them.
|
|
||||||
function maxTokenAmount(tokenBalance) {
|
|
||||||
return tokenBalance.replace(/(\.\d{18})\d+$/, "$1");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate a pending transfer against the balances that must cover it.
|
// Validate a pending transfer against the balances that must cover it.
|
||||||
//
|
//
|
||||||
// isErc20 — token transfer rather than a native ETH transfer
|
// isErc20 — token transfer rather than a native ETH transfer
|
||||||
@@ -161,14 +139,7 @@ function validateTransfer({
|
|||||||
const feeFp = known ? feeWei : null;
|
const feeFp = known ? feeWei : null;
|
||||||
|
|
||||||
if (isErc20) {
|
if (isErc20) {
|
||||||
// Only the first 18 places of the balance are read: an amount with
|
const tokenFp = toFixedPoint(tokenBalance) ?? 0n;
|
||||||
// more was refused above, so the places after them cannot decide
|
|
||||||
// whether the amount fits.
|
|
||||||
const tokenText =
|
|
||||||
typeof tokenBalance === "string"
|
|
||||||
? maxTokenAmount(tokenBalance)
|
|
||||||
: tokenBalance;
|
|
||||||
const tokenFp = toFixedPoint(tokenText) ?? 0n;
|
|
||||||
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
|
if (amountFp > tokenFp) codes.push(CODES.INSUFFICIENT_TOKEN);
|
||||||
if (feeFp !== null && feeFp > ethFp) {
|
if (feeFp !== null && feeFp > ethFp) {
|
||||||
codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE);
|
codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE);
|
||||||
@@ -195,8 +166,6 @@ module.exports = {
|
|||||||
SCALE_DECIMALS,
|
SCALE_DECIMALS,
|
||||||
feeReserveWei,
|
feeReserveWei,
|
||||||
feeEstimateWei,
|
feeEstimateWei,
|
||||||
maxEthAmount,
|
|
||||||
maxTokenAmount,
|
|
||||||
toFixedPoint,
|
toFixedPoint,
|
||||||
validateTransfer,
|
validateTransfer,
|
||||||
};
|
};
|
||||||
|
|||||||
+28
-90
@@ -2,10 +2,10 @@
|
|||||||
// swap details. Designed to be extended with other DEX decoders later.
|
// swap details. Designed to be extended with other DEX decoders later.
|
||||||
|
|
||||||
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
|
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
|
||||||
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
const { truncateAmountNeverZero } = require("./amountDisplay");
|
const { truncateAmountNeverZero } = require("./amountDisplay");
|
||||||
const {
|
const {
|
||||||
resolveTokenDecimals,
|
resolveTokenDecimals,
|
||||||
resolveTokenSymbol,
|
|
||||||
unknownDecimalsAmount,
|
unknownDecimalsAmount,
|
||||||
} = require("./approvalAmount");
|
} = require("./approvalAmount");
|
||||||
|
|
||||||
@@ -84,43 +84,22 @@ function present(value) {
|
|||||||
//
|
//
|
||||||
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
|
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
|
||||||
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
|
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
|
||||||
// a zero minimum is a literal zero slippage floor and is stated as one. Nor
|
// a zero minimum is a literal zero slippage floor and is stated as one. Nor do
|
||||||
// does the V3 path have it — universal-router's `V3SwapRouter.v3SwapExactInput`
|
// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput`
|
||||||
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
|
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
|
||||||
// so a zero V3 `amountIn` is a literal zero and is displayed as one. The V2
|
// so a zero `amountIn` there is a literal zero and is displayed as one.
|
||||||
// exact-in path gives zero a meaning of its own: see ALREADY_PAID.
|
|
||||||
const OPEN_DELTA = Symbol("v4-open-delta");
|
const OPEN_DELTA = Symbol("v4-open-delta");
|
||||||
|
|
||||||
// The Universal Router's V2 exact-in spells "the pair already holds the input
|
// The two amount lines that state a fact instead of a quantity. Same register
|
||||||
// tokens" as an amount of zero: universal-router
|
// as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread
|
||||||
// `contracts/libraries/Constants.sol` declares
|
// as a number — and deliberately not a third phrasing of "not named": these
|
||||||
// `uint256 internal constant ALREADY_PAID = 0` ("Used for identifying cases
|
// say different things.
|
||||||
// when a v2 pair has already received input tokens"), and
|
|
||||||
// `V2SwapRouter.v2SwapExactInput` makes no payment of its own when `amountIn`
|
|
||||||
// equals it. The swap then spends whatever an earlier step sent to the pair.
|
|
||||||
// As with OPEN_DELTA, the calldata states no quantity, and "0.0000" would say
|
|
||||||
// that nothing is swapped.
|
|
||||||
const ALREADY_PAID = Symbol("v2-already-paid");
|
|
||||||
|
|
||||||
// The amount lines that state a fact instead of a quantity. Same register as
|
|
||||||
// UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a
|
|
||||||
// number — and deliberately not another phrasing of "not named": these say
|
|
||||||
// different things.
|
|
||||||
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
|
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
|
||||||
const ALREADY_PAID_AMOUNT =
|
|
||||||
"Whatever an earlier step sent to the pair (V2 already paid)";
|
|
||||||
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".
|
||||||
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
|
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
|
||||||
|
|
||||||
// WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia.
|
|
||||||
// decode() is not told the network, so it takes either.
|
|
||||||
const WETH_ADDRESSES = [
|
|
||||||
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
|
|
||||||
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
|
|
||||||
];
|
|
||||||
|
|
||||||
// `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
|
||||||
@@ -144,8 +123,9 @@ function tokenInfo(address, sources) {
|
|||||||
if (address === "0x0000000000000000000000000000000000000000") {
|
if (address === "0x0000000000000000000000000000000000000000") {
|
||||||
return { symbol: "ETH", decimals: 18, address: null };
|
return { symbol: "ETH", decimals: 18, address: null };
|
||||||
}
|
}
|
||||||
|
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
||||||
return {
|
return {
|
||||||
symbol: resolveTokenSymbol(address, sources),
|
symbol: t ? t.symbol : null,
|
||||||
decimals: resolveTokenDecimals(address, sources),
|
decimals: resolveTokenDecimals(address, sources),
|
||||||
address,
|
address,
|
||||||
};
|
};
|
||||||
@@ -199,7 +179,6 @@ function decodeBalanceCheck(input) {
|
|||||||
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
|
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
|
||||||
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
|
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
|
||||||
// address[] path, bool payerIsUser)
|
// address[] path, bool payerIsUser)
|
||||||
// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID.
|
|
||||||
function decodeV2SwapExactIn(input) {
|
function decodeV2SwapExactIn(input) {
|
||||||
try {
|
try {
|
||||||
const d = coder.decode(
|
const d = coder.decode(
|
||||||
@@ -207,7 +186,7 @@ function decodeV2SwapExactIn(input) {
|
|||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
|
amountIn: d[1],
|
||||||
amountOutMin: d[2],
|
amountOutMin: d[2],
|
||||||
tokenIn: d[3][0],
|
tokenIn: d[3][0],
|
||||||
tokenOut: d[3][d[3].length - 1],
|
tokenOut: d[3][d[3].length - 1],
|
||||||
@@ -220,6 +199,11 @@ function decodeV2SwapExactIn(input) {
|
|||||||
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
|
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
|
||||||
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
|
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
|
||||||
// address[] path, bool payerIsUser)
|
// address[] path, bool payerIsUser)
|
||||||
|
//
|
||||||
|
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
|
||||||
|
// its command name and no token or amount detail. Kept for the fix, which is
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/283.
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
function decodeV2SwapExactOut(input) {
|
function decodeV2SwapExactOut(input) {
|
||||||
try {
|
try {
|
||||||
const d = coder.decode(
|
const d = coder.decode(
|
||||||
@@ -464,7 +448,6 @@ function decode(data, toAddress, sources) {
|
|||||||
let outputToken = null;
|
let outputToken = null;
|
||||||
let minOutput = null;
|
let minOutput = null;
|
||||||
let hasUnwrapWeth = false;
|
let hasUnwrapWeth = false;
|
||||||
let hasV2ExactOut = false;
|
|
||||||
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
|
||||||
@@ -517,13 +500,7 @@ function decode(data, toAddress, sources) {
|
|||||||
|
|
||||||
if (cmdId === 0x0e) {
|
if (cmdId === 0x0e) {
|
||||||
const b = decodeBalanceCheck(inputs[i]);
|
const b = decodeBalanceCheck(inputs[i]);
|
||||||
// The router passes this check whenever the owner holds at
|
if (b) setOutput(b.token, b.minBalance);
|
||||||
// least minBalance, so a zero one guarantees nothing and
|
|
||||||
// does not replace a minimum an earlier step stated. Any
|
|
||||||
// other minBalance sets the output side as a swap does.
|
|
||||||
if (b && !(b.minBalance === 0n && present(minOutput))) {
|
|
||||||
setOutput(b.token, b.minBalance);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdId === 0x00) {
|
if (cmdId === 0x00) {
|
||||||
@@ -545,16 +522,6 @@ function decode(data, toAddress, sources) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdId === 0x09) {
|
|
||||||
// Buys exactly amountOut and spends at most amountInMax.
|
|
||||||
hasV2ExactOut = true;
|
|
||||||
const s = decodeV2SwapExactOut(inputs[i]);
|
|
||||||
if (s) {
|
|
||||||
setInputOnce(s.tokenIn, s.amountInMax);
|
|
||||||
setOutput(s.tokenOut, s.amountOut);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmdId === 0x0b) {
|
if (cmdId === 0x0b) {
|
||||||
const w = decodeWrapEth(inputs[i]);
|
const w = decodeWrapEth(inputs[i]);
|
||||||
if (w) {
|
if (w) {
|
||||||
@@ -593,19 +560,12 @@ function decode(data, toAddress, sources) {
|
|||||||
|
|
||||||
// Resolve token info. A null token on either side means the calldata
|
// Resolve token info. A null token on either side means the calldata
|
||||||
// named no currency for it; tokenInfo() refuses rather than calling it
|
// named no currency for it; tokenInfo() refuses rather than calling it
|
||||||
// ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH
|
// ETH. UNWRAP_WETH is the one output that is ETH without a currency to
|
||||||
// when the output side is WETH, or when no step set the output side.
|
// decode, and it is answered here rather than left to that rule.
|
||||||
// Any other output keeps its own token and figure: a swap that buys
|
|
||||||
// USDC and then unwraps the WETH it did not spend receives USDC.
|
|
||||||
const outputIsWeth =
|
|
||||||
present(outputToken) &&
|
|
||||||
WETH_ADDRESSES.includes(outputToken.toLowerCase());
|
|
||||||
const outputUnset = !present(outputToken) && !present(minOutput);
|
|
||||||
const inInfo = tokenInfo(inputToken, sources);
|
const inInfo = tokenInfo(inputToken, sources);
|
||||||
const outInfo =
|
const outInfo = hasUnwrapWeth
|
||||||
hasUnwrapWeth && (outputIsWeth || outputUnset)
|
? { symbol: "ETH", decimals: 18, address: null }
|
||||||
? { symbol: "ETH", decimals: 18, address: null }
|
: tokenInfo(outputToken, sources);
|
||||||
: tokenInfo(outputToken, sources);
|
|
||||||
|
|
||||||
const inSymbol = inInfo.symbol;
|
const inSymbol = inInfo.symbol;
|
||||||
const outSymbol = outInfo.symbol;
|
const outSymbol = outInfo.symbol;
|
||||||
@@ -644,33 +604,16 @@ function decode(data, toAddress, sources) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (present(inputAmount)) {
|
if (present(inputAmount)) {
|
||||||
// Three amounts need no scale to describe and are named rather
|
// Two amounts need no scale to describe and are named rather than
|
||||||
// than formatted: V4's open delta and V2's already-paid zero,
|
// formatted: V4's open delta, which is not a quantity at all (see
|
||||||
// neither of which is a quantity at all (see OPEN_DELTA and
|
// OPEN_DELTA), and an unbounded permit. The open-delta test comes
|
||||||
// ALREADY_PAID), and an unbounded permit. Those two tests come
|
// first — the sentinel is not a bigint and cannot be compared with
|
||||||
// first — the sentinels are not bigints and cannot be compared
|
// one.
|
||||||
// with one.
|
|
||||||
let amount;
|
let amount;
|
||||||
if (inputAmount === OPEN_DELTA) {
|
if (inputAmount === OPEN_DELTA) {
|
||||||
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
||||||
} else if (inputAmount === ALREADY_PAID) {
|
|
||||||
amount = {
|
|
||||||
raw: ALREADY_PAID_AMOUNT,
|
|
||||||
display: ALREADY_PAID_AMOUNT,
|
|
||||||
};
|
|
||||||
} else if (inputAmount >= MAX_UINT160) {
|
} else if (inputAmount >= MAX_UINT160) {
|
||||||
amount = { raw: "Unlimited", display: "Unlimited" };
|
amount = { raw: "Unlimited", display: "Unlimited" };
|
||||||
} else if (hasV2ExactOut) {
|
|
||||||
// A V2 exact-out swap spends at most this figure, whichever
|
|
||||||
// step set the line (its amountInMax, the WRAP_ETH of a swap
|
|
||||||
// paid in ETH, a permit), so it is said to be a maximum, in
|
|
||||||
// `raw` too: the wait, success and error screens show `raw` as
|
|
||||||
// the transaction's amount.
|
|
||||||
const most = amountText(inputAmount, inInfo);
|
|
||||||
amount = {
|
|
||||||
raw: "Up to " + most.raw,
|
|
||||||
display: "Up to " + most.display,
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
amount = amountText(inputAmount, inInfo);
|
amount = amountText(inputAmount, inInfo);
|
||||||
}
|
}
|
||||||
@@ -724,15 +667,10 @@ function decode(data, toAddress, sources) {
|
|||||||
|
|
||||||
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });
|
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });
|
||||||
|
|
||||||
// A JavaScript date reaches only to 275760-09-13 00:00:00 UTC. A
|
|
||||||
// later deadline, such as the uint256 maximum, makes an invalid date,
|
|
||||||
// and toISOString() throws on one, so that deadline is said in words.
|
|
||||||
const deadlineDate = new Date(Number(deadline) * 1000);
|
const deadlineDate = new Date(Number(deadline) * 1000);
|
||||||
details.push({
|
details.push({
|
||||||
label: "Deadline",
|
label: "Deadline",
|
||||||
value: isNaN(deadlineDate.getTime())
|
value: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
|
||||||
? "After 275760-09-13 00:00:00 (no deadline in practice)"
|
|
||||||
: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+11
-15
@@ -13,17 +13,11 @@ const NON_MASTER_XPRV = "non-master-xprv";
|
|||||||
|
|
||||||
// An "xprv" wallet stores the neutered BIP-44 Ethereum node, four levels below
|
// An "xprv" wallet stores the neutered BIP-44 Ethereum node, four levels below
|
||||||
// the key that was imported: the current import path derives the absolute
|
// the key that was imported: the current import path derives the absolute
|
||||||
// m/44'/60'/0'/0 from a depth-0 key, and the path before #210 (57959b7)
|
// m/44'/60'/0'/0 from a depth-0 key, and the pre-#210 path derived the same
|
||||||
// derived the same four levels as a relative path beneath whatever depth it
|
// four levels as a relative path beneath whatever depth it was given. A master
|
||||||
// was given. A master import therefore stores a depth-4 xpub and a depth-d
|
// import therefore stores a depth-4 xpub and a depth-d import stores depth
|
||||||
// import stores depth d + 4, which makes the stored xpub an exact read on the
|
// d + 4, which makes the stored xpub an exact read on the imported key's
|
||||||
// imported key's depth — and it is readable without the password, unlike the
|
// depth — and it is readable without the password, unlike the key itself.
|
||||||
// key itself.
|
|
||||||
//
|
|
||||||
// The first import path (7a7f9c5) does not fit: it stored the imported key's
|
|
||||||
// own xpub with no derivation, so a wallet it wrote is judged wrongly here (a
|
|
||||||
// master import as defective, a depth-4 import as sound). 57959b7 replaced it
|
|
||||||
// in the same push, and no tag contains it.
|
|
||||||
const BIP44_ETH_XPUB_DEPTH = 4;
|
const BIP44_ETH_XPUB_DEPTH = 4;
|
||||||
|
|
||||||
const DEFECTS = {
|
const DEFECTS = {
|
||||||
@@ -47,10 +41,12 @@ const DEFECTS = {
|
|||||||
"changed or removed, and this wallet stays until you delete " +
|
"changed or removed, and this wallet stays until you delete " +
|
||||||
"it yourself.",
|
"it yourself.",
|
||||||
],
|
],
|
||||||
// One line, for the flash on a blocked Send and the inline error on
|
// One sentence for the places that have room for one: the flash on a
|
||||||
// the approval screens. It must fit on the flash line; see showFlash()
|
// blocked Send, the inline error on the approval screens.
|
||||||
// in src/popup/views/helpers.js.
|
shortMessage:
|
||||||
shortMessage: "This wallet cannot sign. See the wallet list.",
|
"This wallet cannot sign, because it was imported from an " +
|
||||||
|
"extended private key that is not a master key. The wallet list " +
|
||||||
|
"explains what happened.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,12 @@ function sameAddress(a, b) {
|
|||||||
return String(a).toLowerCase() === String(b).toLowerCase();
|
return String(a).toLowerCase() === String(b).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forget every site permission held against the given addresses: the
|
// Forget every site permission held against the given addresses.
|
||||||
// remembered ones in `state`, and the connections approved without
|
|
||||||
// "Remember", which only the background holds, in memory.
|
|
||||||
function dropSitePermissions(state, addresses) {
|
function dropSitePermissions(state, addresses) {
|
||||||
for (const addr of addresses) {
|
for (const addr of addresses) {
|
||||||
delete state.allowedSites[addr];
|
delete state.allowedSites[addr];
|
||||||
delete state.deniedSites[addr];
|
delete state.deniedSites[addr];
|
||||||
}
|
}
|
||||||
notify({ type: "AUTISTMASK_ADDRESSES_REMOVED", addresses });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove wallet `walletIdx` from `state` and repair the derived state.
|
// Remove wallet `walletIdx` from `state` and repair the derived state.
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
// Adding a second wallet accepts a password different from the first one's
|
|
||||||
// with nothing on screen saying the two are separate — each wallet has its
|
|
||||||
// own encryptedSecret, so per-wallet passwords are by design, but the add
|
|
||||||
// screen said only "Choose a password"
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/374).
|
|
||||||
//
|
|
||||||
// The fix is copy: a note on the password screen that says each wallet has
|
|
||||||
// its own password and this one need not match. It is only meaningful once
|
|
||||||
// a wallet exists — on the very first wallet there is no other password to
|
|
||||||
// be separate from — so it is shown then and hidden otherwise. These boot
|
|
||||||
// the real popup and reach the add-wallet screen through the same button a
|
|
||||||
// user presses, so the note's visibility is decided by the real show().
|
|
||||||
|
|
||||||
const {
|
|
||||||
bootPopup,
|
|
||||||
cleanupPopup,
|
|
||||||
unversionedValidProfile,
|
|
||||||
POPUP_HTML,
|
|
||||||
} = require("./support/popupBoot");
|
|
||||||
|
|
||||||
const NOTE = "add-wallet-separate-password-note";
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanupPopup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("second-wallet password note", () => {
|
|
||||||
test("hidden while onboarding the first wallet", async () => {
|
|
||||||
const page = await bootPopup(undefined);
|
|
||||||
expect(page.pageErrors).toEqual([]);
|
|
||||||
await page.click("btn-welcome-add");
|
|
||||||
expect(page.visibleViews()).toContain("add-wallet");
|
|
||||||
expect(page.hidden(NOTE)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shown when a wallet already exists", async () => {
|
|
||||||
const page = await bootPopup(unversionedValidProfile());
|
|
||||||
expect(page.pageErrors).toEqual([]);
|
|
||||||
await page.click("btn-main-add-wallet");
|
|
||||||
expect(page.visibleViews()).toContain("add-wallet");
|
|
||||||
expect(page.hidden(NOTE)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The copy states the two facts the definition of done asks for — each
|
|
||||||
// wallet has its own password, and this one need not match — and stays
|
|
||||||
// consistent with the no-password-reset reality of
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/312 by not promising any
|
|
||||||
// recovery or reset here.
|
|
||||||
test("the note says the password is per-wallet and need not match", () => {
|
|
||||||
const note = /id="add-wallet-separate-password-note"[^>]*>([^]*?)<\/p>/
|
|
||||||
.exec(POPUP_HTML)[1]
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
expect(note).toContain("its own");
|
|
||||||
expect(note).toContain("need not match");
|
|
||||||
expect(note).not.toMatch(/recover|reset/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -22,6 +22,8 @@ const {
|
|||||||
prices,
|
prices,
|
||||||
clearPrices,
|
clearPrices,
|
||||||
getAddressValue,
|
getAddressValue,
|
||||||
|
getWalletValue,
|
||||||
|
getTotalValue,
|
||||||
formatAddressTotal,
|
formatAddressTotal,
|
||||||
} = require("../src/shared/prices");
|
} = require("../src/shared/prices");
|
||||||
const { state } = require("../src/shared/state");
|
const { state } = require("../src/shared/state");
|
||||||
@@ -134,6 +136,17 @@ describe("the value of an address, and whether it is the whole value", () => {
|
|||||||
partial: false,
|
partial: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("one unpriced holding makes a wallet and the grand total partial", () => {
|
||||||
|
const wallet = { addresses: [FULLY_PRICED, UNPRICED_ONLY] };
|
||||||
|
expect(getWalletValue(wallet)).toEqual({ usd: 5500, partial: true });
|
||||||
|
expect(getTotalValue([wallet])).toEqual({ usd: 5500, partial: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a wallet of fully priced addresses stays complete", () => {
|
||||||
|
const wallet = { addresses: [FULLY_PRICED, EMPTY] };
|
||||||
|
expect(getWalletValue(wallet)).toEqual({ usd: 5500, partial: false });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("how that value is written on screen", () => {
|
describe("how that value is written on screen", () => {
|
||||||
@@ -194,14 +207,6 @@ describe("the wallet list on Home", () => {
|
|||||||
clearPrices();
|
clearPrices();
|
||||||
expect(walletListTotal(FULLY_PRICED)).toBe(" ");
|
expect(walletListTotal(FULLY_PRICED)).toBe(" ");
|
||||||
});
|
});
|
||||||
|
|
||||||
// A total under a cent is written "< $0.01", and the "<" is escaped
|
|
||||||
// here as the removal warning escapes it.
|
|
||||||
test("a total under a cent is escaped, as on the removal warning", () => {
|
|
||||||
const tiny = { ...EMPTY, balance: "0.000001" };
|
|
||||||
expect(walletListTotal(tiny)).toBe("Total: < $0.01");
|
|
||||||
expect(removalWarningTotal(tiny)).toBe("Total: < $0.01");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("the balance warning on the address-removal confirmation", () => {
|
describe("the balance warning on the address-removal confirmation", () => {
|
||||||
|
|||||||
@@ -31,15 +31,11 @@ const iface = new Interface(ERC20_ABI);
|
|||||||
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
|
const NOVEL_TOKEN = "0xE2E0000000000000000000000000000000000E2e";
|
||||||
// In the bundled list, at 6 decimals.
|
// In the bundled list, at 6 decimals.
|
||||||
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||||
// In the bundled list, at 0 decimals.
|
|
||||||
const SLP = "0xCC8Fa225D80b9c7D42F96e9570156c65D6cAAa25";
|
|
||||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||||
const SPENDER = "0x1111111111111111111111111111111111111111";
|
const SPENDER = "0x1111111111111111111111111111111111111111";
|
||||||
|
|
||||||
// 5,000 units of a 6-decimal token, the amount from the issue.
|
// 5,000 units of a 6-decimal token, the amount from the issue.
|
||||||
const FIVE_THOUSAND_AT_SIX = 5000000000n;
|
const FIVE_THOUSAND_AT_SIX = 5000000000n;
|
||||||
// 5,000 units of a 0-decimal token, which are 5,000 tokens.
|
|
||||||
const FIVE_THOUSAND_AT_ZERO = 5000n;
|
|
||||||
const MAX_UINT256 = (1n << 256n) - 1n;
|
const MAX_UINT256 = (1n << 256n) - 1n;
|
||||||
|
|
||||||
function transferData(amount) {
|
function transferData(amount) {
|
||||||
@@ -117,21 +113,6 @@ describe("resolveTokenDecimals", () => {
|
|||||||
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
|
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Zero decimals is a real scale, not a missing one, so a source that
|
|
||||||
// answers 0 is used rather than fallen past like the unusable entry above.
|
|
||||||
test("uses a bundled scale of zero", () => {
|
|
||||||
state.trackedTokens = [{ address: SLP, symbol: "SLP", decimals: 18 }];
|
|
||||||
expect(resolveTokenDecimals(SLP, state)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses a tracked scale of zero", () => {
|
|
||||||
state.trackedTokens = [
|
|
||||||
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 0 },
|
|
||||||
];
|
|
||||||
state.wallets = walletsHolding(NOVEL_TOKEN, 18);
|
|
||||||
expect(resolveTokenDecimals(NOVEL_TOKEN, state)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("refuses a scale the explorer's own entries disagree about", () => {
|
test("refuses a scale the explorer's own entries disagree about", () => {
|
||||||
const wallets = walletsHolding(NOVEL_TOKEN, 6);
|
const wallets = walletsHolding(NOVEL_TOKEN, 6);
|
||||||
wallets[0].addresses.push({
|
wallets[0].addresses.push({
|
||||||
@@ -162,18 +143,16 @@ describe("decodeCalldata amount", () => {
|
|||||||
state.trackedTokens = [
|
state.trackedTokens = [
|
||||||
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
|
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
|
||||||
];
|
];
|
||||||
// The tracked entry supplies both: the scale (5000.0000) and, since
|
|
||||||
// issue #323, the symbol that the scale is counted in.
|
|
||||||
expect(
|
expect(
|
||||||
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
|
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
|
||||||
).toBe("5000.0000 NOVEL");
|
).toBe("5000.0000");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("transfer priced off the explorer's decimals shows the true quantity", () => {
|
test("transfer priced off the explorer's decimals shows the true quantity", () => {
|
||||||
state.wallets = walletsHolding(NOVEL_TOKEN, "6");
|
state.wallets = walletsHolding(NOVEL_TOKEN, "6");
|
||||||
expect(
|
expect(
|
||||||
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
|
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
|
||||||
).toBe("5000.0000 NOVEL");
|
).toBe("5000.0000");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("transfer of an unknown-decimals token shows base units, not a number", () => {
|
test("transfer of an unknown-decimals token shows base units, not a number", () => {
|
||||||
@@ -193,7 +172,7 @@ describe("decodeCalldata amount", () => {
|
|||||||
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
|
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 6 },
|
||||||
];
|
];
|
||||||
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
|
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
|
||||||
"5000.0000 NOVEL",
|
"5000.0000",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,22 +182,6 @@ describe("decodeCalldata amount", () => {
|
|||||||
expect(line).not.toMatch(/0\.0000/);
|
expect(line).not.toMatch(/0\.0000/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A token added by hand carries whatever its decimals() returned, and a
|
|
||||||
// uint8 reaches 255, but formatUnits() throws above 80. The throw left the
|
|
||||||
// call undecoded rather than refused
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
|
|
||||||
test("a token reporting more than 80 decimals shows base units", () => {
|
|
||||||
state.trackedTokens = [
|
|
||||||
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 81 },
|
|
||||||
];
|
|
||||||
expect(
|
|
||||||
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
|
|
||||||
).toBe("5000000000 base units (decimals unknown)");
|
|
||||||
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
|
|
||||||
"5000000000 base units (decimals unknown)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an unbounded allowance is still named, with or without a scale", () => {
|
test("an unbounded allowance is still named, with or without a scale", () => {
|
||||||
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
|
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
|
||||||
"Unlimited",
|
"Unlimited",
|
||||||
@@ -232,21 +195,6 @@ describe("decodeCalldata amount", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a bundled token with zero decimals shows the true quantity", () => {
|
|
||||||
expect(amountLine(transferData(FIVE_THOUSAND_AT_ZERO), SLP)).toBe(
|
|
||||||
"5000.0000 SLP",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a tracked token with zero decimals shows the true quantity", () => {
|
|
||||||
state.trackedTokens = [
|
|
||||||
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 0 },
|
|
||||||
];
|
|
||||||
expect(
|
|
||||||
amountLine(transferData(FIVE_THOUSAND_AT_ZERO), NOVEL_TOKEN),
|
|
||||||
).toBe("5000.0000 NOVEL");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the amount carried to the status screens is the same string", () => {
|
test("the amount carried to the status screens is the same string", () => {
|
||||||
const decoded = decodeCalldata(
|
const decoded = decodeCalldata(
|
||||||
transferData(FIVE_THOUSAND_AT_SIX),
|
transferData(FIVE_THOUSAND_AT_SIX),
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
// A nonce the page supplies is not used
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/404). With it a page could
|
|
||||||
// replace one of the user's pending transactions (the same nonce at a higher
|
|
||||||
// fee) or leave the new one stuck behind a gap, so the transaction is always
|
|
||||||
// given the account's next nonce from the network.
|
|
||||||
//
|
|
||||||
// Driven through the preparation the background runs on a page's
|
|
||||||
// eth_sendTransaction (src/shared/approvalTx.js) and the real approval screen,
|
|
||||||
// password and Confirm included, against a minimal DOM stub in the shape
|
|
||||||
// tests/approvalOrigin.test.js uses. The vault is mocked so that no password
|
|
||||||
// has to be hashed.
|
|
||||||
|
|
||||||
jest.mock("../src/shared/vault", () => ({
|
|
||||||
decryptWithPassword: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { Network, Transaction } = require("ethers");
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const { decryptWithPassword } = require("../src/shared/vault");
|
|
||||||
const { prepareApprovalTx } = require("../src/shared/approvalTx");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
|
|
||||||
// A well-known test phrase, and its first address.
|
|
||||||
const PHRASE = "test test test test test test test test test test test junk";
|
|
||||||
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
|
||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
|
|
||||||
// The account's next nonce, as the network reports it.
|
|
||||||
const NETWORK_NONCE = 7;
|
|
||||||
|
|
||||||
const network = {
|
|
||||||
getNetwork: async () => Network.from(1),
|
|
||||||
getTransactionCount: async () => NETWORK_NONCE,
|
|
||||||
estimateGas: async () => 21000n,
|
|
||||||
getFeeData: async () => ({
|
|
||||||
gasPrice: 2000000000n,
|
|
||||||
maxFeePerGas: 2000000000n,
|
|
||||||
maxPriorityFeePerGas: 1000000000n,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function click(id) {
|
|
||||||
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the transaction approval screen the way the popup does: the background
|
|
||||||
// hands over the populated transaction and show() draws it. Returns every
|
|
||||||
// message the screen sends to the background. The background's answer to the
|
|
||||||
// signed transaction does not matter here; a retryable refusal keeps the
|
|
||||||
// screen where it is.
|
|
||||||
async function openTxApproval(approvedTx) {
|
|
||||||
const sent = [];
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
globalThis.window = { location: { search: "" }, close: () => {} };
|
|
||||||
globalThis.chrome.runtime = {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
sent.push(msg);
|
|
||||||
if (!reply) return;
|
|
||||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
|
|
||||||
return reply({ error: "Not sent.", retryable: true });
|
|
||||||
}
|
|
||||||
reply({
|
|
||||||
type: "tx",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: FROM,
|
|
||||||
approvedTx,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
state.activeAddress = FROM;
|
|
||||||
state.wallets = [
|
|
||||||
{
|
|
||||||
type: "hd",
|
|
||||||
name: "Wallet 1",
|
|
||||||
xpub: "xpub-wallet-1",
|
|
||||||
encryptedSecret: "encrypted-secret-1",
|
|
||||||
nextIndex: 1,
|
|
||||||
addresses: [{ address: FROM, balance: "0", tokenBalances: [] }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
approval.init({});
|
|
||||||
await approval.show(1);
|
|
||||||
return sent;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a page's nonce is replaced by the network's, on screen and in the signed transaction", async () => {
|
|
||||||
// What the background does with the page's request before it opens the
|
|
||||||
// approval window.
|
|
||||||
const approvedTx = await prepareApprovalTx(network, FROM, {
|
|
||||||
from: FROM,
|
|
||||||
to: RECIPIENT,
|
|
||||||
value: "0x0",
|
|
||||||
data: "0x",
|
|
||||||
nonce: "0x2",
|
|
||||||
});
|
|
||||||
|
|
||||||
const sent = await openTxApproval(approvedTx);
|
|
||||||
expect(node("approve-tx-nonce").textContent).toBe("7");
|
|
||||||
|
|
||||||
decryptWithPassword.mockResolvedValue(PHRASE);
|
|
||||||
node("approve-tx-password").value = "any password";
|
|
||||||
await click("btn-approve-tx");
|
|
||||||
|
|
||||||
const response = sent.find((msg) => msg.type === "AUTISTMASK_TX_RESPONSE");
|
|
||||||
expect(Transaction.from(response.rawSignedTx).nonce).toBe(NETWORK_NONCE);
|
|
||||||
});
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
// The connection, transaction and signature prompts name the site by its full
|
|
||||||
// origin, scheme and port included, not by its bare hostname
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/402). A page served over http,
|
|
||||||
// or on another port, of a host the user trusts over https must not raise a
|
|
||||||
// prompt that reads as that trusted site.
|
|
||||||
//
|
|
||||||
// Driven against a minimal DOM stub in the shape
|
|
||||||
// tests/contractCreation.test.js uses.
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
|
|
||||||
// The site asking, in cleartext and on a port, which is what the hostname
|
|
||||||
// alone, dapp.example, used to hide.
|
|
||||||
const ORIGIN = "http://dapp.example:8080";
|
|
||||||
const FROM = "0x0000000000000000000000000000000000000a11";
|
|
||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
|
|
||||||
function makeElement(id) {
|
|
||||||
const classes = new Set();
|
|
||||||
const el = {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
value: "",
|
|
||||||
innerHTML: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
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: () => {},
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the prompt the background describes with `details`, the way the popup
|
|
||||||
// does: it asks for the approval and show() draws it.
|
|
||||||
async function openApproval(details) {
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
globalThis.window = { location: { search: "" } };
|
|
||||||
globalThis.chrome.runtime = {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
if (!reply) return;
|
|
||||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
|
||||||
reply({
|
|
||||||
origin: ORIGIN,
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: FROM,
|
|
||||||
...details,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
approval.init({});
|
|
||||||
await approval.show(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
state.wallets = [];
|
|
||||||
state.activeAddress = FROM;
|
|
||||||
state.viewData = {};
|
|
||||||
state.viewStack = [];
|
|
||||||
state.currentView = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the connection prompt shows the origin", async () => {
|
|
||||||
await openApproval({});
|
|
||||||
expect(node("approve-origin").textContent).toBe(ORIGIN);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the transaction prompt shows the origin", async () => {
|
|
||||||
await openApproval({
|
|
||||||
type: "tx",
|
|
||||||
approvedTx: {
|
|
||||||
type: 2,
|
|
||||||
from: FROM,
|
|
||||||
chainId: "0x1",
|
|
||||||
nonce: "0x7",
|
|
||||||
gasLimit: "0x5208",
|
|
||||||
maxPriorityFeePerGas: "0x3b9aca00",
|
|
||||||
maxFeePerGas: "0x77359400",
|
|
||||||
to: RECIPIENT,
|
|
||||||
value: "0x0",
|
|
||||||
data: "0x",
|
|
||||||
accessList: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(node("approve-tx-origin").textContent).toBe(ORIGIN);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the signature prompt shows the origin", async () => {
|
|
||||||
await openApproval({
|
|
||||||
type: "sign",
|
|
||||||
// "Hello" as the hex a dApp passes to personal_sign.
|
|
||||||
signParams: {
|
|
||||||
method: "personal_sign",
|
|
||||||
message: "0x48656c6c6f",
|
|
||||||
from: FROM,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(node("approve-sign-origin").textContent).toBe(ORIGIN);
|
|
||||||
});
|
|
||||||
@@ -121,7 +121,7 @@ describe("prepareApprovalTx", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps a gas limit and fee the request did fix, but not its nonce", async () => {
|
test("keeps a nonce, gas limit and fee the request did fix", async () => {
|
||||||
const approved = await prepareApprovalTx(
|
const approved = await prepareApprovalTx(
|
||||||
providerWith(),
|
providerWith(),
|
||||||
signer.address,
|
signer.address,
|
||||||
@@ -133,7 +133,7 @@ describe("prepareApprovalTx", () => {
|
|||||||
maxPriorityFeePerGas: "0x3b9aca00",
|
maxPriorityFeePerGas: "0x3b9aca00",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(approved.nonce).toBe("0x7");
|
expect(approved.nonce).toBe("0x2");
|
||||||
expect(approved.gasLimit).toBe("0x30d40");
|
expect(approved.gasLimit).toBe("0x30d40");
|
||||||
expect(approved.maxFeePerGas).toBe("0x12a05f200");
|
expect(approved.maxFeePerGas).toBe("0x12a05f200");
|
||||||
});
|
});
|
||||||
@@ -212,24 +212,6 @@ describe("prepareApprovalTx", () => {
|
|||||||
).rejects.toThrow(/gas limit no network this wallet supports/);
|
).rejects.toThrow(/gas limit no network this wallet supports/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The combined bound at population: a gas limit and a fee that are each
|
|
||||||
// under their own ceiling but multiply to thousands of ETH is refused
|
|
||||||
// before the approval window opens, so the user is never shown a
|
|
||||||
// balance-draining fee to click past.
|
|
||||||
test("refuses a fee whose product with the gas limit is over the bound", async () => {
|
|
||||||
const gouging = providerWith({
|
|
||||||
estimateGas: async () => 30000000n,
|
|
||||||
getFeeData: async () => ({
|
|
||||||
gasPrice: MAX_FEE_PER_GAS,
|
|
||||||
maxFeePerGas: MAX_FEE_PER_GAS,
|
|
||||||
maxPriorityFeePerGas: 1000000000n,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
prepareApprovalTx(gouging, signer.address, TX_PARAMS),
|
|
||||||
).rejects.toThrow(/network fee of up to/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// No approval and no window: the failure goes back to the page the click
|
// No approval and no window: the failure goes back to the page the click
|
||||||
// came from, in a sentence.
|
// came from, in a sentence.
|
||||||
test("reports a failed estimate as a full sentence", async () => {
|
test("reports a failed estimate as a full sentence", async () => {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ const {
|
|||||||
TX_STAGE_NONCE,
|
TX_STAGE_NONCE,
|
||||||
MAX_GAS_LIMIT,
|
MAX_GAS_LIMIT,
|
||||||
MAX_FEE_PER_GAS,
|
MAX_FEE_PER_GAS,
|
||||||
MAX_TOTAL_FEE,
|
|
||||||
} = require("../src/shared/approvalVerify");
|
} = require("../src/shared/approvalVerify");
|
||||||
const { prepareApprovalTx } = require("../src/shared/approvalTx");
|
const { prepareApprovalTx } = require("../src/shared/approvalTx");
|
||||||
const { getSignerForAddress } = require("../src/shared/wallet");
|
const { getSignerForAddress } = require("../src/shared/wallet");
|
||||||
@@ -476,149 +475,18 @@ describe("verifySignedTx field comparison", () => {
|
|||||||
assertWithinCeilings({ [key]: MAX_FEE_PER_GAS + 1n }),
|
assertWithinCeilings({ [key]: MAX_FEE_PER_GAS + 1n }),
|
||||||
).toThrow(/fee per gas far above any plausible value/);
|
).toThrow(/fee per gas far above any plausible value/);
|
||||||
}
|
}
|
||||||
// Each field at its own ceiling multiplies to about 10,000 ETH, which
|
|
||||||
// is exactly the combination the per-field ceilings cannot see and the
|
|
||||||
// product bound is for: it is refused, not accepted.
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
assertWithinCeilings({
|
assertWithinCeilings({
|
||||||
gasLimit: MAX_GAS_LIMIT,
|
gasLimit: MAX_GAS_LIMIT,
|
||||||
maxFeePerGas: MAX_FEE_PER_GAS,
|
maxFeePerGas: MAX_FEE_PER_GAS,
|
||||||
maxPriorityFeePerGas: MAX_FEE_PER_GAS,
|
maxPriorityFeePerGas: MAX_FEE_PER_GAS,
|
||||||
}),
|
}),
|
||||||
).toThrow(/network fee of up to/);
|
|
||||||
// An ordinary transaction — a modest gas limit and a modest fee, each
|
|
||||||
// far under its ceiling and their product far under the bound — passes.
|
|
||||||
expect(() =>
|
|
||||||
assertWithinCeilings({
|
|
||||||
gasLimit: 21000n,
|
|
||||||
maxFeePerGas: 2000000000n,
|
|
||||||
maxPriorityFeePerGas: 1000000000n,
|
|
||||||
}),
|
|
||||||
).not.toThrow();
|
).not.toThrow();
|
||||||
// Nothing to bound is not a failure: a type 2 approval carries no gas
|
// Nothing to bound is not a failure: a type 2 approval carries no gas
|
||||||
// price, and a bare object must not be refused for lacking one.
|
// price, and a bare object must not be refused for lacking one.
|
||||||
expect(() => assertWithinCeilings({})).not.toThrow();
|
expect(() => assertWithinCeilings({})).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The defect this issue closes: gasLimit and maxFeePerGas each under their
|
|
||||||
// own ceiling, but their product — the fee a gas-consuming contract can
|
|
||||||
// really extract — thousands of ETH. The per-field ceilings accept it; the
|
|
||||||
// product bound refuses it, on either side of the screen.
|
|
||||||
describe("the combined fee bound", () => {
|
|
||||||
// A gas limit and a fee that are each comfortably under their own
|
|
||||||
// ceiling but multiply to well over 1 ETH: 30,000,000 gas at 100,000
|
|
||||||
// gwei is about 3,000 ETH.
|
|
||||||
const OVER = { gasLimit: 30000000n, maxFeePerGas: 100000000000000n };
|
|
||||||
|
|
||||||
test("each field is under its own ceiling", () => {
|
|
||||||
expect(OVER.gasLimit).toBeLessThan(MAX_GAS_LIMIT);
|
|
||||||
expect(OVER.maxFeePerGas).toBeLessThanOrEqual(MAX_FEE_PER_GAS);
|
|
||||||
expect(OVER.gasLimit * OVER.maxFeePerGas).toBeGreaterThan(
|
|
||||||
MAX_TOTAL_FEE,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("assertWithinCeilings refuses the product over the bound", () => {
|
|
||||||
expect(() =>
|
|
||||||
assertWithinCeilings({
|
|
||||||
...OVER,
|
|
||||||
maxPriorityFeePerGas: 1000000000n,
|
|
||||||
}),
|
|
||||||
).toThrow(/network fee of up to 3000\.0 ETH/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("assertWithinCeilings bounds a legacy gasPrice the same way", () => {
|
|
||||||
expect(() =>
|
|
||||||
assertWithinCeilings({
|
|
||||||
gasLimit: OVER.gasLimit,
|
|
||||||
gasPrice: OVER.maxFeePerGas,
|
|
||||||
}),
|
|
||||||
).toThrow(/network fee of up to/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The boundary itself, pinned rather than only some value well past
|
|
||||||
// it. Both fields stay under their own ceilings, so it is the product
|
|
||||||
// and nothing else that decides these two cases: a gas limit of 10,000
|
|
||||||
// at the per-gas ceiling is exactly 1 ETH.
|
|
||||||
test("assertWithinCeilings accepts a product exactly at the bound and refuses one wei over", () => {
|
|
||||||
expect(MAX_FEE_PER_GAS * 10000n).toBe(MAX_TOTAL_FEE);
|
|
||||||
expect(() =>
|
|
||||||
assertWithinCeilings({
|
|
||||||
gasLimit: 10000n,
|
|
||||||
maxFeePerGas: MAX_FEE_PER_GAS,
|
|
||||||
}),
|
|
||||||
).not.toThrow();
|
|
||||||
expect(() =>
|
|
||||||
assertWithinCeilings({
|
|
||||||
gasLimit: 10001n,
|
|
||||||
maxFeePerGas: MAX_FEE_PER_GAS,
|
|
||||||
}),
|
|
||||||
).toThrow(/network fee of up to/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The dApp path: an artifact whose fee is within each field's ceiling
|
|
||||||
// but over the product bound, both displayed and signed, is refused at
|
|
||||||
// verification just as it is at population.
|
|
||||||
test("verifySignedTx refuses an over-bound product even when displayed", async () => {
|
|
||||||
const raw = await signedWith(OVER);
|
|
||||||
expect(() =>
|
|
||||||
verifySignedTx(
|
|
||||||
raw,
|
|
||||||
approvedFor(TX_PARAMS, OVER),
|
|
||||||
signer.address,
|
|
||||||
SELECTED,
|
|
||||||
),
|
|
||||||
).toThrow(/network fee of up to/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("verifySignedTx accepts a product just under the bound", async () => {
|
|
||||||
// 21,000 gas at 40 gwei is 0.00084 ETH — an ordinary send.
|
|
||||||
const under = { gasLimit: 21000n, maxFeePerGas: 40000000000n };
|
|
||||||
expect(under.gasLimit * under.maxFeePerGas).toBeLessThan(
|
|
||||||
MAX_TOTAL_FEE,
|
|
||||||
);
|
|
||||||
const raw = await signedWith(under);
|
|
||||||
expect(() =>
|
|
||||||
verifySignedTx(
|
|
||||||
raw,
|
|
||||||
approvedFor(TX_PARAMS, under),
|
|
||||||
signer.address,
|
|
||||||
SELECTED,
|
|
||||||
),
|
|
||||||
).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the refusal names the fee and the limit in a full sentence", () => {
|
|
||||||
try {
|
|
||||||
assertWithinCeilings(OVER);
|
|
||||||
throw new Error("expected a rejection");
|
|
||||||
} catch (e) {
|
|
||||||
expect(e.approvalMismatch).toBe(true);
|
|
||||||
expect(e.message).toMatch(/^[A-Z].*\.$/);
|
|
||||||
expect(e.message).toContain("3000.0 ETH");
|
|
||||||
expect(e.message).toContain("1.0 ETH");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// The fee is in the native currency of the network the transaction is
|
|
||||||
// for, whether its chain id is the hex string the background prepares
|
|
||||||
// or the number ethers parses from a signed transaction.
|
|
||||||
test.each([
|
|
||||||
["0x1", "ETH"],
|
|
||||||
[1n, "ETH"],
|
|
||||||
["0xaa36a7", "SepoliaETH"],
|
|
||||||
[11155111n, "SepoliaETH"],
|
|
||||||
])("the refusal on chain %p names %s", (chainId, nativeCurrency) => {
|
|
||||||
expect(() => assertWithinCeilings({ ...OVER, chainId })).toThrow(
|
|
||||||
"up to 3000.0 " +
|
|
||||||
nativeCurrency +
|
|
||||||
", which is more than the 1.0 " +
|
|
||||||
nativeCurrency +
|
|
||||||
" this wallet",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("every field mismatch is a refusal, not a warning", async () => {
|
test("every field mismatch is a refusal, not a warning", async () => {
|
||||||
const raw = await signedWith({ nonce: 8 });
|
const raw = await signedWith({ nonce: 8 });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -25,10 +25,6 @@ const { Network, Wallet } = require("ethers");
|
|||||||
// what the user is actually shown.
|
// what the user is actually shown.
|
||||||
const { describeSigningFailure } = require("../src/shared/approvalVerify");
|
const { describeSigningFailure } = require("../src/shared/approvalVerify");
|
||||||
const { makeStorageStub } = require("./support/storageStub");
|
const { makeStorageStub } = require("./support/storageStub");
|
||||||
const {
|
|
||||||
removeAddressFromState,
|
|
||||||
removeWalletFromState,
|
|
||||||
} = require("../src/shared/walletDelete");
|
|
||||||
|
|
||||||
const SIGNER_KEY =
|
const SIGNER_KEY =
|
||||||
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
||||||
@@ -39,6 +35,7 @@ const other = new Wallet(OTHER_KEY);
|
|||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const ORIGIN = "https://dapp.example";
|
const ORIGIN = "https://dapp.example";
|
||||||
|
const HOSTNAME = "dapp.example";
|
||||||
// A page the wallet has never been connected to, whose requests are refused.
|
// A page the wallet has never been connected to, whose requests are refused.
|
||||||
const UNCONNECTED_ORIGIN = "https://stranger.example";
|
const UNCONNECTED_ORIGIN = "https://stranger.example";
|
||||||
const EXT_URL = "chrome-extension://autistmask/";
|
const EXT_URL = "chrome-extension://autistmask/";
|
||||||
@@ -74,22 +71,6 @@ const NONCE = 7;
|
|||||||
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
|
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
|
||||||
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
|
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
|
||||||
|
|
||||||
// An EIP-712 document as a dApp passes it to eth_signTypedData_v4. The
|
|
||||||
// background only carries it to the approval screen, so a small one does.
|
|
||||||
const TYPED_DATA = JSON.stringify({
|
|
||||||
domain: { name: "AutistMask Test", version: "1", chainId: 1 },
|
|
||||||
primaryType: "Note",
|
|
||||||
types: {
|
|
||||||
EIP712Domain: [
|
|
||||||
{ name: "name", type: "string" },
|
|
||||||
{ name: "version", type: "string" },
|
|
||||||
{ name: "chainId", type: "uint256" },
|
|
||||||
],
|
|
||||||
Note: [{ name: "contents", type: "string" }],
|
|
||||||
},
|
|
||||||
message: { contents: "Hello AutistMask" },
|
|
||||||
});
|
|
||||||
|
|
||||||
// The transaction the background populates and the approval screen displays.
|
// The transaction the background populates and the approval screen displays.
|
||||||
// The nonce is a parameter because the duplicate case turns on two artifacts
|
// The nonce is a parameter because the duplicate case turns on two artifacts
|
||||||
// differing in a field the dApp fixed nothing for.
|
// differing in a field the dApp fixed nothing for.
|
||||||
@@ -214,7 +195,7 @@ function loadBackground(options) {
|
|||||||
networkId: "mainnet",
|
networkId: "mainnet",
|
||||||
rpcUrl: "https://rpc.invalid",
|
rpcUrl: "https://rpc.invalid",
|
||||||
activeAddress: signer.address,
|
activeAddress: signer.address,
|
||||||
allowedSites: { [signer.address]: [ORIGIN] },
|
allowedSites: { [signer.address]: [HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -245,7 +226,6 @@ function loadBackground(options) {
|
|||||||
// raised through action.openPopup() opens no window at all, so this is
|
// raised through action.openPopup() opens no window at all, so this is
|
||||||
// the only place its id appears.
|
// the only place its id appears.
|
||||||
const actionPopups = [];
|
const actionPopups = [];
|
||||||
const openPopup = jest.fn(() => Promise.resolve());
|
|
||||||
|
|
||||||
global.chrome = {
|
global.chrome = {
|
||||||
storage,
|
storage,
|
||||||
@@ -267,22 +247,9 @@ function loadBackground(options) {
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
windows: {
|
windows: {
|
||||||
getLastFocused: (cb) => cb(opts.lastFocused || null),
|
getLastFocused: (cb) => cb(null),
|
||||||
create: (options2, cb) => {
|
create: (options2, cb) => {
|
||||||
// A copy, as the browser takes it at the call: the background
|
created.push(options2);
|
||||||
// reuses the object when it asks a second time.
|
|
||||||
created.push({ ...options2 });
|
|
||||||
// A browser that refuses any position it is given, as Chrome
|
|
||||||
// does for one it judges too far off screen.
|
|
||||||
if (opts.refusePosition && options2.left !== undefined) {
|
|
||||||
global.chrome.runtime.lastError = {
|
|
||||||
message:
|
|
||||||
"Invalid value for bounds. Bounds must be at least 50% within visible screen space.",
|
|
||||||
};
|
|
||||||
cb(undefined);
|
|
||||||
global.chrome.runtime.lastError = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// A browser that answers with no window at all. The approval
|
// A browser that answers with no window at all. The approval
|
||||||
// then has no window it can ever be answered in.
|
// then has no window it can ever be answered in.
|
||||||
cb(opts.noWindow ? undefined : { id: created.length });
|
cb(opts.noWindow ? undefined : { id: created.length });
|
||||||
@@ -313,7 +280,7 @@ function loadBackground(options) {
|
|||||||
// popup: no window is created, so windows.onRemoved can never
|
// popup: no window is created, so windows.onRemoved can never
|
||||||
// fire for it and the port disconnect is the only close signal
|
// fire for it and the port disconnect is the only close signal
|
||||||
// that exists.
|
// that exists.
|
||||||
...(opts.actionPopup ? { openPopup } : {}),
|
...(opts.actionPopup ? { openPopup: () => Promise.resolve() } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -360,7 +327,7 @@ function loadBackground(options) {
|
|||||||
|
|
||||||
// The same for a message-signing approval, which pins the signing address
|
// The same for a message-signing approval, which pins the signing address
|
||||||
// at approval time in exactly the same way.
|
// at approval time in exactly the same way.
|
||||||
function requestSign(from, origin) {
|
function requestSign(from) {
|
||||||
let rpcResult = null;
|
let rpcResult = null;
|
||||||
messageListener(
|
messageListener(
|
||||||
{
|
{
|
||||||
@@ -368,7 +335,7 @@ function loadBackground(options) {
|
|||||||
method: "personal_sign",
|
method: "personal_sign",
|
||||||
params: [MESSAGE, from || signer.address],
|
params: [MESSAGE, from || signer.address],
|
||||||
},
|
},
|
||||||
{ origin: origin || ORIGIN },
|
{ origin: ORIGIN },
|
||||||
(r) => {
|
(r) => {
|
||||||
rpcResult = r;
|
rpcResult = r;
|
||||||
},
|
},
|
||||||
@@ -382,24 +349,6 @@ function loadBackground(options) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The same through eth_signTypedData_v4, whose params name the address
|
|
||||||
// first and the typed data second.
|
|
||||||
function requestTypedData() {
|
|
||||||
let rpcResult = null;
|
|
||||||
messageListener(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_RPC",
|
|
||||||
method: "eth_signTypedData_v4",
|
|
||||||
params: [signer.address, TYPED_DATA],
|
|
||||||
},
|
|
||||||
{ origin: ORIGIN },
|
|
||||||
(r) => {
|
|
||||||
rpcResult = r;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return { result: () => rpcResult };
|
|
||||||
}
|
|
||||||
|
|
||||||
// A dApp asking to connect. The origin defaults to one the persisted
|
// A dApp asking to connect. The origin defaults to one the persisted
|
||||||
// state has never allowed, so the request really does raise a prompt
|
// state has never allowed, so the request really does raise a prompt
|
||||||
// instead of being answered from allowedSites.
|
// instead of being answered from allowedSites.
|
||||||
@@ -474,15 +423,12 @@ function loadBackground(options) {
|
|||||||
send,
|
send,
|
||||||
requestTx,
|
requestTx,
|
||||||
requestSign,
|
requestSign,
|
||||||
requestTypedData,
|
|
||||||
requestSite,
|
requestSite,
|
||||||
connectApproval,
|
connectApproval,
|
||||||
closeWindow,
|
closeWindow,
|
||||||
broadcastTransaction,
|
broadcastTransaction,
|
||||||
created,
|
created,
|
||||||
removed,
|
removed,
|
||||||
actionPopups,
|
|
||||||
openPopup,
|
|
||||||
storage,
|
storage,
|
||||||
// The user switching account in the toolbar popup, as the background
|
// The user switching account in the toolbar popup, as the background
|
||||||
// sees it: the persisted active address changes underneath a pending
|
// sees it: the persisted active address changes underneath a pending
|
||||||
@@ -872,238 +818,6 @@ describe("one transaction approval at a time", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// A page that asks again before the user has answered its last connection or
|
|
||||||
// signature request is refused, instead of opening one more window per call.
|
|
||||||
describe("one connection and one signature approval per site at a time", () => {
|
|
||||||
const PENDING_REFUSAL = {
|
|
||||||
error: {
|
|
||||||
code: -32002,
|
|
||||||
message: expect.stringMatching(/already waiting for your answer/),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
test("a loop of eth_requestAccounts opens one approval and refuses the rest", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const requests = [];
|
|
||||||
for (let i = 0; i < 5; i++) requests.push(bg.requestSite());
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(bg.created).toHaveLength(1);
|
|
||||||
expect(requests[0].result()).toBeNull();
|
|
||||||
for (const extra of requests.slice(1)) {
|
|
||||||
expect(extra.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the user has answered, the site may ask again.
|
|
||||||
bg.closeWindow(1);
|
|
||||||
await settle();
|
|
||||||
expect(requests[0].result()).toEqual({
|
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
|
||||||
});
|
|
||||||
const again = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
expect(again.result()).toBeNull();
|
|
||||||
expect(bg.created).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a loop of personal_sign opens one approval and refuses the rest", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const requests = [];
|
|
||||||
for (let i = 0; i < 5; i++) requests.push(bg.requestSign());
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(bg.created).toHaveLength(1);
|
|
||||||
expect(requests[0].result()).toBeNull();
|
|
||||||
for (const extra of requests.slice(1)) {
|
|
||||||
expect(extra.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a loop of eth_signTypedData_v4 opens one approval and refuses the rest", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const requests = [];
|
|
||||||
for (let i = 0; i < 5; i++) requests.push(bg.requestTypedData());
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(bg.created).toHaveLength(1);
|
|
||||||
expect(requests[0].result()).toBeNull();
|
|
||||||
for (const extra of requests.slice(1)) {
|
|
||||||
expect(extra.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a pending personal_sign also refuses eth_signTypedData_v4", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
bg.requestSign();
|
|
||||||
const typed = bg.requestTypedData();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(typed.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
expect(bg.created).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("in the toolbar popup, a loop of eth_requestAccounts opens it once", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const requests = [];
|
|
||||||
for (let i = 0; i < 5; i++) requests.push(bg.requestSite());
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(bg.openPopup).toHaveBeenCalledTimes(1);
|
|
||||||
for (const extra of requests.slice(1)) {
|
|
||||||
expect(extra.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// A toolbar popup that closes before it connects tells the background
|
|
||||||
// nothing, so its prompt stays pending. Once the toolbar popup has been set
|
|
||||||
// to open something else, nothing shows that prompt, and the site asking
|
|
||||||
// again must show it again rather than be refused for good.
|
|
||||||
test("a toolbar prompt nothing shows any more is shown again when the site asks again", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const first = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
const id = first.id();
|
|
||||||
|
|
||||||
// Its popup closed without connecting. Another site's prompt takes the
|
|
||||||
// toolbar popup and is answered, which sets it back to the wallet.
|
|
||||||
const other = bg.requestSite(UNCONNECTED_ORIGIN);
|
|
||||||
await settle();
|
|
||||||
const otherPort = bg.connectApproval(other.id());
|
|
||||||
otherPort.decide(false, false);
|
|
||||||
otherPort.disconnect();
|
|
||||||
await settle();
|
|
||||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
|
||||||
"src/popup/index.html",
|
|
||||||
);
|
|
||||||
|
|
||||||
const repeat = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
expect(bg.openPopup).toHaveBeenCalledTimes(3);
|
|
||||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
|
||||||
"src/popup/index.html?approval=" + id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The user answers it, and the first request gets that answer.
|
|
||||||
bg.connectApproval(id).decide(true, false);
|
|
||||||
await settle();
|
|
||||||
expect(first.result()).toEqual({ result: [signer.address] });
|
|
||||||
});
|
|
||||||
|
|
||||||
// The user rejects a signature request from another site, which is
|
|
||||||
// connected already. Answering any approval sets the toolbar popup back to
|
|
||||||
// the wallet.
|
|
||||||
async function rejectSignatureFromAnotherSite(bg) {
|
|
||||||
const sign = bg.requestSign(undefined, ORIGIN);
|
|
||||||
await settle();
|
|
||||||
bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
|
||||||
id: sign.id(),
|
|
||||||
approved: false,
|
|
||||||
},
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
expect(sign.result()).toEqual({
|
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
|
||||||
});
|
|
||||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
|
||||||
"src/popup/index.html",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a toolbar prompt is shown again after another site's signature request is answered", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const first = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
const id = first.id();
|
|
||||||
|
|
||||||
// Its popup closed without connecting.
|
|
||||||
await rejectSignatureFromAnotherSite(bg);
|
|
||||||
|
|
||||||
const repeat = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
expect(bg.openPopup).toHaveBeenCalledTimes(2);
|
|
||||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
|
||||||
"src/popup/index.html?approval=" + id,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a prompt in a window is not opened again when the site asks again", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
// The browser will not open the toolbar popup, so the prompt goes to a
|
|
||||||
// window of its own.
|
|
||||||
bg.openPopup.mockImplementation(() =>
|
|
||||||
Promise.reject(new Error("no toolbar popup")),
|
|
||||||
);
|
|
||||||
bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
expect(bg.created).toHaveLength(1);
|
|
||||||
|
|
||||||
await rejectSignatureFromAnotherSite(bg);
|
|
||||||
expect(bg.created).toHaveLength(2);
|
|
||||||
|
|
||||||
const repeat = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
expect(bg.openPopup).toHaveBeenCalledTimes(1);
|
|
||||||
expect(bg.created).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a prompt in a connected toolbar popup is not opened again when the site asks again", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const first = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
// The popup is open and showing the prompt.
|
|
||||||
bg.connectApproval(first.id());
|
|
||||||
|
|
||||||
await rejectSignatureFromAnotherSite(bg);
|
|
||||||
|
|
||||||
const repeat = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
expect(bg.openPopup).toHaveBeenCalledTimes(1);
|
|
||||||
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
|
|
||||||
"src/popup/index.html",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("another site's connection request is not held up", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
bg.requestSite();
|
|
||||||
const repeat = bg.requestSite();
|
|
||||||
const other = bg.requestSite(UNCONNECTED_ORIGIN);
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
expect(other.result()).toBeNull();
|
|
||||||
expect(bg.created).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("another site's signature request is not held up", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
// Connect a second site, so that it may ask for a signature at all.
|
|
||||||
const connecting = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
const port = bg.connectApproval(connecting.id());
|
|
||||||
port.decide(true, false);
|
|
||||||
port.disconnect();
|
|
||||||
await settle();
|
|
||||||
expect(connecting.result()).toEqual({ result: [signer.address] });
|
|
||||||
|
|
||||||
bg.requestSign();
|
|
||||||
const repeat = bg.requestSign();
|
|
||||||
const other = bg.requestSign(signer.address, FRESH_ORIGIN);
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(repeat.result()).toEqual(PENDING_REFUSAL);
|
|
||||||
expect(other.result()).toBeNull();
|
|
||||||
expect(bg.created).toHaveLength(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A nonce collision found before the transaction reaches the network is the
|
// A nonce collision found before the transaction reaches the network is the
|
||||||
// one send failure the wallet can speak about with certainty. The user is told
|
// one send failure the wallet can speak about with certainty. The user is told
|
||||||
// it did not go out and to send it again, rather than being warned it might
|
// it did not go out and to send it again, rather than being warned it might
|
||||||
@@ -1827,149 +1541,6 @@ describe("a claimed approval outlives every other retirement path", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The approval popup connects a port named for its approval whatever the
|
|
||||||
// approval's kind, so a decision or a disconnect on that port can reach a
|
|
||||||
// transaction approval. Both must be declined: the port decides only
|
|
||||||
// site-connection approvals, and settling a transaction approval it does not
|
|
||||||
// own — while an attempt is broadcasting behind it — is the round-3 fund-loss
|
|
||||||
// bug, where the page is told the request was rejected as the transaction goes
|
|
||||||
// out. These three paths route through settleApproval() and, before this
|
|
||||||
// suite, were exercised only against site approvals.
|
|
||||||
describe("the site-connection port never retires a transaction approval", () => {
|
|
||||||
async function txMidBroadcast() {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const pending = bg.requestTx();
|
|
||||||
await settle();
|
|
||||||
const id = pending.id();
|
|
||||||
|
|
||||||
const inFlight = deferred();
|
|
||||||
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
|
|
||||||
const first = bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_TX_RESPONSE",
|
|
||||||
id,
|
|
||||||
approved: true,
|
|
||||||
rawSignedTx: await signedAtNonce(7),
|
|
||||||
},
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
|
||||||
return { bg, pending, id, inFlight, first };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("an approve on the port does not settle it", async () => {
|
|
||||||
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
|
|
||||||
|
|
||||||
bg.connectApproval(id).decide(true, false);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toBeNull();
|
|
||||||
|
|
||||||
inFlight.resolve({ hash: "0xfeed" });
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
||||||
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a reject on the port does not settle it", async () => {
|
|
||||||
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
|
|
||||||
|
|
||||||
bg.connectApproval(id).decide(false, false);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toBeNull();
|
|
||||||
|
|
||||||
inFlight.resolve({ hash: "0xfeed" });
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
||||||
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a port disconnect does not settle it", async () => {
|
|
||||||
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
|
|
||||||
|
|
||||||
bg.connectApproval(id).disconnect();
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toBeNull();
|
|
||||||
|
|
||||||
inFlight.resolve({ hash: "0xfeed" });
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({ result: "0xfeed" });
|
|
||||||
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// AUTISTMASK_TX_RESPONSE signs and broadcasts a transaction, so it is honoured
|
|
||||||
// only for a transaction approval. A reject shaped as this message used to
|
|
||||||
// retire a sign or connection approval outright, and an approve carrying a
|
|
||||||
// signed artifact used to run the broadcast path against an approval that names
|
|
||||||
// no transaction, failing closed only by throwing deeper in.
|
|
||||||
describe("a transaction response is honoured only for a transaction approval", () => {
|
|
||||||
test("a reject does not retire a sign approval", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const pending = bg.requestSign();
|
|
||||||
await settle();
|
|
||||||
const id = pending.id();
|
|
||||||
|
|
||||||
bg.send(
|
|
||||||
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toBeNull();
|
|
||||||
|
|
||||||
// Still live: its own reject settles it.
|
|
||||||
bg.send(
|
|
||||||
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: false },
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({
|
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a reject does not retire a connection approval", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const pending = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
const id = pending.id();
|
|
||||||
|
|
||||||
bg.send(
|
|
||||||
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toBeNull();
|
|
||||||
|
|
||||||
// Still live: the port that owns it connects the site.
|
|
||||||
const port = bg.connectApproval(id);
|
|
||||||
port.decide(true, false);
|
|
||||||
port.disconnect();
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({ result: [signer.address] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an approve carrying a signed transaction never broadcasts against a sign approval", async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
const pending = bg.requestSign();
|
|
||||||
await settle();
|
|
||||||
const id = pending.id();
|
|
||||||
|
|
||||||
bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_TX_RESPONSE",
|
|
||||||
id,
|
|
||||||
approved: true,
|
|
||||||
rawSignedTx: await signedAtNonce(7),
|
|
||||||
},
|
|
||||||
{ url: bg.fromPopup.url },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
|
|
||||||
expect(pending.result()).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A handler that throws must still answer. `sendResponse` is the only thing
|
// A handler that throws must still answer. `sendResponse` is the only thing
|
||||||
// that settles the page's window.ethereum.request() promise, so a throw that
|
// that settles the page's window.ethereum.request() promise, so a throw that
|
||||||
// escapes a handler leaves that promise pending forever — no error, no
|
// escapes a handler leaves that promise pending forever — no error, no
|
||||||
@@ -2248,27 +1819,6 @@ describe("a site connection decided as the popup closes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The prompt is decided before the toolbar popup raised for it has
|
|
||||||
// loaded; that popup is torn down and openPopup() rejects only after.
|
|
||||||
test("a toolbar prompt already decided opens no window when openPopup() rejects", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const opening = deferred();
|
|
||||||
bg.openPopup.mockImplementation(() => opening.promise);
|
|
||||||
const pending = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
const port = bg.connectApproval(pending.id());
|
|
||||||
port.decide(true, false);
|
|
||||||
port.disconnect();
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({ result: [signer.address] });
|
|
||||||
|
|
||||||
opening.reject(new Error("the toolbar popup closed before it loaded"));
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(bg.created).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The port carries a decision now, so it carries the sender check the
|
// The port carries a decision now, so it carries the sender check the
|
||||||
// one-off message used to carry. A content script that guessed an
|
// one-off message used to carry. A content script that guessed an
|
||||||
// approval id must not be able to connect the site it is running on.
|
// approval id must not be able to connect the site it is running on.
|
||||||
@@ -2403,403 +1953,3 @@ describe("a site connection decided as the popup closes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// What a site is told when it asks which account it may use.
|
|
||||||
async function siteAccounts(bg, origin) {
|
|
||||||
const { sendResponse } = bg.send(
|
|
||||||
{ type: "AUTISTMASK_RPC", method: "eth_accounts", params: [] },
|
|
||||||
{ origin: origin || FRESH_ORIGIN },
|
|
||||||
);
|
|
||||||
await settle();
|
|
||||||
return sendResponse.mock.calls[0][0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// A site connected without "Remember" is held only in the background's memory,
|
|
||||||
// keyed to the address it was connected to. Removing that address, or the
|
|
||||||
// wallet holding it, must end the connection as part of the removal itself.
|
|
||||||
// The accountsChanged broadcast the views send afterwards also clears it, but
|
|
||||||
// only when the active address moved, and nothing waits for it to arrive.
|
|
||||||
//
|
|
||||||
// Each test asks the background while storage still names the removed address
|
|
||||||
// as active, because the popup has not saved yet, so the answer turns on the
|
|
||||||
// connection alone.
|
|
||||||
describe("removing an address ends a site's connection to it", () => {
|
|
||||||
// FRESH_ORIGIN connected to the active address without "Remember", in a
|
|
||||||
// wallet holding a second address so that one can be removed at all. The
|
|
||||||
// popup's messages reach the background as they would from the popup.
|
|
||||||
async function connectedBackground() {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const stored = bg.storage.read("autistmask");
|
|
||||||
stored.wallets[0].addresses.push({
|
|
||||||
address: other.address,
|
|
||||||
balance: "0",
|
|
||||||
tokenBalances: [],
|
|
||||||
});
|
|
||||||
bg.storage.write("autistmask", stored);
|
|
||||||
|
|
||||||
const pending = bg.requestSite();
|
|
||||||
await settle();
|
|
||||||
bg.connectApproval(pending.id()).decide(true, false);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({ result: [signer.address] });
|
|
||||||
|
|
||||||
global.chrome.runtime.sendMessage = (msg) => {
|
|
||||||
bg.send(msg, bg.fromPopup);
|
|
||||||
};
|
|
||||||
return bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("removing the connected address ends the connection", async () => {
|
|
||||||
const bg = await connectedBackground();
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
|
|
||||||
|
|
||||||
const popupState = bg.storage.read("autistmask");
|
|
||||||
expect(removeAddressFromState(popupState, 0, 0).removed).toBe(true);
|
|
||||||
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("deleting the wallet holding the connected address ends the connection", async () => {
|
|
||||||
const bg = await connectedBackground();
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
|
|
||||||
|
|
||||||
const popupState = bg.storage.read("autistmask");
|
|
||||||
removeWalletFromState(popupState, 0);
|
|
||||||
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removing a different address leaves the connection alone", async () => {
|
|
||||||
const bg = await connectedBackground();
|
|
||||||
|
|
||||||
const popupState = bg.storage.read("autistmask");
|
|
||||||
expect(removeAddressFromState(popupState, 0, 1).removed).toBe(true);
|
|
||||||
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a page cannot end the connection", async () => {
|
|
||||||
const bg = await connectedBackground();
|
|
||||||
|
|
||||||
const spoof = bg.send(
|
|
||||||
{
|
|
||||||
type: "AUTISTMASK_ADDRESSES_REMOVED",
|
|
||||||
addresses: [signer.address],
|
|
||||||
},
|
|
||||||
{ url: FRESH_ORIGIN + "/index.html" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(spoof.sendResponse).toHaveBeenCalledWith({
|
|
||||||
error: "Unauthorized sender",
|
|
||||||
});
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A remembered permission belongs to the origin it was granted to, scheme and
|
|
||||||
// port included (https://git.eeqj.de/sneak/AutistMask/issues/402). The stored
|
|
||||||
// state allows ORIGIN, https://dapp.example. A cleartext page on the same host,
|
|
||||||
// which a network attacker can serve, and another port on it are other sites.
|
|
||||||
describe("a remembered permission is held by the full origin", () => {
|
|
||||||
for (const origin of ["http://dapp.example", "https://dapp.example:8443"]) {
|
|
||||||
test(`an https grant does not authorise ${origin}`, async () => {
|
|
||||||
const bg = loadBackground();
|
|
||||||
expect(await siteAccounts(bg, ORIGIN)).toEqual({
|
|
||||||
result: [signer.address],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await siteAccounts(bg, origin)).toEqual({ result: [] });
|
|
||||||
const send = bg.requestTx(TX_PARAMS, origin);
|
|
||||||
await settle();
|
|
||||||
expect(send.result()).toEqual({
|
|
||||||
error: { code: 4100, message: "Unauthorized" },
|
|
||||||
});
|
|
||||||
expect(send.id()).toBeNull();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Remember stores the origin, so the cleartext page on that host is asked again", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const granted = bg.requestSite(FRESH_ORIGIN);
|
|
||||||
await settle();
|
|
||||||
const grantedId = granted.id();
|
|
||||||
bg.connectApproval(grantedId).decide(true, true);
|
|
||||||
await settle();
|
|
||||||
expect(granted.result()).toEqual({ result: [signer.address] });
|
|
||||||
expect(
|
|
||||||
bg.storage.read("autistmask").allowedSites[signer.address],
|
|
||||||
).toEqual([ORIGIN, FRESH_ORIGIN]);
|
|
||||||
|
|
||||||
const cleartext = bg.requestSite("http://fresh.example");
|
|
||||||
await settle();
|
|
||||||
// Unanswered: it is waiting on a prompt of its own.
|
|
||||||
expect(cleartext.result()).toBeNull();
|
|
||||||
expect(cleartext.id()).not.toBe(grantedId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Settings lists the sites allowed without "Remember", which only the
|
|
||||||
// background holds, and removing a site there, from either list, disconnects
|
|
||||||
// it. These drive the real Settings view against the real background and
|
|
||||||
// click the [x] the user clicks.
|
|
||||||
describe("removing a site in Settings disconnects it", () => {
|
|
||||||
// The host of FRESH_ORIGIN on another port, which makes it another site.
|
|
||||||
const FRESH_OTHER_PORT = "https://fresh.example:8443";
|
|
||||||
|
|
||||||
// A site list's container. Its [x] buttons, data attributes and all, are
|
|
||||||
// read back out of the rows the view wrote into it, so clicking one runs
|
|
||||||
// the handler the view attached to it.
|
|
||||||
function fakeSiteList() {
|
|
||||||
const list = {
|
|
||||||
innerHTML: "",
|
|
||||||
buttons: [],
|
|
||||||
querySelectorAll() {
|
|
||||||
const tags = list.innerHTML.match(/<button[^>]*>/g) || [];
|
|
||||||
list.buttons = tags.map((tag) => ({
|
|
||||||
dataset: Object.fromEntries(
|
|
||||||
[...tag.matchAll(/data-(\w+)="([^"]*)"/g)].map(
|
|
||||||
(match) => [match[1], match[2]],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
addEventListener(event, handler) {
|
|
||||||
this[event] = handler;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
return list.buttons;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The origins a site list shows.
|
|
||||||
function listed(list) {
|
|
||||||
return [...list.innerHTML.matchAll(/data-origin="([^"]*)"/g)].map(
|
|
||||||
(match) => match[1],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A site connected the way the user does it, in the approval popup.
|
|
||||||
async function connect(bg, origin, remember) {
|
|
||||||
const pending = bg.requestSite(origin);
|
|
||||||
await settle();
|
|
||||||
bg.connectApproval(pending.id()).decide(true, remember);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({ result: [signer.address] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings, opened over the background's storage and wired to it the way
|
|
||||||
// the popup is: what Settings sends reaches the background from the
|
|
||||||
// extension's own page, and the answer comes back.
|
|
||||||
async function openSettings(bg) {
|
|
||||||
const lists = {};
|
|
||||||
const element = (id) => (lists[id] ||= fakeSiteList());
|
|
||||||
global.document = { getElementById: element };
|
|
||||||
global.chrome.runtime.sendMessage = (msg, callback) => {
|
|
||||||
const { sendResponse } = bg.send(msg, bg.fromPopup);
|
|
||||||
if (callback) callback(sendResponse.mock.calls[0]?.[0]);
|
|
||||||
};
|
|
||||||
await require("../src/shared/state").loadState();
|
|
||||||
await require("../src/popup/views/settings").renderSiteLists();
|
|
||||||
return {
|
|
||||||
allowed: element("settings-allowed-sites"),
|
|
||||||
connected: element("settings-connected-sites"),
|
|
||||||
// Click the [x] beside a site, and let what it sends run.
|
|
||||||
remove: async (list, origin) => {
|
|
||||||
expect(listed(list)).toContain(origin);
|
|
||||||
const button = list.buttons.find(
|
|
||||||
(b) => b.dataset.origin === origin,
|
|
||||||
);
|
|
||||||
await button.click();
|
|
||||||
await settle();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
delete global.document;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Settings lists each site by its origin", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
await connect(bg, FRESH_ORIGIN, false);
|
|
||||||
await connect(bg, FRESH_OTHER_PORT, true);
|
|
||||||
|
|
||||||
const settings = await openSettings(bg);
|
|
||||||
|
|
||||||
expect(listed(settings.connected)).toEqual([FRESH_ORIGIN]);
|
|
||||||
expect(listed(settings.allowed)).toEqual([ORIGIN, FRESH_OTHER_PORT]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removing a site connected without Remember disconnects it and tells its tabs", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
await connect(bg, FRESH_ORIGIN, false);
|
|
||||||
const sentToTabs = [];
|
|
||||||
global.chrome.tabs = {
|
|
||||||
query: (q, cb) =>
|
|
||||||
cb([
|
|
||||||
{ id: 1, url: FRESH_ORIGIN + "/app" },
|
|
||||||
{ id: 2, url: ORIGIN + "/app" },
|
|
||||||
{ id: 3, url: FRESH_OTHER_PORT + "/app" },
|
|
||||||
]),
|
|
||||||
sendMessage: (tabId, msg, cb) => {
|
|
||||||
sentToTabs.push({ tabId, msg });
|
|
||||||
cb();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const settings = await openSettings(bg);
|
|
||||||
|
|
||||||
await settings.remove(settings.connected, FRESH_ORIGIN);
|
|
||||||
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [] });
|
|
||||||
expect(sentToTabs).toEqual([
|
|
||||||
{
|
|
||||||
tabId: 1,
|
|
||||||
msg: {
|
|
||||||
type: "AUTISTMASK_EVENT",
|
|
||||||
eventName: "accountsChanged",
|
|
||||||
data: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(listed(settings.connected)).toEqual([]);
|
|
||||||
// The other site is untouched.
|
|
||||||
expect(await siteAccounts(bg, ORIGIN)).toEqual({
|
|
||||||
result: [signer.address],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// One origin can hold both kinds of connection under two addresses:
|
|
||||||
// remembered for one, allowed without Remember for the other.
|
|
||||||
test("removing a remembered site also ends its connection made without Remember", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
const stored = bg.storage.read("autistmask");
|
|
||||||
stored.wallets[0].addresses.push({
|
|
||||||
address: other.address,
|
|
||||||
balance: "0",
|
|
||||||
tokenBalances: [],
|
|
||||||
});
|
|
||||||
bg.storage.write("autistmask", stored);
|
|
||||||
await connect(bg, FRESH_ORIGIN, true);
|
|
||||||
bg.setActiveAddress(other.address);
|
|
||||||
const pending = bg.requestSite(FRESH_ORIGIN);
|
|
||||||
await settle();
|
|
||||||
bg.connectApproval(pending.id()).decide(true, false);
|
|
||||||
await settle();
|
|
||||||
expect(pending.result()).toEqual({ result: [other.address] });
|
|
||||||
const settings = await openSettings(bg);
|
|
||||||
|
|
||||||
await settings.remove(settings.allowed, FRESH_ORIGIN);
|
|
||||||
|
|
||||||
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({ result: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removing a remembered site leaves the same host on another port connected", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
await connect(bg, FRESH_ORIGIN, false);
|
|
||||||
await connect(bg, FRESH_OTHER_PORT, true);
|
|
||||||
const settings = await openSettings(bg);
|
|
||||||
|
|
||||||
await settings.remove(settings.allowed, FRESH_OTHER_PORT);
|
|
||||||
|
|
||||||
expect(await siteAccounts(bg, FRESH_OTHER_PORT)).toEqual({
|
|
||||||
result: [],
|
|
||||||
});
|
|
||||||
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({
|
|
||||||
result: [signer.address],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a page can neither remove a site nor list the connected ones", async () => {
|
|
||||||
const bg = loadBackground({ actionPopup: true });
|
|
||||||
await connect(bg, FRESH_ORIGIN, false);
|
|
||||||
const page = { url: FRESH_ORIGIN + "/index.html" };
|
|
||||||
|
|
||||||
const remove = bg.send(
|
|
||||||
{ type: "AUTISTMASK_REMOVE_SITE", origin: FRESH_ORIGIN },
|
|
||||||
page,
|
|
||||||
);
|
|
||||||
const list = bg.send({ type: "AUTISTMASK_GET_CONNECTED_SITES" }, page);
|
|
||||||
|
|
||||||
expect(remove.sendResponse).toHaveBeenCalledWith({
|
|
||||||
error: "Unauthorized sender",
|
|
||||||
});
|
|
||||||
expect(list.sendResponse).toHaveBeenCalledWith({
|
|
||||||
error: "Unauthorized sender",
|
|
||||||
});
|
|
||||||
expect(await siteAccounts(bg)).toEqual({ result: [signer.address] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// An approval window still open is often the last focused window, and headless
|
|
||||||
// Chrome reports one as 1280x720. Centred on that, the next approval window
|
|
||||||
// lands where the browser refuses to create it, and its request failed with no
|
|
||||||
// window at all (https://git.eeqj.de/sneak/AutistMask/issues/290).
|
|
||||||
describe("where an approval window opens", () => {
|
|
||||||
test("centred on the browser window the user was last in", async () => {
|
|
||||||
const bg = loadBackground({
|
|
||||||
lastFocused: {
|
|
||||||
type: "normal",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
width: 1280,
|
|
||||||
height: 720,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
bg.requestSign();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(bg.created).toHaveLength(1);
|
|
||||||
expect(bg.created[0]).toMatchObject({ left: 460, top: 60 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("not centred on an approval window the user was last in", async () => {
|
|
||||||
const bg = loadBackground({
|
|
||||||
lastFocused: {
|
|
||||||
type: "popup",
|
|
||||||
left: 440,
|
|
||||||
top: 0,
|
|
||||||
width: 1280,
|
|
||||||
height: 720,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
bg.requestSign();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
// Centred, it would be at left 900, the position the browser refused.
|
|
||||||
expect(bg.created).toHaveLength(1);
|
|
||||||
expect(bg.created[0].left).toBeUndefined();
|
|
||||||
expect(bg.created[0].top).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("placed by the browser when it refuses the centred position", async () => {
|
|
||||||
const bg = loadBackground({
|
|
||||||
refusePosition: true,
|
|
||||||
lastFocused: {
|
|
||||||
type: "normal",
|
|
||||||
left: 1500,
|
|
||||||
top: 900,
|
|
||||||
width: 400,
|
|
||||||
height: 300,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const sign = bg.requestSign();
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
expect(bg.created).toHaveLength(2);
|
|
||||||
expect(bg.created[0]).toMatchObject({ left: 1520, top: 750 });
|
|
||||||
expect(bg.created[1].left).toBeUndefined();
|
|
||||||
expect(bg.created[1].top).toBeUndefined();
|
|
||||||
|
|
||||||
// The request waits on the second window rather than failing:
|
|
||||||
// closing that window is refusing the prompt.
|
|
||||||
expect(sign.result()).toBeNull();
|
|
||||||
bg.closeWindow(2);
|
|
||||||
await settle();
|
|
||||||
expect(sign.result()).toEqual({
|
|
||||||
error: { code: 4001, message: "User rejected the request." },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const signer = new Wallet(SIGNER_KEY);
|
|||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
const EXT_URL = "chrome-extension://autistmask/";
|
const EXT_URL = "chrome-extension://autistmask/";
|
||||||
|
|
||||||
const MAINNET = networkById("mainnet");
|
const MAINNET = networkById("mainnet");
|
||||||
@@ -80,7 +81,7 @@ function storedProfile(networkId) {
|
|||||||
networkId,
|
networkId,
|
||||||
rpcUrl: net.defaultRpcUrl,
|
rpcUrl: net.defaultRpcUrl,
|
||||||
blockscoutUrl: net.defaultBlockscoutUrl,
|
blockscoutUrl: net.defaultBlockscoutUrl,
|
||||||
allowedSites: { [signer.address]: [CONNECTED_ORIGIN] },
|
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
trackedTokens: [],
|
trackedTokens: [],
|
||||||
lastBalanceRefresh: 0,
|
lastBalanceRefresh: 0,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//
|
//
|
||||||
// The browser half of the same claim — that a real Chrome renders that
|
// The browser half of the same claim — that a real Chrome renders that
|
||||||
// string as text and puts no iframe in the popup DOM — is in
|
// string as text and puts no iframe in the popup DOM — is in
|
||||||
// tests/e2e/run.js. This half runs inside the 60-second make test cap.
|
// tests/e2e/run.js. This half runs inside the 20-second make test cap.
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
@@ -66,11 +66,4 @@ describe("balanceLine", () => {
|
|||||||
expect(html).toContain("<span>1.5000</span>");
|
expect(html).toContain("<span>1.5000</span>");
|
||||||
expect(html).toContain('data-token="0xabc"');
|
expect(html).toContain('data-token="0xabc"');
|
||||||
});
|
});
|
||||||
|
|
||||||
// formatUsd() writes a value under a cent as "< $0.01".
|
|
||||||
test("escapes the USD value along with the symbol", () => {
|
|
||||||
const html = balanceLine("USDC", 0.001, 1, null);
|
|
||||||
expect(html).toContain("< $0.01");
|
|
||||||
expect(html).not.toContain("< $0.01");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
// The balance refresh, and the address scan after a wallet is created, do not
|
|
||||||
// report a request the popup's own closing cancelled, and still report one
|
|
||||||
// that failed while the popup was open
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/218,
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/475).
|
|
||||||
//
|
|
||||||
// In the popup a cancelled fetch() fails with the same "Failed to fetch" as a
|
|
||||||
// server that cannot be reached, so every request here fails that way, and
|
|
||||||
// only the signal the popup aborts on pagehide tells the two cases apart.
|
|
||||||
|
|
||||||
const { FetchRequest } = require("ethers");
|
|
||||||
const { refreshBalances, scanForAddresses } = require("../src/shared/balances");
|
|
||||||
const {
|
|
||||||
generateMnemonic,
|
|
||||||
hdWalletFromMnemonic,
|
|
||||||
} = require("../src/shared/wallet");
|
|
||||||
|
|
||||||
const RPC_URL = "https://rpc.example.invalid";
|
|
||||||
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
|
|
||||||
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
|
||||||
|
|
||||||
const realFetch = globalThis.fetch;
|
|
||||||
let logged;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
logged = [];
|
|
||||||
jest.spyOn(console, "error").mockImplementation((...args) => {
|
|
||||||
logged.push(args.map(String).join(" "));
|
|
||||||
});
|
|
||||||
const failedToFetch = async () => {
|
|
||||||
throw new TypeError("Failed to fetch");
|
|
||||||
};
|
|
||||||
// The RPC calls (ETH balance, ENS name, and the scan's balance and
|
|
||||||
// transaction count) and the explorer request (token balances) all fail
|
|
||||||
// the same way.
|
|
||||||
FetchRequest.registerGetUrl(failedToFetch);
|
|
||||||
globalThis.fetch = jest.fn(failedToFetch);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
|
|
||||||
globalThis.fetch = realFetch;
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
function refresh(signal) {
|
|
||||||
const wallets = [{ addresses: [{ address: ADDRESS }] }];
|
|
||||||
return refreshBalances(
|
|
||||||
wallets,
|
|
||||||
RPC_URL,
|
|
||||||
EXPLORER_URL,
|
|
||||||
[],
|
|
||||||
"mainnet",
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a failure while the popup is open is reported", async () => {
|
|
||||||
await refresh(new AbortController().signal);
|
|
||||||
for (const label of [
|
|
||||||
"ETH balance failed",
|
|
||||||
"ENS reverse failed",
|
|
||||||
"fetchTokenBalances failed: Failed to fetch",
|
|
||||||
]) {
|
|
||||||
expect(logged.some((line) => line.includes(label))).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a failure once the popup has closed is not", async () => {
|
|
||||||
const pageClosed = new AbortController();
|
|
||||||
pageClosed.abort();
|
|
||||||
await refresh(pageClosed.signal);
|
|
||||||
expect(logged).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
function scan(signal) {
|
|
||||||
// What the scan found is logged at info level, which is not under test.
|
|
||||||
jest.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
const { xpub } = hdWalletFromMnemonic(generateMnemonic());
|
|
||||||
return scanForAddresses(xpub, RPC_URL, "mainnet", signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a scan failure while the popup is open is reported", async () => {
|
|
||||||
await scan(new AbortController().signal);
|
|
||||||
expect(
|
|
||||||
logged.some((line) => line.includes("scanForAddresses check failed")),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a scan failure once the popup has closed is not", async () => {
|
|
||||||
const pageClosed = new AbortController();
|
|
||||||
pageClosed.abort();
|
|
||||||
await scan(pageClosed.signal);
|
|
||||||
expect(logged).toEqual([]);
|
|
||||||
});
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
// `make dev` runs `node build.js --watch`
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/332). These drive build.js's
|
|
||||||
// watch() over a temp directory with a stand-in for build(), so nothing here
|
|
||||||
// builds or writes dist/.
|
|
||||||
|
|
||||||
const fs = require("fs");
|
|
||||||
const os = require("os");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const { watch } = require("../build");
|
|
||||||
|
|
||||||
let dir;
|
|
||||||
let watchers = [];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-watch-"));
|
|
||||||
fs.mkdirSync(path.join(dir, "nested"));
|
|
||||||
jest.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const watcher of watchers) watcher.close();
|
|
||||||
watchers = [];
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
// Wait until `condition()` holds; fail the test if it has not within 3s.
|
|
||||||
async function until(condition) {
|
|
||||||
const deadline = Date.now() + 3000;
|
|
||||||
while (!condition()) {
|
|
||||||
if (Date.now() > deadline) throw new Error("timed out waiting");
|
|
||||||
await sleep(10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the way many editors do: write a new copy, then rename it over the
|
|
||||||
// original, so the file at that path is a different one afterwards.
|
|
||||||
function saveByRename(file, contents) {
|
|
||||||
fs.writeFileSync(`${file}.tmp`, contents);
|
|
||||||
fs.renameSync(`${file}.tmp`, file);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("builds at start, then once per change to a file in a subdirectory", async () => {
|
|
||||||
const file = path.join(dir, "nested", "a.js");
|
|
||||||
fs.writeFileSync(file, "1");
|
|
||||||
let builds = 0;
|
|
||||||
watchers = watch([dir], async () => {
|
|
||||||
builds++;
|
|
||||||
});
|
|
||||||
await until(() => builds === 1);
|
|
||||||
|
|
||||||
fs.writeFileSync(file, "2");
|
|
||||||
await until(() => builds === 2);
|
|
||||||
await sleep(300);
|
|
||||||
expect(builds).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps seeing a file that is saved by renaming a new copy over it", async () => {
|
|
||||||
const file = path.join(dir, "nested", "a.js");
|
|
||||||
fs.writeFileSync(file, "1");
|
|
||||||
let builds = 0;
|
|
||||||
watchers = watch([dir], async () => {
|
|
||||||
builds++;
|
|
||||||
});
|
|
||||||
await until(() => builds === 1);
|
|
||||||
|
|
||||||
saveByRename(file, "2");
|
|
||||||
await until(() => builds === 2);
|
|
||||||
saveByRename(file, "3");
|
|
||||||
await until(() => builds === 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a failed build is reported and watching carries on", async () => {
|
|
||||||
let builds = 0;
|
|
||||||
watchers = watch([dir], async () => {
|
|
||||||
builds++;
|
|
||||||
if (builds === 1) throw new Error("unexpected token");
|
|
||||||
});
|
|
||||||
await until(() => console.error.mock.calls.length === 1);
|
|
||||||
expect(console.error).toHaveBeenCalledWith(
|
|
||||||
"Build failed: unexpected token",
|
|
||||||
);
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(dir, "a.js"), "1");
|
|
||||||
await until(() => builds === 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a change made while a build runs causes one more build after it", async () => {
|
|
||||||
let builds = 0;
|
|
||||||
watchers = watch([dir], async () => {
|
|
||||||
builds++;
|
|
||||||
if (builds === 1) {
|
|
||||||
fs.writeFileSync(path.join(dir, "a.js"), "1");
|
|
||||||
await sleep(200);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await until(() => builds === 2);
|
|
||||||
await sleep(300);
|
|
||||||
expect(builds).toBe(2);
|
|
||||||
});
|
|
||||||
@@ -19,6 +19,7 @@ const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|||||||
|
|
||||||
// The site the persisted state has connected, and one it has never heard of.
|
// The site the persisted state has connected, and one it has never heard of.
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
const STRANGER_ORIGIN = "https://stranger.example";
|
const STRANGER_ORIGIN = "https://stranger.example";
|
||||||
|
|
||||||
const MAINNET = networkById("mainnet");
|
const MAINNET = networkById("mainnet");
|
||||||
@@ -85,7 +86,7 @@ function loadBackground() {
|
|||||||
tokenHolderCache: {},
|
tokenHolderCache: {},
|
||||||
fraudContracts: [],
|
fraudContracts: [],
|
||||||
activeAddress: ADDRESS,
|
activeAddress: ADDRESS,
|
||||||
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
};
|
};
|
||||||
const storage = makeStorageStub({ autistmask: persisted });
|
const storage = makeStorageStub({ autistmask: persisted });
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const { networkById } = require("../src/shared/networks");
|
|||||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
const UNKNOWN_ORIGIN = "https://stranger.example";
|
const UNKNOWN_ORIGIN = "https://stranger.example";
|
||||||
|
|
||||||
const MAINNET = networkById("mainnet");
|
const MAINNET = networkById("mainnet");
|
||||||
@@ -40,7 +41,7 @@ function storedProfile(networkId) {
|
|||||||
networkId,
|
networkId,
|
||||||
rpcUrl: networkById(networkId).defaultRpcUrl,
|
rpcUrl: networkById(networkId).defaultRpcUrl,
|
||||||
blockscoutUrl: networkById(networkId).defaultBlockscoutUrl,
|
blockscoutUrl: networkById(networkId).defaultBlockscoutUrl,
|
||||||
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
trackedTokens: [],
|
trackedTokens: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const { makeStorageStub } = require("./support/storageStub");
|
|||||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
|
|
||||||
const MAINNET = networkById("mainnet");
|
const MAINNET = networkById("mainnet");
|
||||||
const SEPOLIA = networkById("sepolia");
|
const SEPOLIA = networkById("sepolia");
|
||||||
@@ -49,7 +50,7 @@ function storedProfile(networkId) {
|
|||||||
networkId,
|
networkId,
|
||||||
rpcUrl: CUSTOM_RPC,
|
rpcUrl: CUSTOM_RPC,
|
||||||
blockscoutUrl: CUSTOM_BLOCKSCOUT,
|
blockscoutUrl: CUSTOM_BLOCKSCOUT,
|
||||||
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
trackedTokens: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
|
trackedTokens: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
@@ -167,7 +168,7 @@ describe("a chain switch on a worker that never loaded state", () => {
|
|||||||
expect(after.wallets).toEqual(walletFixture());
|
expect(after.wallets).toEqual(walletFixture());
|
||||||
expect(after.hasWallet).toBe(true);
|
expect(after.hasWallet).toBe(true);
|
||||||
expect(after.activeAddress).toBe(ADDRESS);
|
expect(after.activeAddress).toBe(ADDRESS);
|
||||||
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
|
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_HOSTNAME] });
|
||||||
expect(after.trackedTokens).toEqual([
|
expect(after.trackedTokens).toEqual([
|
||||||
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const signer = new Wallet(SIGNER_KEY);
|
|||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
const EXT_URL = "chrome-extension://autistmask/";
|
const EXT_URL = "chrome-extension://autistmask/";
|
||||||
|
|
||||||
const SEPOLIA = networkById("sepolia");
|
const SEPOLIA = networkById("sepolia");
|
||||||
@@ -66,7 +67,7 @@ function storedProfile(networkId) {
|
|||||||
networkId,
|
networkId,
|
||||||
rpcUrl: net.defaultRpcUrl,
|
rpcUrl: net.defaultRpcUrl,
|
||||||
blockscoutUrl: net.defaultBlockscoutUrl,
|
blockscoutUrl: net.defaultBlockscoutUrl,
|
||||||
allowedSites: { [signer.address]: [CONNECTED_ORIGIN] },
|
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
trackedTokens: [],
|
trackedTokens: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
// The wallet's OWN send path enforces the same combined fee bound the dApp
|
|
||||||
// path does (https://git.eeqj.de/sneak/AutistMask/issues/399).
|
|
||||||
//
|
|
||||||
// The send in src/popup/views/confirmTx.js pins no fee fields, so ethers fills
|
|
||||||
// maxFeePerGas and the gas limit from whatever the configured RPC node
|
|
||||||
// answers. Nothing bounded that: a hostile node could report a fee whose
|
|
||||||
// product with the gas limit is thousands of ETH, and it would be both
|
|
||||||
// displayed and signed. populateVerifyAndSend() populates the transaction and
|
|
||||||
// runs assertWithinCeilings() on the populated fees before signing, so an
|
|
||||||
// over-bound send is refused before anything is broadcast.
|
|
||||||
//
|
|
||||||
// The check is driven here with a fake connected signer rather than a real
|
|
||||||
// one: populateTransaction() returns the fees the node would have produced,
|
|
||||||
// and sendTransaction() records whether the send actually happened. The real
|
|
||||||
// DOM path around it — reading the fee error into the reserved errors box — is
|
|
||||||
// covered by the Chrome e2e suite.
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
|
|
||||||
global.fetch = jest.fn(() => {
|
|
||||||
throw new Error("tests must not perform network requests");
|
|
||||||
});
|
|
||||||
|
|
||||||
const { populateVerifyAndSend } = require("../src/popup/views/confirmTx");
|
|
||||||
const {
|
|
||||||
MAX_FEE_PER_GAS,
|
|
||||||
MAX_TOTAL_FEE,
|
|
||||||
} = require("../src/shared/approvalVerify");
|
|
||||||
|
|
||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
|
|
||||||
// A signer whose populateTransaction() fills in the fees a node quoted and
|
|
||||||
// whose sendTransaction() records the call, so a test can assert whether the
|
|
||||||
// send was reached at all.
|
|
||||||
function fakeSigner(fees) {
|
|
||||||
const sent = [];
|
|
||||||
return {
|
|
||||||
sent,
|
|
||||||
populateTransaction: async (request) => ({
|
|
||||||
...request,
|
|
||||||
from: RECIPIENT,
|
|
||||||
nonce: 0,
|
|
||||||
type: 2,
|
|
||||||
chainId: 1n,
|
|
||||||
gasLimit: fees.gasLimit,
|
|
||||||
maxFeePerGas: fees.maxFeePerGas,
|
|
||||||
maxPriorityFeePerGas: 1000000000n,
|
|
||||||
}),
|
|
||||||
sendTransaction: async (tx) => {
|
|
||||||
sent.push(tx);
|
|
||||||
return { hash: "0xabc" };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ETH_SEND = { token: "ETH", to: RECIPIENT, amount: "1.0" };
|
|
||||||
|
|
||||||
describe("populateVerifyAndSend enforces the combined fee bound", () => {
|
|
||||||
// A gas limit and a fee that are each under their own field ceiling, but
|
|
||||||
// multiply to about 3,000 ETH — the combination the per-field ceilings
|
|
||||||
// cannot see.
|
|
||||||
const OVER = { gasLimit: 30000000n, maxFeePerGas: MAX_FEE_PER_GAS };
|
|
||||||
|
|
||||||
test("each field is under its ceiling but the product is over the bound", () => {
|
|
||||||
expect(OVER.maxFeePerGas).toBeLessThanOrEqual(MAX_FEE_PER_GAS);
|
|
||||||
expect(OVER.gasLimit * OVER.maxFeePerGas).toBeGreaterThan(
|
|
||||||
MAX_TOTAL_FEE,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("refuses an over-bound send without broadcasting it", async () => {
|
|
||||||
const signer = fakeSigner(OVER);
|
|
||||||
let thrown;
|
|
||||||
try {
|
|
||||||
await populateVerifyAndSend(signer, ETH_SEND);
|
|
||||||
} catch (e) {
|
|
||||||
thrown = e;
|
|
||||||
}
|
|
||||||
expect(thrown).toBeDefined();
|
|
||||||
expect(thrown.approvalMismatch).toBe(true);
|
|
||||||
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
|
|
||||||
expect(thrown.message).toContain("3000.0 ETH");
|
|
||||||
expect(thrown.message).toContain("1.0 ETH");
|
|
||||||
// The one guarantee that matters: nothing was signed or sent.
|
|
||||||
expect(signer.sent).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("broadcasts a send whose product is just under the bound", async () => {
|
|
||||||
// 21,000 gas at 40 gwei is 0.00084 ETH — an ordinary send.
|
|
||||||
const under = { gasLimit: 21000n, maxFeePerGas: 40000000000n };
|
|
||||||
expect(under.gasLimit * under.maxFeePerGas).toBeLessThan(MAX_TOTAL_FEE);
|
|
||||||
const signer = fakeSigner(under);
|
|
||||||
const tx = await populateVerifyAndSend(signer, ETH_SEND);
|
|
||||||
expect(tx.hash).toBe("0xabc");
|
|
||||||
expect(signer.sent).toHaveLength(1);
|
|
||||||
expect(signer.sent[0].gasLimit).toBe(under.gasLimit);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
// The recipient line of a contract creation
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/250).
|
|
||||||
//
|
|
||||||
// A transaction with no `to` creates a contract. The approval screen, the
|
|
||||||
// wait, success and error screens, the transaction detail view and the
|
|
||||||
// transaction history rows each say so in a sentence, where they used to show
|
|
||||||
// a blank line (an empty address, with a colour dot whose colour was
|
|
||||||
// `undefined`) or, on the approval screen, "(contract creation)". A
|
|
||||||
// transaction with a real `to` still shows that address.
|
|
||||||
//
|
|
||||||
// Driven against a minimal DOM stub in the shape
|
|
||||||
// tests/typedDataPermit.test.js uses.
|
|
||||||
|
|
||||||
jest.mock("../src/shared/log", () => ({
|
|
||||||
log: {
|
|
||||||
debugf: () => {},
|
|
||||||
infof: () => {},
|
|
||||||
warnf: () => {},
|
|
||||||
errorf: () => {},
|
|
||||||
},
|
|
||||||
// The transaction detail view fetches on-chain details after drawing; an
|
|
||||||
// answer that is not ok leaves the drawn lines as they are.
|
|
||||||
debugFetch: async () => ({ ok: false }),
|
|
||||||
setRuntimeDebug: () => {},
|
|
||||||
isDebug: () => false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// The wait screen polls for a receipt; this one never arrives.
|
|
||||||
jest.mock("../src/shared/balances", () => ({
|
|
||||||
getProvider: () => ({ getTransactionReceipt: () => new Promise(() => {}) }),
|
|
||||||
refreshBalances: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// The history lists ask the explorer for their transactions and resolve ENS
|
|
||||||
// names for them; here the explorer answers with mockHistory and no name
|
|
||||||
// resolves.
|
|
||||||
let mockHistory = [];
|
|
||||||
jest.mock("../src/shared/transactions", () => ({
|
|
||||||
...jest.requireActual("../src/shared/transactions"),
|
|
||||||
fetchRecentTransactions: async () => mockHistory,
|
|
||||||
}));
|
|
||||||
jest.mock("../src/shared/ens", () => ({
|
|
||||||
...jest.requireActual("../src/shared/ens"),
|
|
||||||
resolveEnsNames: async () => new Map(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
const txStatus = require("../src/popup/views/txStatus");
|
|
||||||
const transactionDetail = require("../src/popup/views/transactionDetail");
|
|
||||||
const home = require("../src/popup/views/home");
|
|
||||||
const addressDetail = require("../src/popup/views/addressDetail");
|
|
||||||
const addressToken = require("../src/popup/views/addressToken");
|
|
||||||
|
|
||||||
const SENTENCE =
|
|
||||||
"This transaction creates a new contract. It has no recipient.";
|
|
||||||
|
|
||||||
const FROM = "0x0000000000000000000000000000000000000a11";
|
|
||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|
||||||
const TX_HASH =
|
|
||||||
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
|
||||||
// Init code for a contract creation's data.
|
|
||||||
const INIT_CODE = "0x600160005500";
|
|
||||||
|
|
||||||
function makeElement(id) {
|
|
||||||
const classes = new Set();
|
|
||||||
const el = {
|
|
||||||
id,
|
|
||||||
textContent: "",
|
|
||||||
value: "",
|
|
||||||
innerHTML: "",
|
|
||||||
disabled: false,
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
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: () => {},
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The line a transaction with a real `to` shows: that address, and nothing
|
|
||||||
// left over from an empty one.
|
|
||||||
function expectAddressLine(html) {
|
|
||||||
expect(html).toContain(RECIPIENT);
|
|
||||||
expect(html).not.toContain(SENTENCE);
|
|
||||||
expect(html).not.toContain("undefined");
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
globalThis.window = { location: { search: "" } };
|
|
||||||
state.wallets = [];
|
|
||||||
state.trackedTokens = [];
|
|
||||||
state.viewData = {};
|
|
||||||
state.viewStack = [];
|
|
||||||
state.currentView = null;
|
|
||||||
txStatus.init({ doRefreshAndRender: () => {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
txStatus.endWait();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open the transaction approval screen the way the popup does: the background
|
|
||||||
// hands over the populated transaction and show() draws it.
|
|
||||||
async function openTxApproval(to, data) {
|
|
||||||
globalThis.chrome.runtime = {
|
|
||||||
connect: () => ({ postMessage: () => {} }),
|
|
||||||
sendMessage: (msg, reply) => {
|
|
||||||
if (!reply) return;
|
|
||||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
|
||||||
reply({
|
|
||||||
type: "tx",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: FROM,
|
|
||||||
approvedTx: {
|
|
||||||
type: 2,
|
|
||||||
from: FROM,
|
|
||||||
chainId: "0x1",
|
|
||||||
nonce: "0x7",
|
|
||||||
gasLimit: "0x5208",
|
|
||||||
maxPriorityFeePerGas: "0x3b9aca00",
|
|
||||||
maxFeePerGas: "0x77359400",
|
|
||||||
to,
|
|
||||||
value: "0x0",
|
|
||||||
data,
|
|
||||||
accessList: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
approval.init({});
|
|
||||||
await approval.show(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("the transaction approval screen", () => {
|
|
||||||
test("a contract creation says so instead of naming a contract", async () => {
|
|
||||||
await openTxApproval(null, INIT_CODE);
|
|
||||||
expect(node("approve-tx-to").innerHTML).toBe(SENTENCE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its address", async () => {
|
|
||||||
await openTxApproval(RECIPIENT, "0x");
|
|
||||||
expectAddressLine(node("approve-tx-to").innerHTML);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// approval.js carries a contract creation to these screens with `to` as "".
|
|
||||||
describe("the wait, success and error screens", () => {
|
|
||||||
const creation = {
|
|
||||||
to: "",
|
|
||||||
amount: "0.0000",
|
|
||||||
token: "ETH",
|
|
||||||
tokenSymbol: null,
|
|
||||||
};
|
|
||||||
const transfer = { ...creation, to: RECIPIENT };
|
|
||||||
|
|
||||||
test("a contract creation says so on the wait screen", () => {
|
|
||||||
txStatus.showWait(creation, TX_HASH);
|
|
||||||
expect(node("wait-tx-to").innerHTML).toBe(SENTENCE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its address on the wait screen", () => {
|
|
||||||
txStatus.showWait(transfer, TX_HASH);
|
|
||||||
expectAddressLine(node("wait-tx-to").innerHTML);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a contract creation says so on the success and error screens", () => {
|
|
||||||
state.viewData = {
|
|
||||||
amount: "0.0000",
|
|
||||||
symbol: "ETH",
|
|
||||||
to: "",
|
|
||||||
hash: TX_HASH,
|
|
||||||
blockNumber: 1,
|
|
||||||
};
|
|
||||||
txStatus.renderSuccess();
|
|
||||||
expect(node("success-tx-to").innerHTML).toBe(SENTENCE);
|
|
||||||
|
|
||||||
txStatus.showError(creation, TX_HASH, "The transaction failed.");
|
|
||||||
expect(node("error-tx-to").innerHTML).toBe(SENTENCE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its address on the success and error screens", () => {
|
|
||||||
state.viewData = {
|
|
||||||
amount: "0.0050",
|
|
||||||
symbol: "ETH",
|
|
||||||
to: RECIPIENT,
|
|
||||||
hash: TX_HASH,
|
|
||||||
blockNumber: 1,
|
|
||||||
};
|
|
||||||
txStatus.renderSuccess();
|
|
||||||
expectAddressLine(node("success-tx-to").innerHTML);
|
|
||||||
|
|
||||||
txStatus.showError(transfer, TX_HASH, "The transaction failed.");
|
|
||||||
expectAddressLine(node("error-tx-to").innerHTML);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// A transaction FROM sent, as the history lists hold it. The explorer reports a
|
|
||||||
// contract creation with no `to`, which src/shared/transactions.js turns into
|
|
||||||
// `to: ""`.
|
|
||||||
function historyTx(to) {
|
|
||||||
return {
|
|
||||||
hash: TX_HASH,
|
|
||||||
from: FROM,
|
|
||||||
to,
|
|
||||||
value: "0.0000",
|
|
||||||
exactValue: "0.0",
|
|
||||||
rawAmount: "0",
|
|
||||||
rawUnit: "wei",
|
|
||||||
symbol: "ETH",
|
|
||||||
timestamp: 1790000000,
|
|
||||||
isError: false,
|
|
||||||
directionLabel: "Sent",
|
|
||||||
direction: "sent",
|
|
||||||
contractAddress: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The detail view is opened with the transaction a history row holds.
|
|
||||||
describe("the transaction detail view", () => {
|
|
||||||
test("a contract creation says so", () => {
|
|
||||||
transactionDetail.show(historyTx(""));
|
|
||||||
expect(node("tx-detail-to").innerHTML).toBe(SENTENCE);
|
|
||||||
expect(node("tx-detail-type").textContent).toBe("Contract Creation");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its address", () => {
|
|
||||||
transactionDetail.show(historyTx(RECIPIENT));
|
|
||||||
expectAddressLine(node("tx-detail-to").innerHTML);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The same rows are drawn on Home, AddressDetail and AddressToken (for ETH).
|
|
||||||
describe.each([
|
|
||||||
["Home", "home-tx-list", () => home.render({})],
|
|
||||||
["AddressDetail", "tx-list", () => addressDetail.show()],
|
|
||||||
["AddressToken", "address-token-tx-list", () => addressToken.show()],
|
|
||||||
])("the transaction history on %s", (_name, listId, open) => {
|
|
||||||
async function rowsFor(tx) {
|
|
||||||
mockHistory = [tx];
|
|
||||||
open();
|
|
||||||
// The list is drawn once the history has been fetched.
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
return node(listId).innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
state.wallets = [
|
|
||||||
{
|
|
||||||
name: "Main",
|
|
||||||
type: "key",
|
|
||||||
addresses: [{ address: FROM, balance: "0.0000" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
state.selectedWallet = 0;
|
|
||||||
state.selectedAddress = 0;
|
|
||||||
state.selectedToken = "ETH";
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a contract creation's row says so, with no colour dot and no address line", async () => {
|
|
||||||
const html = await rowsFor(historyTx(""));
|
|
||||||
expect(html).toContain(SENTENCE);
|
|
||||||
expect(html).not.toContain("bg-[#");
|
|
||||||
expect(html).not.toContain("am-address");
|
|
||||||
expect(html).not.toContain("undefined");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a transaction with a recipient shows its colour dot and address", async () => {
|
|
||||||
const html = await rowsFor(historyTx(RECIPIENT));
|
|
||||||
expectAddressLine(html);
|
|
||||||
expect(html).toContain("bg-[#");
|
|
||||||
expect(html).toContain(`<div class="am-address">${RECIPIENT}</div>`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
// Tests for the debug/testnet banner (issue #375).
|
|
||||||
//
|
|
||||||
// On a testnet the banner is raised even in a release build, but it must not
|
|
||||||
// append the active view's internal id: the user should see "[TESTNET]", never
|
|
||||||
// "[TESTNET] (approve-tx)". The suffix is gated on the compile-time DEBUG
|
|
||||||
// constant, which is false in a plain test load, so this drives exactly the
|
|
||||||
// text a shipped build renders. Revert the gate to the old unconditional
|
|
||||||
// suffix and this fails.
|
|
||||||
//
|
|
||||||
// The banner is created on demand by updateDebugBanner(); the document stub
|
|
||||||
// records what it prepends so the assertion can read the resulting text.
|
|
||||||
|
|
||||||
function makeBanner() {
|
|
||||||
return {
|
|
||||||
id: "",
|
|
||||||
textContent: "",
|
|
||||||
style: { cssText: "" },
|
|
||||||
remove() {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDocument() {
|
|
||||||
let banner = null;
|
|
||||||
return {
|
|
||||||
getElementById(id) {
|
|
||||||
return id === "debug-banner" ? banner : null;
|
|
||||||
},
|
|
||||||
createElement: () => makeBanner(),
|
|
||||||
body: {
|
|
||||||
prepend(node) {
|
|
||||||
banner = node;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function load() {
|
|
||||||
jest.resetModules();
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
globalThis.document = makeDocument();
|
|
||||||
const helpers = require("../src/popup/views/helpers");
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
return { helpers, state };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("the release banner on a testnet", () => {
|
|
||||||
test("carries no internal view id", () => {
|
|
||||||
const { helpers, state } = load();
|
|
||||||
state.networkId = "sepolia";
|
|
||||||
|
|
||||||
helpers.updateDebugBanner("approve-tx");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
globalThis.document.getElementById("debug-banner").textContent,
|
|
||||||
).toBe("[TESTNET]");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
// What debugFetch writes to the console in debug mode.
|
|
||||||
//
|
|
||||||
// RPC providers put the API key in the URL's path or query string, and the
|
|
||||||
// debug log used to print the whole URL and request body, so turning debug
|
|
||||||
// mode on wrote the key to the console
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/410). The log now names the
|
|
||||||
// HTTP method, the URL's origin and the JSON-RPC method, and nothing else of
|
|
||||||
// the request.
|
|
||||||
|
|
||||||
const { debugFetch, urlOrigin, setRuntimeDebug } = require("../src/shared/log");
|
|
||||||
|
|
||||||
const realFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = realFetch;
|
|
||||||
setRuntimeDebug(false);
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logs the origin and JSON-RPC method, not the key in the URL", async () => {
|
|
||||||
setRuntimeDebug(true);
|
|
||||||
const consoleLog = jest.spyOn(console, "log").mockImplementation(() => {});
|
|
||||||
globalThis.fetch = jest.fn(async () => ({ status: 200 }));
|
|
||||||
|
|
||||||
await debugFetch(
|
|
||||||
"https://rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "eth_chainId",
|
|
||||||
params: [],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const logged = consoleLog.mock.calls.flat().join(" ");
|
|
||||||
expect(logged).not.toContain("PATHKEY123");
|
|
||||||
expect(logged).not.toContain("QUERYTOKEN456");
|
|
||||||
expect(logged).toContain("https://rpc.example.invalid");
|
|
||||||
expect(logged).toContain("eth_chainId");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the origin leaves out a user name and password in the URL", () => {
|
|
||||||
expect(
|
|
||||||
urlOrigin("https://user:SECRETPASS@rpc.example.invalid/v3/KEY"),
|
|
||||||
).toBe("https://rpc.example.invalid");
|
|
||||||
expect(urlOrigin("wss://user:SECRETPASS@rpc.example.invalid:8546/")).toBe(
|
|
||||||
"wss://rpc.example.invalid:8546",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -46,9 +46,6 @@ const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
|||||||
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
|
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
|
||||||
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||||
|
|
||||||
// U+200B, built from its code point so that it can be seen in this file.
|
|
||||||
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
|
|
||||||
|
|
||||||
// ------------------------------------------------------------ DOM stub
|
// ------------------------------------------------------------ DOM stub
|
||||||
|
|
||||||
function makeElement(id) {
|
function makeElement(id) {
|
||||||
@@ -143,14 +140,8 @@ function load() {
|
|||||||
state.selectedWallet = 0;
|
state.selectedWallet = 0;
|
||||||
state.selectedAddress = 0;
|
state.selectedAddress = 0;
|
||||||
state.activeAddress = A0;
|
state.activeAddress = A0;
|
||||||
state.allowedSites = {
|
state.allowedSites = { [A0]: ["a.example"], [B0]: ["b.example"] };
|
||||||
[A0]: ["https://a.example"],
|
state.deniedSites = { [B0]: ["c.example"], [C0]: ["d.example"] };
|
||||||
[B0]: ["https://b.example"],
|
|
||||||
};
|
|
||||||
state.deniedSites = {
|
|
||||||
[B0]: ["https://c.example"],
|
|
||||||
[C0]: ["https://d.example"],
|
|
||||||
};
|
|
||||||
state.viewStack = ["main", "settings"];
|
state.viewStack = ["main", "settings"];
|
||||||
state.currentView = "settings";
|
state.currentView = "settings";
|
||||||
|
|
||||||
@@ -181,15 +172,6 @@ async function openLostPassword(deleteWallet, walletIdx) {
|
|||||||
await click("btn-delete-wallet-lost-password");
|
await click("btn-delete-wallet-lost-password");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete a wallet through the password route: open its confirm screen,
|
|
||||||
// enter the password, and confirm. The vault is mocked, so the password
|
|
||||||
// text itself is irrelevant — decryptWithPassword decides pass or fail.
|
|
||||||
async function deleteWithPassword(deleteWallet, walletIdx) {
|
|
||||||
deleteWallet.show(walletIdx);
|
|
||||||
node("delete-wallet-password").value = "any password";
|
|
||||||
await click("btn-delete-wallet-confirm");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------ tests
|
// ------------------------------------------------------------ tests
|
||||||
|
|
||||||
// The stub is what every persistence assertion below rests on, so its one
|
// The stub is what every persistence assertion below rests on, so its one
|
||||||
@@ -345,72 +327,6 @@ describe("the typed confirmation", () => {
|
|||||||
"secret-three",
|
"secret-three",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A name of only spaces compares as nothing, and so does an empty
|
|
||||||
// field. Typing nothing must still delete nothing.
|
|
||||||
test.each(["", " "])(
|
|
||||||
"typing %j deletes nothing when the name is only spaces",
|
|
||||||
async (typedValue) => {
|
|
||||||
const { deleteWallet, state, storage } = load();
|
|
||||||
state.wallets[1].name = " ";
|
|
||||||
await openLostPassword(deleteWallet, 1);
|
|
||||||
|
|
||||||
node("delete-wallet-lost-name-input").value = typedValue;
|
|
||||||
await click("btn-delete-wallet-lost-confirm");
|
|
||||||
|
|
||||||
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
|
|
||||||
"visible",
|
|
||||||
);
|
|
||||||
expect(state.wallets).toHaveLength(3);
|
|
||||||
expect(await persistedWallets(storage)).toHaveLength(3);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// A name that shows nothing would leave nothing on screen to type
|
|
||||||
// back, so the screen names the wallet by its position instead, and
|
|
||||||
// that is what the user types.
|
|
||||||
test.each([
|
|
||||||
["spaces", " "],
|
|
||||||
["a zero-width space", ZERO_WIDTH_SPACE],
|
|
||||||
])(
|
|
||||||
"a name of only %s is shown and typed back as Wallet 2",
|
|
||||||
async (_label, storedName) => {
|
|
||||||
const { deleteWallet, state, storage } = load();
|
|
||||||
state.wallets[1].name = storedName;
|
|
||||||
await openLostPassword(deleteWallet, 1);
|
|
||||||
|
|
||||||
expect(node("delete-wallet-lost-name").textContent).toBe(
|
|
||||||
"Wallet 2",
|
|
||||||
);
|
|
||||||
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
|
||||||
await click("btn-delete-wallet-lost-confirm");
|
|
||||||
|
|
||||||
const persisted = await persistedWallets(storage);
|
|
||||||
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
|
|
||||||
"secret-one",
|
|
||||||
"secret-three",
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// A zero-width space paints nothing, so "My", a zero-width space and
|
|
||||||
// "Wallet" reads as "MyWallet", and that is all the user can type. HTML
|
|
||||||
// does not collapse it the way it collapses spaces, so it has to be
|
|
||||||
// removed explicitly.
|
|
||||||
test("a zero-width space inside the name is not part of it", async () => {
|
|
||||||
const { deleteWallet, state, storage } = load();
|
|
||||||
state.wallets[1].name = "My" + ZERO_WIDTH_SPACE + "Wallet";
|
|
||||||
await openLostPassword(deleteWallet, 1);
|
|
||||||
|
|
||||||
node("delete-wallet-lost-name-input").value = "MyWallet";
|
|
||||||
await click("btn-delete-wallet-lost-confirm");
|
|
||||||
|
|
||||||
const persisted = await persistedWallets(storage);
|
|
||||||
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
|
|
||||||
"secret-one",
|
|
||||||
"secret-three",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleting without the password", () => {
|
describe("deleting without the password", () => {
|
||||||
@@ -463,8 +379,8 @@ describe("deleting without the password", () => {
|
|||||||
await click("btn-delete-wallet-lost-confirm");
|
await click("btn-delete-wallet-lost-confirm");
|
||||||
|
|
||||||
const saved = (await storage.get("autistmask")).autistmask;
|
const saved = (await storage.get("autistmask")).autistmask;
|
||||||
expect(saved.allowedSites).toEqual({ [A0]: ["https://a.example"] });
|
expect(saved.allowedSites).toEqual({ [A0]: ["a.example"] });
|
||||||
expect(saved.deniedSites).toEqual({ [C0]: ["https://d.example"] });
|
expect(saved.deniedSites).toEqual({ [C0]: ["d.example"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
// The route shares finishDelete() with the password route, so the
|
// The route shares finishDelete() with the password route, so the
|
||||||
@@ -482,9 +398,7 @@ describe("deleting without the password", () => {
|
|||||||
expect(saved.activeAddress).toBe(A0);
|
expect(saved.activeAddress).toBe(A0);
|
||||||
expect(saved.selectedWallet).toBe(0);
|
expect(saved.selectedWallet).toBe(0);
|
||||||
expect(saved.selectedAddress).toBe(0);
|
expect(saved.selectedAddress).toBe(0);
|
||||||
expect(sent).toEqual([
|
expect(sent).toEqual([]);
|
||||||
{ type: "AUTISTMASK_ADDRESSES_REMOVED", addresses: [B0] },
|
|
||||||
]);
|
|
||||||
// Settings is stubbed, so this is where the route hands over, not
|
// Settings is stubbed, so this is where the route hands over, not
|
||||||
// where it renders.
|
// where it renders.
|
||||||
expect(mockSettingsShow).toHaveBeenCalled();
|
expect(mockSettingsShow).toHaveBeenCalled();
|
||||||
@@ -503,16 +417,13 @@ describe("deleting without the password", () => {
|
|||||||
"Wallet 3",
|
"Wallet 3",
|
||||||
]);
|
]);
|
||||||
expect(saved.activeAddress).toBe(B0);
|
expect(saved.activeAddress).toBe(B0);
|
||||||
expect(sent).toEqual([
|
expect(sent).toEqual([{ type: "AUTISTMASK_ACTIVE_CHANGED" }]);
|
||||||
{ type: "AUTISTMASK_ADDRESSES_REMOVED", addresses: [A0, A1] },
|
|
||||||
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("deleting the last wallet lands on Welcome with nothing left", async () => {
|
test("deleting the last wallet lands on Welcome with nothing left", async () => {
|
||||||
const { deleteWallet, state, storage } = load();
|
const { deleteWallet, state, storage } = load();
|
||||||
state.wallets = [wallet("Wallet 1", "secret-one", [A0])];
|
state.wallets = [wallet("Wallet 1", "secret-one", [A0])];
|
||||||
state.allowedSites = { [A0]: ["https://a.example"] };
|
state.allowedSites = { [A0]: ["a.example"] };
|
||||||
state.deniedSites = {};
|
state.deniedSites = {};
|
||||||
|
|
||||||
await openLostPassword(deleteWallet, 0);
|
await openLostPassword(deleteWallet, 0);
|
||||||
@@ -545,21 +456,15 @@ describe("what the screen leaves behind", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Both routes now re-enable through finishDelete(), not their leave
|
// Left mid-delete, the screen has to come back usable.
|
||||||
// hooks, so the button comes back live once a delete completes.
|
test("the confirm button is re-enabled on the way out", async () => {
|
||||||
test("the confirm button is re-enabled after a delete", async () => {
|
const { helpers, deleteWallet } = load();
|
||||||
const { deleteWallet } = load();
|
|
||||||
await openLostPassword(deleteWallet, 1);
|
await openLostPassword(deleteWallet, 1);
|
||||||
|
|
||||||
node("delete-wallet-lost-name-input").value = "Wallet 2";
|
node("btn-delete-wallet-lost-confirm").disabled = true;
|
||||||
await click("btn-delete-wallet-lost-confirm");
|
helpers.showView("settings");
|
||||||
|
|
||||||
expect(node("btn-delete-wallet-lost-confirm").disabled).toBe(false);
|
expect(node("btn-delete-wallet-lost-confirm").disabled).toBe(false);
|
||||||
expect(
|
|
||||||
node("btn-delete-wallet-lost-confirm").classList.contains(
|
|
||||||
"text-muted",
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// A wallet name is not a secret, so the screen is excluded for the
|
// A wallet name is not a secret, so the screen is excluded for the
|
||||||
@@ -570,48 +475,3 @@ describe("what the screen leaves behind", () => {
|
|||||||
expect(RESTORABLE_VIEWS.has("delete-wallet-confirm")).toBe(false);
|
expect(RESTORABLE_VIEWS.has("delete-wallet-confirm")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The password route is the pre-existing bug this file's fix addresses:
|
|
||||||
// its Confirm Delete button was disabled before the decrypt and never
|
|
||||||
// re-enabled on success, so a second delete in the same popup session
|
|
||||||
// found a dead button. Now both routes re-enable through finishDelete().
|
|
||||||
//
|
|
||||||
// Against head these tests fail: with the re-enable absent, the button
|
|
||||||
// stays disabled after the first delete, so the disabled assertions read
|
|
||||||
// true where they expect false.
|
|
||||||
describe("the password route's confirm button", () => {
|
|
||||||
test("is re-enabled after a successful delete", async () => {
|
|
||||||
const { deleteWallet, vault } = load();
|
|
||||||
vault.decryptWithPassword.mockResolvedValue();
|
|
||||||
|
|
||||||
await deleteWithPassword(deleteWallet, 1);
|
|
||||||
|
|
||||||
expect(node("btn-delete-wallet-confirm").disabled).toBe(false);
|
|
||||||
expect(
|
|
||||||
node("btn-delete-wallet-confirm").classList.contains("text-muted"),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The reported symptom: delete one wallet, then open Delete Wallet for
|
|
||||||
// a second one without reopening the popup. The button must be live on
|
|
||||||
// that second visit, and the second delete must actually persist.
|
|
||||||
test("a second delete works in the same popup session", async () => {
|
|
||||||
const { deleteWallet, vault, storage } = load();
|
|
||||||
vault.decryptWithPassword.mockResolvedValue();
|
|
||||||
|
|
||||||
await deleteWithPassword(deleteWallet, 1);
|
|
||||||
|
|
||||||
// Wallet 2 is gone; the list is now [Wallet 1, Wallet 3]. Opening
|
|
||||||
// the confirm screen for the wallet now at index 1 (Wallet 3) must
|
|
||||||
// find its button live, not the dead one the first delete left.
|
|
||||||
deleteWallet.show(1);
|
|
||||||
expect(node("btn-delete-wallet-confirm").disabled).toBe(false);
|
|
||||||
|
|
||||||
node("delete-wallet-password").value = "any password";
|
|
||||||
await click("btn-delete-wallet-confirm");
|
|
||||||
|
|
||||||
expect((await persistedWallets(storage)).map((w) => w.name)).toEqual([
|
|
||||||
"Wallet 1",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -99,13 +99,12 @@ describe("the flash line the message is shown in", () => {
|
|||||||
// length, including one that wrapped to two lines and pushed the
|
// length, including one that wrapped to two lines and pushed the
|
||||||
// settings view down 12px.
|
// settings view down 12px.
|
||||||
//
|
//
|
||||||
// The line cuts a message too long for it with an ellipsis (see
|
// The assertion that actually measures — empty line vs. the message,
|
||||||
// showFlash() in src/popup/views/helpers.js). The assertions that
|
// real Chromium, documented 360x600 popup — is
|
||||||
// measure that, in a real browser at the documented 360x600 popup, are
|
// "a rejected dust threshold shifts no layout (#233)" in
|
||||||
// "a rejected dust threshold shifts no layout (#233)" and "an over-long
|
// tests/e2e/run.js, run by make test-e2e. It is not in make check
|
||||||
// flash message keeps to one line (#252)" in tests/e2e/run.js, run by
|
// because REPO_POLICIES.md caps make test at 20 seconds and a browser
|
||||||
// make test-e2e. They are not in make check because REPO_POLICIES.md
|
// suite does not fit; run it before changing the wording.
|
||||||
// caps make test at 60 seconds and a browser suite does not fit.
|
|
||||||
test("reserves its height in the markup", () => {
|
test("reserves its height in the markup", () => {
|
||||||
const flashLine = POPUP_HTML.match(
|
const flashLine = POPUP_HTML.match(
|
||||||
/<div\s+id="flash-msg"\s+class="([^"]*)"/,
|
/<div\s+id="flash-msg"\s+class="([^"]*)"/,
|
||||||
|
|||||||
+16
-151
@@ -8,7 +8,7 @@
|
|||||||
// node tests/e2e/firefox/run.js [dist/firefox]
|
// node tests/e2e/firefox/run.js [dist/firefox]
|
||||||
//
|
//
|
||||||
// Deliberately not part of script/check, and deliberately not named
|
// Deliberately not part of script/check, and deliberately not named
|
||||||
// *.test.js: REPO_POLICIES.md caps make test at 60 seconds and a browser
|
// *.test.js: REPO_POLICIES.md caps make test at 20 seconds and a browser
|
||||||
// suite does not fit.
|
// suite does not fit.
|
||||||
//
|
//
|
||||||
// This shares no driver layer with the Chrome suite in tests/e2e/, and the
|
// This shares no driver layer with the Chrome suite in tests/e2e/, and the
|
||||||
@@ -46,7 +46,6 @@
|
|||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { isDeepStrictEqual } = require("util");
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
Transaction,
|
Transaction,
|
||||||
@@ -63,10 +62,6 @@ const {
|
|||||||
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
|
const { ConsoleErrors, EXTENSION_ORIGIN, start, sleep } = require("./driver");
|
||||||
const { startDappServer } = require("./dapp");
|
const { startDappServer } = require("./dapp");
|
||||||
const { STUB_COUNTERPARTY } = require("../network");
|
const { STUB_COUNTERPARTY } = require("../network");
|
||||||
const {
|
|
||||||
STATE_SCHEMA_VERSION,
|
|
||||||
stateProblem,
|
|
||||||
} = require("../../../src/shared/stateSchema");
|
|
||||||
|
|
||||||
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
const REPO_ROOT = path.resolve(__dirname, "..", "..", "..");
|
||||||
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
|
const POPUP_URL = EXTENSION_ORIGIN + "/src/popup/index.html";
|
||||||
@@ -113,137 +108,6 @@ step("popup loads and reaches the welcome view", async (env) => {
|
|||||||
assert(title === "AutistMask", "unexpected popup title: " + title);
|
assert(title === "AutistMask", "unexpected popup title: " + title);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The same check as the Chrome suite's (#418), so both browsers are held to
|
|
||||||
// the same font.
|
|
||||||
step("the popup is drawn in the monospace font it declares", async (env) => {
|
|
||||||
const font = await env.driver.execute(
|
|
||||||
"return getComputedStyle(document.body).fontFamily;",
|
|
||||||
);
|
|
||||||
// --font-mono in src/popup/styles/main.css, as the browser writes it out.
|
|
||||||
assert(
|
|
||||||
font ===
|
|
||||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
|
||||||
"the popup is drawn in " + font + ", not in --font-mono",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The recovery screen (#361): the Chrome suite's four cases, run before any
|
|
||||||
// wallet exists for the same reason. With no wallet nothing saves on a timer,
|
|
||||||
// so no save can write a good record over the unreadable one. The last of them
|
|
||||||
// erases it, which leaves the popup on Welcome for wallet creation.
|
|
||||||
|
|
||||||
// A profile a newer build wrote: a wallet with its encrypted secret, under a
|
|
||||||
// schema version this build refuses to read.
|
|
||||||
const UNREADABLE_RECORD = {
|
|
||||||
schemaVersion: STATE_SCHEMA_VERSION + 1,
|
|
||||||
wallets: [
|
|
||||||
{
|
|
||||||
type: "hd",
|
|
||||||
name: "Main",
|
|
||||||
xpub: "xpub-written-by-a-newer-build",
|
|
||||||
encryptedSecret: "ciphertext-written-by-a-newer-build",
|
|
||||||
nextIndex: 1,
|
|
||||||
addresses: [{ address: STUB_COUNTERPARTY }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// The whole stored record, read on the popup page.
|
|
||||||
function storedRecord(d) {
|
|
||||||
return d.executeAsync(
|
|
||||||
`const done = arguments[arguments.length - 1];
|
|
||||||
browser.storage.local.get("autistmask").then((r) => done(r.autistmask));`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
step(
|
|
||||||
"an unreadable stored record opens the popup on the recovery screen",
|
|
||||||
async (env) => {
|
|
||||||
const d = env.driver;
|
|
||||||
// The popup the first step opened saves once, as it shows Welcome.
|
|
||||||
// Stored before that save lands, the record would be written over.
|
|
||||||
const deadline = Date.now() + 15000;
|
|
||||||
for (;;) {
|
|
||||||
const stored = await storedRecord(d);
|
|
||||||
if (stored && stored.currentView === "welcome") break;
|
|
||||||
assert(
|
|
||||||
Date.now() < deadline,
|
|
||||||
"the Welcome screen's save never landed: " +
|
|
||||||
JSON.stringify(stored),
|
|
||||||
);
|
|
||||||
await sleep(100);
|
|
||||||
}
|
|
||||||
await d.executeAsync(
|
|
||||||
`const done = arguments[arguments.length - 1];
|
|
||||||
browser.storage.local.set({ autistmask: arguments[0] }).then(() => done());`,
|
|
||||||
[UNREADABLE_RECORD],
|
|
||||||
);
|
|
||||||
|
|
||||||
await d.navigate(POPUP_URL);
|
|
||||||
await d.waitVisible("#view-state-recovery");
|
|
||||||
const problem = await d.text("#state-recovery-problem");
|
|
||||||
assert(
|
|
||||||
problem === stateProblem(UNREADABLE_RECORD),
|
|
||||||
"the recovery screen names the problem as " +
|
|
||||||
JSON.stringify(problem),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
step("Export Saved Data shows the stored record verbatim", async (env) => {
|
|
||||||
const d = env.driver;
|
|
||||||
await d.click("#btn-state-recovery-export");
|
|
||||||
await d.waitVisible("#state-recovery-blob");
|
|
||||||
const exported = await d.value("#state-recovery-blob");
|
|
||||||
assert(exported !== "", "Export Saved Data left the text box empty");
|
|
||||||
assert(
|
|
||||||
isDeepStrictEqual(JSON.parse(exported), UNREADABLE_RECORD),
|
|
||||||
"the text box does not hold the stored record: " + exported,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
step("a near-miss confirmation phrase erases nothing", async (env) => {
|
|
||||||
const d = env.driver;
|
|
||||||
await d.fill("#state-recovery-reset-input", "ERASE MY WALLETS");
|
|
||||||
await d.click("#btn-state-recovery-reset");
|
|
||||||
await d.waitFor(
|
|
||||||
"the refusal on the error line",
|
|
||||||
`return document.getElementById("state-recovery-flash").textContent ===
|
|
||||||
"Type ERASE MY WALLET to confirm. Nothing was erased.";`,
|
|
||||||
);
|
|
||||||
const stored = await storedRecord(d);
|
|
||||||
assert(
|
|
||||||
isDeepStrictEqual(stored, UNREADABLE_RECORD),
|
|
||||||
"the stored record changed: " + JSON.stringify(stored),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
step(
|
|
||||||
"the exact confirmation phrase erases the record and reloads into Welcome",
|
|
||||||
async (env) => {
|
|
||||||
const d = env.driver;
|
|
||||||
try {
|
|
||||||
await d.fill("#state-recovery-reset-input", "ERASE MY WALLET");
|
|
||||||
await d.click("#btn-state-recovery-reset");
|
|
||||||
// Welcome is the proof of the erase: the record still stored
|
|
||||||
// would put the recovery screen up again, and its wallet would
|
|
||||||
// open Home.
|
|
||||||
await d.waitVisible("#view-welcome");
|
|
||||||
} finally {
|
|
||||||
// Whatever failed in these four steps, wallet creation starts
|
|
||||||
// from Welcome. The record left stored would fail every step
|
|
||||||
// after this.
|
|
||||||
if (!(await d.isVisible("#view-welcome"))) {
|
|
||||||
await d.executeAsync(
|
|
||||||
`const done = arguments[arguments.length - 1];
|
|
||||||
browser.storage.local.remove("autistmask").then(() => done());`,
|
|
||||||
);
|
|
||||||
await d.navigate(POPUP_URL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
step("wallet creation through the UI reaches the main view", async (env) => {
|
step("wallet creation through the UI reaches the main view", async (env) => {
|
||||||
const d = env.driver;
|
const d = env.driver;
|
||||||
await d.click("#btn-welcome-add");
|
await d.click("#btn-welcome-add");
|
||||||
@@ -508,10 +372,10 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
|
|||||||
STEP_TIMEOUT_MS,
|
STEP_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
// EIP-6963, asked of the provider itself. The announcement carries a
|
// EIP-6963, asked of the provider itself. The announcement carries the
|
||||||
// UUIDv4 inpage.js generates fresh for this page load (nothing persists
|
// uuid src/content/index.js reads out of extension storage — call site 1
|
||||||
// it — see issue #398) and has to name this extension and hand back the
|
// in the issue — and it has to name this extension and hand back the very
|
||||||
// very object on window.ethereum.
|
// object on window.ethereum.
|
||||||
const announced = await d.executeAsync(
|
const announced = await d.executeAsync(
|
||||||
`const done = arguments[arguments.length - 1];
|
`const done = arguments[arguments.length - 1];
|
||||||
const onAnnounce = (e) => {
|
const onAnnounce = (e) => {
|
||||||
@@ -538,7 +402,7 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
|
|||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
typeof announced.uuid === "string" && announced.uuid.length === 36,
|
typeof announced.uuid === "string" && announced.uuid.length === 36,
|
||||||
"the announcement carries no provider uuid: " +
|
"the announcement carries no stored provider uuid: " +
|
||||||
JSON.stringify(announced.uuid),
|
JSON.stringify(announced.uuid),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -574,10 +438,11 @@ step(
|
|||||||
await d.switchToWindow(popup);
|
await d.switchToWindow(popup);
|
||||||
await d.waitVisible("#view-approve-site");
|
await d.waitVisible("#view-approve-site");
|
||||||
|
|
||||||
const origin = await d.text("#approve-origin");
|
const hostname = await d.text("#approve-hostname");
|
||||||
assert(
|
assert(
|
||||||
origin === env.server.origin,
|
hostname === "127.0.0.1",
|
||||||
"the site prompt names the wrong origin: " + JSON.stringify(origin),
|
"the site prompt names the wrong origin: " +
|
||||||
|
JSON.stringify(hostname),
|
||||||
);
|
);
|
||||||
const shown = await d.text("#approve-address");
|
const shown = await d.text("#approve-address");
|
||||||
assert(
|
assert(
|
||||||
@@ -629,16 +494,16 @@ step(
|
|||||||
|
|
||||||
const screen = await d.execute(
|
const screen = await d.execute(
|
||||||
`return {
|
`return {
|
||||||
origin: document.getElementById("approve-sign-origin").textContent,
|
hostname: document.getElementById("approve-sign-hostname").textContent,
|
||||||
type: document.getElementById("approve-sign-type").textContent,
|
type: document.getElementById("approve-sign-type").textContent,
|
||||||
message: document.getElementById("approve-sign-message").textContent,
|
message: document.getElementById("approve-sign-message").textContent,
|
||||||
from: document.getElementById("approve-sign-from").textContent,
|
from: document.getElementById("approve-sign-from").textContent,
|
||||||
};`,
|
};`,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.origin === env.server.origin,
|
screen.hostname === "127.0.0.1",
|
||||||
"the sign prompt names the wrong origin: " +
|
"the sign prompt names the wrong origin: " +
|
||||||
JSON.stringify(screen.origin),
|
JSON.stringify(screen.hostname),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.type === "Personal message",
|
screen.type === "Personal message",
|
||||||
@@ -706,7 +571,7 @@ step(
|
|||||||
|
|
||||||
const screen = await d.execute(
|
const screen = await d.execute(
|
||||||
`return {
|
`return {
|
||||||
origin: document.getElementById("approve-tx-origin").textContent,
|
hostname: document.getElementById("approve-tx-hostname").textContent,
|
||||||
from: document.getElementById("approve-tx-from").textContent,
|
from: document.getElementById("approve-tx-from").textContent,
|
||||||
to: document.getElementById("approve-tx-to").textContent,
|
to: document.getElementById("approve-tx-to").textContent,
|
||||||
value: document.getElementById("approve-tx-value").textContent,
|
value: document.getElementById("approve-tx-value").textContent,
|
||||||
@@ -717,9 +582,9 @@ step(
|
|||||||
};`,
|
};`,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.origin === env.server.origin,
|
screen.hostname === "127.0.0.1",
|
||||||
"the transaction prompt names the wrong origin: " +
|
"the transaction prompt names the wrong origin: " +
|
||||||
JSON.stringify(screen.origin),
|
JSON.stringify(screen.hostname),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.from.toLowerCase().includes(env.address.toLowerCase()),
|
screen.from.toLowerCase().includes(env.address.toLowerCase()),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// This runs inside the pinned Playwright container; see script/test-e2e.
|
// This runs inside the pinned Playwright container; see script/test-e2e.
|
||||||
// It is deliberately NOT part of make check — REPO_POLICIES.md caps
|
// It is deliberately NOT part of make check — REPO_POLICIES.md caps
|
||||||
// make test at 60 seconds and a browser suite does not fit.
|
// make test at 20 seconds and a browser suite does not fit.
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
|||||||
+26
-62
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const { AbiCoder, Transaction } = require("ethers");
|
const { Transaction } = require("ethers");
|
||||||
|
|
||||||
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
|
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
|
||||||
// must not collide with any entry in src/shared/tokenList.js, or
|
// must not collide with any entry in src/shared/tokenList.js, or
|
||||||
@@ -244,39 +244,26 @@ function latestBlock() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// keccak("decimals()")[0:4], and the same for symbol() and name().
|
// keccak("decimals()")[0:4].
|
||||||
const SELECTOR_DECIMALS = "0x313ce567";
|
const SELECTOR_DECIMALS = "0x313ce567";
|
||||||
const SELECTOR_SYMBOL = "0x95d89b41";
|
|
||||||
const SELECTOR_NAME = "0x06fdde03";
|
|
||||||
|
|
||||||
// Every eth_call still answers with a zero word except decimals(), symbol()
|
// Every eth_call still answers with a zero word except decimals() on the
|
||||||
// and name() on the stub token. The wallet reads decimals() back at signing
|
// stub token, which the wallet reads back at signing time to compare with
|
||||||
// time to compare with the scale the confirmation screen rendered (issue
|
// the scale the confirmation screen rendered (issue #305).
|
||||||
// #305). Adding the token by its contract address reads all three (issue
|
|
||||||
// #295); symbol() and name() answer what the explorer reports for it.
|
|
||||||
//
|
//
|
||||||
// opts.tokenDecimalsOverride is the lying contract: set it and decimals()
|
// opts.tokenDecimalsOverride is the lying contract: set it and decimals()
|
||||||
// answers something other than the value this same fixture reports through
|
// answers something other than the value this same fixture reports through
|
||||||
// Blockscout, which is exactly the disagreement the wallet must refuse to
|
// Blockscout, which is exactly the disagreement the wallet must refuse to
|
||||||
// sign over. It is read at request time, so a test flips it on the options
|
// sign over. It is read at request time, so a test flips it on the options
|
||||||
// object the route was registered with — after the confirmation screen has
|
// object the route was registered with — after the confirmation screen has
|
||||||
// been built — without re-registering anything. Only null or undefined means
|
// been built — without re-registering anything.
|
||||||
// no override: 0 is a token with no decimal places, and is answered as one.
|
|
||||||
function ethCallResult(req, opts) {
|
function ethCallResult(req, opts) {
|
||||||
const call = Array.isArray(req.params) ? req.params[0] : null;
|
const call = Array.isArray(req.params) ? req.params[0] : null;
|
||||||
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 (to !== STUB_TOKEN.address) return ZERO_WORD;
|
if (data.startsWith(SELECTOR_DECIMALS) && to === STUB_TOKEN.address) {
|
||||||
if (data.startsWith(SELECTOR_DECIMALS)) {
|
return word(opts.tokenDecimalsOverride || STUB_TOKEN.decimals);
|
||||||
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals);
|
|
||||||
}
|
|
||||||
const abi = AbiCoder.defaultAbiCoder();
|
|
||||||
if (data.startsWith(SELECTOR_SYMBOL)) {
|
|
||||||
return abi.encode(["string"], [tokenObject(opts).symbol]);
|
|
||||||
}
|
|
||||||
if (data.startsWith(SELECTOR_NAME)) {
|
|
||||||
return abi.encode(["string"], [tokenObject(opts).name]);
|
|
||||||
}
|
}
|
||||||
return ZERO_WORD;
|
return ZERO_WORD;
|
||||||
}
|
}
|
||||||
@@ -418,23 +405,27 @@ function sleep(ms) {
|
|||||||
const HOLD_POLL_MS = 25;
|
const HOLD_POLL_MS = 25;
|
||||||
const HOLD_MAX_MS = 30000;
|
const HOLD_MAX_MS = 30000;
|
||||||
|
|
||||||
// Hold a reply open for as long as the test asks: until opts[name] is false.
|
// Hold a gas estimate open for as long as the test asks.
|
||||||
//
|
//
|
||||||
// The switch (holdGasEstimate, holdTransactionCount or holdBlockscout) is read
|
// opts.holdGasEstimate is read here rather than captured, so a test flips it
|
||||||
// here rather than captured, so a test flips it on the same options object the
|
// on the same options object the route was registered with — the same
|
||||||
// route was registered with — the same pattern as seedTokenTransfer. This is
|
// pattern as seedTokenTransfer. This is the only way to observe the
|
||||||
// the only way to observe a screen while its request is genuinely in flight;
|
// confirmation screen while its estimate is genuinely in flight; sampling
|
||||||
// sampling the screen and hoping to win a race against the network would
|
// the screen and hoping to win a race against the network would assert
|
||||||
// assert nothing on a slow machine.
|
// nothing on a slow machine.
|
||||||
//
|
//
|
||||||
// It never gives up quietly. A hold that outlives the bound is reported like
|
// It never gives up quietly. A hold that outlives the bound is reported like
|
||||||
// any other harness fault, because a "pending" state that stopped being
|
// any other harness fault, because a "pending" state that stopped being
|
||||||
// pending on its own is a green assertion about the wrong screen.
|
// pending on its own is a green assertion about the wrong screen.
|
||||||
async function awaitRelease(opts, name, report) {
|
async function awaitRelease(opts, report) {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
while (opts[name]) {
|
while (opts.holdGasEstimate) {
|
||||||
if (Date.now() - started > HOLD_MAX_MS) {
|
if (Date.now() - started > HOLD_MAX_MS) {
|
||||||
report(name + " was never released after " + HOLD_MAX_MS + "ms");
|
report(
|
||||||
|
"held gas estimate was never released after " +
|
||||||
|
HOLD_MAX_MS +
|
||||||
|
"ms",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await sleep(HOLD_POLL_MS);
|
await sleep(HOLD_POLL_MS);
|
||||||
@@ -456,16 +447,6 @@ function rpcReply(req, opts, report) {
|
|||||||
return Object.assign(envelope, { result: ethCallResult(req, opts) });
|
return Object.assign(envelope, { result: ethCallResult(req, opts) });
|
||||||
}
|
}
|
||||||
if (req.method === "eth_getTransactionReceipt") {
|
if (req.method === "eth_getTransactionReceipt") {
|
||||||
// A lookup that fails, which the wait screen counts differently from
|
|
||||||
// one that answers "not mined yet" (README.md, WaitTx).
|
|
||||||
if (opts.failReceiptLookup) {
|
|
||||||
return Object.assign(envelope, {
|
|
||||||
error: {
|
|
||||||
code: -32000,
|
|
||||||
message: "e2e fixture: receipt lookup failed",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const hash = Array.isArray(req.params) ? req.params[0] : null;
|
const hash = Array.isArray(req.params) ? req.params[0] : null;
|
||||||
return Object.assign(envelope, {
|
return Object.assign(envelope, {
|
||||||
result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
|
result: opts.seedReceipt && hash ? transactionReceipt(hash) : null,
|
||||||
@@ -558,10 +539,7 @@ async function handleRpc(route, postData, opts, report) {
|
|||||||
return route.abort();
|
return route.abort();
|
||||||
}
|
}
|
||||||
if (batch.some((req) => req.method === "eth_estimateGas")) {
|
if (batch.some((req) => req.method === "eth_estimateGas")) {
|
||||||
await awaitRelease(opts, "holdGasEstimate", report);
|
await awaitRelease(opts, report);
|
||||||
}
|
|
||||||
if (batch.some((req) => req.method === "eth_getTransactionCount")) {
|
|
||||||
await awaitRelease(opts, "holdTransactionCount", report);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const replies = batch.map((req) => rpcReply(req, opts, report));
|
const replies = batch.map((req) => rpcReply(req, opts, report));
|
||||||
@@ -617,25 +595,16 @@ function traceEnabled(raw) {
|
|||||||
* node-side refusal.
|
* node-side refusal.
|
||||||
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
|
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
|
||||||
* eth_estimateGas until this is cleared again.
|
* eth_estimateGas until this is cleared again.
|
||||||
* @param {boolean} [opts.holdTransactionCount] hold every batch containing an
|
|
||||||
* eth_getTransactionCount until this is cleared again.
|
|
||||||
* @param {boolean} [opts.holdBlockscout] hold every Blockscout request until
|
|
||||||
* this is cleared again.
|
|
||||||
* @param {boolean} [opts.failTransactionList] fail every request for an
|
|
||||||
* address's transaction list as a network error; read at request time.
|
|
||||||
* @param {string[]} [opts.broadcastTransactions] every raw signed
|
* @param {string[]} [opts.broadcastTransactions] every raw signed
|
||||||
* transaction handed to eth_sendRawTransaction, appended in order.
|
* transaction handed to eth_sendRawTransaction, appended in order.
|
||||||
* @param {number|string|null} [opts.tokenDecimalsOverride] the scale
|
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for
|
||||||
* decimals() answers for the stub token, in place of the value Blockscout
|
* the stub token, in place of the value Blockscout reports for it. This is
|
||||||
* reports for it; null for none, while 0 is a scale like any other. This
|
* the token that lies about its scale; read at request time.
|
||||||
* is the token that lies about its scale; read at request time.
|
|
||||||
* @param {string} [opts.tokenSymbolOverride] what the explorer reports as
|
* @param {string} [opts.tokenSymbolOverride] what the explorer reports as
|
||||||
* the stub token's symbol, in place of "E2E". This is the token whose
|
* the stub token's symbol, in place of "E2E". This is the token whose
|
||||||
* symbol is markup; read at request time.
|
* symbol is markup; read at request time.
|
||||||
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
|
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
|
||||||
* confirmed receipt instead of null, so a wait screen resolves.
|
* confirmed receipt instead of null, so a wait screen resolves.
|
||||||
* @param {boolean} [opts.failReceiptLookup] answer eth_getTransactionReceipt
|
|
||||||
* with an error, so every receipt lookup fails; read at request time.
|
|
||||||
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
|
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
|
||||||
* Promise<string|null>}>}
|
* Promise<string|null>}>}
|
||||||
*/
|
*/
|
||||||
@@ -698,12 +667,7 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
|
|
||||||
// Blockscout v2
|
// Blockscout v2
|
||||||
if (p.includes("/api/v2/")) {
|
if (p.includes("/api/v2/")) {
|
||||||
await awaitRelease(opts, "holdBlockscout", report);
|
|
||||||
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
|
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
|
||||||
// Aborted rather than answered with an error status: to the
|
|
||||||
// page this is a server that cannot be reached, and fetch()
|
|
||||||
// rejects with "Failed to fetch".
|
|
||||||
if (opts.failTransactionList) return route.abort();
|
|
||||||
const addr = blockscoutAddress(p);
|
const addr = blockscoutAddress(p);
|
||||||
return jsonResponse(route, {
|
return jsonResponse(route, {
|
||||||
items:
|
items:
|
||||||
|
|||||||
+459
-1613
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