Compare commits
203
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de22b4edb0 | ||
|
|
a65e84c2dd | ||
|
|
64ec571a17 | ||
|
|
1d40e2d7d5 | ||
|
|
9c691dfa6b | ||
|
|
448afdb5ce | ||
|
|
49713e1cdb | ||
|
|
1ea0730a81 | ||
|
|
4531e8bce5 | ||
|
|
f79390cad5 | ||
|
|
21d5e42528 | ||
|
|
ba61bedaaf | ||
|
|
42a5d94b49 | ||
|
|
267b69382e | ||
|
|
1a4fb0803d | ||
|
|
076726b13d | ||
|
|
c90efa988b | ||
|
|
754cf9686f | ||
|
|
430ea0166e | ||
|
|
ae0b86a8ac | ||
|
|
d89099d90a | ||
|
|
664629a1b6 | ||
|
|
26a2a51387 | ||
|
|
b5737f915c | ||
|
|
016acbcda5 | ||
|
|
7ababcf69e | ||
|
|
3191577f73 | ||
|
|
acb2577fec | ||
|
|
a33a40a6fa | ||
|
|
5192419cac | ||
|
|
41842b197e | ||
|
|
8998d6c661 | ||
|
|
08e47c39ad | ||
|
|
7ba0a5ff75 | ||
|
|
cd5951a4f8 | ||
|
|
bcdffa56a4 | ||
|
|
c0a3a02418 | ||
|
|
f550d8c8d2 | ||
|
|
fc6cffcaba | ||
|
|
5615b08760 | ||
|
|
ec6b6b57a3 | ||
|
|
36b200247a | ||
|
|
b4e166cdc7 | ||
|
|
53551c6382 | ||
|
|
33ab99b7be | ||
|
|
b58ecec1f9 | ||
|
|
387f40689e | ||
|
|
bf8aa9f5dd | ||
|
|
c316d9ee78 | ||
|
|
b9bb1569fd | ||
|
|
4be625dc06 | ||
|
|
e2965469ba | ||
|
|
2691702f80 | ||
|
|
5f2b606754 | ||
|
|
f53b503b15 | ||
|
|
45aead6340 | ||
|
|
e517219038 | ||
|
|
a3da448f0d | ||
|
|
93c34e1a2a | ||
|
|
4472746b89 | ||
|
|
4109c4d44a | ||
|
|
7b9821b06f | ||
|
|
070ad82338 | ||
|
|
657dc14f04 | ||
|
|
ba27ddced0 | ||
|
|
0e5f86f146 | ||
|
|
5b264f4b3e | ||
|
|
ca9cd438d9 | ||
|
|
3aed2bb5f0 | ||
|
|
5376723c41 | ||
|
|
671b4d3216 | ||
|
|
e46d894527 | ||
|
|
96446f4661 | ||
|
|
30dca1454e | ||
|
|
2c882c75d6 | ||
|
|
d6fb002656 | ||
|
|
cab7cb02b6 | ||
|
|
3db066413e | ||
|
|
c99772a520 | ||
|
|
a73fb17295 | ||
|
|
99269675b7 | ||
|
|
20e4f1652f | ||
|
|
9c67e87ae5 | ||
|
|
e0e8de8111 | ||
|
|
5e4b8ff3b0 | ||
|
|
e6fe9b2593 | ||
|
|
1f5cdeb7b4 | ||
|
|
b5d2cedb57 | ||
|
|
f8b050f501 | ||
|
|
e791dc72d2 | ||
|
|
cb58e9d5f7 | ||
|
|
8b6c2b4df9 | ||
|
|
85fa864db4 | ||
|
|
d2fd0f532a | ||
|
|
b923b2c19c | ||
|
|
e2e1c5d40e | ||
|
|
fc042c3191 | ||
|
|
8b0a1bff02 | ||
|
|
1f5ec1c8de | ||
|
|
f00954e2c5 | ||
|
|
e48308c5b6 | ||
|
|
aa10a2a5f5 | ||
|
|
2c0888e112 | ||
|
|
f857bb0a3f | ||
|
|
70a41ed70e | ||
|
|
f9f32ab32d | ||
|
|
bad6494e43 | ||
|
|
f4551dcb67 | ||
|
|
575f1658ae | ||
|
|
c40c7432a5 | ||
|
|
8e20a4e21c | ||
|
|
f283a5bb91 | ||
|
|
cb27281b21 | ||
|
|
00d1fecc36 | ||
|
|
dfc3a35fe3 | ||
|
|
2442441830 | ||
|
|
f1a13a50a6 | ||
|
|
b4059af827 | ||
|
|
8c08b3d467 | ||
|
|
fd5921f62e | ||
|
|
83f3549d37 | ||
|
|
eb012b9eb3 | ||
|
|
adff1e0b9d | ||
|
|
8e52528f8b | ||
|
|
b8b13ef5cd | ||
|
|
ff05bd50f7 | ||
|
|
ca18beb97f | ||
|
|
bb60b399ec | ||
|
|
447d714313 | ||
|
|
29ba54d5b6 | ||
|
|
e3dd0e44da | ||
|
|
860db6034c | ||
|
|
cd8e45ae0c | ||
|
|
eeb10c20ef | ||
|
|
0aaa94471f | ||
|
|
763b50b0e5 | ||
|
|
4dafd88fad | ||
|
|
1cfd69e72d | ||
|
|
e14f6e9eb5 | ||
|
|
e865099c5b | ||
|
|
cd730241bb | ||
|
|
ad1b4f0557 | ||
|
|
cb23611a17 | ||
|
|
0206b2f77e | ||
|
|
329e64a54d | ||
|
|
88c79e7e05 | ||
|
|
25ead55eac | ||
|
|
a8452a1d46 | ||
|
|
3663f02bf5 | ||
|
|
e590b83df0 | ||
|
|
a0360a7874 | ||
|
|
9776f62f28 | ||
|
|
9bd607b411 | ||
|
|
5d26283cd0 | ||
|
|
d0bbb3d9eb | ||
|
|
35125db6d1 | ||
|
|
eec3e23099 | ||
|
|
0af8b09305 | ||
|
|
83b169d991 | ||
|
|
6fece80afd | ||
|
|
6613a1f6bc | ||
|
|
a207ac70bd | ||
|
|
cf7ca99215 | ||
|
|
90a9d5597f | ||
|
|
6a86b726d2 | ||
|
|
18bdafd130 | ||
|
|
8c8caafe33 | ||
|
|
6c885a0c05 | ||
|
|
f86740ce69 | ||
|
|
e3790c5da4 | ||
|
|
2b97aae04a | ||
|
|
6127fd9432 | ||
|
|
f4a51e1679 | ||
|
|
375998beaf | ||
|
|
3b713809c8 | ||
|
|
8ac2c87c2c | ||
|
|
d1751beb32 | ||
|
|
de3f7a9a11 | ||
|
|
1144fdb71b | ||
|
|
f24b5bca19 | ||
|
|
9f0e88e963 | ||
|
|
45f11ee920 | ||
|
|
a68f30c480 | ||
|
|
43c236451f | ||
|
|
1247c24c4d | ||
|
|
bec20aa2bb | ||
|
|
467b849a13 | ||
|
|
5bf8b5ff1f | ||
|
|
4b62e31e80 | ||
|
|
49a7da87e8 | ||
|
|
5f54fcbb24 | ||
|
|
00d6193ee7 | ||
|
|
6c70a82de8 | ||
|
|
add11e57de | ||
|
|
598de3ff1a | ||
|
|
ae61792aee | ||
|
|
a1f082d686 | ||
|
|
33fa25adca | ||
|
|
2fe6447625 | ||
|
|
2da790fbe9 | ||
|
|
9ac7df0128 | ||
|
|
99292b9188 | ||
|
|
1197d2171b |
+77
-7
@@ -1,7 +1,77 @@
|
||||
# .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for
|
||||
# build-info stamping and the Dockerfile runs `make build`, so excluding it
|
||||
# would make every built extension report commitHash "unknown".
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
release
|
||||
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
||||
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
||||
# `/` and an unprefixed pattern is anchored at the context root. Every
|
||||
# depth-independent pattern therefore needs `**/`, or `config/.env` and
|
||||
# `certs/server.key` still ship while this file reads as solved. Only
|
||||
# genuinely root-anchored entries go unprefixed. Never transplant these
|
||||
# into .gitignore, where `**/` is wrong.
|
||||
#
|
||||
# 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
|
||||
|
||||
+14
-10
@@ -2,11 +2,11 @@ name: e2e
|
||||
on: [push]
|
||||
|
||||
# The browser end-to-end suites, one job per browser, deliberately kept out
|
||||
# of the check workflow: REPO_POLICIES.md caps make test at 20 seconds and
|
||||
# script/cibuild is a plain `docker build .` whose Dockerfile runs
|
||||
# make check, so folding a browser suite into either would blow that cap
|
||||
# and slow the local fast path. Before this workflow every browser-level
|
||||
# guarantee in this repo held only when a human remembered to run it.
|
||||
# of the check workflow: REPO_POLICIES.md caps make test at 60 seconds and
|
||||
# script/cibuild runs script/check, so folding a browser suite into either
|
||||
# would blow that cap and slow the local fast path. Before this workflow
|
||||
# every browser-level 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
|
||||
# does not hide the Firefox result.
|
||||
@@ -22,11 +22,9 @@ on: [push]
|
||||
# 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
|
||||
# here is a red mark a reviewer has to account for rather than a hard
|
||||
# block. Making e2e-chrome a required check is blocked on the measured
|
||||
# flake in the dApp signing wait -- two of six runs of unmutated code on a
|
||||
# loaded machine -- tracked as
|
||||
# https://git.eeqj.de/sneak/AutistMask/issues/287. A gate that fails at
|
||||
# random teaches people to merge past red.
|
||||
# block. Making e2e-chrome a required check is blocked while reports of the
|
||||
# Chrome suite failing under load are still open. 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
|
||||
# `|| true`. Both scripts exit non-zero when docker is missing, when the
|
||||
@@ -36,6 +34,9 @@ on: [push]
|
||||
jobs:
|
||||
e2e-chrome:
|
||||
runs-on: ubuntu-latest
|
||||
# Bounds the image build, a cold cache included, and the three Chrome
|
||||
# programs, so a hung browser frees the shared runner.
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
# actions/checkout v4.2.2, 2026-02-22
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
@@ -43,6 +44,9 @@ jobs:
|
||||
|
||||
e2e-firefox:
|
||||
runs-on: ubuntu-latest
|
||||
# Bounds the image build, a cold cache included, and both Firefox
|
||||
# programs, so a hung browser frees the shared runner.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# actions/checkout v4.2.2, 2026-02-22
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
|
||||
+31
-5
@@ -11,14 +11,40 @@ Thumbs.db
|
||||
.vscode/
|
||||
*.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_modules/
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
# Secrets. Unanchored like every entry above, so each matches at every
|
||||
# depth. Matching is case-sensitive on Linux, so names use character
|
||||
# ranges rather than a lowercase form that misses `Server.Key`.
|
||||
|
||||
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||
# 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
|
||||
dist/
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
node_modules/
|
||||
yarn.lock
|
||||
dist/
|
||||
release/
|
||||
.claude/
|
||||
|
||||
+66
-27
@@ -1,42 +1,81 @@
|
||||
# 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
|
||||
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS base
|
||||
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS lint
|
||||
|
||||
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
|
||||
COPY script/ script/
|
||||
COPY package.json yarn.lock ./
|
||||
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
|
||||
RUN script/bootstrap
|
||||
|
||||
# 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
|
||||
COPY . .
|
||||
|
||||
RUN yarn run lint
|
||||
RUN script/check-censored
|
||||
|
||||
# Test phase, same shape and for the same reason: the jest suite (its worker
|
||||
# cap is in package.json), rerun verbose on failure, then
|
||||
# script/test-verify-build.
|
||||
#
|
||||
# node:22-slim (22.x LTS), 2026-02-24
|
||||
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS test
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 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 package.json yarn.lock ./
|
||||
RUN script/bootstrap
|
||||
|
||||
COPY . .
|
||||
|
||||
# Lint stage — fail fast on static analysis and formatting, before the tests
|
||||
# and the build. This is also the stage script/lint builds from a host, which
|
||||
# is how linting stays on the pinned ESLint rather than the host's.
|
||||
FROM base AS lint
|
||||
RUN make lint
|
||||
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=test /app/package.json /dev/null
|
||||
|
||||
RUN make check
|
||||
RUN make build
|
||||
# script/bootstrap installs all prerequisites, git included. Manifests are
|
||||
# copied first so that layer stays cached until dependencies change.
|
||||
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}"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist clean dev
|
||||
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist icons enrich-tokens apply-enrichment clean dev
|
||||
|
||||
# Standard targets are thin shims; the implementations live in script/
|
||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||
@@ -104,9 +104,38 @@ build-debug:
|
||||
vendor-blocklist:
|
||||
@script/vendor-blocklist
|
||||
|
||||
# Redraw the toolbar icons in icons/ from script/lib/icons.js, at every size
|
||||
# manifest/chrome.json declares, leaving alone a file that already holds the
|
||||
# drawn image. Commit the result with the drawing: tests/icons.test.js fails
|
||||
# while the two disagree.
|
||||
icons:
|
||||
@node script/lib/icons.js
|
||||
|
||||
# Fetch a name and a website for every token in src/shared/tokenList.js into
|
||||
# script/lib/token-enrichment.json. Run deliberately, land the diff: the names
|
||||
# come from live data, so a later run can return different ones.
|
||||
enrich-tokens:
|
||||
@node script/lib/enrich-tokens.js
|
||||
|
||||
# Write the names and websites in script/lib/token-enrichment.json into
|
||||
# src/shared/tokenList.js. Commit the result with the data file:
|
||||
# tests/applyEnrichment.test.js fails while the list differs from what this
|
||||
# writes, or while an address in the data or in the script's table of websites
|
||||
# matches no entry in the list.
|
||||
apply-enrichment:
|
||||
@node script/lib/apply-enrichment.js
|
||||
|
||||
clean:
|
||||
@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:
|
||||
@echo "Building in watch mode..."
|
||||
@yarn run build --watch 2>&1
|
||||
|
||||
+357
-86
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-07-06
|
||||
last_modified: 2026-10-04
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
@@ -60,17 +60,28 @@ style conventions are in separate documents:
|
||||
prerequisite since nvm requires bash. yarn is then pinned via
|
||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||
repo root and runs `docker build .`; the Gitea workflow calls it. 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
|
||||
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
||||
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||
pristine checkout with nothing installed the run dies there, after the
|
||||
containerised gates have passed. **The bootstrap alone is not enough**:
|
||||
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
||||
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
||||
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
||||
source nvm for the pinned node version before invoking it, exactly as
|
||||
`script/bootstrap`'s own install step does. A runner carrying nothing but
|
||||
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
|
||||
must document the provided scripts in an **Entrypoints** section (see the
|
||||
README requirements below).
|
||||
@@ -89,87 +100,198 @@ style conventions are in separate documents:
|
||||
contributor should be able to understand the entire development workflow by
|
||||
reading the Makefile.
|
||||
|
||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
||||
as a build step so the build fails if the branch is not green. For non-server
|
||||
repos, the Dockerfile should bring up a development environment and run
|
||||
`make check`. For server repos, `make check` should run as an early build
|
||||
stage before the final image is assembled. Dockerfiles install development
|
||||
prerequisites by running `script/bootstrap` rather than duplicating installs
|
||||
inline; COPY `script/` and the dependency manifests (`package.json` +
|
||||
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
||||
layer stays cached until dependencies change.
|
||||
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
|
||||
`lint` phase and a `test` phase, with the final stage depending on both so the
|
||||
image cannot be built unless they pass. For non-server repos the final stage
|
||||
brings up a development environment; for server repos it is the runtime image.
|
||||
The gate phases and the build stage start from their pinned base images and
|
||||
install what those images lack either inline, as the canonical Go `Dockerfile`
|
||||
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
||||
repo's own `Dockerfile` does for its yarn packages. The development
|
||||
environment stage installs development prerequisites by running
|
||||
`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.
|
||||
|
||||
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
||||
repos use a multistage build where linting runs in an independent stage based
|
||||
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.
|
||||
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||
and nothing else:
|
||||
|
||||
The standard pattern for a Go repo Dockerfile is:
|
||||
```sh
|
||||
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
|
||||
# Lint stage — fast feedback on formatting and lint issues
|
||||
# Lint phase
|
||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||
FROM golangci/golangci-lint@sha256:... AS lint
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN make fmt-check
|
||||
RUN make lint
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Build stage
|
||||
# golang:1.x-alpine, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS builder
|
||||
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||
# image ships and the alpine one does not.
|
||||
# golang:1.x, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS test
|
||||
WORKDIR /src
|
||||
|
||||
# Force BuildKit to run the lint stage before proceeding
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN make test
|
||||
RUN go test -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -timeout 90s -race -v ./...; exit 1; }
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o /app ./cmd/app/
|
||||
# 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
|
||||
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
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
# Runtime stage
|
||||
# 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.
|
||||
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
|
||||
FROM alpine@sha256:...
|
||||
COPY --from=builder /app /usr/local/bin/app
|
||||
ENTRYPOINT ["app"]
|
||||
```
|
||||
|
||||
Key points:
|
||||
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
||||
includes both Go and the linter), so there is no need to install the
|
||||
linter separately.
|
||||
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
||||
a stage dependency. BuildKit runs stages in parallel by default; without
|
||||
this line, the build stage would not wait for lint to finish and a lint
|
||||
failure might not fail the overall build.
|
||||
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
||||
both Go and the linter), so nothing needs installing.
|
||||
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
||||
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
||||
and a stage nothing depends on is not built at all, so without these two
|
||||
lines a red gate would not fail the build.
|
||||
- Keep the runtime stage last, and if you add a stage after it, give it the
|
||||
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
|
||||
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
||||
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||
create placeholder files so the embed directives resolve. Example:
|
||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||
The lint stage should not depend on the actual build output — it exists to
|
||||
fail fast.
|
||||
- If the project requires CGO or system libraries for linting (e.g.
|
||||
`vips-dev`), install them in the lint stage with `apk add`.
|
||||
- The build stage runs `make test` after compilation setup. Tests run in the
|
||||
build stage, not the lint stage, because they may require compiled
|
||||
artifacts or heavier dependencies.
|
||||
- If the project requires CGO or system libraries for linting, install them
|
||||
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||
has no `apk`, so install with `apt-get` under the Debian package name
|
||||
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||
lists in the same `RUN`, so the layer does not keep them:
|
||||
|
||||
```dockerfile
|
||||
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
|
||||
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
||||
Dockerfile already runs `make check`, a successful build implies all checks
|
||||
pass.
|
||||
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
||||
That script bootstraps, runs the gate phases, and then builds the image, so a
|
||||
successful run means every check passed; a bare `docker build .` does not
|
||||
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
|
||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||
@@ -189,14 +311,21 @@ style conventions are in separate documents:
|
||||
module under test to verify it compiles/parses. There is no excuse for
|
||||
`make test` to be a no-op.
|
||||
|
||||
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||
Makefile.
|
||||
- `make test` must complete in under 60 seconds. That is the hard cap, and a
|
||||
suite that exceeds it fails. Under 20 seconds is the target. A suite between
|
||||
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.
|
||||
|
||||
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
||||
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
||||
show full output. This keeps CI logs and `docker build` output clean on
|
||||
success (just package/suite summaries) while providing full diagnostic detail
|
||||
on failure (every test case, every assertion). The general shell pattern:
|
||||
- **The test command should use the conditional verbose rerun pattern.** Run
|
||||
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
||||
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
||||
on success (just package/suite summaries) while providing full diagnostic
|
||||
detail on failure (every test case, every assertion). The command lives in the
|
||||
`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
|
||||
test:
|
||||
@@ -209,11 +338,26 @@ style conventions are in separate documents:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@go test -timeout 30s -race -cover ./... || \
|
||||
@go test -count=1 -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -timeout 30s -race -v ./...; exit 1; }
|
||||
go test -count=1 -timeout 90s -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:
|
||||
|
||||
```makefile
|
||||
@@ -239,10 +383,84 @@ style conventions are in separate documents:
|
||||
must be in `.gitignore`. No exceptions.
|
||||
|
||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||
Fetch the standard `.gitignore` from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||
a new repo.
|
||||
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
||||
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
|
||||
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
||||
setting up a new repo. These patterns are written to `.gitignore`'s own
|
||||
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
|
||||
bundles, minified output, generated assets) must never be committed to the
|
||||
@@ -258,9 +476,56 @@ style conventions are in separate documents:
|
||||
- Make all changes on a feature branch. You can do whatever you want on a
|
||||
feature branch.
|
||||
|
||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||
manually by the user. Fetch from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
||||
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
||||
_NEVER_ be modified by an agent: fetch it from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
|
||||
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
|
||||
with the version and date (YYYY-MM-DD).
|
||||
@@ -374,12 +639,14 @@ style conventions are in separate documents:
|
||||
settings.
|
||||
|
||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||
subdirectory names:
|
||||
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
||||
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
||||
and language-specific config). Everything else goes in a subdirectory.
|
||||
Canonical subdirectory names:
|
||||
- `bin/` — executable scripts and tools
|
||||
- `cmd/` — Go command entrypoints
|
||||
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
||||
body is a single call into `internal/` or `pkg/`, no project logic in
|
||||
`cmd/`
|
||||
- `configs/` — configuration templates and examples
|
||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||
- `docs/` — documentation and markdown (README.md stays in root)
|
||||
@@ -406,3 +673,7 @@ style conventions are in separate documents:
|
||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||
- 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.
|
||||
|
||||
@@ -118,4 +118,7 @@ contradicts either, the originals govern.
|
||||
- [ ] "Address" not "account" or "derived key"
|
||||
- [ ] "Password" not "encryption key" or "vault passphrase"
|
||||
- [ ] Error messages are full sentences
|
||||
- [ ] No competitor mentioned by name in code or documentation
|
||||
- [ ] No competitor mentioned by name in code or documentation. Written
|
||||
exception: the injected provider in `src/content/inpage.js` sets the flag
|
||||
named after the other wallet to `true`, an interface-compatibility flag
|
||||
many dApps check (README.md, Policies)
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
- `git pull` `next` and cut a branch from it — one branch per issue, named
|
||||
`issue-<N>-<slug>`. Never branch from `main`.
|
||||
- Do the work as one commit whose title ends with ` (closes #N)`, with the
|
||||
`TODO.md` update in that same commit.
|
||||
- Move Next Step to the top of Completed Steps; move the top item of Future
|
||||
Steps into Next Step.
|
||||
- Do the work as one commit whose title ends with ` (closes #N)`.
|
||||
- Add no entry to Completed Steps. What landed is recorded on the tracker and in
|
||||
`git log`, where each commit title names the issue it closes.
|
||||
- Run `make fmt`, then `make check`. A feature branch may be red; `next` and
|
||||
`main` may not.
|
||||
- Rebase onto current `next` immediately before pushing — other branches land on
|
||||
@@ -23,28 +22,939 @@
|
||||
|
||||
pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. The
|
||||
milestone is in flight on `next`; its `next` -> `main` PR is
|
||||
[#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified
|
||||
green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces
|
||||
`dist/chrome/` and `dist/firefox/`, verified against the build's own receipt to
|
||||
hold exactly the regular files and symlinks that build emitted, with `DEBUG`
|
||||
compiled off.
|
||||
[#388](https://git.eeqj.de/sneak/AutistMask/pulls/388). `make build` produces
|
||||
`dist/chrome/` and `dist/firefox/` with `DEBUG` compiled off, and checks them
|
||||
against the build's own receipt to hold exactly the regular files and symlinks
|
||||
that build emitted.
|
||||
|
||||
The backlog lives on the
|
||||
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
|
||||
authoritative; this file does not duplicate it. Full policy file set present.
|
||||
Real-browser end-to-end suites (`make test-e2e` for Chrome,
|
||||
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which now does
|
||||
static analysis as well as formatting, and `.gitea/workflows/e2e.yml` runs both
|
||||
of them on every push.
|
||||
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which runs the
|
||||
tests, static analysis and the formatting check, and `.gitea/workflows/e2e.yml`
|
||||
runs both of them on every push.
|
||||
|
||||
# Next Step
|
||||
|
||||
Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
|
||||
input validation) before any 1.0rc tag. Individual filed issues are parts of it,
|
||||
but the review is broader than any of them.
|
||||
Cut 1.0.0 once the
|
||||
[1.0.0 milestone](https://git.eeqj.de/sneak/AutistMask/milestone/6) is empty,
|
||||
then continue tagging as milestones land.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-08: The browser suites no longer read a screen before the page has
|
||||
shown it ([#502](https://git.eeqj.de/sneak/AutistMask/issues/502)). Their wait
|
||||
for a screen used to pass as soon as the element laid out, which every view
|
||||
does until the page's stylesheet has applied, so an approval test could read
|
||||
the prompt's fields before the page's script had filled them. `visible()` in
|
||||
`tests/e2e/harness.js` and `waitVisible()` in `tests/e2e/firefox/driver.js`
|
||||
now also wait for the page to finish loading and for neither the element nor
|
||||
anything around it to carry the `hidden` class that `showView()` puts on every
|
||||
view but the current one. No caller changed.
|
||||
|
||||
- 2026-10-08: Switching the network in Settings now tells open pages
|
||||
([#500](https://git.eeqj.de/sneak/AutistMask/issues/500)). Once the switch is
|
||||
saved, Settings asks the background to send `chainChanged` with the new chain
|
||||
id to every open tab, through the same function an approved site request uses.
|
||||
Choosing the network already active changes nothing and sends nothing. Only
|
||||
the extension's own pages can ask for this. `tests/chainSwitchGate.test.js`
|
||||
drives the real Settings view against the background, and the Chrome suite
|
||||
checks that the test page hears both switches.
|
||||
|
||||
- 2026-10-07: A site can no longer switch the wallet's network by itself
|
||||
([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)). A connected site's
|
||||
`wallet_switchEthereumChain` request for the other supported network opens a
|
||||
prompt in its own window, through the same approval machinery as the
|
||||
transaction and signature prompts, naming the site, the current network and
|
||||
the requested one. The network, its endpoints, the balances and the caches
|
||||
change, and `chainChanged` is sent, only when the user approves it; rejecting
|
||||
or closing the prompt answers 4001. One such prompt per site at a time. The
|
||||
approval window no longer shows the connection prompt while it waits for the
|
||||
background to describe the approval, because that prompt's "Allow" answers on
|
||||
the same port as the new one; `tests/approvalWindow.test.js` checks that it
|
||||
shows no screen until then. `tests/chainSwitchGate.test.js` and both browser
|
||||
suites drive the prompt.
|
||||
|
||||
- 2026-10-07: Two contradictions between the documents and the code are resolved
|
||||
as ruled on [#165](https://git.eeqj.de/sneak/AutistMask/issues/165). The
|
||||
README's 1.0 non-goal now puts Ethereum mainnet and the Sepolia testnet in
|
||||
scope and other chains out of it. The injected provider's flag named after the
|
||||
other wallet stays, as an interface-compatibility flag many dApps check, and
|
||||
is written down as an exception to the rule against naming competitors in the
|
||||
README's Policies section, in `RULES.md` and in a comment beside it in
|
||||
`src/content/inpage.js`. `script/check-censored` already allows that flag only
|
||||
in that file and its built copies, so it is unchanged.
|
||||
|
||||
- 2026-10-07: Two holes in what `tests/persistedFieldContract.test.js` checks
|
||||
are closed ([#379](https://git.eeqj.de/sneak/AutistMask/issues/379)). The
|
||||
check that every swept field is driven both truthy and falsy counts only
|
||||
`hostile` and `falsy` values, which the sweep drives onto every restorable
|
||||
view, and no longer a `hostileRestore` value, which reaches only the views its
|
||||
entry names; the stale index 5 moves into `hostile` for `selectedWallet` and
|
||||
`selectedAddress`, as the one value of either still truthy after the floor.
|
||||
Each `hostileRestore` entry declares whether its boot lands on its view or
|
||||
falls back to Home, and is held to it. The limits that remain, fields that
|
||||
share a slot on one boot and anything no stored record reaches by itself, are
|
||||
restated as built in the file's header and the README, which now also say
|
||||
which boots are held to where the popup lands and that a healthy profile is
|
||||
booted onto every restorable view.
|
||||
|
||||
- 2026-10-07: Every password error in the popup is shown the same way
|
||||
([#493](https://git.eeqj.de/sneak/AutistMask/issues/493)): with `showError()`
|
||||
and `hideError()` in a fixed-height error line below the password field, as
|
||||
the send confirmation and approval screens already did. The add wallet screen
|
||||
showed a missing, short or mismatched password in the flash line, and the
|
||||
private key export, recovery phrase and delete wallet screens each had a line
|
||||
of their own above the field. On the add wallet screen the line sits beside
|
||||
the Import button, so the button stays where it was at 360x600. Each line
|
||||
clears when the screen is shown again and when the password is tried again.
|
||||
The add wallet screen's other messages, such as an invalid recovery phrase, a
|
||||
duplicate wallet and the address scan, stay in the flash line.
|
||||
`tests/passwordErrorLines.test.js` drives all four screens in the popup.
|
||||
|
||||
- 2026-10-07: Pre-1.0 security review of the extension
|
||||
([#383](https://git.eeqj.de/sneak/AutistMask/issues/383)), reading the tree at
|
||||
`99292b9` for key handling, the DEBUG mode policy, and what the background
|
||||
accepts from pages, the configured RPC endpoint and the explorer, with what
|
||||
the approval screens show from it; the site permission model and storage were
|
||||
read as well. Its summary on that issue lists ten findings, each filed as its
|
||||
own issue, and all ten are fixed on `next`; one,
|
||||
[#399](https://git.eeqj.de/sneak/AutistMask/issues/399), put funds at risk.
|
||||
Three decisions it raised are still open with the owner: the Argon2id cost for
|
||||
the vault key ([#401](https://git.eeqj.de/sneak/AutistMask/issues/401)), a
|
||||
connected site switching the network with no prompt
|
||||
([#408](https://git.eeqj.de/sneak/AutistMask/issues/408)), and `eth_sign`
|
||||
signing as a personal message
|
||||
([#409](https://git.eeqj.de/sneak/AutistMask/issues/409)). Not covered: the
|
||||
end-to-end suites were not run, the bundled phishing blocklist and token list
|
||||
were not checked entry by entry, `ethers` and `libsodium-wrappers-sumo` were
|
||||
taken as audited, and nothing was tried against a real network with real funds
|
||||
([#385](https://git.eeqj.de/sneak/AutistMask/issues/385)). The planned
|
||||
independent second check of each finding did not run; the findings rest on the
|
||||
reviewer's own reading of the code.
|
||||
|
||||
- 2026-10-07: Stale branches pruned from `origin`
|
||||
([#167](https://git.eeqj.de/sneak/AutistMask/issues/167)). The issue
|
||||
classifies each branch it lists, with the evidence. The eighteen still on
|
||||
`origin` that it classifies as landed, or superseded by merged pull requests,
|
||||
were deleted. `feat/message-signing` and `fix/59-transaction-view-ui-policies`
|
||||
had been deleted on 2026-09-09; both landed and stay deleted. Four are kept
|
||||
because their work is not in `next`: `fix/consistent-error-display`,
|
||||
`fix/87-consistent-error-display` and `fix/87-consistent-error-display-v2`,
|
||||
the change for [#87](https://git.eeqj.de/sneak/AutistMask/issues/87) that
|
||||
never landed, as reference for
|
||||
[#493](https://git.eeqj.de/sneak/AutistMask/issues/493); and
|
||||
`chore/token-list-enrichment`, deleted on 2026-09-09 and re-created at
|
||||
`f7a2437`, whose `scripts/` tooling waits on
|
||||
[#495](https://git.eeqj.de/sneak/AutistMask/issues/495).
|
||||
|
||||
- 2026-10-07: The README says that the wallet never clears the clipboard after
|
||||
the private key or the recovery phrase is copied, and why
|
||||
([#492](https://git.eeqj.de/sneak/AutistMask/issues/492)): the clipboard is
|
||||
the user's, clearing it would go against what they expect, and it could
|
||||
destroy something else they copied since. It is under ExportPrivKey, with a
|
||||
line under ShowRecoveryPhrase, and names the warning each password screen
|
||||
actually shows, which says nothing about the clipboard.
|
||||
|
||||
- 2026-10-07: The Firefox end-to-end suite no longer tolerates any uncaught
|
||||
extension error ([#487](https://git.eeqj.de/sneak/AutistMask/issues/487)). Its
|
||||
one entry, Firefox reporting a popup promise that settled after the page
|
||||
unloaded, had lost its cause with
|
||||
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275); the entry and the
|
||||
code that printed tolerated errors are gone from `tests/e2e/firefox/run.js`,
|
||||
and so is the README paragraph that described it.
|
||||
|
||||
- 2026-10-07: The toolbar icons in `icons/` can be redrawn in the tree
|
||||
([#378](https://git.eeqj.de/sneak/AutistMask/issues/378)): `make icons` runs
|
||||
`script/lib/icons.js`, which draws the mark and writes each PNG with node's
|
||||
own `zlib`, no new dependency, leaving alone a file that already holds the
|
||||
drawn image. `tests/icons.test.js` requires each committed file to hold
|
||||
exactly that image, the same IHDR and every pixel. The compressed bytes are
|
||||
not compared, because node's bundled zlib does not compress them as the stock
|
||||
zlib that made the committed files did. `build.js` now copies each manifest
|
||||
from the same path `copyIcons()` reads its icons from.
|
||||
|
||||
- 2026-10-07: The README's end-to-end limits now match what the two browser
|
||||
suites do to the extension
|
||||
([#293](https://git.eeqj.de/sneak/AutistMask/issues/293)). The `window.close`
|
||||
override the issue named went with
|
||||
[#275](https://git.eeqj.de/sneak/AutistMask/issues/275), so it needs no entry.
|
||||
Added: both suites load the popup in a tab rather than from the toolbar;
|
||||
Chrome waits 1.5 seconds before each site-connection request, records what
|
||||
approval windows send and click, grants clipboard permission, writes text
|
||||
straight into the page in two layout tests, and forces the leave during a
|
||||
decrypt; Firefox forces the site-connection prompt into a window. Corrected:
|
||||
Firefox's one tolerated error, whose cause that fix removed (the entry goes in
|
||||
[#487](https://git.eeqj.de/sneak/AutistMask/issues/487)), the phishing
|
||||
blocklist the extension no longer fetches, and two stale figures.
|
||||
|
||||
- 2026-10-07: Back from Settings no longer shows Settings again after the
|
||||
settings gear was pressed on the recovery phrase or a delete wallet screen
|
||||
opened from Settings, with or without a reopen in between
|
||||
([#481](https://git.eeqj.de/sneak/AutistMask/issues/481)). Those screens take
|
||||
themselves off the Back stack when left, which leaves Settings under Settings;
|
||||
`goBack()` now skips an entry for the screen already showing.
|
||||
`tests/showPhrase.test.js`, `tests/deleteWalletLostPassword.test.js` and
|
||||
`tests/backNavigation.test.js` drive each path.
|
||||
|
||||
- 2026-10-07: `script/discard-dist-on-failure` returns the failed step's own
|
||||
exit status even when it cannot write its message, to a closed stderr or to a
|
||||
pipe nobody reads any more
|
||||
([#342](https://git.eeqj.de/sneak/AutistMask/issues/342)); it used to return 2
|
||||
or 141 instead. An interrupt while a step runs now removes nothing and says
|
||||
nothing whichever shell `/bin/sh` is, even when the step catches it and exits
|
||||
with a status of its own, as `script/check-censored` does: the wrapper exits
|
||||
with 130 once the step has ended. Under bash such a step used to have `dist/`
|
||||
removed. A failed `check-censored --require-dist` still removes `dist/` like
|
||||
any other step, because a `dist/` not cleared of the name `RULES.md` bars must
|
||||
not ship. The header states both. `script/test-verify-build` runs the wrapper
|
||||
with stderr closed and with stderr a pipe nobody reads, and interrupts it
|
||||
under dash and under bash.
|
||||
|
||||
- 2026-10-06: Back from Settings no longer lands on the delete wallet or
|
||||
lost-password screen after either was left by the settings gear
|
||||
([#480](https://git.eeqj.de/sneak/AutistMask/issues/480)), the defect
|
||||
[#461](https://git.eeqj.de/sneak/AutistMask/issues/461) fixed for the two
|
||||
secret screens. Leaving drops the screen's wallet selection, so its leave
|
||||
handler now also takes it off the Back stack, as a reopened popup already
|
||||
does. `tests/deleteWalletLostPassword.test.js` drives the gear and then Back
|
||||
on both screens.
|
||||
|
||||
- 2026-10-06: `script/bootstrap` no longer reports success while node cannot
|
||||
find a package listed in `dependencies` or `devDependencies` of `package.json`
|
||||
([#263](https://git.eeqj.de/sneak/AutistMask/issues/263)). After the install
|
||||
it asks node for each one's `package.json`, and fails naming the missing
|
||||
package and the fix. yarn skips the install whenever
|
||||
`node_modules/.yarn-integrity` matches `yarn.lock`, so a package deleted from
|
||||
`node_modules` stayed deleted while bootstrap said it was complete. The
|
||||
fresh-clone failure the issue reports did not reproduce.
|
||||
|
||||
- 2026-10-06: Back from Settings no longer lands on the private key export or
|
||||
recovery phrase screen after either was left by the settings gear
|
||||
([#461](https://git.eeqj.de/sneak/AutistMask/issues/461)). Leaving drops the
|
||||
screen's selection, so its leave handler now also takes it off the Back stack,
|
||||
as a reopened popup already does. `tests/exportPrivkey.test.js` and
|
||||
`tests/showPhrase.test.js` drive the gear and then Back, and
|
||||
`leavePrivkeyScreen()` in `tests/e2e/run.js` expects the address screen after
|
||||
Settings.
|
||||
|
||||
- 2026-10-06: A token symbol or name read off a contract is no longer stored cut
|
||||
between the two halves of an emoji
|
||||
([#458](https://git.eeqj.de/sneak/AutistMask/issues/458)). `lookupTokenInfo()`
|
||||
cut both by UTF-16 units; it now counts code points, as `displaySymbol()` has
|
||||
since [#329](https://git.eeqj.de/sneak/AutistMask/issues/329).
|
||||
`tests/tokenLookupTruncation.test.js` looks up a token whose symbol and name
|
||||
are made of emoji outside the Basic Multilingual Plane. A symbol already
|
||||
stored broken is not repaired.
|
||||
|
||||
- 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 private key export screen opens again in the same popup
|
||||
session ([#460](https://git.eeqj.de/sneak/AutistMask/issues/460)). `show()`
|
||||
found the address line through the element inside it, which its own rendering
|
||||
replaced, so the second open threw before it navigated. The line now carries
|
||||
the `export-privkey-address` id itself. `tests/exportPrivkey.test.js` opens
|
||||
the screen twice, its DOM stub now takes an element out of the document when
|
||||
its parent's contents are replaced, and the `#253` e2e case no longer reopens
|
||||
the popup before its second open.
|
||||
|
||||
- 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
|
||||
16/32/48/128 ship inside both archives
|
||||
([#371](https://git.eeqj.de/sneak/AutistMask/issues/371)). Neither manifest
|
||||
@@ -1066,6 +1976,3 @@ but the review is broader than any of them.
|
||||
|
||||
Only work that has no issue of its own belongs here; everything else is on the
|
||||
tracker.
|
||||
|
||||
- Cut 1.0.0 once the milestone is empty, then continue tagging as milestones
|
||||
land.
|
||||
|
||||
@@ -15,6 +15,15 @@ const DIST_CHROME = path.join(DIST, "chrome");
|
||||
const DIST_FIREFOX = path.join(DIST, "firefox");
|
||||
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
|
||||
// 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
|
||||
@@ -31,7 +40,7 @@ const AUDITED_MODULE = "src/shared/constants.js";
|
||||
// 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
|
||||
// worker is protected by default rather than by someone remembering this file.
|
||||
// Dockerfile:42 runs `make build`, so it is enforced in CI.
|
||||
// The Dockerfile's last stage runs `make build`, so it is enforced in CI.
|
||||
|
||||
// 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
|
||||
@@ -441,6 +450,10 @@ function getBuildInfo() {
|
||||
async function build() {
|
||||
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();
|
||||
if (!receiptPath) {
|
||||
console.warn(
|
||||
@@ -575,15 +588,10 @@ async function build() {
|
||||
copyIcons(distDir);
|
||||
}
|
||||
|
||||
// copy manifests
|
||||
copyEmitted(
|
||||
path.join(__dirname, "manifest", "chrome.json"),
|
||||
path.join(DIST_CHROME, "manifest.json"),
|
||||
);
|
||||
copyEmitted(
|
||||
path.join(__dirname, "manifest", "firefox.json"),
|
||||
path.join(DIST_FIREFOX, "manifest.json"),
|
||||
);
|
||||
// copy manifests, the same files copyIcons() read
|
||||
for (const [distDir, manifestPath] of MANIFEST_SOURCES) {
|
||||
copyEmitted(manifestPath, path.join(distDir, "manifest.json"));
|
||||
}
|
||||
|
||||
assertForbiddenTableCovered(forbiddenRecord);
|
||||
|
||||
@@ -597,27 +605,94 @@ async function build() {
|
||||
console.log("Build complete: dist/chrome/ and dist/firefox/");
|
||||
}
|
||||
|
||||
// Run only as a program. Required as a module — which is how
|
||||
// tests/buildForbiddenInputs.test.js reaches the checks below — this file
|
||||
// builds nothing and writes nothing.
|
||||
if (require.main === module) {
|
||||
build().catch((err) => {
|
||||
console.error(
|
||||
`Build failed: ${err && err.message ? err.message : err}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Exported for tests/buildForbiddenInputs.test.js only. The prohibition these
|
||||
// three functions enforce is the guarantee behind
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/324, and `make check` does not
|
||||
// run `make build` — so they are unit tested against synthetic metafiles
|
||||
// rather than being exercised only by CI, where "it ran" is not "it works".
|
||||
// Run only as a program. Required as a module — which is how
|
||||
// tests/buildForbiddenInputs.test.js and tests/buildWatch.test.js reach the
|
||||
// functions below — this file builds nothing and writes nothing.
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
// An argument this file does not know fails rather than being ignored.
|
||||
if (args.length > 1 || (args.length === 1 && args[0] !== "--watch")) {
|
||||
console.error("usage: node build.js [--watch]");
|
||||
process.exit(2);
|
||||
}
|
||||
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
|
||||
// tests/buildForbiddenInputs.test.js. The prohibition those enforce is the
|
||||
// guarantee behind https://git.eeqj.de/sneak/AutistMask/issues/324, and
|
||||
// `make check` does not run `make build` — so they are unit tested against
|
||||
// synthetic metafiles rather than being exercised only by CI, where "it ran"
|
||||
// is not "it works".
|
||||
module.exports = {
|
||||
importChain,
|
||||
newForbiddenRecord,
|
||||
recordBundledInputs,
|
||||
assertNoForbiddenInputs,
|
||||
assertForbiddenTableCovered,
|
||||
watch,
|
||||
};
|
||||
|
||||
+659
-319
File diff suppressed because it is too large
Load Diff
+225
@@ -0,0 +1,225 @@
|
||||
# Releasing AutistMask
|
||||
|
||||
A release is a tagged commit on the `main` branch and the two archives that
|
||||
`make package` writes from that commit. This file is the procedure, in order.
|
||||
|
||||
Only the owner ([@sneak](https://sneak.berlin)):
|
||||
|
||||
- decides whether and when a release is tagged;
|
||||
- creates, moves or deletes a tag;
|
||||
- decides where the archives are published.
|
||||
|
||||
Anyone can run steps 1 to 4. No script in this repository uploads or publishes
|
||||
the archives. None signs them, and none packs a CRX (Chrome's packaged extension
|
||||
file).
|
||||
|
||||
## Terms
|
||||
|
||||
- The tracker is the repository's Gitea site (Gitea is the software that hosts
|
||||
the repository): https://git.eeqj.de/sneak/AutistMask. The tracker's Actions
|
||||
page, https://git.eeqj.de/sneak/AutistMask/actions, lists the workflow runs.
|
||||
- A workflow is a file under `.gitea/workflows/` that lists jobs. The tracker
|
||||
runs every workflow on each push. Each job runs one script on the tracker's
|
||||
runner (the machine that runs jobs).
|
||||
- The release commit is a commit on `main`. Releases are tagged from `main` only
|
||||
(`TODO.md`, Workflow).
|
||||
- A clean checkout is a working tree (the checked-out files) at the release
|
||||
commit where `git status --porcelain` prints nothing. The build stamps the
|
||||
commit hash (the commit's id) into the extension and appends `-dirty` when the
|
||||
tree has local changes.
|
||||
- A tag is a name that git attaches to one commit. Release tags read `vX.Y.Z`,
|
||||
such as `v0.1.0`.
|
||||
- A digest is the sha256 hash of a file (a fixed-length fingerprint of its
|
||||
bytes), written as 64 hexadecimal characters.
|
||||
|
||||
## What you need
|
||||
|
||||
- A clean checkout of the release commit.
|
||||
- `make`.
|
||||
- Docker (a tool that runs programs in containers), for `script/cibuild` in
|
||||
step 1. `make package` needs no Docker.
|
||||
- Node and yarn, on the `PATH`. `make package` runs both directly.
|
||||
`script/bootstrap` can install both through nvm (a Node version manager), but
|
||||
it leaves them off the `PATH`.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Confirm the release commit is green
|
||||
|
||||
The `check` workflow (`.gitea/workflows/check.yml`) runs `script/cibuild` on
|
||||
every push. `script/cibuild` runs:
|
||||
|
||||
- `script/bootstrap`, which installs the dependencies;
|
||||
- `script/check`, which is what `make check` runs;
|
||||
- the Docker image build (an image is Docker's packaged form of a program).
|
||||
|
||||
`script/check` runs:
|
||||
|
||||
- `script/test`: the tests, in Docker;
|
||||
- `script/lint`, which runs three checks in Docker:
|
||||
- ESLint, a code checker;
|
||||
- `prettier --check .`, the formatting check;
|
||||
- `script/check-censored`, which fails when the name that `RULES.md` bars
|
||||
appears outside its allowed places;
|
||||
- `script/fmt-check`: `prettier --check .` again, on the host.
|
||||
|
||||
The `e2e` workflow (`.gitea/workflows/e2e.yml`) has two jobs. One runs
|
||||
`script/test-e2e`, the browser tests in Chrome. The other runs
|
||||
`script/test-e2e-firefox`, the browser tests in Firefox. A red `e2e` job does
|
||||
not fail the `check` run.
|
||||
|
||||
Run `script/cibuild` on the clean checkout. `script/cibuild` runs the same steps
|
||||
as the `check` run, and must exit 0.
|
||||
|
||||
Check: on the Actions page, the `check` run for the release commit succeeded,
|
||||
and both `e2e` jobs for that commit succeeded. `script/cibuild` exited 0 on the
|
||||
clean checkout.
|
||||
|
||||
### 2. Confirm the version
|
||||
|
||||
Three files declare the version:
|
||||
|
||||
- `package.json`;
|
||||
- `manifest/chrome.json`;
|
||||
- `manifest/firefox.json`.
|
||||
|
||||
`script/lib/version.js` fails the build when the three disagree.
|
||||
`tests/version.test.js` fails when the version is not three numbers separated by
|
||||
dots. Nothing compares the version with a tag. That comparison is this step.
|
||||
|
||||
Check: all three files read the same `X.Y.Z`, and `vX.Y.Z` is the tag that the
|
||||
owner creates in step 5.
|
||||
|
||||
### 3. Build and package
|
||||
|
||||
On the clean checkout, run:
|
||||
|
||||
```bash
|
||||
make setup
|
||||
make package
|
||||
```
|
||||
|
||||
`make setup` installs two things:
|
||||
|
||||
- the dependencies, from the lockfile `yarn.lock`, which pins each one's
|
||||
version;
|
||||
- the git pre-commit hook, a script that git runs before each commit. Here the
|
||||
hook runs `script/check`.
|
||||
|
||||
`make package` runs `make build`, then `script/lib/package.js`.
|
||||
|
||||
`make build`:
|
||||
|
||||
- runs `build.js`. `build.js` writes `dist/chrome/`, `dist/firefox/` and a build
|
||||
receipt. The receipt lists every file that `build.js` wrote, with each file's
|
||||
digest. The receipt lives outside the repository and is deleted when the build
|
||||
ends;
|
||||
- runs `script/verify-build --expect release` against the receipt. The check
|
||||
fails when:
|
||||
- `dist/` differs from the receipt;
|
||||
- a bundle (one JavaScript file built from many source files) was compiled
|
||||
with the build-time constant `DEBUG` on;
|
||||
- any file carries the test recovery phrase, which a debug build gives every
|
||||
new wallet;
|
||||
- runs `script/check-censored --require-dist` against the built files;
|
||||
- removes `dist/` when any of those steps fails.
|
||||
|
||||
`script/lib/package.js`:
|
||||
|
||||
- empties `release/`;
|
||||
- refuses a symbolic link (a file that points at another file) under `dist/`;
|
||||
- checks that the browser's directory (`dist/chrome/` or `dist/firefox/`) holds
|
||||
every file named in `manifest.json` or in the popup's HTML page.
|
||||
`manifest.json` is the file that describes the extension to the browser. The
|
||||
popup is the window that the toolbar button opens;
|
||||
- checks that the `version` in each `manifest.json` is the version from step 2;
|
||||
- writes one archive per browser;
|
||||
- reads each archive back;
|
||||
- compares every member (a file inside the archive) with `dist/` byte for byte;
|
||||
- writes `SHA256SUMS`, with one line per archive:
|
||||
- the digest;
|
||||
- two spaces;
|
||||
- the file name.
|
||||
|
||||
`make package` writes three files:
|
||||
|
||||
```
|
||||
release/autistmask-chrome-<version>.zip
|
||||
release/autistmask-firefox-<version>.xpi
|
||||
release/SHA256SUMS
|
||||
```
|
||||
|
||||
The `.xpi` is Firefox's add-on package file (an add-on is Firefox's word for an
|
||||
extension). The `.xpi` is not signed.
|
||||
|
||||
`make package` prints a line that starts `Not shipped` and names `styles.css`.
|
||||
This is expected: each archive holds its own copy of the stylesheet.
|
||||
|
||||
Check: `release/` holds those three files, and `<version>` in their names is the
|
||||
version from step 2.
|
||||
|
||||
### 4. Verify the digests
|
||||
|
||||
From `release/`, run `sha256sum -c SHA256SUMS`.
|
||||
|
||||
The archives are deterministic for a given `dist/`: the same `dist/` always
|
||||
gives the same bytes. `script/lib/zip.js`:
|
||||
|
||||
- sorts the members;
|
||||
- sets every member's timestamp (its recorded date) to 1980-01-01;
|
||||
- compresses each member the same way every time.
|
||||
|
||||
`dist/` itself carries the build date. `build.js` compiles the build date, a UTC
|
||||
(universal time) day, into the extension. So two builds of the same commit on
|
||||
the same UTC day give byte-identical archives. Builds on different days differ.
|
||||
|
||||
Check: `sha256sum -c` prints `OK` for both archives. On the same UTC day, a
|
||||
second `make package` from another clean checkout of the same commit gives the
|
||||
same `SHA256SUMS`.
|
||||
|
||||
### 5. Tag the release commit
|
||||
|
||||
Owner only. Only the owner decides whether and when a release is tagged. Only
|
||||
the owner creates, moves or deletes a tag. On the release commit, the owner
|
||||
runs:
|
||||
|
||||
```bash
|
||||
git tag -a vX.Y.Z -m "AutistMask X.Y.Z"
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
`-a` makes an annotated tag: a tag that carries a message and records who made
|
||||
the tag and when. `v0.1.0` is one.
|
||||
|
||||
Once the tag exists, `git describe --tags` on the release commit prints
|
||||
`vX.Y.Z`. `script/cibuild` and `script/docker` pass that value to the Docker
|
||||
image build as `VERSION`.
|
||||
|
||||
Check: `git rev-parse vX.Y.Z^{commit}` prints the release commit's hash, and the
|
||||
tag is listed at https://git.eeqj.de/sneak/AutistMask/tags.
|
||||
|
||||
### 6. Publish the archives
|
||||
|
||||
Owner only. The owner decides where the archives are published. No script in
|
||||
this repository uploads or publishes them.
|
||||
|
||||
Whoever receives an archive installs it as follows.
|
||||
|
||||
Chrome loads the extension from a directory:
|
||||
|
||||
- unzip the archive into a new, empty directory, for example
|
||||
`unzip autistmask-chrome-0.1.0.zip -d autistmask-chrome`;
|
||||
- open `chrome://extensions/` and turn on "Developer mode";
|
||||
- click "Load unpacked" and pick that directory.
|
||||
|
||||
Chrome then reports the extension id `gipbhkogfopeahplcjhipkgpcimdpkip`. The id
|
||||
is the fixed string that identifies the extension to Chrome; the `key` in
|
||||
`manifest/chrome.json` sets the id.
|
||||
|
||||
Release builds of Firefox refuse to install an unsigned add-on. Desktop Firefox
|
||||
115 or newer, in any edition, loads the `.xpi` as a temporary add-on from the
|
||||
`about:debugging` page. Firefox unloads a temporary add-on when Firefox exits.
|
||||
The section "Installing on Firefox" in `README.md` has the details.
|
||||
|
||||
Check: each archive installs on a fresh browser profile (a new, empty set of
|
||||
browser data) by the route above.
|
||||
+1
-2
@@ -153,8 +153,7 @@ module.exports = [
|
||||
},
|
||||
},
|
||||
|
||||
// The helpers the script/ entrypoints call: plain node programs too, run
|
||||
// from a shell script rather than from yarn, and never bundled.
|
||||
// The files in script/lib/ run on plain node too, and are never bundled.
|
||||
{
|
||||
files: ["script/lib/**/*.js"],
|
||||
languageOptions: {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"permissions": ["storage", "activeTab", "alarms"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"content_security_policy": {
|
||||
"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'"
|
||||
"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'"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Minimal Ethereum wallet for Firefox",
|
||||
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
|
||||
"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'",
|
||||
"incognito": "not_allowed",
|
||||
"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'",
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
@@ -27,7 +28,8 @@
|
||||
"web_accessible_resources": ["src/content/inpage.js"],
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "autistmask@sneak.berlin"
|
||||
"id": "autistmask@sneak.berlin",
|
||||
"strict_min_version": "115.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-4
@@ -6,23 +6,28 @@
|
||||
"license": "GPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "jest --forceExit",
|
||||
"test:verbose": "jest --forceExit --verbose",
|
||||
"test": "node script/lib/jest.js --maxWorkers=3",
|
||||
"test:verbose": "node script/lib/jest.js --maxWorkers=3 --verbose",
|
||||
"build": "node build.js",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fmt": "prettier --write .",
|
||||
"fmt-check": "prettier --check ."
|
||||
},
|
||||
"jest": {
|
||||
"setupFiles": [
|
||||
"<rootDir>/tests/support/navigatorLocks.js"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tailwindcss/cli": "^4.2.1",
|
||||
"@tailwindcss/cli": "4.3.1",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "10.8.1",
|
||||
"globals": "17.11.0",
|
||||
"jest": "^30.2.0",
|
||||
"playwright-core": "1.56.0",
|
||||
"prettier": "^3.8.1",
|
||||
"tailwindcss": "^4.2.1"
|
||||
"tailwindcss": "4.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ethereum-blockies-base64": "^1.0.2",
|
||||
|
||||
@@ -118,6 +118,7 @@ ensure_yarn() {
|
||||
fi
|
||||
}
|
||||
|
||||
# yarn warns about the workspaces field in eslint and babel-plugin-istanbul; no newer release drops it.
|
||||
install_js_deps() {
|
||||
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
|
||||
@@ -127,6 +128,40 @@ install_js_deps() {
|
||||
fi
|
||||
}
|
||||
|
||||
# run_node: run node from the repo root, through nvm when node is not on PATH;
|
||||
# the script comes on stdin
|
||||
run_node() {
|
||||
if missing node && [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && node"
|
||||
else
|
||||
node
|
||||
fi
|
||||
}
|
||||
|
||||
# yarn install exits 0 without touching node_modules once
|
||||
# node_modules/.yarn-integrity matches yarn.lock, so a package deleted from
|
||||
# node_modules stays deleted. Fail unless node finds every package listed in
|
||||
# dependencies and devDependencies of package.json, by its package.json. When a
|
||||
# package's exports does not list that file, node throws
|
||||
# ERR_PACKAGE_PATH_NOT_EXPORTED, which it can only do once it has found the
|
||||
# package, so that error counts as found.
|
||||
check_js_deps() {
|
||||
run_node <<'EOF'
|
||||
const { dependencies, devDependencies } = require("./package.json");
|
||||
for (const name of Object.keys({ ...dependencies, ...devDependencies })) {
|
||||
try {
|
||||
require.resolve(name + "/package.json");
|
||||
} catch (e) {
|
||||
if (e.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") {
|
||||
console.error(`bootstrap: node cannot find ${name} after yarn install`);
|
||||
console.error(" fix: rm -rf node_modules && make bootstrap");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
@@ -136,6 +171,7 @@ main() {
|
||||
ensure_node
|
||||
ensure_yarn
|
||||
install_js_deps
|
||||
check_js_deps
|
||||
|
||||
echo "bootstrap complete"
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
#!/bin/sh
|
||||
# script/check: run all checks (test, test-verify-build, lint, fmt-check).
|
||||
# Our own extension to scripts-to-rule-them-all. Must not modify any files.
|
||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
||||
# extension to scripts-to-rule-them-all. test and lint are Docker
|
||||
# phases; fmt-check is native, because a formatter writes the working
|
||||
# tree. Must not modify any files.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
|
||||
main() {
|
||||
"$SCRIPT_DIR/test"
|
||||
"$SCRIPT_DIR/test-verify-build"
|
||||
"$SCRIPT_DIR/check-censored"
|
||||
"$SCRIPT_DIR/lint"
|
||||
"$SCRIPT_DIR/fmt-check"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/bin/sh
|
||||
# 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
|
||||
# deliberate. Our own extension to scripts-to-rule-them-all, run from
|
||||
# script/check and from make build.
|
||||
# deliberate. Our own extension to scripts-to-rule-them-all, run by the
|
||||
# Dockerfile's lint phase and by make build.
|
||||
#
|
||||
# Where the name is allowed, and why each one is not negotiable away:
|
||||
#
|
||||
@@ -18,6 +18,8 @@
|
||||
# (src/shared/balances.js), and this contract's on-chain name is that
|
||||
# string, so censoring the repo cannot stop the wallet displaying it.
|
||||
# Dropping the entry instead would cost the user MUSD spoof detection.
|
||||
# script/lib/token-enrichment.json, the fetched data tokenList.js's names
|
||||
# are written from, carries the same name for the same reason.
|
||||
#
|
||||
# Everything else fails, in the working tree and under dist/. The last two are
|
||||
# literals rather than whole files, so they are enforced by counting, and each
|
||||
@@ -102,8 +104,8 @@ allowed_literals_for() {
|
||||
src/content/inpage.js | dist/*/src/content/inpage.js)
|
||||
printf 'is%s\n_%s\n' "$NAME" "$NAME" >"$ALLOWED_LITERALS_FILE"
|
||||
;;
|
||||
src/shared/tokenList.js | dist/*/src/background/index.js | \
|
||||
dist/*/src/popup/index.js)
|
||||
src/shared/tokenList.js | script/lib/token-enrichment.json | \
|
||||
dist/*/src/background/index.js | dist/*/src/popup/index.js)
|
||||
printf '%s USD\n' "$NAME" >"$ALLOWED_LITERALS_FILE"
|
||||
;;
|
||||
esac
|
||||
|
||||
+19
-4
@@ -1,13 +1,28 @@
|
||||
#!/bin/sh
|
||||
# script/cibuild: run the CI build. The Dockerfile runs make check, so
|
||||
# a successful build implies all checks pass.
|
||||
# script/cibuild: run the CI build. It bootstraps first: a CI runner
|
||||
# checks out and runs this and nothing else, and script/fmt-check runs
|
||||
# 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
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build .
|
||||
"$SCRIPT_DIR/bootstrap"
|
||||
"$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 "$@"
|
||||
|
||||
@@ -3,22 +3,21 @@
|
||||
# step fails, remove dist/ before returning its exit status. Our own extension
|
||||
# to scripts-to-rule-them-all, wrapped around every step of make build.
|
||||
#
|
||||
# Why: with AUTISTMASK_DEBUG=1 exported in the calling shell, make build
|
||||
# compiles a debug bundle and then fails on it in script/verify-build — but the
|
||||
# bundle is already written. It is loadable, and every wallet it creates gets
|
||||
# the publicly committed test recovery phrase from src/shared/constants.js. A
|
||||
# failed release build that leaves that behind is a smaller version of the trap
|
||||
# the verifier exists to close, and "the failure was loud" only works on an
|
||||
# operator who does not load dist/chrome/ anyway. Removing the artifact does not
|
||||
# depend on that.
|
||||
# Why: with AUTISTMASK_DEBUG=1 exported, make build compiles a debug bundle and
|
||||
# then fails on it in script/verify-build, after the bundle is written. It is
|
||||
# loadable, and every wallet it creates gets the publicly committed test
|
||||
# recovery phrase from src/shared/constants.js, so a failed build must not leave
|
||||
# it behind. The removal is never silent: it says on stderr that dist/ is gone
|
||||
# and why.
|
||||
#
|
||||
# Two things this deliberately does not do. It does not wrap make build-debug: a
|
||||
# debug build that failed is not a mistakable artifact, and its output is the
|
||||
# evidence of what went wrong. And it never removes anything on a step that
|
||||
# SUCCEEDS, including the final check-censored --require-dist pass.
|
||||
#
|
||||
# The removal is never silent: it says dist/ is gone and why, on stderr, above
|
||||
# the build's own failure.
|
||||
# A failed check-censored --require-dist removes dist/ like any other step: a
|
||||
# dist/ not cleared of the name RULES.md bars must not ship either. A step that
|
||||
# succeeds removes nothing. An interrupt (Ctrl-C) while a step runs removes
|
||||
# nothing and says nothing, even when the step catches it and exits with a
|
||||
# status of its own: the wrapper exits with 130 once the step has ended. An
|
||||
# interrupt is not a build failure, and whoever interrupted the build knows it
|
||||
# did not finish. make build-debug is not wrapped: a debug build that failed is
|
||||
# not a mistakable artifact, and its output is the evidence of what went wrong.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
@@ -66,12 +65,20 @@ main() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# An interrupt is not a build failure: once the step has ended, exit
|
||||
# without removing anything, even if the step caught the interrupt and
|
||||
# exited with a status of its own. bash as /bin/sh would otherwise carry on.
|
||||
trap 'exit 130' INT
|
||||
|
||||
_status=0
|
||||
"$@" || _status=$?
|
||||
|
||||
[ "$_status" -ne 0 ] || return 0
|
||||
|
||||
discard_dist
|
||||
# A message that cannot be written, to a closed stderr or to a pipe nobody
|
||||
# reads any more, must not replace the step's status.
|
||||
trap '' PIPE
|
||||
discard_dist || true
|
||||
exit "$_status"
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -1,6 +1,8 @@
|
||||
#!/bin/sh
|
||||
# script/docker: build the Docker image tagged with the project name.
|
||||
# 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
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -8,7 +10,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
||||
# 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 "$@"
|
||||
|
||||
+27
-1
@@ -4,10 +4,36 @@ set -eu
|
||||
|
||||
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() {
|
||||
cd "$ROOT"
|
||||
# Without the repo's own prettier, yarn runs whatever prettier is on
|
||||
# the PATH, which may be a version that formats files differently.
|
||||
if [ ! -x node_modules/.bin/prettier ]; then
|
||||
echo "fmt: this repo's prettier is not installed in node_modules/," \
|
||||
"so run script/bootstrap first." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Formatting..."
|
||||
yarn run fmt 2>&1
|
||||
run_yarn run fmt
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+27
-1
@@ -5,10 +5,36 @@ set -eu
|
||||
|
||||
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() {
|
||||
cd "$ROOT"
|
||||
# Without the repo's own prettier, yarn runs whatever prettier is on
|
||||
# the PATH, which may be a version that formats files differently.
|
||||
if [ ! -x node_modules/.bin/prettier ]; then
|
||||
echo "fmt-check: this repo's prettier is not installed in" \
|
||||
"node_modules/, so run script/bootstrap first." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Checking formatting..."
|
||||
yarn run fmt-check 2>&1
|
||||
run_yarn run fmt-check
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Writes the names and URLs in script/lib/token-enrichment.json, which
|
||||
// script/lib/enrich-tokens.js fetches, into the matching entries of
|
||||
// src/shared/tokenList.js: the name and url an entry already has are replaced,
|
||||
// and an entry without them gets them after its decimals. Where the fetched
|
||||
// data has no URL for a token, EXTRA_URLS below supplies one. An entry the data
|
||||
// does not mention is left as it is. An address in the data or in EXTRA_URLS
|
||||
// that matches no entry in tokenList.js, capitals included, is an error naming
|
||||
// that address. Run by
|
||||
// `make apply-enrichment`; no build runs it. tests/applyEnrichment.test.js
|
||||
// checks that the committed tokenList.js is exactly what this writes from the
|
||||
// committed data.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const TOKEN_LIST = path.join(__dirname, "..", "..", "src/shared/tokenList.js");
|
||||
|
||||
// Project websites by token address, each spelled as tokenList.js spells it. A
|
||||
// token gets its URL here only where the fetched data has none, so an address
|
||||
// the data has a URL for is never used.
|
||||
const EXTRA_URLS = {
|
||||
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": "https://weth.io",
|
||||
"0xdAC17F958D2ee523a2206206994597C13D831ec7": "https://tether.to",
|
||||
"0xB8c77482e45F1F44dE1745F52C74426C631bDD52": "https://www.bnbchain.org",
|
||||
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": "https://www.circle.com/usdc",
|
||||
"0xdC035D45d973E3EC169d2276DDab16f1e407384F": "https://sky.money",
|
||||
"0x2AF5D2aD76741191D15Dfe7bF6aC92d4Bd912Ca3": "https://www.bitfinex.com",
|
||||
"0x514910771AF9Ca656af840dff83E8264EcF986CA": "https://chain.link",
|
||||
"0x4c9EDD5852cd905f086C759E8383e09bff1E68B3": "https://ethena.fi",
|
||||
"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d":
|
||||
"https://www.worldlibertyfinancial.com",
|
||||
"0x6B175474E89094C44Da98b954EedeAC495271d0F": "https://makerdao.com",
|
||||
"0x6c3ea9036406852006290770BEdFcAbA0e23A0e8":
|
||||
"https://www.paypal.com/us/digital-wallet/manage-money/crypto/pyusd",
|
||||
"0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE": "https://shibatoken.com",
|
||||
"0xdA5e1988097297dCdc1f90D4dFE7909e847CBeF6":
|
||||
"https://www.worldlibertyfinancial.com",
|
||||
"0xA0b73E1Ff0B80914AB6fe0444E65848C4C34450b": "https://crypto.com",
|
||||
"0x582d872A1B094FC48F5DE31D3B73F2D9bE47def1": "https://ton.org",
|
||||
"0x68749665FF8D2d112Fa859AA293F07A622782F38": "https://gold.tether.to",
|
||||
"0x196C20DA81Fbc324EcdF55501e95Ce9f0bD84d14": "https://polkadot.network",
|
||||
"0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984": "https://uniswap.org",
|
||||
"0x45804880De22913dAFE09f4980848ECE6EcbAf78":
|
||||
"https://www.paxos.com/paxgold",
|
||||
"0x3c3a81e81dc49A522A592e7622A7E711c06bf354": "https://www.mantle.xyz",
|
||||
"0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9": "https://aave.com",
|
||||
"0x75231F58b43240C9718Dd58B4967c5114342a86c": "https://www.okx.com",
|
||||
"0x6982508145454Ce325dDbE47a25d4ec3d2311933": "https://www.pepe.vip",
|
||||
"0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32": "https://lido.fi",
|
||||
"0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72": "https://ens.domains",
|
||||
"0xD533a949740bb3306d119CC777fa900bA034cd52": "https://curve.fi",
|
||||
"0x111111111117dC0aa78b770fA6A738034120C302": "https://1inch.io",
|
||||
"0x163f8C2467924be0ae7B5347228CABF260318753": "https://worldcoin.org",
|
||||
"0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F": "https://synthetix.io",
|
||||
"0xBBbbCA6A901c926F240b89EacB641d8Aec7AEafD": "https://loopring.org",
|
||||
"0xC669928185DbCE49d2230CC9B0979BE6DC797957": "https://bitstable.finance",
|
||||
"0xfAbA6f8e4a5E8Ab82F62fe7C39859FA577269BE3": "https://ondo.finance",
|
||||
"0x6De037ef9aD2725EB40118Bb1702EBb27e4Aeb24": "https://www.render.com",
|
||||
"0x4a220E6096B25EADb88358cb44068A3248254675": "https://www.quant.network",
|
||||
"0xba5BDe662c17e2aDFF1075610382B9B691296350": "https://superrare.com",
|
||||
"0xc00e94Cb662C3520282E6f5717214004A7f26888": "https://compound.finance",
|
||||
"0xE41d2489571d322189246DaFA5ebDe1F4699F498": "https://0x.org",
|
||||
"0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e": "https://yearn.fi",
|
||||
"0x6fB3e0A217407EFFf7Ca062D46c26E5d60a14d69": "https://iotex.io",
|
||||
"0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF": "https://immutable.com",
|
||||
"0x0F5D2fB29fb7d3CFeE444a200298f468908cC942": "https://decentraland.org",
|
||||
"0x3845badAde8e6dFF049820680d1F14bD3903a5d0": "https://www.sandbox.game",
|
||||
"0x6810e776880C02933D47DB1b9fc05908e5386b96": "https://gnosis.io",
|
||||
"0x5f98805A4E8be255a32880FDeC7F6728C6568bA0": "https://www.liquity.org",
|
||||
"0x0D8775F648430679A709E98d2b0Cb6250d2887EF":
|
||||
"https://basicattentiontoken.org",
|
||||
"0xc944E90C64B2c07662A292be6244BDf05Cda44a7": "https://thegraph.com",
|
||||
"0x853d955aCEf822Db058eb8505911ED77F175b99e": "https://frax.finance",
|
||||
"0x0000000000085d4780B73119b644AE5ecd22b376": "https://www.trueusd.com",
|
||||
"0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0": "https://frax.finance",
|
||||
"0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E": "https://curve.fi",
|
||||
"0x808507121B80c02388fAd14726482e061B8da827": "https://pendle.finance",
|
||||
"0xCa14007Eff0dB1f8135f4C25B34De49AB0d42766": "https://starknet.io",
|
||||
"0x8457CA5040ad67fdebbCC8EdCE889A335Bc0fbFB": "https://alt.layer",
|
||||
"0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1": "https://arbitrum.io",
|
||||
"0x6985884C4392D348587B19cb9eAAf157F13271cd": "https://layerzero.network",
|
||||
"0xFe0c30065B384F05761f15d0CC899D4F9F9Cc0eB": "https://ethfi.org",
|
||||
"0x5283D291DBCF85356A21bA090E6db59121208b44": "https://blur.io",
|
||||
"0x43Dfc4159D86F3A37A5A4B3D4580b888ad7d4DDd": "https://dydx.exchange",
|
||||
"0xcf0C122c6b73ff809C693DB761e7BaeBe62b6a2E": "https://floki.com",
|
||||
"0xe53EC727dbDEB9E2d5456c3be40cFF031AB40A55": "https://superfarm.com",
|
||||
"0x32353A6C91143bfd6C7d363B546e62a9A2489A20": "https://www.agora.finance",
|
||||
"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c": "https://www.euro-coin.com",
|
||||
"0xA35923162C49cF95e6BF26623385eb431ad920D3": "https://www.turbo-eth.com",
|
||||
"0x1151CB3d861920e07a38e03eEAd12C32178567F6": "https://bonk.com",
|
||||
"0x090185f2135308BaD17527004364eBcC2D37e5F6": "https://spell.club",
|
||||
"0xAf5191B0De278C7286d6C7CC6ab6BB8A73bA2Cd6": "https://stargate.finance",
|
||||
};
|
||||
|
||||
// Returns tokenList, the text of tokenList.js, with the name and url of each
|
||||
// token in enrichment, the parsed data file, written into its entry.
|
||||
function applyEnrichment(tokenList, enrichment) {
|
||||
for (const addr of Object.keys(EXTRA_URLS)) {
|
||||
if (!tokenList.includes(`address: "${addr}"`)) {
|
||||
throw new Error(
|
||||
`No entry in src/shared/tokenList.js has the address ${addr} from EXTRA_URLS.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let result = tokenList;
|
||||
|
||||
for (const [addr, data] of Object.entries(enrichment)) {
|
||||
// Escape special regex characters in the address
|
||||
const escaped = addr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
// Match the token block: { address: "...", symbol: "...", decimals: N, }
|
||||
// and the name: "...", url: "..." after decimals when it has them.
|
||||
// The closing }, may have trailing whitespace
|
||||
const blockRe = new RegExp(
|
||||
`(\\{\\s*address:\\s*"${escaped}",\\s*symbol:\\s*"[^"]+",\\s*decimals:\\s*\\d+)` +
|
||||
`(?:,\\s*name:\\s*"(?:[^"\\\\]|\\\\.)*",\\s*url:\\s*"(?:[^"\\\\]|\\\\.)*")?` +
|
||||
`,?(\\s*\\})`,
|
||||
);
|
||||
|
||||
const match = result.match(blockRe);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`No entry in src/shared/tokenList.js matches the address ${addr} from script/lib/token-enrichment.json.`,
|
||||
);
|
||||
}
|
||||
|
||||
// A URL from EXTRA_URLS only where the fetched data has none.
|
||||
const url = data.url || EXTRA_URLS[addr] || "";
|
||||
|
||||
// Build replacement: original fields + name + url
|
||||
const nameEscaped = data.name
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"');
|
||||
const urlEscaped = url.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
const replacement =
|
||||
match[1] +
|
||||
`,\n name: "${nameEscaped}",` +
|
||||
`\n url: "${urlEscaped}",` +
|
||||
match[2];
|
||||
|
||||
// A function, so a "$" in a name is not read as a replacement pattern.
|
||||
result = result.replace(blockRe, () => replacement);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const enrichment = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "token-enrichment.json"), "utf8"),
|
||||
);
|
||||
const original = fs.readFileSync(TOKEN_LIST, "utf8");
|
||||
fs.writeFileSync(TOKEN_LIST, applyEnrichment(original, enrichment));
|
||||
}
|
||||
|
||||
module.exports = { applyEnrichment };
|
||||
@@ -0,0 +1,115 @@
|
||||
// Fetches a name and a website for every token in src/shared/tokenList.js and
|
||||
// writes them, keyed by address, to script/lib/token-enrichment.json, which
|
||||
// script/lib/apply-enrichment.js then writes into tokenList.js. Run by
|
||||
// `make enrich-tokens`; no build runs it and nothing here is shipped.
|
||||
//
|
||||
// Names come from CoinGecko's coin list, fetched whole in one request, matched
|
||||
// on the token's Ethereum contract address. A token CoinGecko does not list
|
||||
// gets its symbol as its name. The list is live data with no fixed version, so
|
||||
// a later run can return different names.
|
||||
//
|
||||
// Websites come from the ethereum-lists/tokens repository, one file per token,
|
||||
// read at a pinned commit. Most tokens have no file there and get an empty URL.
|
||||
// A file that is missing is not an error; any other failed request stops the
|
||||
// run, so a network fault cannot quietly empty a URL.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const TOKEN_LIST = path.join(__dirname, "..", "..", "src/shared/tokenList.js");
|
||||
const OUTPUT = path.join(__dirname, "token-enrichment.json");
|
||||
|
||||
const COINGECKO_LIST =
|
||||
"https://api.coingecko.com/api/v3/coins/list?include_platform=true";
|
||||
// ethereum-lists/tokens at its master of 2026-02-27, when the committed
|
||||
// token-enrichment.json was made.
|
||||
const ETHEREUM_LISTS =
|
||||
"https://raw.githubusercontent.com/ethereum-lists/tokens/c3560d499a380a38e94caa7c4968d59aeffde59a/tokens/eth";
|
||||
|
||||
const CONCURRENT = 10;
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function main() {
|
||||
const src = fs.readFileSync(TOKEN_LIST, "utf8");
|
||||
|
||||
const tokens = [];
|
||||
const re =
|
||||
/\{\s*address:\s*"(0x[a-fA-F0-9]{40})",\s*symbol:\s*"([^"]+)",\s*decimals:\s*(\d+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(src)) !== null) {
|
||||
tokens.push({ address: m[1], symbol: m[2], decimals: parseInt(m[3]) });
|
||||
}
|
||||
console.error(`Parsed ${tokens.length} tokens`);
|
||||
|
||||
// --- Names from CoinGecko ---
|
||||
console.error("Fetching CoinGecko coins list...");
|
||||
const listResp = await fetch(COINGECKO_LIST);
|
||||
if (!listResp.ok) throw new Error(`CoinGecko list HTTP ${listResp.status}`);
|
||||
const coins = await listResp.json();
|
||||
|
||||
const addrMap = new Map();
|
||||
for (const coin of coins) {
|
||||
const eth = (coin.platforms?.ethereum || "").toLowerCase().trim();
|
||||
if (eth && eth.startsWith("0x")) {
|
||||
addrMap.set(eth, { name: coin.name, id: coin.id });
|
||||
}
|
||||
}
|
||||
console.error(`CoinGecko map: ${addrMap.size} Ethereum tokens`);
|
||||
|
||||
const enriched = new Map();
|
||||
let namesMatched = 0;
|
||||
for (const t of tokens) {
|
||||
const info = addrMap.get(t.address.toLowerCase());
|
||||
if (info) namesMatched++;
|
||||
enriched.set(t.address, { name: info?.name || t.symbol, url: "" });
|
||||
}
|
||||
console.error(`Matched ${namesMatched}/${tokens.length} names`);
|
||||
|
||||
// --- Websites from ethereum-lists ---
|
||||
console.error("Fetching URLs from ethereum-lists GitHub...");
|
||||
let urlsFound = 0;
|
||||
|
||||
for (let i = 0; i < tokens.length; i += CONCURRENT) {
|
||||
const batch = tokens.slice(i, i + CONCURRENT);
|
||||
await Promise.all(
|
||||
batch.map(async (t) => {
|
||||
const r = await fetch(`${ETHEREUM_LISTS}/${t.address}.json`);
|
||||
if (r.status === 404) return;
|
||||
if (!r.ok) {
|
||||
throw new Error(
|
||||
`ethereum-lists HTTP ${r.status} for ${t.address}`,
|
||||
);
|
||||
}
|
||||
const data = await r.json();
|
||||
const website = (data.website || "").trim();
|
||||
if (website && website !== "https://") {
|
||||
enriched.get(t.address).url = website;
|
||||
urlsFound++;
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (i % 100 === 0) {
|
||||
console.error(
|
||||
` GitHub: ${Math.min(i + CONCURRENT, tokens.length)}/${tokens.length}, ${urlsFound} URLs`,
|
||||
);
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
console.error(`Found ${urlsFound} URLs in ethereum-lists`);
|
||||
|
||||
// --- Output, in the layout prettier gives it ---
|
||||
const output = {};
|
||||
for (const [addr, e] of enriched) {
|
||||
output[addr] = { name: e.name, url: e.url };
|
||||
}
|
||||
fs.writeFileSync(OUTPUT, JSON.stringify(output, null, 4) + "\n");
|
||||
console.error(`Done! ${tokens.length} names, ${urlsFound} URLs.`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`enrich-tokens: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
// 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
|
||||
// performed, so no specifier syntax and no resolution rule can slip past it,
|
||||
// and Dockerfile:42 runs `make build` in CI.
|
||||
// and the Dockerfile's last stage runs `make build` in CI.
|
||||
//
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Draws the toolbar icon and writes it to every file manifest/chrome.json
|
||||
// declares under "icons". Run by `make icons`; tests/icons.test.js checks that
|
||||
// the committed files hold exactly the image this draws.
|
||||
//
|
||||
// The icon is a dark-navy rounded square carrying a teal triangle with a
|
||||
// smaller triangle cut out of it. Every coordinate below is a fraction of the
|
||||
// icon's side, so one drawing serves every size. A pixel's colour is the
|
||||
// average of an 8x8 grid of samples, one at the centre of each cell, which
|
||||
// smooths the edges.
|
||||
//
|
||||
// The PNG is written here rather than by a library: RGBA at 8 bits per
|
||||
// channel, filter type 0 (none) on every row, one IDAT chunk compressed by
|
||||
// node's zlib at level 9. The committed files were compressed by stock zlib,
|
||||
// which node's bundled zlib does not reproduce byte for byte, so the image is
|
||||
// compared rather than the file: the IHDR and every pixel.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const zlib = require("zlib");
|
||||
|
||||
const { icons } = require("../../manifest/chrome.json");
|
||||
|
||||
const NAVY = [0x10, 0x1a, 0x2e];
|
||||
const TEAL = [0x35, 0xe0, 0xc2];
|
||||
const CORNER_RADIUS = 0.22;
|
||||
const OUTER_TRIANGLE = [
|
||||
[0.5, 0.15],
|
||||
[0.115, 0.855],
|
||||
[0.885, 0.855],
|
||||
];
|
||||
const INNER_TRIANGLE = [
|
||||
[0.5, 0.4],
|
||||
[0.29, 0.7],
|
||||
[0.71, 0.7],
|
||||
];
|
||||
const SAMPLES_PER_SIDE = 8;
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]);
|
||||
|
||||
// Points are in pixels from the icon's top-left corner.
|
||||
function insideRoundedSquare(x, y, size) {
|
||||
const r = CORNER_RADIUS * size;
|
||||
// How far the point is past the start of a corner's curve, on each axis.
|
||||
const dx = Math.max(r - x, 0, x - (size - r));
|
||||
const dy = Math.max(r - y, 0, y - (size - r));
|
||||
return dx * dx + dy * dy <= r * r;
|
||||
}
|
||||
|
||||
// Inside or on an edge: the point is not on opposite sides of two edges.
|
||||
function insideTriangle(x, y, [a, b, c]) {
|
||||
const side = (p, q) =>
|
||||
(q[0] - p[0]) * (y - p[1]) - (q[1] - p[1]) * (x - p[0]);
|
||||
const sides = [side(a, b), side(b, c), side(c, a)];
|
||||
return !(sides.some((s) => s < 0) && sides.some((s) => s > 0));
|
||||
}
|
||||
|
||||
// Rounds to the nearest integer and a half to the even neighbour, as the
|
||||
// committed icons were made. Math.round takes a half up, which would change
|
||||
// some pixels by one.
|
||||
function roundHalfToEven(v) {
|
||||
const down = Math.floor(v);
|
||||
if (v - down !== 0.5) {
|
||||
return Math.round(v);
|
||||
}
|
||||
return down % 2 === 0 ? down : down + 1;
|
||||
}
|
||||
|
||||
// The RGBA bytes of the pixel whose top-left corner is (px, py).
|
||||
function pixel(px, py, size, outer, inner) {
|
||||
let inSquare = 0;
|
||||
let inTeal = 0;
|
||||
for (let j = 0; j < SAMPLES_PER_SIDE; j++) {
|
||||
const y = py + (j + 0.5) / SAMPLES_PER_SIDE;
|
||||
for (let i = 0; i < SAMPLES_PER_SIDE; i++) {
|
||||
const x = px + (i + 0.5) / SAMPLES_PER_SIDE;
|
||||
if (!insideRoundedSquare(x, y, size)) {
|
||||
continue;
|
||||
}
|
||||
inSquare++;
|
||||
if (insideTriangle(x, y, outer) && !insideTriangle(x, y, inner)) {
|
||||
inTeal++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (inSquare === 0) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
const colour = NAVY.map((navy, k) =>
|
||||
roundHalfToEven(navy + ((TEAL[k] - navy) * inTeal) / inSquare),
|
||||
);
|
||||
const alpha = roundHalfToEven((255 * inSquare) / SAMPLES_PER_SIDE ** 2);
|
||||
return [...colour, alpha];
|
||||
}
|
||||
|
||||
// A PNG chunk: the data's length, the type, the data, then the CRC-32 of the
|
||||
// type and the data.
|
||||
function chunk(type, data) {
|
||||
const typeAndData = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(zlib.crc32(typeAndData));
|
||||
return Buffer.concat([length, typeAndData, crc]);
|
||||
}
|
||||
|
||||
// The complete PNG file for the icon at `size` pixels square.
|
||||
function drawIcon(size) {
|
||||
const toPixels = (corners) => corners.map(([x, y]) => [x * size, y * size]);
|
||||
const outer = toPixels(OUTER_TRIANGLE);
|
||||
const inner = toPixels(INNER_TRIANGLE);
|
||||
|
||||
const rows = [];
|
||||
for (let py = 0; py < size; py++) {
|
||||
const row = [0]; // filter type 0: the row's bytes are stored as they are
|
||||
for (let px = 0; px < size; px++) {
|
||||
row.push(...pixel(px, py, size, outer, inner));
|
||||
}
|
||||
rows.push(Buffer.from(row));
|
||||
}
|
||||
|
||||
const header = Buffer.alloc(13); // compression, filter, interlace: all 0
|
||||
header.writeUInt32BE(size, 0); // width
|
||||
header.writeUInt32BE(size, 4); // height
|
||||
header[8] = 8; // bits per channel
|
||||
header[9] = 6; // colour type: RGBA
|
||||
|
||||
return Buffer.concat([
|
||||
PNG_SIGNATURE,
|
||||
chunk("IHDR", header),
|
||||
chunk("IDAT", zlib.deflateSync(Buffer.concat(rows), { level: 9 })),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// The image in a PNG: its IHDR data, and its rows after decompression, each
|
||||
// row's filter type byte first. With filter type 0 on every row, as drawIcon
|
||||
// writes, the rows are the pixels themselves; a file using another filter does
|
||||
// not match even where its pixels do. Refuses a file that is not a PNG, a chunk
|
||||
// whose CRC-32 is wrong, and any chunk but IHDR, IDAT and IEND, rather than
|
||||
// ignoring what it cannot compare.
|
||||
function decodePng(png) {
|
||||
if (!png.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
||||
throw new Error("not a PNG");
|
||||
}
|
||||
let header = null;
|
||||
const idat = [];
|
||||
for (let pos = 8; pos < png.length; ) {
|
||||
const length = png.readUInt32BE(pos);
|
||||
const typeAndData = png.subarray(pos + 4, pos + 8 + length);
|
||||
const type = typeAndData.toString("ascii", 0, 4);
|
||||
if (zlib.crc32(typeAndData) !== png.readUInt32BE(pos + 8 + length)) {
|
||||
throw new Error(`the ${type} chunk fails its CRC-32`);
|
||||
}
|
||||
if (type === "IHDR") {
|
||||
header = typeAndData.subarray(4);
|
||||
} else if (type === "IDAT") {
|
||||
idat.push(typeAndData.subarray(4));
|
||||
} else if (type !== "IEND") {
|
||||
throw new Error(`unexpected ${type} chunk`);
|
||||
}
|
||||
pos += 12 + length;
|
||||
}
|
||||
if (header === null) {
|
||||
throw new Error("no IHDR chunk");
|
||||
}
|
||||
return { header, rows: zlib.inflateSync(Buffer.concat(idat)) };
|
||||
}
|
||||
|
||||
// True when `file` already holds the image in `png`. A file that is missing or
|
||||
// cannot be read as a PNG does not, and is written over.
|
||||
function holdsSameImage(file, png) {
|
||||
let existing;
|
||||
try {
|
||||
existing = decodePng(fs.readFileSync(file));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const drawn = decodePng(png);
|
||||
return (
|
||||
existing.header.equals(drawn.header) && existing.rows.equals(drawn.rows)
|
||||
);
|
||||
}
|
||||
|
||||
// A file already holding the drawn image is left alone, so running this does
|
||||
// not rewrite the committed icons with node's compression.
|
||||
if (require.main === module) {
|
||||
for (const [size, file] of Object.entries(icons)) {
|
||||
const target = path.join(__dirname, "..", "..", file);
|
||||
const png = drawIcon(Number(size));
|
||||
if (holdsSameImage(target, png)) {
|
||||
console.log(`icons: ${file} already holds this image`);
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(target, png);
|
||||
console.log(`icons: wrote ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { drawIcon, decodePng };
|
||||
@@ -0,0 +1,40 @@
|
||||
// Runs jest with the arguments it is given, and fails the run when jest had to
|
||||
// force-stop the worker process that ran a test file. Jest itself only prints a
|
||||
// warning when that happens, and its run still passes. The warning means a test
|
||||
// left a timer or another open handle running. The test scripts in package.json
|
||||
// run jest through this file.
|
||||
//
|
||||
// Jest runs inside this process and writes to the terminal directly, so its
|
||||
// output looks the same as a plain jest run. This file only checks each write
|
||||
// to stderr for the warning below.
|
||||
|
||||
"use strict";
|
||||
|
||||
// The start of the warning that jest prints on stderr after it force-stops a
|
||||
// worker process.
|
||||
const WORKER_WARNING = "A worker process has failed to exit gracefully";
|
||||
|
||||
let forceStopped = false;
|
||||
|
||||
const writeStderr = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk, ...rest) => {
|
||||
if (String(chunk).includes(WORKER_WARNING)) {
|
||||
forceStopped = true;
|
||||
}
|
||||
return writeStderr(chunk, ...rest);
|
||||
};
|
||||
|
||||
// Runs after jest has finished, as the process exits.
|
||||
process.on("exit", () => {
|
||||
if (forceStopped) {
|
||||
console.error(
|
||||
"script/lib/jest.js: failing the run, because jest force-stopped " +
|
||||
"a worker process. A test left a timer or another open " +
|
||||
"handle running.",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Jest's own command-line program. It reads its arguments from process.argv.
|
||||
require("jest/bin/jest");
|
||||
@@ -1,7 +1,7 @@
|
||||
// Turn a verified dist/ into the two distributable archives.
|
||||
//
|
||||
// Invoked by script/package, which runs `make build` first so that dist/ has
|
||||
// already been checked against the build's own receipt (see the Build Receipts
|
||||
// already been checked against the build's own receipt (see the Builds
|
||||
// section of README.md). This program does not build anything and does not
|
||||
// write into dist/: it reads the emitted tree and writes release/.
|
||||
//
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+13
-41
@@ -1,51 +1,23 @@
|
||||
#!/bin/sh
|
||||
# script/lint: run the linter (eslint, then prettier --check).
|
||||
# script/lint: run the linter. Linting is a phase of the Dockerfile and
|
||||
# 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.
|
||||
#
|
||||
# Linting is containerized. ESLint results depend on the ESLint version, and
|
||||
# the pinned one is the one in the image; a host's own install must not be
|
||||
# able to decide whether this repo is green. From a host this therefore builds
|
||||
# the Dockerfile's `lint` stage, which runs this same script inside the image.
|
||||
#
|
||||
# AUTISTMASK_LINT_NATIVE is set only in that image (see the Dockerfile) and is
|
||||
# what stops the recursion, so `make check` inside the CI build lints in place
|
||||
# instead of trying to reach a docker daemon it does not have.
|
||||
# The phase is not the last stage in the file, so it is built only when
|
||||
# --target names it. --no-cache because a cached lint layer is a lint
|
||||
# that did not run. The tag makes each build replace the previous image
|
||||
# instead of leaving a dangling one behind.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
case "${AUTISTMASK_LINT_NATIVE:-}" in
|
||||
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
|
||||
docker build --no-cache \
|
||||
--target lint \
|
||||
-t "$("$SCRIPT_DIR/projectname")-lint" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+10
-40
@@ -1,49 +1,19 @@
|
||||
#!/bin/sh
|
||||
# script/test: run the test suite.
|
||||
#
|
||||
# The timeout bounds a hung suite; it is not a performance budget. On a
|
||||
# developer host the suite finishes in about 8s and REPO_POLICIES' 30s cap is
|
||||
# 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".
|
||||
# script/test: run the test suite. Testing is a phase of the Dockerfile
|
||||
# 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
|
||||
# named, --no-cache because a cached test layer is a test that did not
|
||||
# run, and a tag so each build replaces the previous image.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
TIMEOUT="${AUTISTMASK_TEST_TIMEOUT:-30}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
echo "Running tests (timeout ${TIMEOUT}s)..."
|
||||
|
||||
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
|
||||
docker build --no-cache \
|
||||
--target test \
|
||||
-t "$("$SCRIPT_DIR/projectname")-test" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+44
-16
@@ -4,7 +4,7 @@
|
||||
# scripts-to-rule-them-all.
|
||||
#
|
||||
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md
|
||||
# caps make test at 20 seconds and a browser suite does not fit. Run it
|
||||
# caps make test at 60 seconds and a browser suite does not fit. Run it
|
||||
# yourself before touching popup views. ESLint's no-undef now catches a
|
||||
# used-but-not-imported identifier in make check, but only this suite sees
|
||||
# what a view actually does when it runs.
|
||||
@@ -22,13 +22,29 @@ set -eu
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-chrome"
|
||||
|
||||
IIDFILE=""
|
||||
# Each run builds, runs and then removes an image under a tag of its own.
|
||||
# Under one tag shared by every run, a second clone's build moves the tag
|
||||
# off this run's image, and the image store can then delete that untagged
|
||||
# image before or while this run uses it. The tag is random rather than
|
||||
# $$ because CI jobs share one docker daemon, but each job's container
|
||||
# numbers its processes from scratch.
|
||||
SUFFIX="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
|
||||
NAME="$("$SCRIPT_DIR/projectname")-e2e-chrome"
|
||||
IMAGE="$NAME:$SUFFIX"
|
||||
# Each docker run below uses this container name: docker run --rm returns
|
||||
# only once its container is removed, so no run meets the one before it.
|
||||
CONTAINER="$NAME-$SUFFIX"
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$IIDFILE" ]; then
|
||||
rm -f "$IIDFILE"
|
||||
# After a third Ctrl-C docker's client exits whether or not the
|
||||
# container has stopped, and a container still running keeps the image
|
||||
# in use.
|
||||
if docker container inspect "$CONTAINER" >/dev/null 2>&1; then
|
||||
docker container rm -f "$CONTAINER" >/dev/null
|
||||
fi
|
||||
# No image to remove when the build failed or was interrupted.
|
||||
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
docker image rm "$IMAGE" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -40,19 +56,16 @@ main() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IIDFILE="$(mktemp)"
|
||||
trap cleanup EXIT
|
||||
trap 'cleanup; exit 130' INT TERM
|
||||
# HUP: the terminal or SSH session running the suite closed.
|
||||
trap 'cleanup; exit 130' HUP INT TERM
|
||||
|
||||
echo "Building the Chrome e2e image (extension included)..."
|
||||
docker build --iidfile "$IIDFILE" -t "$IMAGE" -f tests/e2e/Dockerfile .
|
||||
# --no-cache: the image build runs make build and its checks, and a
|
||||
# cached layer is a check that did not run.
|
||||
docker build --no-cache -t "$IMAGE" -f tests/e2e/Dockerfile .
|
||||
|
||||
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 suite at once, the other build can move the tag between this
|
||||
# build and this run, and the suite would then silently test the other
|
||||
# checkout.
|
||||
#
|
||||
# --ipc=host: Chromium's shared-memory needs more than the default
|
||||
# 64MB /dev/shm or renderers crash.
|
||||
# HOME=/tmp: the image's root home is not a reliable place for the
|
||||
@@ -72,11 +85,12 @@ main() {
|
||||
# probe. The image is pinned by digest, so this can only ever bite
|
||||
# on a deliberate bump.
|
||||
docker run --rm \
|
||||
--name "$CONTAINER" \
|
||||
--ipc=host \
|
||||
-e HOME=/tmp \
|
||||
-e PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1 \
|
||||
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
|
||||
"$(cat "$IIDFILE")" \
|
||||
"$IMAGE" \
|
||||
node tests/e2e/run.js
|
||||
|
||||
# Where chrome.storage.local lives, and what moves it: two unpacked loads
|
||||
@@ -90,10 +104,24 @@ main() {
|
||||
# --host-resolver-rules=MAP * ~NOTFOUND so nothing it does can leave.
|
||||
echo "Running the extension-id and storage-partition observations..."
|
||||
docker run --rm \
|
||||
--name "$CONTAINER" \
|
||||
--ipc=host \
|
||||
-e HOME=/tmp \
|
||||
"$(cat "$IIDFILE")" \
|
||||
"$IMAGE" \
|
||||
node tests/e2e/storagePartition.js
|
||||
|
||||
# Approvals left open for seven minutes in minimized windows, past the
|
||||
# point where Chrome stopped the background worker when nothing kept it
|
||||
# running. Its own program, without Playwright: Playwright keeps DevTools
|
||||
# attached to the worker, and Chrome never stops a worker DevTools is
|
||||
# attached to. It reaches nothing but its own loopback server.
|
||||
echo "Running the approvals left open for seven minutes..."
|
||||
docker run --rm \
|
||||
--name "$CONTAINER" \
|
||||
--ipc=host \
|
||||
-e HOME=/tmp \
|
||||
"$IMAGE" \
|
||||
node tests/e2e/slowApproval.js
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+31
-23
@@ -4,7 +4,7 @@
|
||||
# script/test-e2e. Our own extension to scripts-to-rule-them-all.
|
||||
#
|
||||
# Deliberately NOT called by script/check or script/test, for the same
|
||||
# reason as the Chrome suite: REPO_POLICIES.md caps make test at 20 seconds
|
||||
# reason as the Chrome suite: REPO_POLICIES.md caps make test at 60 seconds
|
||||
# and a browser suite does not fit. .gitea/workflows/e2e.yml also runs it
|
||||
# on every push, in a job separate from check.
|
||||
#
|
||||
@@ -21,13 +21,23 @@ set -eu
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
IMAGE="$("$SCRIPT_DIR/projectname")-e2e-firefox"
|
||||
|
||||
IIDFILE=""
|
||||
# A tag and a container name of this run's own, as in script/test-e2e,
|
||||
# which says why.
|
||||
SUFFIX="$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
|
||||
NAME="$("$SCRIPT_DIR/projectname")-e2e-firefox"
|
||||
IMAGE="$NAME:$SUFFIX"
|
||||
CONTAINER="$NAME-$SUFFIX"
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$IIDFILE" ]; then
|
||||
rm -f "$IIDFILE"
|
||||
# After a third Ctrl-C docker's client exits whether or not the
|
||||
# container has stopped, and a container still running keeps the image
|
||||
# in use.
|
||||
if docker container inspect "$CONTAINER" >/dev/null 2>&1; then
|
||||
docker container rm -f "$CONTAINER" >/dev/null
|
||||
fi
|
||||
# No image to remove when the build failed or was interrupted.
|
||||
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
docker image rm "$IMAGE" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -39,27 +49,23 @@ main() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IIDFILE="$(mktemp)"
|
||||
trap cleanup EXIT
|
||||
trap 'cleanup; exit 130' INT TERM
|
||||
# HUP: the terminal or SSH session running the suite closed.
|
||||
trap 'cleanup; exit 130' HUP INT TERM
|
||||
|
||||
echo "Building the pinned Firefox e2e image (extension included)..."
|
||||
docker build --iidfile "$IIDFILE" -t "$IMAGE" \
|
||||
-f tests/e2e/firefox/Dockerfile .
|
||||
# --no-cache: the image build runs make build and its checks, and a
|
||||
# cached layer is a check that did not run.
|
||||
docker build --no-cache -t "$IMAGE" -f tests/e2e/firefox/Dockerfile .
|
||||
|
||||
echo "Running the Firefox e2e suite..."
|
||||
# The image is run by ID, not by tag: where two clones of this repo run
|
||||
# the suite at once, the other build can move the tag between this
|
||||
# build and this run, and the suite would then silently test the other
|
||||
# checkout.
|
||||
#
|
||||
# --shm-size=1g: Firefox needs more than the default 64MB /dev/shm.
|
||||
# --network none: the suite stubs nothing, so this is what keeps the
|
||||
# run offline and deterministic. The extension swallows its own
|
||||
# fetch failures, so the popup flows work unchanged; see the
|
||||
# network note in README.md. Weaker than the Chrome suite's
|
||||
# fixture interception, and honestly so — it proves no request
|
||||
# escaped, but it cannot report which ones were attempted.
|
||||
# --network none: the container has no network, and the suite reaches
|
||||
# only the test page and stub node on loopback (README.md, Browser
|
||||
# tests). The extension swallows its own fetch failures, so the popup
|
||||
# flows work unchanged. Weaker than the Chrome suite's fixture
|
||||
# interception, and honestly so — it proves no request escaped, but it
|
||||
# cannot report which ones were attempted.
|
||||
# HOME=/tmp: the image's root home is not a reliable place for the
|
||||
# browser profile.
|
||||
#
|
||||
@@ -67,10 +73,11 @@ main() {
|
||||
# "CanCreateUserNamespace() clone() failure: EPERM" on startup here;
|
||||
# it is cosmetic and headless Firefox runs fine without it.
|
||||
docker run --rm \
|
||||
--name "$CONTAINER" \
|
||||
--shm-size=1g \
|
||||
--network none \
|
||||
-e HOME=/tmp \
|
||||
"$(cat "$IIDFILE")" \
|
||||
"$IMAGE" \
|
||||
node tests/e2e/firefox/run.js dist/firefox
|
||||
|
||||
# The install/uninstall/re-install property, against the packaged XPI
|
||||
@@ -80,10 +87,11 @@ main() {
|
||||
# and the suite above shares one session throughout.
|
||||
echo "Running the Firefox re-install suite against the packaged XPI..."
|
||||
docker run --rm \
|
||||
--name "$CONTAINER" \
|
||||
--shm-size=1g \
|
||||
--network none \
|
||||
-e HOME=/tmp \
|
||||
"$(cat "$IIDFILE")" \
|
||||
"$IMAGE" \
|
||||
node tests/e2e/firefox/reinstall.js
|
||||
}
|
||||
|
||||
|
||||
+101
-1
@@ -2,7 +2,8 @@
|
||||
# script/test-verify-build: exercise every failure mode of
|
||||
# script/verify-build, and what make build does with dist/ after one of them
|
||||
# (script/discard-dist-on-failure). Our own extension to
|
||||
# scripts-to-rule-them-all, run from script/check so make check covers it.
|
||||
# scripts-to-rule-them-all, run by the Dockerfile's test phase so make check
|
||||
# covers it.
|
||||
#
|
||||
# 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,
|
||||
@@ -37,6 +38,10 @@ DISCARD_DIST="$ROOT/script/discard-dist-on-failure"
|
||||
MARKER_ON="autistmask-build-debug=on"
|
||||
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"
|
||||
|
||||
NEWLINE='
|
||||
@@ -516,6 +521,24 @@ c_debug_build() {
|
||||
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; }
|
||||
|
||||
# --- dist discard -----------------------------------------------------------
|
||||
@@ -753,6 +776,13 @@ run_cases() {
|
||||
check_case "debug bundles under --expect debug pass" \
|
||||
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" \
|
||||
no no-expect 1 "no --expect argument." c_control
|
||||
|
||||
@@ -885,6 +915,76 @@ run_cases() {
|
||||
discard_case "the wrapper given no command removes nothing" \
|
||||
c_control 1 kept "no command given" ""
|
||||
|
||||
# With stderr closed the wrapper cannot write its message, and must still
|
||||
# remove dist/ and return the step's own status.
|
||||
build_fixture
|
||||
_status=0
|
||||
(cd "$FIXTURE" &&
|
||||
"$FIXTURE/script/discard-dist-on-failure" sh -c 'exit 6' 2>&-) ||
|
||||
_status=$?
|
||||
if [ "$_status" -eq 6 ] && [ ! -e "$FIXTURE/dist" ]; then
|
||||
PASSED=$((PASSED + 1))
|
||||
echo " ok: a failed step's status survives a closed stderr"
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
echo " FAIL: a failed step's status survives a closed stderr"
|
||||
echo " exit status $_status, wanted 6, and dist/ must be gone"
|
||||
fi
|
||||
|
||||
# The same with stderr a pipe nobody reads any more, where the write would
|
||||
# kill the wrapper with SIGPIPE. The FIFO's only reader opens it, exits and
|
||||
# is waited for before the wrapper runs, so the pipe never has a reader.
|
||||
build_fixture
|
||||
mkfifo "$WORK/stderr-fifo"
|
||||
_status=0
|
||||
(
|
||||
: <"$WORK/stderr-fifo" &
|
||||
exec 3>"$WORK/stderr-fifo"
|
||||
wait "$!"
|
||||
cd "$FIXTURE" &&
|
||||
"$FIXTURE/script/discard-dist-on-failure" sh -c 'exit 6' 2>&3
|
||||
) || _status=$?
|
||||
if [ "$_status" -eq 6 ] && [ ! -e "$FIXTURE/dist" ]; then
|
||||
PASSED=$((PASSED + 1))
|
||||
echo " ok: a failed step's status survives a pipe nobody reads"
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
echo " FAIL: a failed step's status survives a pipe nobody reads"
|
||||
echo " exit status $_status, wanted 6, and dist/ must be gone"
|
||||
fi
|
||||
|
||||
# An interrupt while a step runs removes nothing and says nothing, even
|
||||
# when the step catches it and exits with a status of its own, as
|
||||
# script/check-censored does. The step interrupts the wrapper and then
|
||||
# itself, as Ctrl-C interrupts every process of the build at once. Run
|
||||
# under dash and under bash, which /bin/sh may each be: bash carries on
|
||||
# after such a step unless the wrapper stops it.
|
||||
for _shell in dash bash; do
|
||||
_name="an interrupt under $_shell removes nothing"
|
||||
if ! command -v "$_shell" >/dev/null 2>&1; then
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
SKIPPED_NAMES="$SKIPPED_NAMES## - $_name ($_shell not found)$NEWLINE"
|
||||
echo " SKIP ($_shell not found): $_name"
|
||||
continue
|
||||
fi
|
||||
build_fixture
|
||||
_status=0
|
||||
_out="$(cd "$FIXTURE" && "$_shell" \
|
||||
"$FIXTURE/script/discard-dist-on-failure" \
|
||||
sh -c 'trap "exit 4" INT; kill -INT "$PPID" $$; exit 5' 2>&1)" ||
|
||||
_status=$?
|
||||
if [ "$_status" -eq 130 ] && [ -z "$_out" ] &&
|
||||
[ -f "$FIXTURE/dist/chrome/src/popup/index.js" ]; then
|
||||
PASSED=$((PASSED + 1))
|
||||
echo " ok: $_name"
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
echo " FAIL: $_name"
|
||||
echo " exit status $_status, wanted 130; dist/ must be intact" \
|
||||
"and nothing said. Output: $_out"
|
||||
fi
|
||||
done
|
||||
|
||||
check_makefile_wiring
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
# fallback branch; the property only exists in the emitted output, so it has to
|
||||
# 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
|
||||
# taken from this script's environment. It used to be read from
|
||||
# AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1
|
||||
@@ -67,6 +73,15 @@ TAB=' '
|
||||
MARKER_ON="autistmask-build-debug=on"
|
||||
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"
|
||||
|
||||
# Set by the arguments.
|
||||
@@ -283,6 +298,18 @@ check_entry() {
|
||||
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."
|
||||
|
||||
# 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
|
||||
read_marker "$ENTRY_PATH"
|
||||
[ "$MARKER" = "$EXPECT" ] ||
|
||||
|
||||
+568
-178
File diff suppressed because it is too large
Load Diff
+18
-27
@@ -16,17 +16,19 @@
|
||||
// across any number of awaits and no concurrent work can
|
||||
// move it.
|
||||
// updateState(fn) read fresh, apply fn to that fresh record, write it
|
||||
// back — all inside a queue, so two background writes
|
||||
// never interleave, and the read is one storage round trip
|
||||
// ahead of the write rather than a page lifetime ahead of
|
||||
// it (which is what made the popup's saveState() need a
|
||||
// per-field merge against a baseline at all).
|
||||
// back — all holding the lock every write of the stored
|
||||
// record takes, so no other write lands in between, by
|
||||
// the background or by any page (README, "Stored data").
|
||||
// The read is one storage round trip ahead of the write
|
||||
// rather than a page lifetime ahead of it (which is what
|
||||
// made the popup's saveState() need a per-field merge
|
||||
// against a baseline at all).
|
||||
//
|
||||
// A handler that must both read and write therefore does its network work
|
||||
// against a snapshot it owns, and applies the RESULT inside updateState().
|
||||
// It never publishes an object other in-flight work is holding.
|
||||
|
||||
const { storageGet, storageSet } = require("../shared/browserApi");
|
||||
const { storageGet, storageSet, withLock } = require("../shared/browserApi");
|
||||
const { normalizePersisted } = require("../shared/persistedState");
|
||||
const {
|
||||
STATE_SCHEMA_VERSION,
|
||||
@@ -51,11 +53,6 @@ async function getState() {
|
||||
return normalizePersisted(result.autistmask);
|
||||
}
|
||||
|
||||
// Serializes the read-modify-write turns below. Two of them interleaved would
|
||||
// each read before the other wrote, and the second write would carry the first
|
||||
// one's fields back to their pre-turn values.
|
||||
let updateQueue = Promise.resolve();
|
||||
|
||||
async function updateStateOnce(mutate) {
|
||||
const s = await getState();
|
||||
await mutate(s);
|
||||
@@ -70,27 +67,21 @@ async function updateStateOnce(mutate) {
|
||||
|
||||
// Apply `mutate` to a record read fresh from storage and write the result
|
||||
// back. `mutate` receives a detached, normalized profile and mutates it in
|
||||
// place; it may be async, but it must not do anything slow — the window
|
||||
// between the read and the write is the window in which another context's
|
||||
// write is lost, and keeping it to one storage round trip is what makes a
|
||||
// whole-record write safe here. Concretely: the write is the WHOLE record, so
|
||||
// a popup write that lands inside that window is reverted, in every field, by
|
||||
// the record this turn read before it. That is accepted because the window is
|
||||
// one round trip long and the popup is not writing while the worker is;
|
||||
// widening it is what would make it a real hazard.
|
||||
// place; it may be async, but it must not do anything slow. The write is the
|
||||
// WHOLE record, which is safe only because the lock (withLock() in
|
||||
// src/shared/browserApi.js) is held from the read to the write: two
|
||||
// background turns, or a turn and a page's save, never interleave
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits
|
||||
// while it is held (README, "Stored data").
|
||||
//
|
||||
// `mutate` must also not call updateState() itself, directly or through
|
||||
// anything it awaits: the queue is strictly serial, so the inner turn waits on
|
||||
// the outer one, which is waiting on the inner one. That deadlocks the whole
|
||||
// background, not just the caller. Mutate the record you were handed.
|
||||
// anything it awaits: the inner turn waits for the lock the outer one holds,
|
||||
// and the outer one is waiting on the inner one. That deadlocks every write of
|
||||
// the stored record, not just the caller. Mutate the record you were handed.
|
||||
//
|
||||
// Resolves with the record that was written.
|
||||
function updateState(mutate) {
|
||||
const turn = updateQueue.then(() => updateStateOnce(mutate));
|
||||
// The queue must advance even when a turn rejects, or every update after
|
||||
// it queues behind a promise that never settles.
|
||||
updateQueue = turn.catch(() => {});
|
||||
return turn;
|
||||
return withLock("autistmask", () => updateStateOnce(mutate));
|
||||
}
|
||||
|
||||
module.exports = { getState, updateState };
|
||||
|
||||
+18
-36
@@ -5,8 +5,6 @@ const {
|
||||
hasBrowserNamespace,
|
||||
runtimeApi,
|
||||
sendMessage,
|
||||
storageGet,
|
||||
storageSet,
|
||||
} = require("../shared/browserApi");
|
||||
|
||||
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
|
||||
@@ -21,30 +19,6 @@ if (hasBrowserNamespace()) {
|
||||
(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
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window) return;
|
||||
@@ -56,7 +30,6 @@ window.addEventListener("message", (event) => {
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
origin: location.origin,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
@@ -76,14 +49,23 @@ window.addEventListener("message", (event) => {
|
||||
|
||||
// Listen for events pushed from the background (e.g. accountsChanged)
|
||||
runtimeApi().onMessage.addListener((msg) => {
|
||||
if (msg.type === "AUTISTMASK_EVENT") {
|
||||
window.postMessage(
|
||||
{
|
||||
type: "AUTISTMASK_EVENT",
|
||||
eventName: msg.eventName,
|
||||
data: msg.data,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
if (msg.type !== "AUTISTMASK_EVENT") return;
|
||||
// The background chooses the addresses in accountsChanged for the site
|
||||
// at the origin the event names, which is the origin of the tab's URL.
|
||||
// This page hears them only when its own origin is that one. A page whose
|
||||
// origin has no host is not a site: one its server sandboxes with a
|
||||
// Content-Security-Policy has the origin "null" whatever its URL, and is
|
||||
// told no address, as its requests are refused.
|
||||
let data = msg.data;
|
||||
if (msg.eventName === "accountsChanged" && msg.origin !== window.origin) {
|
||||
data = [];
|
||||
}
|
||||
window.postMessage(
|
||||
{
|
||||
type: "AUTISTMASK_EVENT",
|
||||
eventName: msg.eventName,
|
||||
data,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
+19
-19
@@ -31,11 +31,12 @@
|
||||
// an error instead of accepting the refusal.
|
||||
//
|
||||
// Whatever code arrived is passed through verbatim rather than being
|
||||
// matched against a list: the extension emits 4001, 4100 and 4902 today,
|
||||
// and a code this file has never heard of is still the truth about what
|
||||
// happened. An error reported with no code at all stays a plain Error —
|
||||
// a ProviderRpcError whose `code` is undefined would advertise a
|
||||
// conformance it does not have. `message` is untouched in every case.
|
||||
// matched against a list: the extension emits codes such as 4001, 4100,
|
||||
// 4200 and 4902, and a code this file has never heard of is still the
|
||||
// truth about what happened. An error reported with no code at all stays
|
||||
// a plain Error — a ProviderRpcError whose `code` is undefined would
|
||||
// advertise a conformance it does not have. `message` is untouched in
|
||||
// every case.
|
||||
function toPageError(error) {
|
||||
const message = (error && error.message) || "Request failed";
|
||||
if (error && error.code !== undefined && error.code !== null) {
|
||||
@@ -45,7 +46,7 @@
|
||||
}
|
||||
|
||||
// Listen for responses from the content script
|
||||
window.addEventListener("message", function onUuid(event) {
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window) return;
|
||||
if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
|
||||
const { id, result, error } = event.data;
|
||||
@@ -60,7 +61,7 @@
|
||||
});
|
||||
|
||||
// Listen for events pushed from the extension
|
||||
window.addEventListener("message", function onUuid(event) {
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window) return;
|
||||
if (event.data?.type !== "AUTISTMASK_EVENT") return;
|
||||
const { eventName, data } = event.data;
|
||||
@@ -98,7 +99,10 @@
|
||||
|
||||
const provider = {
|
||||
isAutistMask: true,
|
||||
isMetaMask: true, // compatibility — many dApps check this
|
||||
// An interface-compatibility flag many dApps check. Kept as a written
|
||||
// exception to the rule that no competitor is named in code: see
|
||||
// Policies in README.md, and RULES.md.
|
||||
isMetaMask: true,
|
||||
chainId: currentChainId,
|
||||
networkVersion: currentNetworkVersion,
|
||||
selectedAddress: null,
|
||||
@@ -204,7 +208,13 @@
|
||||
"</svg>",
|
||||
);
|
||||
|
||||
let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives
|
||||
// EIP-6963 asks for one UUIDv4 per provider for the life of the page: one
|
||||
// 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() {
|
||||
return {
|
||||
@@ -226,16 +236,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
announceProvider();
|
||||
|
||||
|
||||
@@ -19,13 +19,9 @@
|
||||
// that the user did not type — the same silent substitution the visible
|
||||
// rejection message exists to end.
|
||||
|
||||
// Must render on ONE line of #flash-msg, whose reserved height
|
||||
// (min-h-[1.25rem]) is exactly one line at text-xs. A string long enough to
|
||||
// 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.";
|
||||
// Must render on ONE line of #flash-msg; see showFlash() in
|
||||
// src/popup/views/helpers.js for how long that is.
|
||||
const DUST_THRESHOLD_MESSAGE = "Enter a whole number of gwei, zero or greater.";
|
||||
|
||||
// Returns the threshold in gwei, or null if the input is not one.
|
||||
function parseDustThresholdGwei(raw) {
|
||||
|
||||
+437
-231
File diff suppressed because it is too large
Load Diff
+257
-23
@@ -5,12 +5,15 @@ const {
|
||||
state,
|
||||
saveState,
|
||||
onSaveFailure,
|
||||
takeStoredNetwork,
|
||||
clearBalanceNotFetched,
|
||||
loadState,
|
||||
} = require("../shared/state");
|
||||
const { StateUnusableError } = require("../shared/stateSchema");
|
||||
const { log, setRuntimeDebug } = require("../shared/log");
|
||||
const { refreshPrices } = require("../shared/prices");
|
||||
const { refreshBalances } = require("../shared/balances");
|
||||
const { refreshBalances, dropUntrackedTokens } = require("../shared/balances");
|
||||
const { clearPopupCaches } = require("../shared/chainSwitch");
|
||||
const {
|
||||
$,
|
||||
showView,
|
||||
@@ -21,6 +24,13 @@ const {
|
||||
goBack,
|
||||
} = require("./views/helpers");
|
||||
const { applyTheme } = require("./theme");
|
||||
const {
|
||||
onStorageChanged,
|
||||
tabsGetCurrent,
|
||||
tabsGetZoom,
|
||||
windowsGetCurrent,
|
||||
windowsUpdate,
|
||||
} = require("../shared/browserApi");
|
||||
// Renders a view the popup lands on without having navigated to it forward:
|
||||
// on restore here, and on Back. Only the views that can be fully re-rendered
|
||||
// from persisted state (RESTORABLE_VIEWS, src/shared/restorableViews.js) go
|
||||
@@ -48,41 +58,151 @@ function renderWalletList() {
|
||||
home.render(ctx);
|
||||
}
|
||||
|
||||
let refreshInFlight = false;
|
||||
// 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());
|
||||
|
||||
// While a refresh runs, a request through ctx is not dropped. The request sets
|
||||
// refreshAgain, and one more refresh runs once the running one ends. Several
|
||||
// requests in that time lead to one more refresh, not several. An import asks
|
||||
// for a refresh after its address scan. A refresh already running does not
|
||||
// have the addresses the scan found
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/581).
|
||||
let refreshInFlight = false;
|
||||
let refreshAgain = false;
|
||||
|
||||
// The ten-second refresh startRefresh() starts, stopped when the popup moves to
|
||||
// the recovery screen: there is no profile left to refresh.
|
||||
let refreshTimer = null;
|
||||
|
||||
// The refresh first takes the stored network, "Use Blockscout" and "Check
|
||||
// recipients on Etherscan" (followStoredSettings() below). So the screens it
|
||||
// draws reflect any change saved elsewhere.
|
||||
//
|
||||
// The refresh writes into a copy of the wallets. Inside the save, under the
|
||||
// lock, its results are copied onto the address records in `state` only if the
|
||||
// network it ran on is still the active one, on this page and in the stored
|
||||
// record; the background's switch, after the user approves a site's request,
|
||||
// reaches this page only after the stored record (followStoredSettings()).
|
||||
// A switch while the refresh ran cleared every address's balances
|
||||
// (applyChainSwitchFields() in src/shared/chainSwitchFields.js), and the
|
||||
// previous network's must not come back over them
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped refresh is not
|
||||
// recorded as done. Results are copied by address, so an address added or
|
||||
// removed meanwhile is left as it is. Blockscout may have answered before the
|
||||
// user turned "Use Blockscout" off. So the save drops every token the user has
|
||||
// not tracked if the stored setting is off by then.
|
||||
//
|
||||
// The save also deletes `balanceNotFetched` from each tracked token whose
|
||||
// balance this refresh read at every address. A refresh that did not read every
|
||||
// address this page now holds, and every address in the stored record, deletes
|
||||
// no `balanceNotFetched`. Adding a token sets that field (trackToken() in
|
||||
// src/popup/views/helpers.js). So does adding an address, and so does a network
|
||||
// switch. The save takes the field off the matching tokens of the stored record
|
||||
// (clearBalanceNotFetched() in src/shared/state.js). The rest of the stored
|
||||
// tracked token list stays as it is.
|
||||
//
|
||||
// Once the save is done, the wallet list is drawn again. So are the balances
|
||||
// on AddressDetail or AddressToken when that screen is up. No other screen is
|
||||
// drawn again, DeleteAddress and its balance lines included.
|
||||
async function doRefreshAndRender() {
|
||||
if (refreshInFlight) return;
|
||||
if (refreshInFlight) {
|
||||
refreshAgain = true;
|
||||
return;
|
||||
}
|
||||
refreshInFlight = true;
|
||||
try {
|
||||
await Promise.all([
|
||||
refreshPrices(),
|
||||
await followStoredSettings();
|
||||
const networkId = state.networkId;
|
||||
const wallets = structuredClone(state.wallets);
|
||||
const [, readTokens] = await Promise.all([
|
||||
refreshPrices(pageClosed.signal),
|
||||
refreshBalances(
|
||||
state.wallets,
|
||||
wallets,
|
||||
state.rpcUrl,
|
||||
state.blockscoutUrl,
|
||||
state.trackedTokens,
|
||||
state.networkId,
|
||||
networkId,
|
||||
pageClosed.signal,
|
||||
),
|
||||
]);
|
||||
state.lastBalanceRefresh = Date.now();
|
||||
await saveState();
|
||||
const refreshed = new Map();
|
||||
for (const wallet of wallets) {
|
||||
for (const addr of wallet.addresses) {
|
||||
refreshed.set(addr.address, addr);
|
||||
}
|
||||
}
|
||||
await saveState((stored) => {
|
||||
if (state.networkId !== networkId) return false;
|
||||
if (stored.networkId !== networkId) return false;
|
||||
for (const wallet of state.wallets) {
|
||||
for (const addr of wallet.addresses) {
|
||||
const got = refreshed.get(addr.address);
|
||||
if (!got) continue;
|
||||
for (const key of ["balance", "ensName", "tokenBalances"]) {
|
||||
if (got[key] !== undefined) addr[key] = got[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!stored.useBlockscout) {
|
||||
dropUntrackedTokens(state.wallets, stored.trackedTokens);
|
||||
}
|
||||
const readEveryAddress = [
|
||||
...state.wallets,
|
||||
...stored.wallets,
|
||||
].every((wallet) =>
|
||||
wallet.addresses.every((addr) => refreshed.has(addr.address)),
|
||||
);
|
||||
if (readEveryAddress) clearBalanceNotFetched(stored, readTokens);
|
||||
state.lastBalanceRefresh = Date.now();
|
||||
return true;
|
||||
});
|
||||
renderWalletList();
|
||||
if (state.currentView === "address") addressDetail.renderBalances();
|
||||
if (state.currentView === "address-token") addressToken.renderBalance();
|
||||
} catch (e) {
|
||||
// Every call site fires this and walks away — the boot below, the ten
|
||||
// second interval, and eight views through ctx — so it must never
|
||||
// reject: an unhandled rejection is not a report of anything. The save
|
||||
// inside it reports its own failure through onSaveFailure() (see
|
||||
// src/shared/state.js); what is left here is a failed network round
|
||||
// trip, which the next tick retries.
|
||||
// No caller waits for this function, so it must never reject. An
|
||||
// unhandled rejection reports nothing. The save inside it reports its
|
||||
// own failure through onSaveFailure() (see src/shared/state.js); what
|
||||
// is left here is a failed network round trip, which the next tick
|
||||
// retries.
|
||||
log.errorf("popup: background refresh failed:", e);
|
||||
} finally {
|
||||
refreshInFlight = false;
|
||||
}
|
||||
if (refreshAgain) {
|
||||
refreshAgain = false;
|
||||
doRefreshAndRender();
|
||||
}
|
||||
}
|
||||
|
||||
// Refreshes now and every ten seconds from then on. Called once a wallet
|
||||
// exists: by init() when the popup opens with one, and by AddWallet when it
|
||||
// adds one, which in a popup that opened on Welcome is the first
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the ten-second
|
||||
// refresh already started, it starts no second one. On the recovery screen it
|
||||
// does nothing.
|
||||
function startRefresh() {
|
||||
if (!$("view-state-recovery").classList.contains("hidden")) return;
|
||||
doRefreshAndRender();
|
||||
if (refreshTimer === null) {
|
||||
// A tick that finds a refresh running is dropped.
|
||||
refreshTimer = setInterval(() => {
|
||||
if (!refreshInFlight) return doRefreshAndRender();
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
renderWalletList,
|
||||
doRefreshAndRender,
|
||||
startRefresh,
|
||||
restoreView,
|
||||
pageClosed: pageClosed.signal,
|
||||
showAddWalletView: () => {
|
||||
pushCurrentView();
|
||||
addWallet.show();
|
||||
@@ -149,13 +269,109 @@ function fallbackView() {
|
||||
showView("main");
|
||||
}
|
||||
|
||||
// When the background saves a network switch, once the user approves a site's
|
||||
// request, or another popup page saves one, this page follows the stored
|
||||
// record. It takes the network the record names as a save reads it, holding
|
||||
// the lock, so no later save of this page writes the previous network back, and
|
||||
// then leaves the page as a switch in Settings leaves it. This page's own
|
||||
// switch in Settings is made under the lock as well (onChainSwitch() in
|
||||
// src/shared/chainSwitch.js), so a record naming a network other than this
|
||||
// page's always holds a switch saved elsewhere, and a switch the user picks
|
||||
// here while this waits for the lock is made after it.
|
||||
//
|
||||
// Another page may change "Use Blockscout" or "Check recipients on Etherscan".
|
||||
// This page takes either change the same way and draws its screens again. This
|
||||
// page's own changes to the two settings are made under the lock too
|
||||
// (views/settings.js, views/welcome.js).
|
||||
async function followStoredSettings() {
|
||||
let changed = false;
|
||||
// A failed save is reported by saveState() itself: see onSaveFailure().
|
||||
await saveState((stored) => {
|
||||
if (stored.networkId !== state.networkId) {
|
||||
settings.cutViewStack();
|
||||
takeStoredNetwork(stored);
|
||||
clearPopupCaches();
|
||||
changed = true;
|
||||
}
|
||||
if (stored.useBlockscout !== state.useBlockscout) {
|
||||
settings.setUseBlockscout(stored.useBlockscout);
|
||||
changed = true;
|
||||
}
|
||||
if (
|
||||
stored.checkRecipientsOnEtherscan !==
|
||||
state.checkRecipientsOnEtherscan
|
||||
) {
|
||||
state.checkRecipientsOnEtherscan =
|
||||
stored.checkRecipientsOnEtherscan;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return false;
|
||||
}).catch(() => {});
|
||||
if (changed) settings.showNewSettings();
|
||||
}
|
||||
|
||||
// The background opens the approval window as wide as the page, but the
|
||||
// browser counts the window's frame in that width, and a screen taller than
|
||||
// the window takes a vertical scrollbar out of it too. So once the screen is
|
||||
// up, the window is widened by however far the page would scroll sideways.
|
||||
// That distance is in page pixels and the window's width in screen pixels:
|
||||
// at 150% zoom one page pixel is 1.5 screen pixels. The zoom is read for this
|
||||
// page's own tab, because asked without a tab id Firefox answers for the
|
||||
// window focused last. Firefox draws some zoom steps a little larger than the
|
||||
// zoom it reports, 170% among them, so once the page has its new width (its
|
||||
// resize event) it is measured again, and the window widened again while the
|
||||
// page still falls short, three times at most. A window that cannot grow sends
|
||||
// no resize event, and the page stops there. An approval can also show in the
|
||||
// toolbar popup, whose window is the user's browser window; that one is left
|
||||
// alone.
|
||||
async function widenApprovalWindow() {
|
||||
const page = document.documentElement;
|
||||
if (page.scrollWidth <= page.clientWidth) return;
|
||||
const win = await windowsGetCurrent();
|
||||
if (win.type !== "popup") return;
|
||||
const tab = await tabsGetCurrent();
|
||||
const zoom = await tabsGetZoom(tab.id);
|
||||
let width = win.width;
|
||||
for (let tries = 0; tries < 3; tries++) {
|
||||
const sideways = page.scrollWidth - page.clientWidth;
|
||||
if (sideways <= 0) return;
|
||||
width += Math.ceil(sideways * zoom);
|
||||
const resized = new Promise((resolve) =>
|
||||
window.addEventListener("resize", resolve, { once: true }),
|
||||
);
|
||||
await windowsUpdate(win.id, { width });
|
||||
await resized;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// First, before anything can save: showView() saves on every navigation
|
||||
// without awaiting, so a save that fails from here on has somewhere to be
|
||||
// reported rather than being swallowed by the save queue
|
||||
// reported rather than reaching only the console
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
|
||||
// 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 {
|
||||
await loadState();
|
||||
} catch (e) {
|
||||
@@ -203,9 +419,9 @@ async function init() {
|
||||
// rejection rather than an uncaught error — measured as still failing
|
||||
// the run on both harnesses (Playwright `pageerror`, and the Firefox
|
||||
// driver's console-service drain), so nothing is lost by leaving it
|
||||
// on that path.
|
||||
approval.show(approvalId);
|
||||
showView("approve-site");
|
||||
// on that path. show() puts the approval's own screen up once the
|
||||
// background has described it; the window is widened to fit it then.
|
||||
approval.show(approvalId).then(widenApprovalWindow);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -238,13 +454,31 @@ async function init() {
|
||||
settingsAddToken.init(ctx);
|
||||
deleteAddress.init(ctx);
|
||||
|
||||
// This page follows a record only when its network, "Use Blockscout" or
|
||||
// "Check recipients on Etherscan" differs from this page's. A record
|
||||
// written by this page's own save matches this page. A record naming no
|
||||
// network is no switch.
|
||||
onStorageChanged((changes) => {
|
||||
const written = changes.autistmask && changes.autistmask.newValue;
|
||||
if (!written || !written.networkId) return;
|
||||
if (
|
||||
written.networkId !== state.networkId ||
|
||||
written.useBlockscout !== state.useBlockscout ||
|
||||
written.checkRecipientsOnEtherscan !==
|
||||
state.checkRecipientsOnEtherscan
|
||||
) {
|
||||
followStoredSettings();
|
||||
}
|
||||
});
|
||||
// A change saved since loadState() above, before there was a listener.
|
||||
followStoredSettings();
|
||||
|
||||
if (!state.hasWallet) {
|
||||
showView("welcome");
|
||||
welcome.show();
|
||||
} else {
|
||||
renderWalletList();
|
||||
restoreView();
|
||||
doRefreshAndRender();
|
||||
setInterval(doRefreshAndRender, 10000);
|
||||
startRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,3 +44,33 @@ body {
|
||||
background-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;
|
||||
}
|
||||
|
||||
+35
-26
@@ -13,6 +13,7 @@
|
||||
// cannot be required outside a browser.
|
||||
|
||||
const { RESTORABLE_VIEWS } = require("../shared/restorableViews");
|
||||
const { SUPPORTED_CHAIN_IDS } = require("../shared/networks");
|
||||
|
||||
// The views this page load has rendered.
|
||||
//
|
||||
@@ -37,9 +38,16 @@ function markViewRendered(view) {
|
||||
if (view) renderedViews.add(view);
|
||||
}
|
||||
|
||||
// Begin a fresh page-load scope. The popup gets one by being loaded; the
|
||||
// unit tests, which simulate several page loads against one module
|
||||
// instance, ask for one.
|
||||
// Begin a fresh page-load scope. The popup gets one by being loaded. It also
|
||||
// asks for one when every screen on the page is out of date:
|
||||
//
|
||||
// - after a network switch, since those screens show the previous network
|
||||
// (showNewSettings() in views/settings.js)
|
||||
// - after "Use Blockscout" or "Check recipients on Etherscan" changes, here
|
||||
// or on another page, since those screens were drawn under the old setting
|
||||
//
|
||||
// The unit tests ask for one too. They simulate several page loads against one
|
||||
// module instance.
|
||||
function resetRenderedViews() {
|
||||
renderedViews.clear();
|
||||
}
|
||||
@@ -108,41 +116,34 @@ function isAddressText(value) {
|
||||
return isText(value);
|
||||
}
|
||||
|
||||
// Decoded calldata, as decodedDetailsHtml() (src/popup/views/txStatus.js)
|
||||
// walks it: `for (const d of decoded.details)` needs an iterable, and each
|
||||
// entry's `address` reaches toAddressHtml(). Absent or falsy is the ordinary
|
||||
// case and short-circuits before either.
|
||||
function isRenderableDecoded(value) {
|
||||
if (!value) return true;
|
||||
if (!isRecord(value)) return false;
|
||||
if (!value.details) return true;
|
||||
if (!Array.isArray(value.details)) return false;
|
||||
return value.details.every(
|
||||
(entry) =>
|
||||
isRecord(entry) && (!entry.address || isAddressText(entry.address)),
|
||||
);
|
||||
}
|
||||
|
||||
// The pending transaction confirmTx.show() renders: `token` reaches
|
||||
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
|
||||
// addressTitle(), makeBlockie() and getLocalWarnings().
|
||||
// renderAddressHtml() when it is not "ETH", `from`/`to` reach
|
||||
// addressTitle(), makeBlockie() and getLocalWarnings(), and `amount` reaches
|
||||
// truncateAmountNeverZero(), which splits it. `chainId`, which show() fills in
|
||||
// when it is absent, picks the network whose explorer the address links open,
|
||||
// so it has to name one.
|
||||
function isRenderablePendingTx(value) {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
isText(value.token) &&
|
||||
isAddressText(value.from) &&
|
||||
isAddressText(value.to)
|
||||
isAddressText(value.to) &&
|
||||
isText(value.amount) &&
|
||||
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
|
||||
);
|
||||
}
|
||||
|
||||
// The stored transaction transactionDetail.render() shows. contractAddress is
|
||||
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
|
||||
// `chainId` picks the network whose explorer the links open and whose
|
||||
// Blockscout the transaction is read from, so it has to name one.
|
||||
function isRenderableTx(value) {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
isAddressText(value.from) &&
|
||||
isAddressText(value.to) &&
|
||||
(!value.contractAddress || isAddressText(value.contractAddress))
|
||||
(!value.contractAddress || isAddressText(value.contractAddress)) &&
|
||||
SUPPORTED_CHAIN_IDS.has(value.chainId)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,12 +194,19 @@ function renderView(view, state, views) {
|
||||
case "success-tx":
|
||||
if (!data.hash) return false;
|
||||
if (!isAddressText(data.to)) return false;
|
||||
if (!isRenderableDecoded(data.decoded)) return false;
|
||||
// `chainId` picks the network whose explorer the links open, so
|
||||
// when it is there it has to name one. Likewise on error-tx.
|
||||
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
|
||||
return false;
|
||||
}
|
||||
views.txStatus.renderSuccess();
|
||||
return true;
|
||||
case "error-tx":
|
||||
if (!data.message) return false;
|
||||
if (!isAddressText(data.to)) return false;
|
||||
if (data.chainId && !SUPPORTED_CHAIN_IDS.has(data.chainId)) {
|
||||
return false;
|
||||
}
|
||||
views.txStatus.renderError();
|
||||
return true;
|
||||
default:
|
||||
@@ -213,9 +221,10 @@ function renderView(view, state, views) {
|
||||
// in the two cases where the view is known to be on the page already:
|
||||
//
|
||||
// - It is not one the popup renders from persisted state. The restored
|
||||
// stack is filtered against RESTORABLE_VIEWS, so such a view can only
|
||||
// be on the stack from this page load, where forward navigation
|
||||
// rendered it on the way in.
|
||||
// stack is filtered against RESTORABLE_VIEWS, and a network switch cuts
|
||||
// it the same way (views/settings.js), so such a view can only be on the
|
||||
// stack from this page load, where forward navigation rendered it on the
|
||||
// way in.
|
||||
// - This page load has rendered it. Re-rendering would re-fetch and
|
||||
// clobber what it holds; Home is rendered anyway, see above.
|
||||
//
|
||||
|
||||
+88
-11
@@ -1,14 +1,38 @@
|
||||
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
|
||||
const {
|
||||
$,
|
||||
showView,
|
||||
showFlash,
|
||||
escapeHtml,
|
||||
goBack,
|
||||
trackToken,
|
||||
} = require("./helpers");
|
||||
const { getTopTokens } = require("../../shared/tokenList");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { lookupTokenInfo } = require("../../shared/balances");
|
||||
const { isScamAddress } = require("../../shared/scamlist");
|
||||
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||
const { log } = require("../../shared/log");
|
||||
|
||||
// Counts the times AddToken has opened. A contract lookup started before
|
||||
// AddToken was last opened changes neither the screen nor the status line.
|
||||
let addTokenScreenOpenings = 0;
|
||||
|
||||
function findTracked(address) {
|
||||
const lower = address.toLowerCase();
|
||||
return state.trackedTokens.find((t) => t.address.toLowerCase() === lower);
|
||||
}
|
||||
|
||||
function show() {
|
||||
addTokenScreenOpenings++;
|
||||
$("add-token-address").value = "";
|
||||
$("add-token-info").textContent = "";
|
||||
$("add-token-info").style.visibility = "hidden";
|
||||
// The bundled list holds Ethereum Mainnet contracts, which name no token
|
||||
// on any other network.
|
||||
$("add-token-common").classList.toggle(
|
||||
"hidden",
|
||||
state.networkId !== "mainnet",
|
||||
);
|
||||
const list = $("common-token-list");
|
||||
list.innerHTML = getTopTokens(25)
|
||||
.map(
|
||||
@@ -28,14 +52,10 @@ function init(ctx) {
|
||||
$("btn-add-token-confirm").addEventListener("click", async () => {
|
||||
const contractAddr = $("add-token-address").value.trim();
|
||||
if (!contractAddr || !contractAddr.startsWith("0x")) {
|
||||
showFlash(
|
||||
"Please enter a valid contract address starting with 0x.",
|
||||
);
|
||||
showFlash("Enter a valid contract address starting with 0x.");
|
||||
return;
|
||||
}
|
||||
const already = state.trackedTokens.find(
|
||||
(t) => t.address.toLowerCase() === contractAddr.toLowerCase(),
|
||||
);
|
||||
const already = findTracked(contractAddr);
|
||||
if (already) {
|
||||
showFlash(already.symbol + " is already being tracked.");
|
||||
return;
|
||||
@@ -48,14 +68,49 @@ function init(ctx) {
|
||||
infoEl.textContent = "Looking up token...";
|
||||
infoEl.style.visibility = "visible";
|
||||
log.debugf("Looking up token contract", contractAddr);
|
||||
// The network the lookup asks, which Settings may switch away from
|
||||
// before it answers, as for a transaction in confirmTx.js.
|
||||
const chainId = currentNetwork().chainId;
|
||||
const opening = addTokenScreenOpenings;
|
||||
try {
|
||||
const info = await lookupTokenInfo(
|
||||
contractAddr,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
ctx.pageClosed,
|
||||
);
|
||||
// A contract address names a token on one network only, so an
|
||||
// answer after a switch is not added to the new network's list.
|
||||
if (currentNetwork().chainId !== chainId) {
|
||||
showFlash(
|
||||
"The active network changed before the lookup finished," +
|
||||
" so the token was not added.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Another click on Add may have tracked this token while this
|
||||
// lookup waited.
|
||||
const trackedMeanwhile = findTracked(contractAddr);
|
||||
if (trackedMeanwhile) {
|
||||
showFlash(
|
||||
trackedMeanwhile.symbol + " is already being tracked.",
|
||||
);
|
||||
if (addTokenScreenOpenings !== opening) return;
|
||||
infoEl.textContent = "";
|
||||
infoEl.style.visibility = "hidden";
|
||||
return;
|
||||
}
|
||||
// The balance list never shows a token that the spoof rule
|
||||
// refuses, so such a token is not tracked.
|
||||
if (isSpoofedSymbol(info.symbol, contractAddr, chainId)) {
|
||||
showFlash("Not added: this token fakes a known symbol.");
|
||||
if (addTokenScreenOpenings !== opening) return;
|
||||
infoEl.textContent = "";
|
||||
infoEl.style.visibility = "hidden";
|
||||
return;
|
||||
}
|
||||
log.infof("Adding token", info.symbol, contractAddr);
|
||||
state.trackedTokens.push({
|
||||
trackToken({
|
||||
address: contractAddr,
|
||||
symbol: info.symbol,
|
||||
decimals: info.decimals,
|
||||
@@ -63,6 +118,17 @@ function init(ctx) {
|
||||
});
|
||||
await saveState();
|
||||
ctx.doRefreshAndRender();
|
||||
// If the lookup answers after AddToken was opened again, the
|
||||
// token is still added, but the screen and the status line stay
|
||||
// as they are.
|
||||
if (addTokenScreenOpenings !== opening) return;
|
||||
// The lookup is over, so a later Back onto AddToken shows no
|
||||
// status.
|
||||
infoEl.textContent = "";
|
||||
infoEl.style.visibility = "hidden";
|
||||
// If the lookup answers while another screen shows, the token is
|
||||
// still added but the screen does not change.
|
||||
if (state.currentView !== "add-token") return;
|
||||
// Pop the stack (back to address detail) and re-render it
|
||||
// so the newly added token is visible immediately.
|
||||
if (state.viewStack.length > 0) {
|
||||
@@ -70,9 +136,20 @@ function init(ctx) {
|
||||
}
|
||||
require("./addressDetail").show();
|
||||
} 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);
|
||||
log.errorf("Token lookup failed for", contractAddr, detail);
|
||||
showFlash(detail);
|
||||
log.errorf("Adding token failed for", contractAddr, detail);
|
||||
// lookupTokenInfo() rejects a contract with a one-line message
|
||||
// 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.",
|
||||
);
|
||||
if (addTokenScreenOpenings !== opening) return;
|
||||
infoEl.textContent = "";
|
||||
infoEl.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
+102
-38
@@ -2,9 +2,12 @@ const {
|
||||
$,
|
||||
showView,
|
||||
showFlash,
|
||||
showError,
|
||||
hideError,
|
||||
goBack,
|
||||
clearViewStack,
|
||||
onViewLeave,
|
||||
markTrackedTokensNotFetched,
|
||||
} = require("./helpers");
|
||||
const {
|
||||
generateMnemonic,
|
||||
@@ -18,6 +21,7 @@ const {
|
||||
const { encryptWithPassword } = require("../../shared/vault");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { scanForAddresses } = require("../../shared/balances");
|
||||
const { notify } = require("../../shared/browserApi");
|
||||
|
||||
/**
|
||||
* Check if an address already exists in ANY wallet (hd, xprv, or key).
|
||||
@@ -98,11 +102,27 @@ function clear() {
|
||||
$("add-wallet-password").value = "";
|
||||
$("add-wallet-password-confirm").value = "";
|
||||
$("add-wallet-phrase-warning").style.visibility = "hidden";
|
||||
hideError("add-wallet-password-error");
|
||||
}
|
||||
|
||||
// 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() {
|
||||
clear();
|
||||
switchMode("mnemonic");
|
||||
updateSeparatePasswordNote();
|
||||
showView("add-wallet");
|
||||
}
|
||||
|
||||
@@ -110,15 +130,19 @@ function validatePassword() {
|
||||
const pw = $("add-wallet-password").value;
|
||||
const pw2 = $("add-wallet-password-confirm").value;
|
||||
if (!pw) {
|
||||
showFlash("Please choose a password.");
|
||||
showError("add-wallet-password-error", "Please choose a password.");
|
||||
return null;
|
||||
}
|
||||
if (pw.length < 12) {
|
||||
showFlash("Password must be at least 12 characters.");
|
||||
// "Allow weak passwords" in Settings lifts the 12-character minimum.
|
||||
if (!state.allowWeakPasswords && pw.length < 12) {
|
||||
showError(
|
||||
"add-wallet-password-error",
|
||||
"Password must be at least 12 characters.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (pw !== pw2) {
|
||||
showFlash("Passwords do not match.");
|
||||
showError("add-wallet-password-error", "Passwords do not match.");
|
||||
return null;
|
||||
}
|
||||
return pw;
|
||||
@@ -127,15 +151,13 @@ function validatePassword() {
|
||||
async function importMnemonic(ctx) {
|
||||
const mnemonic = $("wallet-mnemonic").value.trim();
|
||||
if (!mnemonic) {
|
||||
showFlash("Enter a recovery phrase or press the die to generate one.");
|
||||
showFlash("Enter a recovery phrase, or press the die.");
|
||||
return;
|
||||
}
|
||||
const words = mnemonic.split(/\s+/);
|
||||
if (words.length !== 12 && words.length !== 24) {
|
||||
showFlash(
|
||||
"Recovery phrase must be 12 or 24 words. You entered " +
|
||||
words.length +
|
||||
".",
|
||||
"Recovery phrase must be 12 or 24 words, not " + words.length + ".",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -148,14 +170,12 @@ async function importMnemonic(ctx) {
|
||||
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
|
||||
const xpubDup = findWalletByXpub(xpub);
|
||||
if (xpubDup) {
|
||||
showFlash(
|
||||
"This recovery phrase is already added (" + xpubDup.name + ").",
|
||||
);
|
||||
showFlash("This recovery phrase is already added.");
|
||||
return;
|
||||
}
|
||||
const addrDup = findWalletByAddress(firstAddress);
|
||||
if (addrDup) {
|
||||
showFlash("Address already exists in wallet (" + addrDup.name + ").");
|
||||
showFlash("Address already exists in a wallet.");
|
||||
return;
|
||||
}
|
||||
const encrypted = await encryptWithPassword(mnemonic, pw);
|
||||
@@ -167,11 +187,20 @@ async function importMnemonic(ctx) {
|
||||
encryptedSecret: encrypted,
|
||||
nextIndex: 1,
|
||||
addresses: [
|
||||
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
|
||||
{
|
||||
address: firstAddress,
|
||||
index: 0,
|
||||
balance: null,
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
state.wallets.push(wallet);
|
||||
markTrackedTokensNotFetched();
|
||||
state.hasWallet = true;
|
||||
if (state.activeAddress === null) {
|
||||
state.activeAddress = firstAddress;
|
||||
}
|
||||
await saveState();
|
||||
clearViewStack();
|
||||
ctx.renderWalletList();
|
||||
@@ -179,22 +208,37 @@ async function importMnemonic(ctx) {
|
||||
|
||||
// Scan for used HD addresses beyond index 0.
|
||||
showFlash("Scanning for addresses...", 30000);
|
||||
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
|
||||
const scan = await scanForAddresses(
|
||||
xpub,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
ctx.pageClosed,
|
||||
);
|
||||
if (scan.addresses.length > 1) {
|
||||
wallet.addresses = scan.addresses.map((a) => ({
|
||||
address: a.address,
|
||||
balance: "0.0000",
|
||||
index: a.index,
|
||||
balance: null,
|
||||
tokenBalances: [],
|
||||
}));
|
||||
wallet.nextIndex = scan.nextIndex;
|
||||
markTrackedTokensNotFetched();
|
||||
// The new list leaves out the first address when the scan found it
|
||||
// unused. If it was the active one, the new list's first takes over,
|
||||
// and the background is told as when the user picks one on Home.
|
||||
const activeMoved =
|
||||
state.activeAddress === firstAddress &&
|
||||
wallet.addresses[0].address !== firstAddress;
|
||||
if (activeMoved) state.activeAddress = wallet.addresses[0].address;
|
||||
await saveState();
|
||||
ctx.renderWalletList();
|
||||
if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
||||
showFlash("Found " + scan.addresses.length + " addresses.");
|
||||
} else {
|
||||
showFlash("Ready.", 1000);
|
||||
}
|
||||
|
||||
ctx.doRefreshAndRender();
|
||||
ctx.startRefresh();
|
||||
}
|
||||
|
||||
async function importPrivateKey(ctx) {
|
||||
@@ -214,9 +258,7 @@ async function importPrivateKey(ctx) {
|
||||
if (!pw) return;
|
||||
const duplicate = findWalletByAddress(addr);
|
||||
if (duplicate) {
|
||||
showFlash(
|
||||
"This address already exists in wallet (" + duplicate.name + ").",
|
||||
);
|
||||
showFlash("This address already exists in a wallet.");
|
||||
return;
|
||||
}
|
||||
const encrypted = await encryptWithPassword(key, pw);
|
||||
@@ -225,15 +267,19 @@ async function importPrivateKey(ctx) {
|
||||
type: "key",
|
||||
name: "Wallet " + walletNum,
|
||||
encryptedSecret: encrypted,
|
||||
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
|
||||
addresses: [{ address: addr, balance: null, tokenBalances: [] }],
|
||||
});
|
||||
markTrackedTokensNotFetched();
|
||||
state.hasWallet = true;
|
||||
if (state.activeAddress === null) {
|
||||
state.activeAddress = addr;
|
||||
}
|
||||
await saveState();
|
||||
clearViewStack();
|
||||
ctx.renderWalletList();
|
||||
showView("main");
|
||||
|
||||
ctx.doRefreshAndRender();
|
||||
ctx.startRefresh();
|
||||
}
|
||||
|
||||
async function importXprvKey(ctx) {
|
||||
@@ -243,36 +289,29 @@ async function importXprvKey(ctx) {
|
||||
return;
|
||||
}
|
||||
if (!isValidXprv(xprv)) {
|
||||
showFlash(
|
||||
"That extended private key is not valid. Please check it and try again.",
|
||||
);
|
||||
showFlash("That extended private key is not valid.");
|
||||
return;
|
||||
}
|
||||
if (!isMasterExtendedKey(xprv)) {
|
||||
showFlash(
|
||||
"That is an account-level or child key, which cannot be imported. " +
|
||||
"Please paste the master extended private key for the wallet.",
|
||||
);
|
||||
showFlash("Please paste the master key, not a child key.");
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = hdWalletFromXprv(xprv);
|
||||
} catch {
|
||||
showFlash(
|
||||
"That extended private key is not valid. Please check it and try again.",
|
||||
);
|
||||
showFlash("That extended private key is not valid.");
|
||||
return;
|
||||
}
|
||||
const { xpub, firstAddress } = result;
|
||||
const xpubDup = findWalletByXpub(xpub);
|
||||
if (xpubDup) {
|
||||
showFlash("This key is already added (" + xpubDup.name + ").");
|
||||
showFlash("This key is already added.");
|
||||
return;
|
||||
}
|
||||
const addrDup = findWalletByAddress(firstAddress);
|
||||
if (addrDup) {
|
||||
showFlash("Address already exists in wallet (" + addrDup.name + ").");
|
||||
showFlash("Address already exists in a wallet.");
|
||||
return;
|
||||
}
|
||||
const pw = validatePassword();
|
||||
@@ -286,11 +325,20 @@ async function importXprvKey(ctx) {
|
||||
encryptedSecret: encrypted,
|
||||
nextIndex: 1,
|
||||
addresses: [
|
||||
{ address: firstAddress, balance: "0.0000", tokenBalances: [] },
|
||||
{
|
||||
address: firstAddress,
|
||||
index: 0,
|
||||
balance: null,
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
state.wallets.push(wallet);
|
||||
markTrackedTokensNotFetched();
|
||||
state.hasWallet = true;
|
||||
if (state.activeAddress === null) {
|
||||
state.activeAddress = firstAddress;
|
||||
}
|
||||
await saveState();
|
||||
clearViewStack();
|
||||
ctx.renderWalletList();
|
||||
@@ -298,22 +346,36 @@ async function importXprvKey(ctx) {
|
||||
|
||||
// Scan for used HD addresses beyond index 0.
|
||||
showFlash("Scanning for addresses...", 30000);
|
||||
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
|
||||
const scan = await scanForAddresses(
|
||||
xpub,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
ctx.pageClosed,
|
||||
);
|
||||
if (scan.addresses.length > 1) {
|
||||
wallet.addresses = scan.addresses.map((a) => ({
|
||||
address: a.address,
|
||||
balance: "0.0000",
|
||||
index: a.index,
|
||||
balance: null,
|
||||
tokenBalances: [],
|
||||
}));
|
||||
wallet.nextIndex = scan.nextIndex;
|
||||
markTrackedTokensNotFetched();
|
||||
// As for a recovery phrase: an active first address the scan left
|
||||
// out gives way to the new list's first.
|
||||
const activeMoved =
|
||||
state.activeAddress === firstAddress &&
|
||||
wallet.addresses[0].address !== firstAddress;
|
||||
if (activeMoved) state.activeAddress = wallet.addresses[0].address;
|
||||
await saveState();
|
||||
ctx.renderWalletList();
|
||||
if (activeMoved) notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
||||
showFlash("Found " + scan.addresses.length + " addresses.");
|
||||
} else {
|
||||
showFlash("Ready.", 1000);
|
||||
}
|
||||
|
||||
ctx.doRefreshAndRender();
|
||||
ctx.startRefresh();
|
||||
}
|
||||
|
||||
function init(ctx) {
|
||||
@@ -330,8 +392,10 @@ function init(ctx) {
|
||||
$("add-wallet-phrase-warning").style.visibility = "visible";
|
||||
});
|
||||
|
||||
// Import / confirm
|
||||
// Import / confirm. Each press starts with no password error on screen:
|
||||
// validatePassword() puts it back if the password is still wrong.
|
||||
$("btn-add-wallet-confirm").addEventListener("click", async () => {
|
||||
hideError("add-wallet-password-error");
|
||||
if (currentMode === "mnemonic") {
|
||||
await importMnemonic(ctx);
|
||||
} else if (currentMode === "privkey") {
|
||||
|
||||
@@ -3,23 +3,26 @@ const {
|
||||
showView,
|
||||
showFlash,
|
||||
balanceLinesForAddress,
|
||||
addressDotHtml,
|
||||
txCounterpartyHtml,
|
||||
HISTORY_OFF_TEXT,
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
truncateMiddle,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
pushCurrentView,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
} = require("./helpers");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
||||
const {
|
||||
fetchRecentTransactions,
|
||||
filterTransactions,
|
||||
} = require("../../shared/transactions");
|
||||
const { resolveEnsNames } = require("../../shared/ens");
|
||||
const { storedNetworkIs } = require("../../shared/storedSettings");
|
||||
const {
|
||||
updateSendBalance,
|
||||
renderSendTokenSelect,
|
||||
@@ -32,8 +35,8 @@ const { walletDefect } = require("../../shared/walletDefects");
|
||||
|
||||
// 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,
|
||||
// so a wallet that cannot derive its keys says so instead of failing after the
|
||||
// user has typed one in.
|
||||
// so a wallet whose key getSignerForAddress refuses says so instead of failing
|
||||
// after the user has typed one in.
|
||||
function selectedWalletDefect() {
|
||||
if (state.selectedWallet === null) return null;
|
||||
return walletDefect(state.wallets[state.selectedWallet]);
|
||||
@@ -64,11 +67,27 @@ function show() {
|
||||
});
|
||||
$("address-line").dataset.full = addr.address;
|
||||
attachCopyHandlers($("address-line"));
|
||||
const usdTotal = formatAddressTotal(getAddressValue(addr));
|
||||
$("address-usd-total").innerHTML = usdTotal || " ";
|
||||
const ensEl = $("address-ens");
|
||||
// ENS is now shown inside renderAddressHtml, hide the separate element
|
||||
ensEl.classList.add("hidden");
|
||||
renderBalances();
|
||||
// Only Blockscout reports the tokens the user has not tracked.
|
||||
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
|
||||
renderSendTokenSelect(addr);
|
||||
$("tx-list").innerHTML =
|
||||
'<div class="text-muted text-xs py-1">Loading...</div>';
|
||||
showView("address");
|
||||
loadTransactions(addr.address);
|
||||
}
|
||||
|
||||
// The USD total and the balance list. Drawn again by each balance refresh
|
||||
// that returns while this screen is up (doRefreshAndRender() in
|
||||
// src/popup/index.js), which leaves the rest of the screen as it is.
|
||||
function renderBalances() {
|
||||
const addr =
|
||||
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
|
||||
const usdTotal = formatAddressTotal(getAddressValue(addr));
|
||||
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || " ";
|
||||
$("address-balances").innerHTML = balanceLinesForAddress(
|
||||
addr,
|
||||
state.trackedTokens,
|
||||
@@ -82,67 +101,6 @@ function show() {
|
||||
ctx.showAddressToken();
|
||||
});
|
||||
});
|
||||
renderSendTokenSelect(addr);
|
||||
$("tx-list").innerHTML =
|
||||
'<div class="text-muted text-xs py-1">Loading...</div>';
|
||||
showView("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 = [];
|
||||
@@ -150,10 +108,12 @@ let loadedTxs = [];
|
||||
let ensNameMap = new Map();
|
||||
|
||||
async function loadTransactions(address) {
|
||||
const networkId = state.networkId;
|
||||
try {
|
||||
const rawTxs = await fetchRecentTransactions(
|
||||
address,
|
||||
state.blockscoutUrl,
|
||||
currentNetwork().chainId,
|
||||
);
|
||||
const result = filterTransactions(rawTxs, {
|
||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||
@@ -165,39 +125,75 @@ async function loadTransactions(address) {
|
||||
});
|
||||
const txs = result.transactions;
|
||||
|
||||
// Persist any newly discovered fraud contracts
|
||||
if (result.newFraudContracts.length > 0) {
|
||||
for (const addr of result.newFraudContracts) {
|
||||
if (!state.fraudContracts.includes(addr)) {
|
||||
state.fraudContracts.push(addr);
|
||||
}
|
||||
}
|
||||
await saveState();
|
||||
}
|
||||
|
||||
loadedTxs = txs;
|
||||
|
||||
// Collect ALL unique addresses (from + to) for ENS resolution so
|
||||
// that reverse lookups work for every displayed address, not just
|
||||
// the ones that were originally entered as ENS names.
|
||||
const counterparties = [
|
||||
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
||||
];
|
||||
let names = new Map();
|
||||
if (counterparties.length > 0) {
|
||||
try {
|
||||
ensNameMap = await resolveEnsNames(
|
||||
names = await resolveEnsNames(
|
||||
counterparties,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
ctx.pageClosed,
|
||||
);
|
||||
} catch {
|
||||
ensNameMap = new Map();
|
||||
// The rows then show no ENS names.
|
||||
}
|
||||
}
|
||||
|
||||
// If the network changed during the lookups above, or the screen now
|
||||
// shows another address, nothing is saved or drawn. The page's network
|
||||
// is compared after the read of storage, because a switch made on this
|
||||
// page can land during that read. See src/shared/storedSettings.js.
|
||||
if (
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
state.networkId !== networkId ||
|
||||
$("address-line").dataset.full !== address
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist any newly discovered fraud contracts, inside the save and
|
||||
// holding the lock. Nothing is added unless this page and the stored
|
||||
// record both still name the request's network.
|
||||
if (result.newFraudContracts.length > 0) {
|
||||
let saved = false;
|
||||
await saveState((stored) => {
|
||||
if (state.networkId !== networkId) return false;
|
||||
if (stored.networkId !== networkId) return false;
|
||||
for (const addr of result.newFraudContracts) {
|
||||
if (!state.fraudContracts.includes(addr)) {
|
||||
state.fraudContracts.push(addr);
|
||||
}
|
||||
}
|
||||
saved = true;
|
||||
});
|
||||
// If the network changed while the save waited for the lock,
|
||||
// nothing is drawn.
|
||||
if (!saved) return;
|
||||
}
|
||||
|
||||
loadedTxs = txs;
|
||||
ensNameMap = names;
|
||||
renderTransactions(txs);
|
||||
} catch (e) {
|
||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||
// src/popup/index.js.
|
||||
if (ctx.pageClosed.aborted) return;
|
||||
log.errorf("loadTransactions failed:", e.message);
|
||||
// If the network changed, or the screen now shows another address,
|
||||
// the failure is not shown.
|
||||
if (
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
state.networkId !== networkId ||
|
||||
$("address-line").dataset.full !== address
|
||||
) {
|
||||
return;
|
||||
}
|
||||
$("tx-list").innerHTML =
|
||||
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
||||
}
|
||||
@@ -205,6 +201,10 @@ async function loadTransactions(address) {
|
||||
|
||||
function renderTransactions(txs) {
|
||||
const list = $("tx-list");
|
||||
if (!state.useBlockscout) {
|
||||
list.innerHTML = `<div class="text-muted text-xs py-1">${escapeHtml(HISTORY_OFF_TEXT)}</div>`;
|
||||
return;
|
||||
}
|
||||
if (txs.length === 0) {
|
||||
list.innerHTML =
|
||||
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
||||
@@ -229,18 +229,18 @@ function renderTransactions(txs) {
|
||||
const amountStr = tx.value
|
||||
? escapeHtml(tx.value + " " + sym)
|
||||
: escapeHtml(sym);
|
||||
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
||||
const displayAddr =
|
||||
title || ensName || truncateMiddle(counterparty, maxAddr);
|
||||
const addrStr = escapeHtml(displayAddr);
|
||||
const dot = addressDotHtml(counterparty);
|
||||
// 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 or ENS name, where there is one, names
|
||||
// it on the line above rather than replacing it.
|
||||
const nameStr = escapeHtml(title || ensName || "");
|
||||
const err = tx.isError ? " (failed)" : "";
|
||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||
const opacity = tx.isError ? " opacity-50" : "";
|
||||
const ago = escapeHtml(timeAgo(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" data-tx="${i}" style="${opacity}">`;
|
||||
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="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
||||
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
||||
html += `</div>`;
|
||||
i++;
|
||||
}
|
||||
@@ -273,13 +273,14 @@ function init(_ctx) {
|
||||
state.wallets[state.selectedWallet].addresses[
|
||||
state.selectedAddress
|
||||
];
|
||||
if (!addr.balance || parseFloat(addr.balance) === 0) {
|
||||
// A null balance is one no refresh has read, so it is not refused.
|
||||
if (parseFloat(addr.balance) === 0) {
|
||||
showFlash("Cannot send \u2014 zero balance.");
|
||||
return;
|
||||
}
|
||||
$("send-to").value = "";
|
||||
$("send-amount").value = "";
|
||||
$("send-token").classList.remove("hidden");
|
||||
$("send-token-dropdown").classList.remove("hidden");
|
||||
$("send-token-static").classList.add("hidden");
|
||||
updateSendBalance();
|
||||
resetSendValidation();
|
||||
@@ -313,9 +314,10 @@ function init(_ctx) {
|
||||
$("btn-export-privkey").addEventListener("click", () => {
|
||||
moreDropdown.classList.add("hidden");
|
||||
moreBtn.classList.remove("bg-fg", "text-bg");
|
||||
// There is no private key to export for an address this wallet
|
||||
// cannot derive. Without this the export screen would take a
|
||||
// password and then report it as wrong.
|
||||
// This address's private key can be derived from the stored key,
|
||||
// but export goes through getSignerForAddress, which refuses a key
|
||||
// that is not a master key. Without this the export screen would
|
||||
// take a password and then refuse.
|
||||
const defect = selectedWalletDefect();
|
||||
if (defect) {
|
||||
showFlash(defect.shortMessage);
|
||||
@@ -329,4 +331,4 @@ function init(_ctx) {
|
||||
exportPrivkey.init();
|
||||
}
|
||||
|
||||
module.exports = { init, show };
|
||||
module.exports = { init, show, renderBalances };
|
||||
|
||||
+136
-119
@@ -6,26 +6,30 @@ const {
|
||||
showView,
|
||||
showFlash,
|
||||
flashCopyFeedback,
|
||||
addressDotHtml,
|
||||
txCounterpartyHtml,
|
||||
HISTORY_OFF_TEXT,
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
truncateMiddle,
|
||||
nativeCurrency,
|
||||
balanceLine,
|
||||
unknownableAmount,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
pushCurrentView,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
} = require("./helpers");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { bundledToken, resolveSymbol } = require("../../shared/tokenList");
|
||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||
const {
|
||||
fetchRecentTransactions,
|
||||
filterTransactions,
|
||||
} = require("../../shared/transactions");
|
||||
const { resolveEnsNames } = require("../../shared/ens");
|
||||
const { storedNetworkIs } = require("../../shared/storedSettings");
|
||||
const {
|
||||
updateSendBalance,
|
||||
renderSendTokenSelect,
|
||||
@@ -37,62 +41,6 @@ const { walletDefect } = require("../../shared/walletDefects");
|
||||
|
||||
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 ensNameMap = new Map();
|
||||
let currentSymbol = null;
|
||||
@@ -102,37 +50,24 @@ function show() {
|
||||
const addr = wallet.addresses[state.selectedAddress];
|
||||
const ai = state.selectedAddress;
|
||||
const tokenId = state.selectedToken;
|
||||
|
||||
// Determine token symbol and balance
|
||||
let symbol, amount, price;
|
||||
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
|
||||
if (tokenId === "ETH") {
|
||||
symbol = "ETH";
|
||||
amount = parseFloat(addr.balance || "0");
|
||||
price = getPrice("ETH");
|
||||
} else {
|
||||
const tb = (addr.tokenBalances || []).find(
|
||||
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
|
||||
);
|
||||
symbol = resolveSymbol(
|
||||
tokenId,
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
);
|
||||
// null when the scale is unknown: no quantity to show, and none to
|
||||
// price. balanceLine() states that rather than printing 0.0000.
|
||||
amount = tb ? unknownableAmount(tb.balance) : 0;
|
||||
price = getPrice(symbol);
|
||||
}
|
||||
|
||||
currentSymbol = symbol;
|
||||
const chainId = currentNetwork().chainId;
|
||||
const knownToken = bundledToken(tokenId, chainId);
|
||||
currentSymbol =
|
||||
tokenId === "ETH"
|
||||
? nativeCurrency()
|
||||
: resolveSymbol(
|
||||
tokenId,
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
chainId,
|
||||
);
|
||||
|
||||
$("address-token-title").textContent =
|
||||
wallet.name +
|
||||
" \u2014 Address " +
|
||||
(ai + 1) +
|
||||
" \u2014 " +
|
||||
displaySymbol(symbol);
|
||||
displaySymbol(currentSymbol);
|
||||
|
||||
// Blockie
|
||||
const blockieEl = $("address-token-jazzicon");
|
||||
@@ -154,13 +89,7 @@ function show() {
|
||||
$("address-token-line").dataset.full = addr.address;
|
||||
attachCopyHandlers($("address-token-line"));
|
||||
|
||||
// USD total for this token only
|
||||
const usdVal = price && amount !== null ? amount * price : null;
|
||||
const usdStr = formatUsd(usdVal);
|
||||
$("address-token-usd-total").innerHTML = usdStr || " ";
|
||||
|
||||
// Single token balance line (no tokenId — not clickable here)
|
||||
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
|
||||
renderBalance();
|
||||
|
||||
// Token contract details (ERC-20 only)
|
||||
const contractInfo = $("address-token-contract-info");
|
||||
@@ -202,9 +131,9 @@ function show() {
|
||||
if (tokenSymbol)
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Symbol:</span> ${tokenSymbol}</div>`;
|
||||
if (tokenDecimals != null)
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${tokenDecimals}</div>`;
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Decimals:</span> ${escapeHtml(tokenDecimals)}</div>`;
|
||||
if (tokenHolders != null)
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${Number(tokenHolders).toLocaleString()}</div>`;
|
||||
infoHtml += `<div class="mb-1"><span class="text-muted">Holders:</span> ${escapeHtml(Number(tokenHolders).toLocaleString())}</div>`;
|
||||
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>`;
|
||||
contractInfo.innerHTML = infoHtml;
|
||||
@@ -222,11 +151,55 @@ function show() {
|
||||
loadTransactions(addr.address, tokenId);
|
||||
}
|
||||
|
||||
// The USD total and the balance line of the selected token. Drawn again by
|
||||
// each balance refresh that returns while this screen is up
|
||||
// (doRefreshAndRender() in src/popup/index.js), which leaves the rest of the
|
||||
// screen as it is.
|
||||
function renderBalance() {
|
||||
const addr =
|
||||
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
|
||||
const tokenId = state.selectedToken;
|
||||
let amount, price;
|
||||
if (tokenId === "ETH") {
|
||||
// null when no refresh has read the balance.
|
||||
amount = unknownableAmount(addr.balance);
|
||||
price = getPrice("ETH");
|
||||
} else {
|
||||
const id = tokenId.toLowerCase();
|
||||
const tb = (addr.tokenBalances || []).find(
|
||||
(t) => t.address.toLowerCase() === id,
|
||||
);
|
||||
const tracked = state.trackedTokens.find(
|
||||
(t) => t.address.toLowerCase() === id,
|
||||
);
|
||||
// null when the scale is unknown, or when no refresh has read the
|
||||
// token's balance: no quantity to show, and none to price.
|
||||
// balanceLine() states that rather than printing 0.0000.
|
||||
if (tb) amount = unknownableAmount(tb.balance);
|
||||
else amount = tracked && tracked.balanceNotFetched ? null : 0;
|
||||
price = getPrice(tokenId);
|
||||
}
|
||||
|
||||
// USD total for this token only
|
||||
const usdVal = price && amount !== null ? amount * price : null;
|
||||
const usdStr = formatUsd(usdVal);
|
||||
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || " ";
|
||||
|
||||
// Single token balance line (no tokenId — not clickable here)
|
||||
$("address-token-balance").innerHTML = balanceLine(
|
||||
currentSymbol,
|
||||
amount,
|
||||
price,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadTransactions(address, tokenId) {
|
||||
const networkId = state.networkId;
|
||||
try {
|
||||
const rawTxs = await fetchRecentTransactions(
|
||||
address,
|
||||
state.blockscoutUrl,
|
||||
currentNetwork().chainId,
|
||||
);
|
||||
const result = filterTransactions(rawTxs, {
|
||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||
@@ -238,16 +211,6 @@ async function loadTransactions(address, tokenId) {
|
||||
});
|
||||
let txs = result.transactions;
|
||||
|
||||
// Persist any newly discovered fraud contracts
|
||||
if (result.newFraudContracts.length > 0) {
|
||||
for (const addr of result.newFraudContracts) {
|
||||
if (!state.fraudContracts.includes(addr)) {
|
||||
state.fraudContracts.push(addr);
|
||||
}
|
||||
}
|
||||
await saveState();
|
||||
}
|
||||
|
||||
// Filter to this token only
|
||||
if (tokenId === "ETH") {
|
||||
txs = txs.filter((tx) => tx.contractAddress === null);
|
||||
@@ -259,28 +222,77 @@ async function loadTransactions(address, tokenId) {
|
||||
);
|
||||
}
|
||||
|
||||
loadedTxs = txs;
|
||||
|
||||
// Collect ALL unique addresses for ENS resolution so reverse
|
||||
// lookups work for every displayed address.
|
||||
const counterparties = [
|
||||
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
|
||||
];
|
||||
let names = new Map();
|
||||
if (counterparties.length > 0) {
|
||||
try {
|
||||
ensNameMap = await resolveEnsNames(
|
||||
names = await resolveEnsNames(
|
||||
counterparties,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
ctx.pageClosed,
|
||||
);
|
||||
} catch {
|
||||
ensNameMap = new Map();
|
||||
// The rows then show no ENS names.
|
||||
}
|
||||
}
|
||||
|
||||
// If the network changed during the lookups above, or the screen now
|
||||
// shows another address or token, nothing is saved or drawn. The page's
|
||||
// network is compared after the read of storage, because a switch made
|
||||
// on this page can land during that read. See
|
||||
// src/shared/storedSettings.js.
|
||||
if (
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
state.networkId !== networkId ||
|
||||
$("address-token-line").dataset.full !== address ||
|
||||
state.selectedToken !== tokenId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist any newly discovered fraud contracts, inside the save and
|
||||
// holding the lock. Nothing is added unless this page and the stored
|
||||
// record both still name the request's network.
|
||||
if (result.newFraudContracts.length > 0) {
|
||||
let saved = false;
|
||||
await saveState((stored) => {
|
||||
if (state.networkId !== networkId) return false;
|
||||
if (stored.networkId !== networkId) return false;
|
||||
for (const addr of result.newFraudContracts) {
|
||||
if (!state.fraudContracts.includes(addr)) {
|
||||
state.fraudContracts.push(addr);
|
||||
}
|
||||
}
|
||||
saved = true;
|
||||
});
|
||||
// If the network changed while the save waited for the lock,
|
||||
// nothing is drawn.
|
||||
if (!saved) return;
|
||||
}
|
||||
|
||||
loadedTxs = txs;
|
||||
ensNameMap = names;
|
||||
renderTransactions(txs);
|
||||
} catch (e) {
|
||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||
// src/popup/index.js.
|
||||
if (ctx.pageClosed.aborted) return;
|
||||
log.errorf("loadTransactions failed:", e.message);
|
||||
// If the network changed, or the screen now shows another address or
|
||||
// token, the failure is not shown.
|
||||
if (
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
state.networkId !== networkId ||
|
||||
$("address-token-line").dataset.full !== address ||
|
||||
state.selectedToken !== tokenId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
$("address-token-tx-list").innerHTML =
|
||||
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
||||
}
|
||||
@@ -288,6 +300,10 @@ async function loadTransactions(address, tokenId) {
|
||||
|
||||
function renderTransactions(txs) {
|
||||
const list = $("address-token-tx-list");
|
||||
if (!state.useBlockscout) {
|
||||
list.innerHTML = `<div class="text-muted text-xs py-1">${escapeHtml(HISTORY_OFF_TEXT)}</div>`;
|
||||
return;
|
||||
}
|
||||
if (txs.length === 0) {
|
||||
list.innerHTML =
|
||||
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
||||
@@ -305,18 +321,18 @@ function renderTransactions(txs) {
|
||||
const amountStr = tx.value
|
||||
? escapeHtml(tx.value + " " + sym)
|
||||
: escapeHtml(sym);
|
||||
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
||||
const displayAddr =
|
||||
title || ensName || truncateMiddle(counterparty, maxAddr);
|
||||
const addrStr = escapeHtml(displayAddr);
|
||||
const dot = addressDotHtml(counterparty);
|
||||
// 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 or ENS name, where there is one, names
|
||||
// it on the line above rather than replacing it.
|
||||
const nameStr = escapeHtml(title || ensName || "");
|
||||
const err = tx.isError ? " (failed)" : "";
|
||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||
const opacity = tx.isError ? " opacity-50" : "";
|
||||
const ago = escapeHtml(timeAgo(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" data-tx="${i}" style="${opacity}">`;
|
||||
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="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
||||
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
|
||||
html += `</div>`;
|
||||
i++;
|
||||
}
|
||||
@@ -357,7 +373,8 @@ function init(_ctx) {
|
||||
state.wallets[state.selectedWallet].addresses[
|
||||
state.selectedAddress
|
||||
];
|
||||
if (!addr.balance || parseFloat(addr.balance) === 0) {
|
||||
// A null balance is one no refresh has read, so it is not refused.
|
||||
if (parseFloat(addr.balance) === 0) {
|
||||
if (state.selectedToken === "ETH") {
|
||||
showFlash("Cannot send \u2014 zero balance.");
|
||||
return;
|
||||
@@ -373,7 +390,7 @@ function init(_ctx) {
|
||||
$("send-token").value = tokenId;
|
||||
}
|
||||
// Hide dropdown, show static token display
|
||||
$("send-token").classList.add("hidden");
|
||||
$("send-token-dropdown").classList.add("hidden");
|
||||
let staticHtml = `<div class="font-bold">${escapeHtml(displaySymbol(currentSymbol))}</div>`;
|
||||
if (tokenId !== "ETH") {
|
||||
staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`;
|
||||
@@ -392,4 +409,4 @@ function init(_ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show };
|
||||
module.exports = { init, show, renderBalance };
|
||||
|
||||
+568
-129
@@ -1,6 +1,7 @@
|
||||
const {
|
||||
$,
|
||||
addressTitle,
|
||||
CONTRACT_CREATION_TEXT,
|
||||
escapeHtml,
|
||||
showView,
|
||||
showError,
|
||||
@@ -8,21 +9,33 @@ const {
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
onViewLeave,
|
||||
formatFee,
|
||||
tokenLabel,
|
||||
blockscoutOffTokenNoteHtml,
|
||||
tokenSourcesByChainId,
|
||||
} = require("./helpers");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { networkByChainId } = require("../../shared/networks");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const {
|
||||
networkById,
|
||||
networkByChainId,
|
||||
nativeCurrencyByChainId,
|
||||
} = require("../../shared/networks");
|
||||
const {
|
||||
formatEther,
|
||||
formatUnits,
|
||||
getAddress,
|
||||
getBigInt,
|
||||
getBytes,
|
||||
Interface,
|
||||
MaxUint256,
|
||||
toUtf8String,
|
||||
} = require("ethers");
|
||||
const { getPrice, formatUsd } = require("../../shared/prices");
|
||||
const { ERC20_ABI } = require("../../shared/constants");
|
||||
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
|
||||
const {
|
||||
resolveTokenDecimals,
|
||||
resolveTokenSymbol,
|
||||
unknownDecimalsAmount,
|
||||
} = require("../../shared/approvalAmount");
|
||||
// Four decimals, with the nonzero floor these screens hold: every amount this
|
||||
@@ -34,16 +47,27 @@ const {
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
const { walletDefect } = require("../../shared/walletDefects");
|
||||
const { describeSigningFailure } = require("../../shared/approvalVerify");
|
||||
const {
|
||||
describeSigningFailure,
|
||||
signedPrimaryType,
|
||||
typedDataMessageRefusal,
|
||||
typedDataChainRefusal,
|
||||
} = require("../../shared/approvalVerify");
|
||||
const txStatus = require("./txStatus");
|
||||
const uniswap = require("../../shared/uniswap");
|
||||
const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
|
||||
const { runtimeApi, sendMessage } = require("../../shared/browserApi");
|
||||
|
||||
const erc20Iface = new Interface(ERC20_ABI);
|
||||
|
||||
function approvalAddressHtml(address) {
|
||||
// The link opens the explorer of the network with `chainId`, or of the active
|
||||
// network when none is given. A chain id that names no network has no
|
||||
// explorer, so the address is drawn without a link.
|
||||
function approvalAddressHtml(address, chainId) {
|
||||
const title = addressTitle(address, state.wallets);
|
||||
return renderAddressHtml(address, { title });
|
||||
if (chainId && !networkByChainId(chainId)) {
|
||||
return renderAddressHtml(address, { title, noLink: true });
|
||||
}
|
||||
return renderAddressHtml(address, { title, chainId });
|
||||
}
|
||||
|
||||
// The amount line for a decoded ERC-20 call. With a known scale it is the
|
||||
@@ -63,30 +87,24 @@ 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 sent from `fromAddress` to `toAddress` on the network
|
||||
// with chain id `chainId`, using known ABIs.
|
||||
// Returns { name, description, details } or null.
|
||||
function decodeCalldata(data, toAddress) {
|
||||
function decodeCalldata(data, toAddress, chainId, fromAddress) {
|
||||
if (!data || data === "0x" || data.length < 10) return null;
|
||||
|
||||
// Where a token's scale is looked for, for every decoder below: the ERC-20
|
||||
// amount line and the swap's Amount and Min. received lines resolve it the
|
||||
// same way, and refuse to format the same way when it is nowhere.
|
||||
const decimalsSources = {
|
||||
trackedTokens: state.trackedTokens,
|
||||
wallets: state.wallets,
|
||||
};
|
||||
// Where a token's name and scale are looked for, for every decoder below:
|
||||
// what the wallet knows of the tokens on the network with `chainId`, which
|
||||
// need not be the active one. The ERC-20 amount line and the swap's Amount
|
||||
// and Min. received lines resolve the scale the same way, and refuse to
|
||||
// format the same way when it is nowhere.
|
||||
const decimalsSources = tokenSourcesByChainId(chainId);
|
||||
|
||||
// Try ERC-20 (approve / transfer)
|
||||
try {
|
||||
const parsed = erc20Iface.parseTransaction({ data });
|
||||
if (parsed) {
|
||||
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
|
||||
const tokenSymbol = token ? token.symbol : null;
|
||||
const tokenSymbol = resolveTokenSymbol(toAddress, decimalsSources);
|
||||
// null when no source knows this token's scale. It is not
|
||||
// defaulted to 18: an amount formatted with a guessed scale is
|
||||
// the wrong number, and for a token with fewer decimals than the
|
||||
@@ -175,7 +193,13 @@ function decodeCalldata(data, toAddress) {
|
||||
}
|
||||
|
||||
// Try Uniswap Universal Router
|
||||
const routerResult = uniswap.decode(data, toAddress, decimalsSources);
|
||||
const routerResult = uniswap.decode(
|
||||
data,
|
||||
toAddress,
|
||||
chainId,
|
||||
decimalsSources,
|
||||
fromAddress,
|
||||
);
|
||||
if (routerResult) return routerResult;
|
||||
|
||||
return null;
|
||||
@@ -198,7 +222,7 @@ function showPhishingWarning(elementId, isPhishing) {
|
||||
// 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
|
||||
// verified but never displayed is verified against nothing the user agreed to.
|
||||
function showTxFee(approvedTx, ethPrice) {
|
||||
function showTxFee(approvedTx) {
|
||||
const network = networkByChainId(approvedTx.chainId);
|
||||
$("approve-tx-network").textContent = network
|
||||
? network.name
|
||||
@@ -206,12 +230,14 @@ function showTxFee(approvedTx, ethPrice) {
|
||||
|
||||
const gasLimit = BigInt(approvedTx.gasLimit);
|
||||
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
|
||||
const maxFeeEth = formatTxValue(formatEther(gasLimit * feePerGas));
|
||||
const usdStr = formatUsd(
|
||||
ethPrice ? parseFloat(maxFeeEth) * ethPrice : null,
|
||||
// Through formatFee(), as the confirmation screen's fee is, so the same
|
||||
// fee reads the same on both. In the native currency of the network shown
|
||||
// above, and at the price on it, as the value is.
|
||||
$("approve-tx-fee").textContent = formatFee(
|
||||
gasLimit * feePerGas,
|
||||
nativeCurrencyByChainId(approvedTx.chainId),
|
||||
approvedTx.chainId,
|
||||
);
|
||||
$("approve-tx-fee").textContent =
|
||||
maxFeeEth + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
|
||||
|
||||
let detail =
|
||||
gasLimit.toString() +
|
||||
@@ -242,8 +268,8 @@ function showTxApproval(details) {
|
||||
const approvedTx = details.approvedTx;
|
||||
|
||||
const toAddr = approvedTx.to;
|
||||
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
|
||||
const ethValue = formatEther(approvedTx.value || "0");
|
||||
const sources = tokenSourcesByChainId(approvedTx.chainId);
|
||||
|
||||
// Build txInfo for status screens
|
||||
pendingTxDetails = {
|
||||
@@ -251,14 +277,25 @@ function showTxApproval(details) {
|
||||
to: toAddr || "",
|
||||
amount: formatTxValue(ethValue),
|
||||
token: "ETH",
|
||||
tokenSymbol: token ? token.symbol : null,
|
||||
tokenSymbol: null,
|
||||
chainId: approvedTx.chainId,
|
||||
};
|
||||
|
||||
// If this is an ERC-20 call, try to extract the real recipient and amount
|
||||
const decoded = decodeCalldata(approvedTx.data, toAddr || "");
|
||||
// If this is an ERC-20 call, try to extract the real recipient and amount.
|
||||
// Decoded for the network the transaction is for, which the Network line
|
||||
// names, and the address the From line names.
|
||||
const decoded = decodeCalldata(
|
||||
approvedTx.data,
|
||||
toAddr || "",
|
||||
approvedTx.chainId,
|
||||
details.approvedFrom,
|
||||
);
|
||||
if (decoded && decoded.details) {
|
||||
let decodedTokenAddr = null;
|
||||
let decodedTokenSymbol = null;
|
||||
// The asset the status summary is counted in: an ERC-20 call's Token
|
||||
// contract, or a swap's input token. Its symbol is resolved from the
|
||||
// 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) {
|
||||
if (d.label === "Recipient" && d.address) {
|
||||
pendingTxDetails.to = d.address;
|
||||
@@ -266,20 +303,37 @@ function showTxApproval(details) {
|
||||
if (d.label === "Amount") {
|
||||
pendingTxDetails.amount = d.rawValue || d.value;
|
||||
}
|
||||
if (d.label === "Token In" && d.isToken && d.address) {
|
||||
const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase());
|
||||
if (t) {
|
||||
decodedTokenAddr = d.address;
|
||||
decodedTokenSymbol = t.symbol;
|
||||
}
|
||||
if (
|
||||
(d.label === "Token" || d.label === "Token In") &&
|
||||
d.isToken &&
|
||||
d.address
|
||||
) {
|
||||
assetAddr = d.address;
|
||||
}
|
||||
}
|
||||
if (token) {
|
||||
pendingTxDetails.token = toAddr;
|
||||
pendingTxDetails.tokenSymbol = token.symbol;
|
||||
} else if (decodedTokenAddr) {
|
||||
pendingTxDetails.token = decodedTokenAddr;
|
||||
pendingTxDetails.tokenSymbol = decodedTokenSymbol;
|
||||
if (assetAddr) {
|
||||
pendingTxDetails.token = assetAddr;
|
||||
pendingTxDetails.tokenSymbol = resolveTokenSymbol(
|
||||
assetAddr,
|
||||
sources,
|
||||
);
|
||||
}
|
||||
// A swap the decoder read no amount for, such as one whose V4 swap
|
||||
// action could not be read or a V4 exact-out swap. The wait, success
|
||||
// and error screens say the amount could not be decoded, with no
|
||||
// currency after it, rather than show the transaction's ETH value as
|
||||
// the amount. A swap paid in native ETH keeps that value: it is the
|
||||
// ETH the swap pays (for an exact-out swap, the most it pays), not a
|
||||
// figure that had to be read from the calldata.
|
||||
const tokenIn = decoded.details.find((d) => d.label === "Token In");
|
||||
if (
|
||||
tokenIn &&
|
||||
tokenIn.value !== "ETH (native)" &&
|
||||
!decoded.details.some((d) => d.label === "Amount")
|
||||
) {
|
||||
pendingTxDetails.amount = "Unknown (could not be decoded)";
|
||||
pendingTxDetails.token = null;
|
||||
pendingTxDetails.tokenSymbol = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,32 +346,47 @@ function showTxApproval(details) {
|
||||
};
|
||||
}
|
||||
|
||||
$("approve-tx-hostname").textContent = details.hostname;
|
||||
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
|
||||
// Every address link on this screen opens the explorer of the network the
|
||||
// transaction is for, which the Network line names, whichever network is
|
||||
// active.
|
||||
$("approve-tx-origin").textContent = details.origin;
|
||||
$("approve-tx-from").innerHTML = approvalAddressHtml(
|
||||
details.approvedFrom,
|
||||
approvedTx.chainId,
|
||||
);
|
||||
|
||||
// Show token symbol next to contract address if known
|
||||
const symbol = toAddr ? tokenLabel(toAddr) : null;
|
||||
// Show token symbol next to contract address if known on the network the
|
||||
// transaction is for
|
||||
const symbol = toAddr ? tokenLabel(toAddr, approvedTx.chainId) : null;
|
||||
if (toAddr) {
|
||||
let toHtml = "";
|
||||
if (symbol) {
|
||||
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
|
||||
toHtml += `<div class="font-bold mb-1 break-words">${escapeHtml(symbol)}</div>`;
|
||||
}
|
||||
toHtml += approvalAddressHtml(toAddr);
|
||||
toHtml += approvalAddressHtml(toAddr, approvedTx.chainId);
|
||||
$("approve-tx-to").innerHTML = toHtml;
|
||||
} else {
|
||||
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)");
|
||||
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
||||
}
|
||||
|
||||
const ethValueFormatted = formatTxValue(
|
||||
formatEther(approvedTx.value || "0"),
|
||||
);
|
||||
const ethPrice = getPrice("ETH");
|
||||
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
|
||||
const ethValueFormatted = formatTxValue(ethValue);
|
||||
// Priced from the exact value, not the four-decimal figure, at the price
|
||||
// on the network the transaction is for, as the confirmation screen prices
|
||||
// its amount, so the same value reads the same on both.
|
||||
const ethPrice = getPrice("ETH", approvedTx.chainId);
|
||||
const ethUsd = ethPrice ? parseFloat(ethValue) * ethPrice : null;
|
||||
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: the active network can
|
||||
// change, in Settings or when the user approves a site's request, after
|
||||
// this transaction is prepared and change back before it is signed.
|
||||
$("approve-tx-value").textContent =
|
||||
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
|
||||
ethValueFormatted +
|
||||
" " +
|
||||
nativeCurrencyByChainId(approvedTx.chainId) +
|
||||
(usdStr ? " (" + usdStr + ")" : "");
|
||||
|
||||
showTxFee(approvedTx, ethPrice);
|
||||
showTxFee(approvedTx);
|
||||
|
||||
// Decode calldata (reuse decoded from above)
|
||||
const decodedEl = $("approve-tx-decoded");
|
||||
@@ -325,18 +394,25 @@ function showTxApproval(details) {
|
||||
$("approve-tx-action").textContent = decoded.name;
|
||||
let detailsHtml = "";
|
||||
if (decoded.description) {
|
||||
detailsHtml += `<div class="mb-2">${escapeHtml(decoded.description)}</div>`;
|
||||
detailsHtml += `<div class="mb-2 break-words">${escapeHtml(decoded.description)}</div>`;
|
||||
}
|
||||
for (const d of decoded.details) {
|
||||
detailsHtml += `<div class="mb-2">`;
|
||||
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
|
||||
if (d.address) {
|
||||
if (d.isToken) {
|
||||
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address) || "Unknown token")}</div>`;
|
||||
detailsHtml += `<div class="font-bold break-words">${escapeHtml(tokenLabel(d.address, approvedTx.chainId) || "Unknown token")}</div>`;
|
||||
detailsHtml += blockscoutOffTokenNoteHtml(
|
||||
d.address,
|
||||
approvedTx.chainId,
|
||||
);
|
||||
}
|
||||
detailsHtml += approvalAddressHtml(d.address);
|
||||
detailsHtml += approvalAddressHtml(
|
||||
d.address,
|
||||
approvedTx.chainId,
|
||||
);
|
||||
} else {
|
||||
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
||||
detailsHtml += `<div class="font-bold break-words">${escapeHtml(d.value)}</div>`;
|
||||
}
|
||||
detailsHtml += `</div>`;
|
||||
}
|
||||
@@ -366,52 +442,325 @@ 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) {
|
||||
try {
|
||||
const bytes = Uint8Array.from(
|
||||
hex
|
||||
.slice(2)
|
||||
.match(/.{1,2}/g)
|
||||
.map((b) => parseInt(b, 16)),
|
||||
);
|
||||
return toUtf8String(bytes);
|
||||
return toUtf8String(getBytes(hex));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTypedDataHtml(jsonStr) {
|
||||
// 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),
|
||||
);
|
||||
}
|
||||
|
||||
// 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. Its message has to hold every declared
|
||||
// field, with each `bool` true or false, and its domain may name no chain but
|
||||
// the active network's.
|
||||
function typedDataRefusal(sp) {
|
||||
if (sp.method === "personal_sign" || sp.method === "eth_sign") return null;
|
||||
let data;
|
||||
let signed;
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
let html = "";
|
||||
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 (
|
||||
typedDataMessageRefusal(data.types, signed, data.message, "") ||
|
||||
typedDataChainRefusal(data.domain, currentNetwork())
|
||||
);
|
||||
}
|
||||
|
||||
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>`;
|
||||
// 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 {
|
||||
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 "";
|
||||
}
|
||||
|
||||
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>`;
|
||||
const sources = tokenSourcesByChainId();
|
||||
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;
|
||||
}
|
||||
|
||||
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>`;
|
||||
html += `<div class="mb-2"><div>Token</div>`;
|
||||
if (token) {
|
||||
html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`;
|
||||
html += blockscoutOffTokenNoteHtml(token);
|
||||
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;
|
||||
}
|
||||
|
||||
return html;
|
||||
// What ethers signs of a message value of type `type`: a struct keeps only
|
||||
// the fields its type declares, in the type's order, and a list is read item
|
||||
// by item. ethers drops every other key, so each one is added to `notSigned`
|
||||
// with its place in the message and its value, for the screen to list apart.
|
||||
function signedValue(types, type, value, path, notSigned) {
|
||||
const itemType = type.replace(/\[\d*\]$/, "");
|
||||
if (itemType !== type) {
|
||||
if (!Array.isArray(value)) return value;
|
||||
return value.map((item, i) =>
|
||||
signedValue(types, itemType, item, `${path}[${i}]`, notSigned),
|
||||
);
|
||||
}
|
||||
const fields = types[type];
|
||||
if (!fields || !value || typeof value !== "object") return value;
|
||||
const at = (name) => (path ? `${path}.${name}` : name);
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (!fields.some((f) => f.name === key)) notSigned.push([at(key), val]);
|
||||
}
|
||||
return Object.fromEntries(
|
||||
fields.map((f) => [
|
||||
f.name,
|
||||
signedValue(types, f.type, value[f.name], at(f.name), notSigned),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// 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. The Message section shows
|
||||
// only what is signed, and any other key the page sent is listed after it as
|
||||
// not part of the signature. Only typed data that cannot be read at all is
|
||||
// shown as raw text, and typedDataRefusal() refuses it.
|
||||
function formatTypedDataHtml(jsonStr) {
|
||||
let data;
|
||||
let primaryType;
|
||||
try {
|
||||
data = JSON.parse(jsonStr);
|
||||
primaryType = signedPrimaryType(data.types);
|
||||
} catch {
|
||||
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>`;
|
||||
|
||||
const lineHtml = (key, val) =>
|
||||
`<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
|
||||
|
||||
if (data.message) {
|
||||
const notSigned = [];
|
||||
const message = signedValue(
|
||||
data.types,
|
||||
primaryType,
|
||||
data.message,
|
||||
"",
|
||||
notSigned,
|
||||
);
|
||||
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
|
||||
for (const [key, val] of Object.entries(message)) {
|
||||
html += lineHtml(key, val);
|
||||
}
|
||||
html += `</div>`;
|
||||
if (notSigned.length > 0) {
|
||||
html += `<div class="mb-2"><div class="text-muted">Not part of the signature</div>`;
|
||||
for (const [key, val] of notSigned) {
|
||||
html += lineHtml(key, val);
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function showSignApproval(details) {
|
||||
@@ -424,7 +773,7 @@ function showSignApproval(details) {
|
||||
pendingSignParams = sp;
|
||||
pendingSignFrom = details.approvedFrom;
|
||||
|
||||
$("approve-sign-hostname").textContent = details.hostname;
|
||||
$("approve-sign-origin").textContent = details.origin;
|
||||
$("approve-sign-from").innerHTML = approvalAddressHtml(
|
||||
details.approvedFrom,
|
||||
);
|
||||
@@ -436,18 +785,36 @@ function showSignApproval(details) {
|
||||
? "Typed data (EIP-712)"
|
||||
: "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) {
|
||||
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
|
||||
} else {
|
||||
refusal = typedDataRefusal(sp);
|
||||
} else if (isHexMessage(sp.message)) {
|
||||
const decoded = decodeHexMessage(sp.message);
|
||||
if (decoded !== null) {
|
||||
$("approve-sign-message").textContent = decoded;
|
||||
$("approve-sign-message").innerHTML =
|
||||
markInvisibleCharacters(decoded);
|
||||
} else {
|
||||
$("approve-sign-message").textContent = sp.message;
|
||||
$("approve-sign-message").textContent = "This message is not text.";
|
||||
}
|
||||
$("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)
|
||||
// The warning the background attaches to an eth_sign request.
|
||||
const warningEl = $("approve-sign-danger-warning");
|
||||
if (warningEl) {
|
||||
if (sp.dangerWarning) {
|
||||
@@ -466,6 +833,12 @@ function showSignApproval(details) {
|
||||
|
||||
showView("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(
|
||||
"approve-sign-error",
|
||||
"btn-approve-sign",
|
||||
@@ -473,9 +846,29 @@ function showSignApproval(details) {
|
||||
);
|
||||
}
|
||||
|
||||
function showNetworkApproval(details) {
|
||||
showPhishingWarning(
|
||||
"approve-network-phishing-warning",
|
||||
details.isPhishingDomain,
|
||||
);
|
||||
$("approve-network-origin").textContent = details.origin;
|
||||
$("approve-network-current").textContent = networkById(
|
||||
details.currentNetworkId,
|
||||
).name;
|
||||
$("approve-network-requested").textContent = networkById(
|
||||
details.requestedNetworkId,
|
||||
).name;
|
||||
showView("approve-network");
|
||||
}
|
||||
|
||||
// Awaited by nobody: the popup entry point calls this and moves on. It
|
||||
// therefore has to absorb its own failure, and a background that cannot
|
||||
// describe the approval is the same outcome as an approval that is gone.
|
||||
//
|
||||
// Nothing is on screen until the background has described the approval, so
|
||||
// the screen shown is always the one for the approval this window answers:
|
||||
// the connection prompt's "Allow" and the network switch prompt's "Switch"
|
||||
// answer on the same port, and either would approve the other.
|
||||
async function show(id) {
|
||||
approvalId = id;
|
||||
approvalPort = runtimeApi().connect({ name: "approval:" + id });
|
||||
@@ -499,15 +892,20 @@ async function show(id) {
|
||||
showSignApproval(details);
|
||||
return;
|
||||
}
|
||||
if (details.type === "network") {
|
||||
showNetworkApproval(details);
|
||||
return;
|
||||
}
|
||||
// Site connection approval
|
||||
showPhishingWarning(
|
||||
"approve-site-phishing-warning",
|
||||
details.isPhishingDomain,
|
||||
);
|
||||
$("approve-hostname").textContent = details.hostname;
|
||||
$("approve-origin").textContent = details.origin;
|
||||
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
|
||||
attachCopyHandlers("view-approve-site");
|
||||
$("approve-remember").checked = state.rememberSiteChoice;
|
||||
showView("approve-site");
|
||||
}
|
||||
|
||||
let approvalId = null;
|
||||
@@ -543,9 +941,10 @@ function setSignButtonBusy(busy) {
|
||||
}
|
||||
|
||||
// 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 keys
|
||||
// cannot be derived. Without this the screen would take a password and fail
|
||||
// after deriving it. Reject stays available; the wallet is not touched.
|
||||
// the address the approval was raised for belongs to a wallet whose key
|
||||
// getSignerForAddress refuses. Without this the screen would take a password
|
||||
// and fail after deriving it. Reject stays available; the wallet is not
|
||||
// touched.
|
||||
// Returns true when it gated.
|
||||
function gateOnWalletDefect(errorId, buttonId, address) {
|
||||
const owner = findWalletFor(address);
|
||||
@@ -557,15 +956,15 @@ function gateOnWalletDefect(errorId, buttonId, address) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Locate the wallet and the address index owning an address. Returns null when
|
||||
// no wallet holds it. Approvals look up the address they were raised for, not
|
||||
// whichever address is active now: the approval named one account, and signing
|
||||
// with another is what verification refuses.
|
||||
// Locate the wallet and the stored address record owning an address. Returns
|
||||
// null when no wallet holds it. Approvals look up the address they were raised
|
||||
// for, not whichever address is active now: the approval named one account,
|
||||
// and signing with another is what verification refuses.
|
||||
function findWalletFor(address) {
|
||||
for (const wallet of state.wallets) {
|
||||
for (let i = 0; i < wallet.addresses.length; i++) {
|
||||
if (wallet.addresses[i].address === address) {
|
||||
return { wallet, addrIndex: i };
|
||||
return { wallet, addr: wallet.addresses[i] };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,20 +985,21 @@ function clearSignPassword() {
|
||||
hideError("approve-sign-error");
|
||||
}
|
||||
|
||||
// Answer a site-connection approval and close. The decision goes out on the
|
||||
// approval port — see approvalPort above for why — and carries no approval id,
|
||||
// because the port name already names the approval the background will settle.
|
||||
// The post is guarded because a throw must not cost the close: posting on a
|
||||
// port whose background worker has been torn down throws, and the approval it
|
||||
// would have settled died with that worker, so the only thing left to do is
|
||||
// what the user asked for — go away.
|
||||
function decideSite(approved) {
|
||||
// Answer a site-connection or network-switch approval and close. The decision
|
||||
// goes out on the approval port — see approvalPort above for why — and carries
|
||||
// no approval id, because the port name already names the approval the
|
||||
// background will settle. `remember` means something only for a site
|
||||
// connection. The post is guarded because a throw must not cost the close:
|
||||
// posting on a port whose background worker has been torn down throws, and the
|
||||
// approval it would have settled died with that worker, so the only thing left
|
||||
// to do is what the user asked for — go away.
|
||||
function decide(approved, remember) {
|
||||
if (approvalPort) {
|
||||
try {
|
||||
approvalPort.postMessage({
|
||||
type: "AUTISTMASK_APPROVAL_DECISION",
|
||||
approved,
|
||||
remember: $("approve-remember").checked,
|
||||
remember,
|
||||
});
|
||||
} catch {
|
||||
// Nothing to report it to; the window closes either way.
|
||||
@@ -618,11 +1018,19 @@ function init(_ctx) {
|
||||
});
|
||||
|
||||
$("btn-approve").addEventListener("click", () => {
|
||||
decideSite(true);
|
||||
decide(true, $("approve-remember").checked);
|
||||
});
|
||||
|
||||
$("btn-reject").addEventListener("click", () => {
|
||||
decideSite(false);
|
||||
decide(false, $("approve-remember").checked);
|
||||
});
|
||||
|
||||
$("btn-approve-network").addEventListener("click", () => {
|
||||
decide(true, false);
|
||||
});
|
||||
|
||||
$("btn-reject-network").addEventListener("click", () => {
|
||||
decide(false, false);
|
||||
});
|
||||
|
||||
$("btn-approve-tx").addEventListener("click", async () => {
|
||||
@@ -683,7 +1091,7 @@ function init(_ctx) {
|
||||
try {
|
||||
const signer = getSignerForAddress(
|
||||
active.wallet,
|
||||
active.addrIndex,
|
||||
active.addr,
|
||||
decryptedSecret,
|
||||
);
|
||||
// Sign the approved transaction exactly as it was displayed. The
|
||||
@@ -734,12 +1142,21 @@ function init(_ctx) {
|
||||
}
|
||||
});
|
||||
|
||||
$("btn-reject-tx").addEventListener("click", () => {
|
||||
notify({
|
||||
// The window closes only once the background has the rejection. A message
|
||||
// sent as the window closes can be lost, and a closed window does not
|
||||
// reject the request. After Confirm, the background refuses a rejection
|
||||
// while the transaction is being sent. The window then stays open and
|
||||
// shows the background's message.
|
||||
$("btn-reject-tx").addEventListener("click", async () => {
|
||||
const response = await sendMessage({
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id: approvalId,
|
||||
approved: false,
|
||||
});
|
||||
}).catch(() => null);
|
||||
if (response && response.error) {
|
||||
showError("approve-tx-error", response.error);
|
||||
return;
|
||||
}
|
||||
window.close();
|
||||
});
|
||||
|
||||
@@ -771,6 +1188,16 @@ function init(_ctx) {
|
||||
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
|
||||
// extension messaging boundary; only the signature does.
|
||||
let decryptedSecret;
|
||||
@@ -801,7 +1228,7 @@ function init(_ctx) {
|
||||
try {
|
||||
const signer = getSignerForAddress(
|
||||
active.wallet,
|
||||
active.addrIndex,
|
||||
active.addr,
|
||||
decryptedSecret,
|
||||
);
|
||||
const sp = pendingSignParams;
|
||||
@@ -852,14 +1279,26 @@ function init(_ctx) {
|
||||
if (outcome.retryable) setSignButtonBusy(false);
|
||||
});
|
||||
|
||||
$("btn-reject-sign").addEventListener("click", () => {
|
||||
notify({
|
||||
// As for the transaction's Reject above. After Sign, the background
|
||||
// refuses a rejection while the signature is being checked.
|
||||
$("btn-reject-sign").addEventListener("click", async () => {
|
||||
const response = await sendMessage({
|
||||
type: "AUTISTMASK_SIGN_RESPONSE",
|
||||
id: approvalId,
|
||||
approved: false,
|
||||
});
|
||||
}).catch(() => null);
|
||||
if (response && response.error) {
|
||||
showError("approve-sign-error", response.error);
|
||||
return;
|
||||
}
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show, decodeCalldata };
|
||||
module.exports = {
|
||||
init,
|
||||
show,
|
||||
decodeCalldata,
|
||||
formatTypedDataHtml,
|
||||
typedDataRefusal,
|
||||
};
|
||||
|
||||
+385
-139
@@ -2,7 +2,13 @@
|
||||
// Shows transaction details, warnings, errors. On Sign & Send,
|
||||
// reads inline password, decrypts secret, signs and broadcasts.
|
||||
|
||||
const { parseEther, parseUnits, formatEther, Contract } = require("ethers");
|
||||
const {
|
||||
parseEther,
|
||||
parseUnits,
|
||||
formatEther,
|
||||
formatUnits,
|
||||
Contract,
|
||||
} = require("ethers");
|
||||
const {
|
||||
$,
|
||||
showError,
|
||||
@@ -12,12 +18,17 @@ const {
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
renderAddressHtml,
|
||||
blockieHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
onViewLeave,
|
||||
formatFee,
|
||||
reviewedNetworkProvider,
|
||||
} = require("./helpers");
|
||||
const { state } = require("../../shared/state");
|
||||
const { state, currentNetwork } = require("../../shared/state");
|
||||
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
||||
const { getSignerForAddress } = require("../../shared/wallet");
|
||||
const { walletDefect } = require("../../shared/walletDefects");
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
@@ -28,8 +39,13 @@ const {
|
||||
const { ERC20_ABI, isBurnAddress } = require("../../shared/constants");
|
||||
const {
|
||||
displayedDecimals,
|
||||
toDecimals,
|
||||
transferAmountUnits,
|
||||
} = require("../../shared/transferAmount");
|
||||
const { assertWithinCeilings } = require("../../shared/approvalVerify");
|
||||
// The amount, the balance line and the fee reserve go through it, as the
|
||||
// approval screen's amounts do.
|
||||
const { truncateAmountNeverZero } = require("../../shared/amountDisplay");
|
||||
const {
|
||||
CODES,
|
||||
FEE_PENDING,
|
||||
@@ -37,10 +53,10 @@ const {
|
||||
FEE_UNAVAILABLE,
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
maxEthAmount,
|
||||
validateTransfer,
|
||||
} = require("../../shared/txValidation");
|
||||
const { log } = require("../../shared/log");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const txStatus = require("./txStatus");
|
||||
|
||||
let pendingTx = null;
|
||||
@@ -48,6 +64,17 @@ let pendingTx = null;
|
||||
// filled in by estimateGas() when the estimate resolves or fails.
|
||||
let feeStatus = FEE_PENDING;
|
||||
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;
|
||||
// The From address's balances on the network this screen is drawn for, read
|
||||
// from that network's node by readBalances() each time the screen is shown,
|
||||
// so a screen the popup reopens onto states them as they are then, not as
|
||||
// Send knew them. Null until the read returns; then `eth` and `token` are
|
||||
// decimal strings, `token` null for an ETH send or a token whose scale is
|
||||
// unknown, and both null if the read failed.
|
||||
let balances = null;
|
||||
|
||||
function restore() {
|
||||
const d = state.viewData;
|
||||
@@ -56,16 +83,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, chainId) {
|
||||
const blockie = blockieHtml(address);
|
||||
return (
|
||||
`<div class="mb-1">${blockie}</div>` +
|
||||
renderAddressHtml(address, { title, ensName })
|
||||
renderAddressHtml(address, { title, ensName, chainId })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,16 +98,96 @@ function valueWithUsd(text, usdAmount) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// The amount as this screen and the wait, success and error screens show it,
|
||||
// written as the approval screen writes a site's amount: the amount being
|
||||
// signed, parsed at the scale it is encoded with and written back as plain
|
||||
// decimal text, then cut to four decimals with the nonzero floor. So `.5` and
|
||||
// `00.5` read `0.5000`, as `0.5` does, except for a token whose scale is
|
||||
// unknown, which cannot be sent: its amount is cut as typed, so `.5` reads
|
||||
// `.5000`. txInfo.amount, which is what gets signed, is left as it is.
|
||||
function shownAmount(txInfo) {
|
||||
let exact = txInfo.amount;
|
||||
try {
|
||||
if (txInfo.token === "ETH") {
|
||||
exact = formatEther(parseEther(txInfo.amount));
|
||||
} else {
|
||||
const decimals = displayedDecimals(txInfo.tokenDecimals);
|
||||
exact = formatUnits(parseUnits(txInfo.amount, decimals), decimals);
|
||||
}
|
||||
} catch {
|
||||
// A token whose scale is unknown, which is never signed, or an amount
|
||||
// the amount check refuses: cut as Send's amount field held it.
|
||||
}
|
||||
return truncateAmountNeverZero(exact);
|
||||
}
|
||||
|
||||
// The Amount line, with its USD value, which is priced from the exact amount.
|
||||
// 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 || "?"
|
||||
: nativeCurrencyByChainId(txInfo.chainId);
|
||||
const price = getPrice(txInfo.token, txInfo.chainId);
|
||||
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
||||
$("confirm-amount").textContent = valueWithUsd(
|
||||
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
|
||||
amountUsd,
|
||||
);
|
||||
}
|
||||
|
||||
// The balance line, with its USD value: "Loading..." until readBalances()
|
||||
// returns, then the balance it read.
|
||||
function renderBalance(txInfo) {
|
||||
const isErc20 = txInfo.token !== "ETH";
|
||||
const rawSymbol = isErc20
|
||||
? txInfo.tokenSymbol || "?"
|
||||
: nativeCurrencyByChainId(txInfo.chainId);
|
||||
const symbol = displaySymbol(rawSymbol);
|
||||
if (balances === null) {
|
||||
$("confirm-balance").textContent = "Loading...";
|
||||
return;
|
||||
}
|
||||
// A null balance is one nobody established, for a token whose scale is
|
||||
// unknown or after a read that failed, and is stated as unknown, never as
|
||||
// zero (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
||||
const bal = isErc20 ? balances.token : balances.eth;
|
||||
if (bal === null) {
|
||||
$("confirm-balance").textContent = "unknown (" + symbol + ")";
|
||||
return;
|
||||
}
|
||||
const price = getPrice(txInfo.token, txInfo.chainId);
|
||||
const balUsd = price ? parseFloat(bal) * price : null;
|
||||
$("confirm-balance").textContent = valueWithUsd(
|
||||
truncateAmountNeverZero(bal) + " " + symbol,
|
||||
balUsd,
|
||||
);
|
||||
}
|
||||
|
||||
function show(txInfo) {
|
||||
pendingTx = txInfo;
|
||||
feeStatus = FEE_PENDING;
|
||||
feeWei = null;
|
||||
maxSendFees = null;
|
||||
balances = null;
|
||||
// The network this screen is drawn for, and the only one it sends on: the
|
||||
// one active when Send handed the transaction over. Drawn again from the
|
||||
// saved transaction, as when the popup reopens onto this screen, it keeps
|
||||
// that network, so Sign & Send refuses once Settings has switched to
|
||||
// another. Everything this screen shows is that network's: native
|
||||
// currency, USD values, explorer links, the balance, and the node and
|
||||
// explorer its checks ask. The wait, success and error screens label
|
||||
// amounts by it.
|
||||
if (!txInfo.chainId) txInfo.chainId = currentNetwork().chainId;
|
||||
|
||||
const isErc20 = txInfo.token !== "ETH";
|
||||
// The raw symbol is the price-table key; the capped one is what the
|
||||
// screen says. Truncating before the lookup would silently drop the
|
||||
// price of any token whose symbol is long enough to be capped.
|
||||
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
|
||||
const rawSymbol = isErc20
|
||||
? txInfo.tokenSymbol || "?"
|
||||
: nativeCurrencyByChainId(txInfo.chainId);
|
||||
const symbol = displaySymbol(rawSymbol);
|
||||
|
||||
// Transaction type
|
||||
@@ -93,7 +195,7 @@ function show(txInfo) {
|
||||
$("confirm-type").textContent =
|
||||
"ERC-20 token transfer (" + symbol + ")";
|
||||
} else {
|
||||
$("confirm-type").textContent = "Native ETH transfer";
|
||||
$("confirm-type").textContent = "Native " + symbol + " transfer";
|
||||
}
|
||||
|
||||
// Token contract section (ERC-20 only)
|
||||
@@ -101,7 +203,7 @@ function show(txInfo) {
|
||||
if (isErc20) {
|
||||
$("confirm-token-contract").innerHTML = renderAddressHtml(
|
||||
txInfo.token,
|
||||
{},
|
||||
{ chainId: txInfo.chainId },
|
||||
);
|
||||
tokenSection.classList.remove("hidden");
|
||||
attachCopyHandlers(tokenSection);
|
||||
@@ -115,6 +217,7 @@ function show(txInfo) {
|
||||
txInfo.from,
|
||||
null,
|
||||
fromTitle,
|
||||
txInfo.chainId,
|
||||
);
|
||||
|
||||
// To (with blockie)
|
||||
@@ -123,38 +226,12 @@ function show(txInfo) {
|
||||
txInfo.to,
|
||||
txInfo.ensName,
|
||||
toTitle,
|
||||
txInfo.chainId,
|
||||
);
|
||||
$("confirm-to-ens").classList.add("hidden");
|
||||
|
||||
// Amount (with inline USD)
|
||||
const ethPrice = getPrice("ETH");
|
||||
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) {
|
||||
// null is a balance whose scale nothing knows, not a balance of zero
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
|
||||
// refused at encode time for the same missing scale; what this line
|
||||
// must not do is state a quantity nobody established.
|
||||
const bal = txInfo.tokenBalance;
|
||||
const balUsd =
|
||||
tokenPrice && bal != null ? parseFloat(bal) * tokenPrice : null;
|
||||
$("confirm-balance").textContent =
|
||||
bal == null
|
||||
? "unknown (" + symbol + ")"
|
||||
: valueWithUsd(bal + " " + symbol, balUsd);
|
||||
} else {
|
||||
const bal = txInfo.balance || "0";
|
||||
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
||||
$("confirm-balance").textContent = valueWithUsd(bal + " ETH", balUsd);
|
||||
}
|
||||
renderAmount(txInfo);
|
||||
renderBalance(txInfo);
|
||||
|
||||
// Check for warnings (synchronous local checks)
|
||||
const localWarnings = getLocalWarnings(txInfo.to, {
|
||||
@@ -186,12 +263,63 @@ function show(txInfo) {
|
||||
// estimate landing later never moves anything.
|
||||
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
||||
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
||||
$("confirm-gas-error").textContent =
|
||||
"You do not have enough " +
|
||||
nativeCurrencyByChainId(txInfo.chainId) +
|
||||
" to pay the network fee for this transfer. Please add " +
|
||||
nativeCurrencyByChainId(txInfo.chainId) +
|
||||
" to this address and try again.";
|
||||
|
||||
// Both shown later, by checkRecipientHistory(). The first sentence says
|
||||
// exactly what getFullWarnings() checks.
|
||||
$("confirm-recipient-warning").textContent =
|
||||
"WARNING: The recipient address has never sent a transaction and" +
|
||||
" holds no " +
|
||||
nativeCurrencyByChainId(txInfo.chainId) +
|
||||
". This may indicate a fresh or unused address. Double-check the" +
|
||||
" address before sending.";
|
||||
$("confirm-contract-warning").textContent =
|
||||
"WARNING: The recipient is a smart contract. Sending " +
|
||||
nativeCurrencyByChainId(txInfo.chainId) +
|
||||
" 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.";
|
||||
|
||||
// So does the message for an amount the balance does not cover. It is
|
||||
// set here, before the first paint, so the balance read returning only
|
||||
// makes it visible and moves nothing, which is why it quotes no balance.
|
||||
// Without the token's scale readBalances() reads no token balance.
|
||||
$("confirm-balance-error").textContent =
|
||||
isErc20 && toDecimals(txInfo.tokenDecimals) === null
|
||||
? "This token's balance is unknown, because nothing this wallet" +
|
||||
" can consult reports how many decimal places it uses, so the" +
|
||||
" amount you are trying to send cannot be checked against it."
|
||||
: "Your " +
|
||||
symbol +
|
||||
" balance does not cover this amount. Please go back and send" +
|
||||
" a smaller amount.";
|
||||
|
||||
renderValidation(txInfo);
|
||||
|
||||
// Reset password field and error
|
||||
// Reset password field and error, and any refusal of an earlier Sign &
|
||||
// Send.
|
||||
$("confirm-tx-password").value = "";
|
||||
hideError("confirm-tx-password-error");
|
||||
setVisible("confirm-network-changed-error", false);
|
||||
setVisible("confirm-from-address-error", false);
|
||||
setVisible("confirm-fee-bound-error", false);
|
||||
|
||||
// Gas estimate — show placeholder then fetch async
|
||||
$("confirm-fee").style.visibility = "visible";
|
||||
@@ -206,67 +334,89 @@ function show(txInfo) {
|
||||
$("confirm-contract-warning").style.visibility = "hidden";
|
||||
$("confirm-burn-warning").style.visibility = "hidden";
|
||||
$("confirm-etherscan-warning").style.visibility = "hidden";
|
||||
// While "Check recipients on Etherscan" is off, there is no Etherscan
|
||||
// warning to reserve space for. A line in its place says the check is off.
|
||||
const etherscanOn = !!state.checkRecipientsOnEtherscan;
|
||||
$("confirm-etherscan-warning").classList.toggle("hidden", !etherscanOn);
|
||||
$("confirm-etherscan-note").classList.toggle("hidden", etherscanOn);
|
||||
|
||||
// Show burn warning via reserved element (in addition to inline warning)
|
||||
if (isBurnAddress(txInfo.to)) {
|
||||
$("confirm-burn-warning").style.visibility = "visible";
|
||||
}
|
||||
|
||||
estimateGas(txInfo);
|
||||
// The fee is estimated once the balances are read, because a max ETH
|
||||
// send's amount is the ETH balance minus the estimate's reserve.
|
||||
readBalances(txInfo).then(() => estimateGas(txInfo));
|
||||
checkRecipientHistory(txInfo);
|
||||
}
|
||||
|
||||
// Read the From address's balances from the node of the network this
|
||||
// transaction was reviewed for: ETH, and for a token send the token, at the
|
||||
// scale the screen renders the amount with. A token whose scale is unknown has
|
||||
// no balance to read. Never rejects.
|
||||
async function readBalances(txInfo) {
|
||||
let read;
|
||||
try {
|
||||
const provider = reviewedNetworkProvider(txInfo);
|
||||
const decimals = toDecimals(txInfo.tokenDecimals);
|
||||
const readToken = txInfo.token !== "ETH" && decimals !== null;
|
||||
const [ethWei, tokenUnits] = await Promise.all([
|
||||
provider.getBalance(txInfo.from),
|
||||
readToken
|
||||
? new Contract(txInfo.token, ERC20_ABI, provider).balanceOf(
|
||||
txInfo.from,
|
||||
)
|
||||
: null,
|
||||
]);
|
||||
read = {
|
||||
eth: formatEther(ethWei),
|
||||
token: readToken ? formatUnits(tokenUnits, decimals) : null,
|
||||
};
|
||||
} catch (e) {
|
||||
log.errorf("balance read failed:", e.shortMessage || e.message);
|
||||
read = { eth: null, token: null };
|
||||
}
|
||||
// The user may have left this transaction while the read was in flight.
|
||||
if (pendingTx !== txInfo) return;
|
||||
balances = read;
|
||||
renderBalance(txInfo);
|
||||
renderValidation(txInfo);
|
||||
}
|
||||
|
||||
// Render the balance check for the transaction on screen. Called once during
|
||||
// show() and again when the fee estimate resolves or fails. Every element it
|
||||
// touches already occupies its space, so re-running it never moves anything.
|
||||
// show(), again when the balances are read, and again when the fee estimate
|
||||
// resolves or fails. Every element it touches already occupies its space, so
|
||||
// re-running it never moves anything.
|
||||
function renderValidation(txInfo) {
|
||||
const isErc20 = txInfo.token !== "ETH";
|
||||
const symbol = isErc20 ? displaySymbol(txInfo.tokenSymbol || "?") : "ETH";
|
||||
|
||||
// Null while the balances are being read and after a read that failed.
|
||||
// validateTransfer() takes a missing balance as nothing to spend from, so
|
||||
// sending stays blocked either way, and the only message shown for it is
|
||||
// the one saying the read failed.
|
||||
const ethBalance = balances ? balances.eth : null;
|
||||
const tokenBalance = balances ? balances.token : null;
|
||||
const balanceRead = ethBalance !== null;
|
||||
|
||||
const { canSend, codes } = validateTransfer({
|
||||
isErc20,
|
||||
amount: txInfo.amount,
|
||||
ethBalance: txInfo.balance,
|
||||
tokenBalance: txInfo.tokenBalance,
|
||||
tokenDecimals: txInfo.tokenDecimals,
|
||||
ethBalance,
|
||||
tokenBalance,
|
||||
feeStatus,
|
||||
feeWei,
|
||||
});
|
||||
|
||||
// Messages carrying the user's own numbers are built here; the fixed
|
||||
// sentences live in the reserved elements in index.html.
|
||||
// An invalid amount is known before the first paint, so its message is
|
||||
// written here. Every message a later result brings has a reserved
|
||||
// element, its sentence fixed in index.html or set by show(), and is only
|
||||
// made visible below, so nothing moves when the result arrives.
|
||||
const messages = [];
|
||||
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
||||
messages.push("Please enter a valid amount to send.");
|
||||
}
|
||||
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
|
||||
messages.push(
|
||||
txInfo.tokenBalance == null
|
||||
? "This token's balance is unknown, because nothing this" +
|
||||
" wallet can consult reports how many decimal places it" +
|
||||
" uses, so the amount you are trying to send cannot be" +
|
||||
" checked against it."
|
||||
: "Insufficient " +
|
||||
symbol +
|
||||
" balance. You have " +
|
||||
txInfo.tokenBalance +
|
||||
" " +
|
||||
symbol +
|
||||
" but are trying to send " +
|
||||
txInfo.amount +
|
||||
" " +
|
||||
symbol +
|
||||
".",
|
||||
);
|
||||
}
|
||||
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
|
||||
messages.push(
|
||||
"Insufficient balance. You have " +
|
||||
txInfo.balance +
|
||||
" ETH but are trying to send " +
|
||||
txInfo.amount +
|
||||
" ETH.",
|
||||
);
|
||||
}
|
||||
|
||||
const errorsEl = $("confirm-errors");
|
||||
if (messages.length > 0) {
|
||||
@@ -279,13 +429,20 @@ function renderValidation(txInfo) {
|
||||
errorsEl.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
setVisible("confirm-balance-read-error", balances !== null && !balanceRead);
|
||||
setVisible(
|
||||
"confirm-balance-error",
|
||||
balanceRead &&
|
||||
(codes.includes(CODES.INSUFFICIENT_TOKEN) ||
|
||||
codes.includes(CODES.INSUFFICIENT_ETH)),
|
||||
);
|
||||
setVisible(
|
||||
"confirm-amount-fee-error",
|
||||
codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
|
||||
balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_WITH_FEE),
|
||||
);
|
||||
setVisible(
|
||||
"confirm-gas-error",
|
||||
codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
|
||||
balanceRead && codes.includes(CODES.INSUFFICIENT_ETH_FOR_FEE),
|
||||
);
|
||||
setVisible(
|
||||
"confirm-fee-unknown-error",
|
||||
@@ -304,17 +461,9 @@ function setVisible(id, visible) {
|
||||
$(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) {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const provider = reviewedNetworkProvider(txInfo);
|
||||
const feeData = await provider.getFeeData();
|
||||
let gasLimit;
|
||||
|
||||
@@ -343,8 +492,8 @@ async function estimateGas(txInfo) {
|
||||
}
|
||||
|
||||
// What the node will require to be reserved, which is what the gate
|
||||
// must be: the send pins no fee fields, so it is broadcast as a
|
||||
// type-2 transaction priced at maxFeePerGas.
|
||||
// must be: the send is broadcast as a type-2 transaction priced at
|
||||
// maxFeePerGas, which only a max ETH send pins (see below).
|
||||
const gasCostWei = feeReserveWei(gasLimit, feeData);
|
||||
if (gasCostWei === null) {
|
||||
throw new Error("no usable gas price from the provider");
|
||||
@@ -358,33 +507,63 @@ async function estimateGas(txInfo) {
|
||||
// flight; a stale fee must not reach the screen or the balance check.
|
||||
if (pendingTx !== txInfo) return;
|
||||
|
||||
const ethPrice = getPrice("ETH");
|
||||
const usd = (wei) =>
|
||||
ethPrice ? parseFloat(formatEther(wei)) * ethPrice : null;
|
||||
|
||||
// The fee line goes through formatFee(), as the approval screen's
|
||||
// does, so the same fee reads the same on both.
|
||||
if (estimateWei !== null && estimateWei < gasCostWei) {
|
||||
$("confirm-fee-amount").textContent = valueWithUsd(
|
||||
"~" + formatFeeEth(estimateWei),
|
||||
usd(estimateWei),
|
||||
);
|
||||
$("confirm-fee-amount").textContent =
|
||||
"~" +
|
||||
formatFee(
|
||||
estimateWei,
|
||||
nativeCurrencyByChainId(txInfo.chainId),
|
||||
txInfo.chainId,
|
||||
);
|
||||
$("confirm-fee-reserve").textContent =
|
||||
"up to " + formatFeeEth(gasCostWei) + " reserved";
|
||||
"up to " +
|
||||
truncateAmountNeverZero(formatEther(gasCostWei)) +
|
||||
" " +
|
||||
nativeCurrencyByChainId(txInfo.chainId) +
|
||||
" reserved";
|
||||
setVisible("confirm-fee-reserve", true);
|
||||
} else {
|
||||
// No spread to report: either there is no estimate, or the node
|
||||
// quotes a gas price at or above maxFeePerGas, so the expected
|
||||
// cost is not below the reserve. Show the reserve alone.
|
||||
$("confirm-fee-amount").textContent = valueWithUsd(
|
||||
formatFeeEth(gasCostWei),
|
||||
usd(gasCostWei),
|
||||
$("confirm-fee-amount").textContent = formatFee(
|
||||
gasCostWei,
|
||||
nativeCurrencyByChainId(txInfo.chainId),
|
||||
txInfo.chainId,
|
||||
);
|
||||
setVisible("confirm-fee-reserve", false);
|
||||
}
|
||||
feeStatus = FEE_KNOWN;
|
||||
feeWei = gasCostWei;
|
||||
// A max ETH send is the balance readBalances() read 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 is unknown or 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(balances?.eth, 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);
|
||||
} catch (e) {
|
||||
log.errorf("gas estimation failed:", e.message);
|
||||
log.errorf("gas estimation failed:", e.shortMessage || e.message);
|
||||
if (pendingTx !== txInfo) return;
|
||||
$("confirm-fee-amount").textContent = "Unable to estimate";
|
||||
setVisible("confirm-fee-reserve", false);
|
||||
@@ -394,12 +573,54 @@ async function estimateGas(txInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the transaction this send describes, enforce the bounds against the
|
||||
// gas limit and 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 gas limit, a fee per gas, or the product gasLimit × maxFeePerGas is
|
||||
// over its bound, and the caller then shows the one refusal reserved for all
|
||||
// three 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);
|
||||
}
|
||||
// The chain id of the network the screen was drawn for: ethers refuses to
|
||||
// populate it for a provider on any other network.
|
||||
const populated = await connectedSigner.populateTransaction({
|
||||
...request,
|
||||
chainId: tx.chainId,
|
||||
});
|
||||
assertWithinCeilings(populated);
|
||||
return connectedSigner.sendTransaction(populated);
|
||||
}
|
||||
|
||||
async function checkRecipientHistory(txInfo) {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const provider = reviewedNetworkProvider(txInfo);
|
||||
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
|
||||
fromAddress: txInfo.from,
|
||||
chainId: txInfo.chainId,
|
||||
});
|
||||
// The user may have left this transaction while the checks were in
|
||||
// flight; its warnings must not reach another recipient's screen.
|
||||
if (pendingTx !== txInfo) return;
|
||||
for (const w of asyncWarnings) {
|
||||
if (w.type === "contract") {
|
||||
$("confirm-contract-warning").style.visibility = "visible";
|
||||
@@ -428,6 +649,38 @@ function init(_ctx) {
|
||||
onViewLeave("confirm-tx", clearPassword);
|
||||
|
||||
$("btn-confirm-send").addEventListener("click", async () => {
|
||||
// A press shows only its own refusal, if any.
|
||||
setVisible("confirm-network-changed-error", false);
|
||||
setVisible("confirm-from-address-error", false);
|
||||
setVisible("confirm-fee-bound-error", false);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// The screen describes the network the transaction was reviewed for,
|
||||
// which Settings may have switched away from since: "Back" from
|
||||
// Settings after a switch draws this screen again from the saved
|
||||
// transaction, as a popup reopened onto it does.
|
||||
if (currentNetwork().chainId !== pendingTx.chainId) {
|
||||
setVisible("confirm-network-changed-error", true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sign as the From address this screen shows. The selection is saved
|
||||
// apart from this screen, so the popup can reopen onto it with another
|
||||
// address selected.
|
||||
const addr = wallet.addresses.find((a) => a.address === pendingTx.from);
|
||||
if (!addr) {
|
||||
setVisible("confirm-from-address-error", true);
|
||||
return;
|
||||
}
|
||||
|
||||
const password = $("confirm-tx-password").value;
|
||||
if (!password) {
|
||||
showError(
|
||||
@@ -437,7 +690,6 @@ function init(_ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wallet = state.wallets[state.selectedWallet];
|
||||
let decryptedSecret;
|
||||
hideError("confirm-tx-password-error");
|
||||
|
||||
@@ -457,49 +709,43 @@ function init(_ctx) {
|
||||
$("btn-confirm-send").disabled = true;
|
||||
$("btn-confirm-send").classList.add("text-muted");
|
||||
|
||||
// The wait, success and error screens show the amount as this screen
|
||||
// does, so they are handed a copy carrying that text. pendingTx keeps
|
||||
// the exact amount, which is the one signed.
|
||||
const shownTx = { ...pendingTx, amount: shownAmount(pendingTx) };
|
||||
let tx;
|
||||
try {
|
||||
const signer = getSignerForAddress(
|
||||
wallet,
|
||||
state.selectedAddress,
|
||||
decryptedSecret,
|
||||
);
|
||||
const signer = getSignerForAddress(wallet, addr, decryptedSecret);
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const connectedSigner = signer.connect(provider);
|
||||
|
||||
if (pendingTx.token === "ETH") {
|
||||
tx = await connectedSigner.sendTransaction({
|
||||
to: pendingTx.to,
|
||||
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);
|
||||
}
|
||||
tx = await populateVerifyAndSend(
|
||||
connectedSigner,
|
||||
pendingTx,
|
||||
maxSendFees,
|
||||
);
|
||||
|
||||
// Best-effort: clear decrypted secret after use.
|
||||
// Note: JS strings are immutable; this nulls the reference but
|
||||
// the original string may persist in memory until GC.
|
||||
decryptedSecret = null;
|
||||
txStatus.showWait(pendingTx, tx.hash);
|
||||
txStatus.showWait(shownTx, tx.hash);
|
||||
} catch (e) {
|
||||
decryptedSecret = null;
|
||||
// A gas limit or fee over its bound is refused before anything is
|
||||
// broadcast, so there is no transaction that may have reached the
|
||||
// network to warn about: the refusal stays on the confirmation
|
||||
// screen where the user can go back, rather than routing to the
|
||||
// sent/failed screen. Its sentence is fixed in index.html, so that
|
||||
// its box can hold its space from the first paint; the reason in
|
||||
// full, naming the bound, is logged.
|
||||
if (e && e.approvalMismatch) {
|
||||
log.errorf("send refused:", e.message);
|
||||
setVisible("confirm-fee-bound-error", true);
|
||||
return;
|
||||
}
|
||||
const hash = tx ? tx.hash : null;
|
||||
txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
|
||||
txStatus.showError(shownTx, hash, e.shortMessage || e.message);
|
||||
} finally {
|
||||
$("btn-confirm-send").disabled = false;
|
||||
$("btn-confirm-send").classList.remove("text-muted");
|
||||
@@ -511,4 +757,4 @@ function init(_ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show, restore };
|
||||
module.exports = { init, show, restore, populateVerifyAndSend };
|
||||
|
||||
@@ -17,9 +17,9 @@ const {
|
||||
attachCopyHandlers,
|
||||
addressHoldsFunds,
|
||||
balanceLinesForAddress,
|
||||
keyMaterialName,
|
||||
} = require("./helpers");
|
||||
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const {
|
||||
canRemoveAddress,
|
||||
@@ -54,9 +54,7 @@ function setFlash(msg) {
|
||||
// The noun follows the wallet: an xprv wallet holds no recovery phrase, and
|
||||
// this screen is offered on xprv wallets too.
|
||||
function recoveryPathText(wallet) {
|
||||
const secret = walletHasRecoveryPhrase(wallet)
|
||||
? "recovery phrase"
|
||||
: "extended private key";
|
||||
const secret = keyMaterialName(wallet);
|
||||
return (
|
||||
"Getting the address back into this list is not easy, so be sure. " +
|
||||
"Adding an address derives the next unused one, not this one, and " +
|
||||
@@ -85,18 +83,27 @@ function recoveryPathText(wallet) {
|
||||
// rounded number would report "0.0000 ETH" for an address holding real money.
|
||||
// The lines below it carry the amounts, in the same format as Home and
|
||||
// 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
|
||||
// when every holding here is one with no price, since "$0.00" directly under
|
||||
// "This address holds a balance." is a contradiction.
|
||||
// total line at all on testnet, before the first price fetch or while no
|
||||
// refresh has read the ETH balance, and no figure 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 0.000001 does not count,
|
||||
// as the lines below leave it out.
|
||||
//
|
||||
// While no refresh has read the ETH balance the address may be empty, so the
|
||||
// sentence says "may hold".
|
||||
function balanceWarningHtml(addr) {
|
||||
if (!addressHoldsFunds(addr)) return " ";
|
||||
const line = formatAddressTotal(getAddressValue(addr));
|
||||
const total = line
|
||||
? `<div class="text-xs text-muted mt-1">${escapeHtml(line)}</div>`
|
||||
: "";
|
||||
const sentence =
|
||||
addr.balance == null
|
||||
? "This address may hold a balance."
|
||||
: "This address holds a balance.";
|
||||
return (
|
||||
`<p class="mb-1">This address holds a balance. Removing it does not ` +
|
||||
`move or spend anything; the balance stays at the address.</p>` +
|
||||
`<p class="mb-1">${sentence} Removing it does not move or spend ` +
|
||||
`anything; the balance stays at the address.</p>` +
|
||||
balanceLinesForAddress(addr, state.trackedTokens, false) +
|
||||
total
|
||||
);
|
||||
@@ -120,6 +127,11 @@ function show(walletIdx, addrIdx) {
|
||||
|
||||
$("delete-address-recovery").textContent = recoveryPathText(wallet);
|
||||
$("delete-address-balance").innerHTML = balanceWarningHtml(addr);
|
||||
// Only Blockscout reports the tokens the user has not tracked.
|
||||
$("delete-address-tokens-note").classList.toggle(
|
||||
"hidden",
|
||||
!!state.useBlockscout,
|
||||
);
|
||||
|
||||
setFlash("");
|
||||
showView("delete-address-confirm");
|
||||
|
||||
@@ -2,9 +2,12 @@ const {
|
||||
$,
|
||||
showView,
|
||||
showFlash,
|
||||
showError,
|
||||
hideError,
|
||||
goBack,
|
||||
clearViewStack,
|
||||
onViewLeave,
|
||||
keyMaterialName,
|
||||
} = require("./helpers");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { decryptWithPassword } = require("../../shared/vault");
|
||||
@@ -12,6 +15,8 @@ const {
|
||||
removeWalletFromState,
|
||||
broadcastActiveChanged,
|
||||
} = require("../../shared/walletDelete");
|
||||
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
|
||||
const welcome = require("./welcome");
|
||||
|
||||
let deleteWalletIndex = null;
|
||||
let lostPasswordIndex = null;
|
||||
@@ -20,53 +25,61 @@ let ctx = null;
|
||||
// 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
|
||||
// confirmation that asks for a name other than the one on screen is
|
||||
// unusable.
|
||||
// unusable. A name that shows nothing at all (only spaces, or only
|
||||
// zero-width characters) is replaced by "Wallet N" for the same reason:
|
||||
// there would be nothing on screen to type back.
|
||||
function displayName(walletIdx) {
|
||||
const wallet = state.wallets[walletIdx];
|
||||
return (wallet && wallet.name) || "Wallet " + (walletIdx + 1);
|
||||
const name = wallet && wallet.name;
|
||||
if (name && confirmKey(name)) return name;
|
||||
return "Wallet " + (walletIdx + 1);
|
||||
}
|
||||
|
||||
// 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
|
||||
// "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
|
||||
// name. Comparing collapsed on both sides is what keeps the confirmation
|
||||
// satisfiable, on the one screen whose whole purpose is unwedging a user
|
||||
// who is already stuck. Case and surrounding space go the same way.
|
||||
// name. Characters that paint nothing, such as a zero-width space, are
|
||||
// invisible the same way and are removed first. Comparing this form on
|
||||
// both sides is what keeps the confirmation satisfiable, on the one screen
|
||||
// whose whole purpose is unwedging a user who is already stuck. Case and
|
||||
// surrounding space go the same way.
|
||||
function confirmKey(name) {
|
||||
return name.trim().replace(/\s+/g, " ").toLowerCase();
|
||||
return name
|
||||
.replace(INVISIBLE_CHARACTERS, "")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// Drop the password from the DOM and the wallet selection from the
|
||||
// closure. Registered as the view-leave handler as well as run on entry,
|
||||
// so the typed password does not sit in the hidden view after the user
|
||||
// navigates away by any route, including the Settings gear.
|
||||
// closure. Run by the view-leave handler as well as on entry, so the typed
|
||||
// password does not sit in the hidden view after the user navigates away
|
||||
// by any route, including the Settings gear.
|
||||
function clear() {
|
||||
deleteWalletIndex = null;
|
||||
$("delete-wallet-password").value = "";
|
||||
$("delete-wallet-flash").textContent = "";
|
||||
$("delete-wallet-flash").style.visibility = "hidden";
|
||||
hideError("delete-wallet-password-error");
|
||||
}
|
||||
|
||||
// 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
|
||||
// confirmation left standing in a hidden view is one click away from
|
||||
// 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.
|
||||
// destroying a wallet the user has since navigated off.
|
||||
function clearLostPassword() {
|
||||
lostPasswordIndex = null;
|
||||
$("delete-wallet-lost-name-input").value = "";
|
||||
$("delete-wallet-lost-flash").textContent = "";
|
||||
$("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) {
|
||||
clear();
|
||||
deleteWalletIndex = walletIdx;
|
||||
$("delete-wallet-name").textContent = displayName(walletIdx);
|
||||
$("delete-wallet-key-material").textContent = keyMaterialName(
|
||||
state.wallets[walletIdx],
|
||||
);
|
||||
showView("delete-wallet-confirm");
|
||||
}
|
||||
|
||||
@@ -86,6 +99,9 @@ function showLostPassword() {
|
||||
clearLostPassword();
|
||||
$("delete-wallet-lost-name").textContent = name;
|
||||
$("delete-wallet-lost-name-echo").textContent = name;
|
||||
$("delete-wallet-lost-key-material").textContent = keyMaterialName(
|
||||
state.wallets[walletIdx],
|
||||
);
|
||||
// showView() runs the leave hook of delete-wallet-confirm, which nulls
|
||||
// deleteWalletIndex, so this screen's own selection is recorded after
|
||||
// it and not before.
|
||||
@@ -98,6 +114,17 @@ function showLostPassword() {
|
||||
// cleanup and the accountsChanged broadcast cannot drift apart between
|
||||
// them.
|
||||
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);
|
||||
|
||||
deleteWalletIndex = null;
|
||||
@@ -109,7 +136,7 @@ async function finishDelete(walletIdx) {
|
||||
// Save before broadcasting: the background reads the active
|
||||
// address back out of storage to build accountsChanged.
|
||||
if (activeAddressChanged) broadcastActiveChanged();
|
||||
showView("welcome");
|
||||
welcome.show();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -129,8 +156,25 @@ async function finishDelete(walletIdx) {
|
||||
function init(_ctx) {
|
||||
ctx = _ctx;
|
||||
|
||||
onViewLeave("delete-wallet-confirm", clear);
|
||||
onViewLeave("delete-wallet-lost-password", clearLostPassword);
|
||||
// Leaving drops the wallet selection, so each screen also comes off the
|
||||
// Back stack, where the settings gear has just put it: Back from
|
||||
// Settings must not land on a screen whose button can only answer "No
|
||||
// wallet selected for deletion." A reopened popup drops them from the
|
||||
// stack the same way (https://git.eeqj.de/sneak/AutistMask/issues/480).
|
||||
onViewLeave("delete-wallet-confirm", () => {
|
||||
clear();
|
||||
const stack = state.viewStack;
|
||||
if (stack[stack.length - 1] === "delete-wallet-confirm") {
|
||||
stack.pop();
|
||||
}
|
||||
});
|
||||
onViewLeave("delete-wallet-lost-password", () => {
|
||||
clearLostPassword();
|
||||
const stack = state.viewStack;
|
||||
if (stack[stack.length - 1] === "delete-wallet-lost-password") {
|
||||
stack.pop();
|
||||
}
|
||||
});
|
||||
|
||||
// No wipe here: goBack() routes through showView(), which runs the
|
||||
// leave hook.
|
||||
@@ -167,14 +211,16 @@ function init(_ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Case, surrounding spaces and repeated inner spaces are not part
|
||||
// of the confirmation; see confirmKey(). This asks whether the
|
||||
// user knows which wallet they are on; it is not a secret, and
|
||||
// refusing "wallet 2" for "Wallet 2" would only teach the user to
|
||||
// distrust the control.
|
||||
const typed = $("delete-wallet-lost-name-input").value;
|
||||
// Case, surrounding spaces, repeated inner spaces and invisible
|
||||
// characters are not part of the confirmation; see confirmKey().
|
||||
// This asks whether the user knows which wallet they are on; it is
|
||||
// not a secret, and refusing "wallet 2" for "Wallet 2" would only
|
||||
// teach the user to distrust the control. An empty field is
|
||||
// refused whatever the wallet is called, so no stored name can
|
||||
// ever be confirmed by typing nothing.
|
||||
const typed = confirmKey($("delete-wallet-lost-name-input").value);
|
||||
const expected = displayName(lostPasswordIndex);
|
||||
if (confirmKey(typed) !== confirmKey(expected)) {
|
||||
if (typed === "" || typed !== confirmKey(expected)) {
|
||||
$("delete-wallet-lost-flash").textContent =
|
||||
"That is not the name of this wallet. Type " +
|
||||
expected +
|
||||
@@ -187,27 +233,30 @@ function init(_ctx) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add("text-muted");
|
||||
|
||||
// finishDelete() navigates, and the leave hook re-enables the
|
||||
// button and wipes the typed name on the way out.
|
||||
// finishDelete() re-enables the button; navigating away then runs
|
||||
// the leave hook that wipes the typed name.
|
||||
await finishDelete(lostPasswordIndex);
|
||||
});
|
||||
|
||||
$("btn-delete-wallet-confirm").addEventListener("click", async () => {
|
||||
const pw = $("delete-wallet-password").value;
|
||||
if (!pw) {
|
||||
$("delete-wallet-flash").textContent =
|
||||
"Please enter your password.";
|
||||
$("delete-wallet-flash").style.visibility = "visible";
|
||||
showError(
|
||||
"delete-wallet-password-error",
|
||||
"Please enter your password.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteWalletIndex === null) {
|
||||
$("delete-wallet-flash").textContent =
|
||||
"No wallet selected for deletion.";
|
||||
$("delete-wallet-flash").style.visibility = "visible";
|
||||
showError(
|
||||
"delete-wallet-password-error",
|
||||
"No wallet selected for deletion.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
hideError("delete-wallet-password-error");
|
||||
const btn = $("btn-delete-wallet-confirm");
|
||||
btn.disabled = true;
|
||||
btn.classList.add("text-muted");
|
||||
@@ -219,9 +268,10 @@ function init(_ctx) {
|
||||
try {
|
||||
await decryptWithPassword(wallet.encryptedSecret, pw);
|
||||
} catch {
|
||||
$("delete-wallet-flash").textContent =
|
||||
"That password is incorrect. Please try again.";
|
||||
$("delete-wallet-flash").style.visibility = "visible";
|
||||
showError(
|
||||
"delete-wallet-password-error",
|
||||
"That password is incorrect. Please try again.",
|
||||
);
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("text-muted");
|
||||
return;
|
||||
|
||||
@@ -20,6 +20,8 @@ const {
|
||||
$,
|
||||
showView,
|
||||
showFlash,
|
||||
showError,
|
||||
hideError,
|
||||
flashCopyFeedback,
|
||||
goBack,
|
||||
onViewLeave,
|
||||
@@ -56,11 +58,6 @@ function isCurrentReveal(generation) {
|
||||
);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
$("export-privkey-flash").textContent = message;
|
||||
$("export-privkey-flash").style.visibility = "visible";
|
||||
}
|
||||
|
||||
// Wipe every trace of the key and drop the address selection. Safe to call
|
||||
// when nothing was ever revealed, and safe to call twice.
|
||||
function clear() {
|
||||
@@ -71,8 +68,7 @@ function clear() {
|
||||
$("export-privkey-password").value = "";
|
||||
$("export-privkey-result").classList.add("hidden");
|
||||
$("export-privkey-password-section").classList.remove("hidden");
|
||||
$("export-privkey-flash").textContent = "";
|
||||
$("export-privkey-flash").style.visibility = "hidden";
|
||||
hideError("export-privkey-password-error");
|
||||
}
|
||||
|
||||
function show(walletIdx, addrIdx) {
|
||||
@@ -98,7 +94,7 @@ function show(walletIdx, addrIdx) {
|
||||
|
||||
$("export-privkey-title").textContent =
|
||||
wallet.name + " — Address " + (addrIdx + 1);
|
||||
const addrContainer = $("export-privkey-dot").parentElement;
|
||||
const addrContainer = $("export-privkey-address");
|
||||
addrContainer.innerHTML = renderAddressHtml(addr.address);
|
||||
attachCopyHandlers(addrContainer);
|
||||
|
||||
@@ -112,15 +108,19 @@ function show(walletIdx, addrIdx) {
|
||||
async function reveal() {
|
||||
const password = $("export-privkey-password").value;
|
||||
if (!password) {
|
||||
fail("Please enter your password.");
|
||||
showError(
|
||||
"export-privkey-password-error",
|
||||
"Please enter your password.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (walletIndex === null) {
|
||||
fail("No address is selected.");
|
||||
showError("export-privkey-password-error", "No address is selected.");
|
||||
return;
|
||||
}
|
||||
const wallet = state.wallets[walletIndex];
|
||||
|
||||
hideError("export-privkey-password-error");
|
||||
const btn = $("btn-export-privkey-confirm");
|
||||
btn.disabled = true;
|
||||
btn.classList.add("text-muted");
|
||||
@@ -135,16 +135,28 @@ async function reveal() {
|
||||
// decrypt ran, the wipe has already happened, so the key is not
|
||||
// even derived, let alone written.
|
||||
if (!isCurrentReveal(generation)) return;
|
||||
const signer = getSignerForAddress(wallet, addressIndex, secret);
|
||||
let signer;
|
||||
try {
|
||||
signer = getSignerForAddress(
|
||||
wallet,
|
||||
wallet.addresses[addressIndex],
|
||||
secret,
|
||||
);
|
||||
} catch (e) {
|
||||
// The password was right; it is the key that was refused.
|
||||
showError("export-privkey-password-error", e.message);
|
||||
return;
|
||||
}
|
||||
$("export-privkey-password").value = "";
|
||||
$("export-privkey-password-section").classList.add("hidden");
|
||||
$("export-privkey-value").textContent = signer.privateKey;
|
||||
$("export-privkey-result").classList.remove("hidden");
|
||||
$("export-privkey-flash").textContent = "";
|
||||
$("export-privkey-flash").style.visibility = "hidden";
|
||||
} catch {
|
||||
if (!isCurrentReveal(generation)) return;
|
||||
fail("That password is incorrect. Please try again.");
|
||||
showError(
|
||||
"export-privkey-password-error",
|
||||
"That password is incorrect. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("text-muted");
|
||||
@@ -152,7 +164,16 @@ async function reveal() {
|
||||
}
|
||||
|
||||
function init() {
|
||||
onViewLeave(VIEW, clear);
|
||||
// Leaving drops the address selection, so the screen also comes off the
|
||||
// Back stack, where the settings gear has just put it: Back from Settings
|
||||
// must not land on a password prompt that can only fail. A reopened popup
|
||||
// drops it from the stack the same way
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
|
||||
onViewLeave(VIEW, () => {
|
||||
clear();
|
||||
const stack = state.viewStack;
|
||||
if (stack[stack.length - 1] === VIEW) stack.pop();
|
||||
});
|
||||
|
||||
// No wipe here: goBack() routes through showView(), which runs the
|
||||
// leave hook. A per-button wipe would only cover this one path.
|
||||
|
||||
+368
-81
@@ -1,4 +1,7 @@
|
||||
// Shared DOM helpers used by all views.
|
||||
// Helpers the views in this directory share: showing a view and going back,
|
||||
// error and flash lines and the warning banners, formatting of amounts, fees,
|
||||
// balances, addresses and dates, the RPC and Blockscout URLs, node and tokens
|
||||
// of the network a chain id names, explorer links, and click-to-copy.
|
||||
//
|
||||
// Escaping rule for every view in this directory, since they all build
|
||||
// markup by concatenation: any VALUE interpolated into an innerHTML string
|
||||
@@ -12,11 +15,26 @@
|
||||
// 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
|
||||
// it from here.
|
||||
const { formatEther } = require("ethers");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const {
|
||||
truncateAmountNeverZero,
|
||||
isBelowOneMillionth,
|
||||
} = require("../../shared/amountDisplay");
|
||||
const {
|
||||
resolveTokenDecimals,
|
||||
resolveTokenSymbol,
|
||||
} = require("../../shared/approvalAmount");
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const { DEBUG } = require("../../shared/constants");
|
||||
const { escapeHtml } = require("../../shared/html");
|
||||
const { isDebug } = require("../../shared/log");
|
||||
const { networkByChainId } = require("../../shared/networks");
|
||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { displaySymbol } = require("../../shared/symbolDisplay");
|
||||
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||
const { markViewRendered } = require("../viewRouter");
|
||||
|
||||
// When views are added, removed, or transitions between them change,
|
||||
@@ -43,12 +61,12 @@ const VIEWS = [
|
||||
"approve-site",
|
||||
"approve-tx",
|
||||
"approve-sign",
|
||||
"approve-network",
|
||||
"export-privkey",
|
||||
"show-phrase",
|
||||
// Shown by src/popup/views/stateRecovery.js when the stored profile
|
||||
// cannot be read. It is never reached through showView() — by then the
|
||||
// state singleton this file writes on every navigation refuses to be read
|
||||
// — but it is listed so that every view-hiding loop covers it.
|
||||
// cannot be read, never by showView() (see there), but listed so that
|
||||
// every view-hiding loop covers it.
|
||||
"state-recovery",
|
||||
];
|
||||
|
||||
@@ -79,12 +97,28 @@ function hideError(id) {
|
||||
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) {
|
||||
// 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;
|
||||
if (leaving && leaving !== name) {
|
||||
const onLeave = viewLeaveHandlers.get(leaving);
|
||||
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) {
|
||||
const el = document.getElementById(`view-${v}`);
|
||||
if (el) {
|
||||
@@ -102,7 +136,8 @@ function showView(name) {
|
||||
}
|
||||
|
||||
// Create or update the debug/insecure warning banner.
|
||||
// Called on every view switch and after the settings debug toggle changes.
|
||||
// Called on every view switch, after the settings debug toggle changes, and
|
||||
// after a network switch in Settings.
|
||||
// The banner is shown when the compile-time DEBUG constant is true OR when
|
||||
// the user has enabled runtime debug mode via the settings easter egg, OR
|
||||
// when the active network is a testnet.
|
||||
@@ -119,7 +154,11 @@ 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;";
|
||||
document.body.prepend(banner);
|
||||
}
|
||||
const suffix = viewName ? " (" + viewName + ")" : "";
|
||||
// The view id is internal vocabulary; it helps while developing but
|
||||
// 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) {
|
||||
banner.textContent = "DEBUG / INSECURE [TESTNET]" + suffix;
|
||||
} else if (net.isTestnet) {
|
||||
@@ -189,10 +228,21 @@ function pushCurrentView() {
|
||||
|
||||
// Pop the navigation stack and show the previous view. If the stack
|
||||
// is empty, fall back to the main (home) view.
|
||||
//
|
||||
// An entry for the view already showing is skipped: landing on it would
|
||||
// make Back seem to do nothing. Settings, the recovery phrase or delete
|
||||
// wallet screen, then the gear leaves Settings under Settings, because that
|
||||
// screen takes itself off the stack when left, and a reopened popup cuts it
|
||||
// off the restored stack the same way
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/481).
|
||||
function goBack() {
|
||||
const stack = state.viewStack;
|
||||
while (stack.length > 0 && stack[stack.length - 1] === state.currentView) {
|
||||
stack.pop();
|
||||
}
|
||||
let target;
|
||||
if (state.viewStack.length > 0) {
|
||||
target = state.viewStack.pop();
|
||||
if (stack.length > 0) {
|
||||
target = stack.pop();
|
||||
} else {
|
||||
target = "main";
|
||||
}
|
||||
@@ -218,26 +268,124 @@ function clearFlash() {
|
||||
flashTimer = null;
|
||||
}
|
||||
$("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, Appearance). 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) {
|
||||
clearFlash();
|
||||
$("flash-msg").textContent = msg;
|
||||
flashTimer = setTimeout(() => {
|
||||
$("flash-msg").textContent = "";
|
||||
flashTimer = null;
|
||||
}, duration);
|
||||
$("flash-msg").title = msg;
|
||||
flashTimer = setTimeout(clearFlash, duration);
|
||||
}
|
||||
|
||||
// A stored token balance as a number, or null when there is no number in it.
|
||||
// balances.js writes null for a holding whose scale nothing knows, and this
|
||||
// keeps that null from becoming a zero one dereference later.
|
||||
// A stored balance as a number, or null when there is no number in it.
|
||||
// balances.js writes null for a holding whose scale nothing knows, and an
|
||||
// address's ETH balance is null until a refresh has read it. This keeps that
|
||||
// null from becoming a zero one dereference later.
|
||||
function unknownableAmount(balance) {
|
||||
if (balance == null) return null;
|
||||
const n = parseFloat(balance);
|
||||
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 screen, which sends on the active network, label a native amount
|
||||
// with this; the confirmation screen, and a transaction already made or
|
||||
// requested, label it by the transaction's 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;
|
||||
}
|
||||
|
||||
// What the wallet knows of the tokens on the network with this chain id, or
|
||||
// on the active network when no chain id is given, as the `sources` that
|
||||
// resolveTokenSymbol() and resolveTokenDecimals() take. A contract address
|
||||
// names a token on one network only, and every token balance held is the
|
||||
// active network's, so another network gets the tokens tracked on it
|
||||
// (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances.
|
||||
//
|
||||
// The wallets' balances hold what Blockscout reported for each token. So the
|
||||
// wallets are returned only while "Use Blockscout" is on.
|
||||
function tokenSourcesByChainId(chainId) {
|
||||
const chainIdOrActive = chainId || currentNetwork().chainId;
|
||||
const net = networkByChainId(chainIdOrActive);
|
||||
if (net && net.id === state.networkId) {
|
||||
return {
|
||||
chainId: chainIdOrActive,
|
||||
trackedTokens: state.trackedTokens,
|
||||
wallets: state.useBlockscout ? state.wallets : [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
chainId: chainIdOrActive,
|
||||
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
|
||||
wallets: [],
|
||||
};
|
||||
}
|
||||
|
||||
// The line shown under a token while "Use Blockscout" is off. The token gets
|
||||
// the line only when the bundled list and the tracked tokens lack its name or
|
||||
// decimals. Otherwise this returns "". The token is a contract address on the
|
||||
// network with `chainId`, or on the active network when none is given. "ETH"
|
||||
// and null are no contract, and get "". A token that is not text also gets "":
|
||||
// WaitTx and ErrorTx pass a token restored from storage unchecked.
|
||||
function blockscoutOffTokenNoteHtml(token, chainId) {
|
||||
if (typeof token !== "string") return "";
|
||||
if (state.useBlockscout || !token || token === "ETH") return "";
|
||||
const sources = tokenSourcesByChainId(chainId);
|
||||
if (
|
||||
resolveTokenSymbol(token, sources) !== null &&
|
||||
resolveTokenDecimals(token, sources) !== null
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return (
|
||||
'<div class="text-xs text-muted">Blockscout\'s report of your balances' +
|
||||
' is not used to name this token, because "Use Blockscout" is off in' +
|
||||
" Settings.</div>"
|
||||
);
|
||||
}
|
||||
|
||||
// The symbol shown for a token line, from these sources in order:
|
||||
//
|
||||
// - the bundled list, for a token on Ethereum Mainnet
|
||||
// - the tokens the user tracks
|
||||
// - the explorer's report, while "Use Blockscout" is on
|
||||
//
|
||||
// The network is the one with `chainId`, or the active network when none is
|
||||
// given. The amount line's scale comes from the same sources. Null when no
|
||||
// source names one, so the token lines keep saying `Unknown token` for a token
|
||||
// nothing knows.
|
||||
function tokenLabel(address, chainId) {
|
||||
return resolveTokenSymbol(address, tokenSourcesByChainId(chainId));
|
||||
}
|
||||
|
||||
// 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 on the network with `chainId`, or on the active
|
||||
// network when no chain id is given. The USD value is of the exact fee, not of
|
||||
// the truncated figure.
|
||||
function formatFee(wei, symbol, chainId) {
|
||||
const eth = formatEther(wei);
|
||||
const ethPrice = getPrice("ETH", chainId);
|
||||
const usd = ethPrice ? formatUsd(parseFloat(eth) * ethPrice) : "";
|
||||
return (
|
||||
truncateAmountNeverZero(eth) +
|
||||
" " +
|
||||
symbol +
|
||||
(usd ? " (" + usd + ")" : "")
|
||||
);
|
||||
}
|
||||
|
||||
// One row of the balance list: symbol, quantity, fiat value.
|
||||
//
|
||||
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
|
||||
@@ -247,15 +395,15 @@ function unknownableAmount(balance) {
|
||||
// holding renders through here.
|
||||
//
|
||||
// `amount` is null for a holding whose scale nothing knows
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349). There is no quantity to
|
||||
// print for it and no fiat value to derive from one, and printing 0.0000 for
|
||||
// a real holding is the failure this whole rule exists to prevent, so the row
|
||||
// says so instead.
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349), and for a balance no
|
||||
// refresh has read. There is no quantity to print for it and no fiat value to
|
||||
// derive from one, and printing 0.0000 for a real holding is the failure this
|
||||
// whole rule exists to prevent, so the row says so instead.
|
||||
function balanceLine(symbol, amount, price, tokenId) {
|
||||
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
||||
const usd =
|
||||
price && amount !== null
|
||||
? formatUsd(amount * price) || " "
|
||||
? escapeHtml(formatUsd(amount * price)) || " "
|
||||
: " ";
|
||||
// tokenId is a contract address out of the same explorer JSON, and it
|
||||
// lands inside a quoted attribute.
|
||||
@@ -265,7 +413,7 @@ function balanceLine(symbol, amount, price, tokenId) {
|
||||
: "";
|
||||
return (
|
||||
`<div class="flex text-xs${clickClass}"${tokenAttr}>` +
|
||||
`<span class="flex justify-between" style="width:42ch;max-width:100%">` +
|
||||
`<span class="flex justify-between w-[42ch] max-w-full">` +
|
||||
`<span>${escapeHtml(displaySymbol(symbol))}</span>` +
|
||||
`<span>${qty}</span>` +
|
||||
`</span>` +
|
||||
@@ -274,15 +422,34 @@ function balanceLine(symbol, amount, price, tokenId) {
|
||||
);
|
||||
}
|
||||
|
||||
// Tracks `token` on the active network, as every add path on AddToken and
|
||||
// SettingsAddToken does. The tracked record carries `balanceNotFetched: true`
|
||||
// until a balance refresh has read the token's balance at every address
|
||||
// (doRefreshAndRender() in src/popup/index.js). Until then, an address with no
|
||||
// balance row for the token shows `quantity unknown` for it, not 0.0000.
|
||||
function trackToken(token) {
|
||||
state.trackedTokens.push({ ...token, balanceNotFetched: true });
|
||||
}
|
||||
|
||||
// Gives every tracked token `balanceNotFetched: true`, as trackToken() gives a
|
||||
// new one. Called when an address is added, before the save: no refresh has
|
||||
// read any token's balance at that address.
|
||||
function markTrackedTokensNotFetched() {
|
||||
for (const token of state.trackedTokens) token.balanceNotFetched = true;
|
||||
}
|
||||
|
||||
function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
let html = balanceLine(
|
||||
"ETH",
|
||||
parseFloat(addr.balance || "0"),
|
||||
nativeCurrency(),
|
||||
unknownableAmount(addr.balance),
|
||||
getPrice("ETH"),
|
||||
"ETH",
|
||||
);
|
||||
const seen = new Set();
|
||||
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
|
||||
// of zero, so the show-zero setting has no say over it: hiding it
|
||||
// would be asserting the zero nobody established. Anything that does
|
||||
@@ -293,7 +460,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
html += balanceLine(
|
||||
t.symbol,
|
||||
bal,
|
||||
getPrice(t.symbol),
|
||||
getPrice(t.address),
|
||||
t.address.toLowerCase(),
|
||||
);
|
||||
seen.add(t.address.toLowerCase());
|
||||
@@ -301,10 +468,17 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
if (showZero && trackedTokens) {
|
||||
for (const t of trackedTokens) {
|
||||
if (seen.has(t.address.toLowerCase())) continue;
|
||||
// A token that the spoof rule refuses has no stored balance, and
|
||||
// gets no zero row either.
|
||||
if (
|
||||
isSpoofedSymbol(t.symbol, t.address, currentNetwork().chainId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
html += balanceLine(
|
||||
t.symbol,
|
||||
0,
|
||||
getPrice(t.symbol),
|
||||
t.balanceNotFetched ? null : 0,
|
||||
getPrice(t.address),
|
||||
t.address.toLowerCase(),
|
||||
);
|
||||
}
|
||||
@@ -313,14 +487,19 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
}
|
||||
|
||||
// Whether an address holds anything at all: ETH or any ERC-20 the wallet
|
||||
// knows about. Deliberately unrounded — the rendered lines round to four
|
||||
// decimals, so a dust balance displays as 0.0000 while still being real
|
||||
// money at a real address. Callers that warn about holdings must ask this,
|
||||
// not the rendered figure.
|
||||
// knows about, except a token holding below 0.000001, which the balance list
|
||||
// under the remove-address warning leaves out too. Deliberately unrounded —
|
||||
// the rendered lines round to four decimals, so a dust balance displays as
|
||||
// 0.0000 while still being real money at a real address. Callers that warn
|
||||
// about holdings must ask this, not the rendered figure.
|
||||
function addressHoldsFunds(addr) {
|
||||
if (!addr) return false;
|
||||
if (parseFloat(addr.balance || "0") > 0) return true;
|
||||
// A null or absent ETH balance reads as unknown, so the address may hold
|
||||
// funds. Warning about funds must err towards warning.
|
||||
if (addr.balance == null) return true;
|
||||
if (parseFloat(addr.balance) > 0) return true;
|
||||
for (const t of addr.tokenBalances || []) {
|
||||
if (isBelowOneMillionth(t.balance)) continue;
|
||||
// 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
|
||||
// consulted, so a row that survived with no quantity is holding
|
||||
@@ -331,11 +510,27 @@ function addressHoldsFunds(addr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The name of a wallet's key material, which is also its only backup: the
|
||||
// recovery phrase of an HD wallet, the private key of a key wallet, the
|
||||
// extended private key of an xprv wallet. DeleteWallet,
|
||||
// DeleteWalletLostPassword and DeleteAddress name the backup with it.
|
||||
function keyMaterialName(wallet) {
|
||||
if (walletHasRecoveryPhrase(wallet)) return "recovery phrase";
|
||||
if (wallet.type === "key") return "private key";
|
||||
return "extended private key";
|
||||
}
|
||||
|
||||
// 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 "…".
|
||||
// Safety: refuses to truncate more than 10 characters, which is the maximum
|
||||
// that still prevents address spoofing attacks (see Display Consistency in
|
||||
// README). Callers that need to display less should use a different UI
|
||||
// approach rather than silently making addresses insecure.
|
||||
// that still prevents address spoofing attacks (see Addresses in README).
|
||||
// Callers that need to display less should use a different UI approach rather
|
||||
// than silently making addresses insecure.
|
||||
function truncateMiddle(str, maxLen) {
|
||||
if (str.length <= maxLen) return str;
|
||||
const removed = str.length - maxLen + 1; // +1 for the ellipsis char
|
||||
@@ -349,23 +544,26 @@ function truncateMiddle(str, maxLen) {
|
||||
|
||||
// 16 colors evenly spaced around the hue wheel (22.5° apart),
|
||||
// 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 = [
|
||||
"#d92626",
|
||||
"#d96926",
|
||||
"#d9ac26",
|
||||
"#c2d926",
|
||||
"#80d926",
|
||||
"#3dd926",
|
||||
"#26d953",
|
||||
"#26d996",
|
||||
"#26d9d9",
|
||||
"#2696d9",
|
||||
"#2653d9",
|
||||
"#3d26d9",
|
||||
"#8026d9",
|
||||
"#c226d9",
|
||||
"#d926ac",
|
||||
"#d92669",
|
||||
"bg-[#d92626]",
|
||||
"bg-[#d96926]",
|
||||
"bg-[#d9ac26]",
|
||||
"bg-[#c2d926]",
|
||||
"bg-[#80d926]",
|
||||
"bg-[#3dd926]",
|
||||
"bg-[#26d953]",
|
||||
"bg-[#26d996]",
|
||||
"bg-[#26d9d9]",
|
||||
"bg-[#2696d9]",
|
||||
"bg-[#2653d9]",
|
||||
"bg-[#3d26d9]",
|
||||
"bg-[#8026d9]",
|
||||
"bg-[#c226d9]",
|
||||
"bg-[#d926ac]",
|
||||
"bg-[#d92669]",
|
||||
];
|
||||
|
||||
function addressColor(address) {
|
||||
@@ -375,7 +573,12 @@ function addressColor(address) {
|
||||
|
||||
function addressDotHtml(address) {
|
||||
const color = addressColor(address);
|
||||
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>`;
|
||||
return `<span class="inline-block w-[8px] h-[8px] rounded-[50%] ${color} mr-[4px] align-middle 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
|
||||
@@ -393,6 +596,35 @@ function addressTitle(address, wallets) {
|
||||
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 line shown in place of the transaction history on Home, AddressDetail and
|
||||
// AddressToken while "Use Blockscout" is off. That history comes from
|
||||
// Blockscout alone.
|
||||
const HISTORY_OFF_TEXT =
|
||||
'No transaction history is shown, because "Use Blockscout" is off in Settings.';
|
||||
|
||||
// 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,
|
||||
// and optional truncation. Title and ENS are shown as bold labels above
|
||||
// the full address.
|
||||
@@ -401,6 +633,9 @@ function formatAddressHtml(address, ensName, maxLen, title) {
|
||||
return renderAddressHtml(address, { title, ensName, maxLen });
|
||||
}
|
||||
|
||||
// A transaction's time as every screen shows it (README, Amounts): 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) {
|
||||
const d = new Date(timestamp * 1000);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
@@ -459,24 +694,50 @@ function timeAgo(timestamp) {
|
||||
|
||||
// Shared external-link icon SVG used across all views.
|
||||
const EXT_ICON =
|
||||
`<span style="display:inline-block;width:10px;height:10px;margin-left:4px;vertical-align:middle">` +
|
||||
`<span class="inline-block w-[10px] h-[10px] ml-[4px] align-middle">` +
|
||||
`<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="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
|
||||
`</svg></span>`;
|
||||
|
||||
// Block-explorer URLs. The origin is a per-network constant from
|
||||
// src/shared/networks.js; only the path segment is data, and it comes out
|
||||
// of explorer JSON (a transaction's from/to, a token's address_hash), which
|
||||
// nothing upstream validates as hex. percent-encoding it keeps a segment
|
||||
// that contains a slash, a query or a fragment from re-pointing the link
|
||||
// somewhere else in the explorer.
|
||||
function explorerUrl(kind, value) {
|
||||
return `${currentNetwork().explorerUrl}/${kind}/${encodeURIComponent(value)}`;
|
||||
// The RPC and Blockscout URLs Settings has for the network with this chain id,
|
||||
// which need not be the active one: ConfirmTx, WaitTx and TransactionDetail
|
||||
// use the network their transaction's chain id names, and the popup can reopen
|
||||
// onto them after Settings switched the network. The active network's are
|
||||
// state.rpcUrl and state.blockscoutUrl, any other's the ones remembered for it,
|
||||
// or its defaults (see applyChainSwitchFields()).
|
||||
function endpointsByChainId(chainId) {
|
||||
const net = networkByChainId(chainId);
|
||||
if (net.id === state.networkId) {
|
||||
return { rpcUrl: state.rpcUrl, blockscoutUrl: state.blockscoutUrl };
|
||||
}
|
||||
const remembered = state.networkEndpoints[net.id] || {};
|
||||
return {
|
||||
rpcUrl: remembered.rpcUrl || net.defaultRpcUrl,
|
||||
blockscoutUrl: remembered.blockscoutUrl || net.defaultBlockscoutUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function etherscanAddressUrl(address) {
|
||||
return explorerUrl("address", address);
|
||||
// The node of the network a transaction was reviewed for.
|
||||
function reviewedNetworkProvider(txInfo) {
|
||||
const net = networkByChainId(txInfo.chainId);
|
||||
return getProvider(endpointsByChainId(txInfo.chainId).rpcUrl, net.id);
|
||||
}
|
||||
|
||||
// Block-explorer URLs. The origin is a per-network constant from
|
||||
// src/shared/networks.js, the explorer of the network with `chainId`, or of
|
||||
// the active network when no chain id is given; only the path segment is
|
||||
// data, and it comes out of explorer JSON (a transaction's from/to, a token's
|
||||
// address_hash), which nothing upstream validates as hex. percent-encoding it
|
||||
// keeps a segment that contains a slash, a query or a fragment from
|
||||
// re-pointing the link somewhere else in the explorer.
|
||||
function explorerUrl(kind, value, chainId) {
|
||||
const network = chainId ? networkByChainId(chainId) : currentNetwork();
|
||||
return `${network.explorerUrl}/${kind}/${encodeURIComponent(value)}`;
|
||||
}
|
||||
|
||||
function etherscanAddressUrl(address, chainId) {
|
||||
return explorerUrl("address", address, chainId);
|
||||
}
|
||||
|
||||
// The URL still has to be escaped on the way into href="...": encoding
|
||||
@@ -518,40 +779,54 @@ function attachCopyHandlers(container) {
|
||||
|
||||
// Unified address rendering.
|
||||
//
|
||||
// Produces consistent HTML for any Ethereum address:
|
||||
// • Color dot
|
||||
// • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address
|
||||
// • Optional ENS name shown bold above address
|
||||
// • Full address (or truncated via maxLen) with dashed-underline click-to-copy
|
||||
// • Etherscan external link icon
|
||||
// Two stacked rows, in this order:
|
||||
// 1. Identity strip — colour dot, optional title (e.g. "Wallet 1 —
|
||||
// Address 2") and the explorer link icon. Optional ENS name below it.
|
||||
// 2. The address itself, alone on a full-width row that never wraps
|
||||
// (see .am-address in styles/main.css).
|
||||
//
|
||||
// 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:
|
||||
// title — wallet title string (from addressTitle)
|
||||
// ensName — ENS name string
|
||||
// maxLen — if set, truncate address display (min 32 chars enforced)
|
||||
// maxLen — if set, truncate address display. Floored at 32 characters
|
||||
// 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
|
||||
// chainId — the network whose explorer the link opens; the active
|
||||
// network's when absent
|
||||
//
|
||||
// After inserting the returned HTML into the DOM, call
|
||||
// attachCopyHandlers() on the parent to wire up click-to-copy.
|
||||
function renderAddressHtml(address, opts) {
|
||||
const { title, ensName, maxLen, noLink } = opts || {};
|
||||
const { title, ensName, maxLen, noLink, chainId } = opts || {};
|
||||
const dot = addressDotHtml(address);
|
||||
const displayAddr = maxLen ? truncateMiddle(address, maxLen) : address;
|
||||
const link = etherscanAddressUrl(address);
|
||||
const displayAddr = maxLen
|
||||
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
|
||||
: address;
|
||||
const link = etherscanAddressUrl(address, chainId);
|
||||
const extLink = noLink ? "" : etherscanLinkHtml(link);
|
||||
|
||||
let html = "";
|
||||
html += `<div class="flex items-center">${dot}`;
|
||||
if (title) {
|
||||
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
|
||||
html += `<span class="font-bold">${escapeHtml(title)}</span>`;
|
||||
}
|
||||
html += `${extLink}</div>`;
|
||||
if (ensName) {
|
||||
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="font-bold">${escapeHtml(ensName)}</div>`;
|
||||
}
|
||||
html += `<div class="am-address">${copyableHtml(displayAddr)}</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -585,20 +860,32 @@ module.exports = {
|
||||
flashCopyFeedback,
|
||||
balanceLine,
|
||||
balanceLinesForAddress,
|
||||
trackToken,
|
||||
markTrackedTokensNotFetched,
|
||||
addressHoldsFunds,
|
||||
keyMaterialName,
|
||||
unknownableAmount,
|
||||
addressColor,
|
||||
nativeCurrency,
|
||||
tokenSourcesByChainId,
|
||||
blockscoutOffTokenNoteHtml,
|
||||
tokenLabel,
|
||||
formatFee,
|
||||
addressDotHtml,
|
||||
blockieHtml,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
addressTitle,
|
||||
CONTRACT_CREATION_TEXT,
|
||||
HISTORY_OFF_TEXT,
|
||||
txCounterpartyHtml,
|
||||
formatAddressHtml,
|
||||
renderAddressHtml,
|
||||
copyableHtml,
|
||||
attachCopyHandlers,
|
||||
etherscanAddressUrl,
|
||||
etherscanLinkHtml,
|
||||
explorerUrl,
|
||||
endpointsByChainId,
|
||||
reviewedNetworkProvider,
|
||||
EXT_ICON,
|
||||
truncateMiddle,
|
||||
isoDate,
|
||||
|
||||
+121
-35
@@ -3,18 +3,27 @@ const {
|
||||
showView,
|
||||
showFlash,
|
||||
balanceLinesForAddress,
|
||||
unknownableAmount,
|
||||
markTrackedTokensNotFetched,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
addressDotHtml,
|
||||
txCounterpartyHtml,
|
||||
HISTORY_OFF_TEXT,
|
||||
addressTitle,
|
||||
escapeHtml,
|
||||
displaySymbol,
|
||||
truncateMiddle,
|
||||
nativeCurrency,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
pushCurrentView,
|
||||
} = require("./helpers");
|
||||
const { state, saveState, currentAddress } = require("../../shared/state");
|
||||
const {
|
||||
state,
|
||||
saveState,
|
||||
currentAddress,
|
||||
currentNetwork,
|
||||
} = require("../../shared/state");
|
||||
const { notify } = require("../../shared/browserApi");
|
||||
const {
|
||||
updateSendBalance,
|
||||
@@ -37,6 +46,7 @@ const {
|
||||
fetchRecentTransactions,
|
||||
filterTransactions,
|
||||
} = require("../../shared/transactions");
|
||||
const { storedNetworkIs } = require("../../shared/storedSettings");
|
||||
const { log } = require("../../shared/log");
|
||||
|
||||
function findActiveAddr() {
|
||||
@@ -57,7 +67,7 @@ function renderTotalValue() {
|
||||
const ethPrice = getPrice("ETH");
|
||||
if (priceEl) {
|
||||
priceEl.innerHTML = ethPrice
|
||||
? formatUsd(ethPrice) + " USD/ETH"
|
||||
? escapeHtml(formatUsd(ethPrice) + " USD/ETH")
|
||||
: " ";
|
||||
}
|
||||
|
||||
@@ -67,13 +77,20 @@ function renderTotalValue() {
|
||||
if (subEl) subEl.innerHTML = " ";
|
||||
return;
|
||||
}
|
||||
const ethBal = parseFloat(addr.balance || "0");
|
||||
const ethStr = ethBal.toFixed(4) + " ETH";
|
||||
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
|
||||
el.textContent = ethStr + ethUsd;
|
||||
const ethBal = unknownableAmount(addr.balance);
|
||||
if (ethBal === null) {
|
||||
el.textContent = "unknown (" + nativeCurrency() + ")";
|
||||
} else {
|
||||
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
|
||||
const ethUsd = ethPrice
|
||||
? " (" + formatUsd(ethBal * ethPrice) + ")"
|
||||
: "";
|
||||
el.textContent = ethStr + ethUsd;
|
||||
}
|
||||
|
||||
if (subEl) {
|
||||
subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || " ";
|
||||
subEl.innerHTML =
|
||||
escapeHtml(formatAddressTotal(getAddressValue(addr))) || " ";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,9 +107,16 @@ function renderActiveAddress() {
|
||||
|
||||
let homeTxs = [];
|
||||
|
||||
// The network that `home-tx-list` holds rows or a message for.
|
||||
let listNetworkId = null;
|
||||
|
||||
function renderHomeTxList(ctx) {
|
||||
const list = $("home-tx-list");
|
||||
if (!list) return;
|
||||
if (!state.useBlockscout) {
|
||||
list.innerHTML = `<div class="text-muted text-xs py-1">${escapeHtml(HISTORY_OFF_TEXT)}</div>`;
|
||||
return;
|
||||
}
|
||||
if (homeTxs.length === 0) {
|
||||
list.innerHTML =
|
||||
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
||||
@@ -117,18 +141,19 @@ function renderHomeTxList(ctx) {
|
||||
const amountStr = tx.value
|
||||
? escapeHtml(tx.value + " " + 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 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 titleStr = title ? escapeHtml(title) : "";
|
||||
const err = tx.isError ? " (failed)" : "";
|
||||
const opacity = tx.isError ? " opacity:0.5;" : "";
|
||||
const opacity = tx.isError ? " opacity-50" : "";
|
||||
const ago = escapeHtml(timeAgo(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" data-tx="${i}" style="${opacity}">`;
|
||||
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="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
||||
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
||||
html += txCounterpartyHtml(counterparty, titleStr, amountStr);
|
||||
html += `</div>`;
|
||||
i++;
|
||||
}
|
||||
@@ -170,6 +195,20 @@ async function loadHomeTxs(ctx) {
|
||||
}
|
||||
if (allAddresses.length === 0) return;
|
||||
|
||||
const networkId = state.networkId;
|
||||
|
||||
// After a network switch the list still holds the previous network's rows
|
||||
// or failure message. They go here, because every way onto Home draws Home
|
||||
// first. A redraw on the same network keeps its rows.
|
||||
if (listNetworkId !== networkId) {
|
||||
listNetworkId = networkId;
|
||||
const list = $("home-tx-list");
|
||||
if (list) {
|
||||
list.innerHTML =
|
||||
'<div class="text-muted text-xs py-1">Loading...</div>';
|
||||
}
|
||||
}
|
||||
|
||||
const filters = {
|
||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||
hideLowHolderTokens: state.hideLowHolderTokens,
|
||||
@@ -181,10 +220,25 @@ async function loadHomeTxs(ctx) {
|
||||
|
||||
try {
|
||||
const fetches = allAddresses.map((addr) =>
|
||||
fetchRecentTransactions(addr, state.blockscoutUrl),
|
||||
fetchRecentTransactions(
|
||||
addr,
|
||||
state.blockscoutUrl,
|
||||
currentNetwork().chainId,
|
||||
),
|
||||
);
|
||||
const results = await Promise.all(fetches);
|
||||
|
||||
// If the network changed during the lookups, nothing is saved or
|
||||
// drawn. The page's network is compared after the read of storage,
|
||||
// because a switch made on this page can land during that read. See
|
||||
// src/shared/storedSettings.js.
|
||||
if (
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
state.networkId !== networkId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge, deduplicate by hash, filter, sort, take 25
|
||||
const seen = new Set();
|
||||
let merged = [];
|
||||
@@ -198,22 +252,44 @@ async function loadHomeTxs(ctx) {
|
||||
|
||||
const filtered = filterTransactions(merged, filters);
|
||||
|
||||
// Persist any newly discovered fraud contracts
|
||||
// Persist any newly discovered fraud contracts, inside the save and
|
||||
// holding the lock. Nothing is added unless this page and the stored
|
||||
// record both still name the request's network.
|
||||
if (filtered.newFraudContracts.length > 0) {
|
||||
for (const addr of filtered.newFraudContracts) {
|
||||
if (!state.fraudContracts.includes(addr)) {
|
||||
state.fraudContracts.push(addr);
|
||||
let saved = false;
|
||||
await saveState((stored) => {
|
||||
if (state.networkId !== networkId) return false;
|
||||
if (stored.networkId !== networkId) return false;
|
||||
for (const addr of filtered.newFraudContracts) {
|
||||
if (!state.fraudContracts.includes(addr)) {
|
||||
state.fraudContracts.push(addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
await saveState();
|
||||
saved = true;
|
||||
});
|
||||
// If the network changed while the save waited for the lock,
|
||||
// nothing is drawn.
|
||||
if (!saved) return;
|
||||
}
|
||||
|
||||
merged = filtered.transactions;
|
||||
merged.sort((a, b) => b.blockNumber - a.blockNumber);
|
||||
homeTxs = merged.slice(0, 25);
|
||||
listNetworkId = networkId;
|
||||
renderHomeTxList(ctx);
|
||||
} 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);
|
||||
// If the network changed, the failure is not shown.
|
||||
if (
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
state.networkId !== networkId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
listNetworkId = networkId;
|
||||
const list = $("home-tx-list");
|
||||
if (list) {
|
||||
list.innerHTML =
|
||||
@@ -229,7 +305,7 @@ function walletListHtml() {
|
||||
state.wallets.forEach((wallet, wi) => {
|
||||
const defect = walletDefect(wallet);
|
||||
html += `<div>`;
|
||||
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
|
||||
html += `<div class="flex justify-between items-center bg-section py-1 px-2 -mx-2">`;
|
||||
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
|
||||
// xpub would only add one more address the key does not produce
|
||||
@@ -243,28 +319,33 @@ function walletListHtml() {
|
||||
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}">`;
|
||||
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" style="padding: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 p-0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
|
||||
// Only where a wallet can spare the address: a wallet holding a
|
||||
// single address has no remove control, because its last address
|
||||
// is never removable.
|
||||
const removeBtn = canRemoveAddress(wallet)
|
||||
? `<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>`
|
||||
? `<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>`
|
||||
: "";
|
||||
const dot = addressDotHtml(addr.address);
|
||||
const titleBold = isActive ? "font-bold" : "";
|
||||
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
|
||||
// [info] and [x] ride on the "Address N" line, which was empty
|
||||
// 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) {
|
||||
// An ENS reverse record is whatever the name owner set it
|
||||
// to; renderAddressHtml() escapes its own copy of this and
|
||||
// this list was the one that did not.
|
||||
html += `<div class="text-xs font-bold flex items-center">${dot}${escapeHtml(addr.ensName)}</div>`;
|
||||
html += `<div class="text-xs font-bold">${escapeHtml(addr.ensName)}</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>`;
|
||||
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
|
||||
const addrTotal = formatAddressTotal(getAddressValue(addr));
|
||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || " "}</div>`;
|
||||
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${escapeHtml(addrTotal) || " "}</div>`;
|
||||
html += balanceLinesForAddress(
|
||||
addr,
|
||||
state.trackedTokens,
|
||||
@@ -280,6 +361,8 @@ function walletListHtml() {
|
||||
|
||||
function render(ctx) {
|
||||
const container = $("wallet-list");
|
||||
// Only Blockscout reports the tokens the user has not tracked.
|
||||
$("home-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
|
||||
if (state.wallets.length === 0) {
|
||||
container.innerHTML =
|
||||
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
|
||||
@@ -334,10 +417,12 @@ function render(ctx) {
|
||||
);
|
||||
wallet.addresses.push({
|
||||
address: newAddr,
|
||||
balance: "0.0000",
|
||||
index: wallet.nextIndex,
|
||||
balance: null,
|
||||
tokenBalances: [],
|
||||
});
|
||||
wallet.nextIndex++;
|
||||
markTrackedTokensNotFetched();
|
||||
await saveState();
|
||||
render(ctx);
|
||||
ctx.doRefreshAndRender();
|
||||
@@ -420,13 +505,14 @@ function init(ctx) {
|
||||
return;
|
||||
}
|
||||
const addr = currentAddress();
|
||||
if (!addr.balance || parseFloat(addr.balance) === 0) {
|
||||
// A null balance is one no refresh has read, so it is not refused.
|
||||
if (parseFloat(addr.balance) === 0) {
|
||||
showFlash("Cannot send \u2014 zero balance.");
|
||||
return;
|
||||
}
|
||||
$("send-to").value = "";
|
||||
$("send-amount").value = "";
|
||||
$("send-token").classList.remove("hidden");
|
||||
$("send-token-dropdown").classList.remove("hidden");
|
||||
$("send-token-static").classList.add("hidden");
|
||||
renderSendTokenSelect(addr);
|
||||
updateSendBalance();
|
||||
|
||||
+243
-81
@@ -5,21 +5,44 @@ const {
|
||||
showFlash,
|
||||
addressTitle,
|
||||
displaySymbol,
|
||||
escapeHtml,
|
||||
nativeCurrency,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
tokenSourcesByChainId,
|
||||
} = require("./helpers");
|
||||
const { state, currentAddress } = require("../../shared/state");
|
||||
const { state, currentAddress, currentNetwork } = require("../../shared/state");
|
||||
let ctx;
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const { resolveTokenDecimals } = require("../../shared/approvalAmount");
|
||||
const { resolveSymbol } = require("../../shared/tokenList");
|
||||
const { isLowHolderCount } = require("../../shared/holders");
|
||||
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||
const { getAddress } = require("ethers");
|
||||
const {
|
||||
truncateAmountNeverZero,
|
||||
isBelowOneMillionth,
|
||||
} = require("../../shared/amountDisplay");
|
||||
const {
|
||||
feeReserveWei,
|
||||
isValidAmount,
|
||||
maxEthAmount,
|
||||
maxTokenAmount,
|
||||
} = require("../../shared/txValidation");
|
||||
const { log } = require("../../shared/log");
|
||||
const { getAddress, parseEther } = require("ethers");
|
||||
|
||||
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.
|
||||
* Returns { valid: true } or { valid: false, error: "..." }.
|
||||
@@ -63,13 +86,13 @@ function validateToAddress(value) {
|
||||
if (checksummed !== v) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Address checksum is invalid. Please double-check the address.",
|
||||
error: "Address checksum is invalid. Check the address.",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Address checksum is invalid. Please double-check the address.",
|
||||
error: "Address checksum is invalid. Check the address.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -119,13 +142,21 @@ function updateToValidation() {
|
||||
}
|
||||
|
||||
function renderSendTokenSelect(addr) {
|
||||
// Only Blockscout reports the tokens the user has not tracked.
|
||||
$("send-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
|
||||
const sel = $("send-token");
|
||||
sel.innerHTML = '<option value="ETH">ETH</option>';
|
||||
sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`;
|
||||
const fraudSet = new Set(
|
||||
(state.fraudContracts || []).map((a) => a.toLowerCase()),
|
||||
);
|
||||
for (const t of addr.tokenBalances || []) {
|
||||
if (isSpoofedSymbol(t.symbol, t.address)) continue;
|
||||
// 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, currentNetwork().chainId)) {
|
||||
continue;
|
||||
}
|
||||
if (fraudSet.has(t.address.toLowerCase())) continue;
|
||||
// An unknown holder count does not withhold a token the user holds:
|
||||
// only a count the explorer actually reported as below the threshold
|
||||
@@ -138,6 +169,56 @@ function renderSendTokenSelect(addr) {
|
||||
}
|
||||
}
|
||||
|
||||
// The token balance the Send screen states, and the scale it hands the
|
||||
// confirmation screen, which reads the balance itself from the network it is
|
||||
// drawn for.
|
||||
//
|
||||
// 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. As on
|
||||
// the approval screens, tokenSourcesByChainId() gives the wallets only while
|
||||
// "Use Blockscout" is on.
|
||||
//
|
||||
// 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, so it is withdrawn: unknown scale means unknown balance. It is
|
||||
// null rather than "0": this screen states it as unknown, as the confirmation
|
||||
// screen does, and Max fills nothing in for it. Only a stored quantity is
|
||||
// withdrawn: the "0" for a token that has no row at all is an absence of
|
||||
// holdings, which is true at every scale. A token with no row whose tracked
|
||||
// record carries `balanceNotFetched` gets null instead, because no refresh has
|
||||
// read its balance.
|
||||
function tokenBalanceAndDecimals(addr, token) {
|
||||
const tb = (addr.tokenBalances || []).find(
|
||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||
);
|
||||
const tokenDecimals = resolveTokenDecimals(token, tokenSourcesByChainId());
|
||||
if (!tb) {
|
||||
const tracked = state.trackedTokens.find(
|
||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||
);
|
||||
const notRead = tracked && tracked.balanceNotFetched;
|
||||
return { tokenBalance: notRead ? null : "0", tokenDecimals };
|
||||
}
|
||||
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
|
||||
return { tokenBalance: tb.balance ?? null, tokenDecimals };
|
||||
}
|
||||
|
||||
function updateSendBalance() {
|
||||
const addr = currentAddress();
|
||||
if (!addr) return;
|
||||
@@ -149,31 +230,149 @@ function updateSendBalance() {
|
||||
attachCopyHandlers($("send-from"));
|
||||
const token = state.selectedToken || $("send-token").value;
|
||||
if (token === "ETH") {
|
||||
// A null balance is one no refresh has read.
|
||||
$("send-balance").textContent =
|
||||
"Current balance: " + (addr.balance || "0") + " ETH";
|
||||
addr.balance == null
|
||||
? "Current balance: unknown (" + nativeCurrency() + ")"
|
||||
: "Current balance: " +
|
||||
truncateAmountNeverZero(addr.balance) +
|
||||
" " +
|
||||
nativeCurrency();
|
||||
} else {
|
||||
const tb = (addr.tokenBalances || []).find(
|
||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||
);
|
||||
const symbol = resolveSymbol(
|
||||
token,
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
currentNetwork().chainId,
|
||||
);
|
||||
// A null balance is a holding whose scale nothing knows. Saying "0"
|
||||
// for it would be a claim about the amount; the send itself is
|
||||
// refused later by transferAmountUnits() for the same missing scale.
|
||||
const bal = tb ? tb.balance : "0";
|
||||
// A null balance is a holding whose scale is unknown, or a tracked
|
||||
// token whose balance no refresh has read. Saying a figure for it would
|
||||
// be a claim about the amount, so it reads as the confirmation screen's
|
||||
// balance line reads it. transferAmountUnits() later refuses a send at
|
||||
// a missing scale.
|
||||
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
||||
$("send-balance").textContent =
|
||||
bal == null
|
||||
? "Current balance: unknown (" + symbol + ")"
|
||||
: "Current balance: " + bal + " " + symbol;
|
||||
: "Current balance: " +
|
||||
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 { tokenBalance: bal, tokenDecimals } = tokenBalanceAndDecimals(
|
||||
addr,
|
||||
token,
|
||||
);
|
||||
if (bal == null) {
|
||||
showFlash("This token's balance is unknown.");
|
||||
return;
|
||||
}
|
||||
const amount = maxTokenAmount(bal);
|
||||
// The balance is stored at the token's own decimal places and cut to
|
||||
// 18 above, so the only balance this refuses is zero.
|
||||
if (!isValidAmount(amount, tokenDecimals)) {
|
||||
showFlash("This token's balance is zero.");
|
||||
return;
|
||||
}
|
||||
$("send-amount").value = amount;
|
||||
amountIsMax = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// A null balance is one no refresh has read.
|
||||
if (addr.balance == null) {
|
||||
showFlash("This token's balance is unknown.");
|
||||
return;
|
||||
}
|
||||
|
||||
// The fee is estimated for this recipient, as the confirmation screen
|
||||
// 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),
|
||||
}),
|
||||
]);
|
||||
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. A network switch in that time
|
||||
// leaves no balance to fill in from.
|
||||
if (
|
||||
addr.balance == null ||
|
||||
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) {
|
||||
ctx = _ctx;
|
||||
$("send-token").addEventListener("change", updateSendBalance);
|
||||
$("send-token").addEventListener("change", () => {
|
||||
// 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
|
||||
$("btn-send-review").disabled = true;
|
||||
@@ -198,7 +397,25 @@ function init(_ctx) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!amount || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
|
||||
|
||||
const token = state.selectedToken || $("send-token").value;
|
||||
const addr = currentAddress();
|
||||
|
||||
let tokenSymbol = null;
|
||||
let tokenDecimals = null;
|
||||
if (token !== "ETH") {
|
||||
tokenSymbol = resolveSymbol(
|
||||
token,
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
currentNetwork().chainId,
|
||||
);
|
||||
({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
|
||||
}
|
||||
|
||||
// The confirmation screen's own amount check, so it is never shown
|
||||
// an amount it would refuse.
|
||||
if (!isValidAmount(amount, tokenDecimals)) {
|
||||
showFlash("Please enter a valid amount.");
|
||||
return;
|
||||
}
|
||||
@@ -210,8 +427,11 @@ function init(_ctx) {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const resolved = await provider.resolveName(to);
|
||||
// getProvider() does not look up a name resolved off-chain,
|
||||
// so such a name comes back with no address too. The message
|
||||
// covers both, in the one line the flash area shows.
|
||||
if (!resolved) {
|
||||
showFlash("Could not resolve " + to);
|
||||
showFlash("That ENS name has no on-chain address.");
|
||||
return;
|
||||
}
|
||||
resolvedTo = resolved;
|
||||
@@ -222,87 +442,29 @@ function init(_ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
const token = state.selectedToken || $("send-token").value;
|
||||
const addr = currentAddress();
|
||||
|
||||
let tokenSymbol = 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;
|
||||
if (token !== "ETH") {
|
||||
const tb = (addr.tokenBalances || []).find(
|
||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||
);
|
||||
tokenSymbol = resolveSymbol(
|
||||
token,
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
);
|
||||
// null carried through rather than flattened to "0": the confirm
|
||||
// screen states 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.
|
||||
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({
|
||||
from: addr.address,
|
||||
to: resolvedTo,
|
||||
ensName: ensName,
|
||||
amount: amount,
|
||||
token: token,
|
||||
balance: addr.balance,
|
||||
tokenSymbol: tokenSymbol,
|
||||
tokenBalance: tokenBalance,
|
||||
tokenDecimals: tokenDecimals,
|
||||
max: amountIsMax,
|
||||
});
|
||||
});
|
||||
|
||||
$("btn-send-back").addEventListener("click", () => {
|
||||
$("send-token").classList.remove("hidden");
|
||||
$("send-token-dropdown").classList.remove("hidden");
|
||||
$("send-token-static").classList.add("hidden");
|
||||
goBack();
|
||||
});
|
||||
}
|
||||
|
||||
// Called each time the Send screen opens, with its fields cleared.
|
||||
function resetSendValidation() {
|
||||
sendScreenOpenings++;
|
||||
amountIsMax = false;
|
||||
const errorEl = $("send-to-error");
|
||||
const btn = $("btn-send-review");
|
||||
if (errorEl) errorEl.textContent = "";
|
||||
|
||||
+292
-56
@@ -16,7 +16,15 @@ const {
|
||||
} = require("../dustThreshold");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||||
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
|
||||
const { restorableStack } = require("../../shared/persistedState");
|
||||
const { dropUntrackedTokens } = require("../../shared/balances");
|
||||
const { resetRenderedViews, markViewRendered } = require("../viewRouter");
|
||||
const {
|
||||
log,
|
||||
debugFetch,
|
||||
urlOrigin,
|
||||
setRuntimeDebug,
|
||||
} = require("../../shared/log");
|
||||
const deleteWallet = require("./deleteWallet");
|
||||
const showPhrase = require("./showPhrase");
|
||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||
@@ -29,46 +37,78 @@ const {
|
||||
GITEA_COMMIT_URL,
|
||||
} = require("../../shared/buildInfo");
|
||||
|
||||
const { notify } = require("../../shared/browserApi");
|
||||
const { notify, sendMessage } = require("../../shared/browserApi");
|
||||
|
||||
let ctx;
|
||||
|
||||
let versionClickCount = 0;
|
||||
let versionClickTimer = null;
|
||||
|
||||
function renderSiteList(containerId, siteMap, stateKey) {
|
||||
// The network that Settings was last drawn for. A follow of a switch saved
|
||||
// elsewhere sets `state` to the new network first. Settings is drawn again only
|
||||
// once the follow's write returns. So both "Save" buttons take this network,
|
||||
// not the one in `state`.
|
||||
let shownNetwork = null;
|
||||
|
||||
// Each endpoint "Save" counts its taps here, under the field that it stores.
|
||||
// Once the user taps the same "Save" again, the earlier tap shows no flash
|
||||
// message. The earlier tap stores its URL only if its write had already begun.
|
||||
const saveTaps = { rpcUrl: 0, blockscoutUrl: 0 };
|
||||
|
||||
// One row per site origin, however many addresses it appears under, each with
|
||||
// an [x] that hands it to onRemove.
|
||||
function renderSiteList(containerId, origins, onRemove) {
|
||||
const container = $(containerId);
|
||||
const hostnames = [...new Set(Object.values(siteMap).flat())];
|
||||
if (hostnames.length === 0) {
|
||||
const unique = [...new Set(origins)];
|
||||
if (unique.length === 0) {
|
||||
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
||||
return;
|
||||
}
|
||||
let html = "";
|
||||
hostnames.forEach((hostname) => {
|
||||
unique.forEach((origin) => {
|
||||
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
|
||||
// A hostname the URL parser produced cannot carry a delimiter, so
|
||||
// An origin the URL parser produced cannot carry a delimiter, so
|
||||
// this is escaped for the rule rather than for a known hole — the
|
||||
// rule being that nothing reaches innerHTML unescaped.
|
||||
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-key="${escapeHtml(stateKey)}" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
|
||||
html += `<span>${escapeHtml(origin)}</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 += `</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
|
||||
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);
|
||||
});
|
||||
btn.addEventListener("click", () => onRemove(btn.dataset.origin));
|
||||
});
|
||||
}
|
||||
|
||||
// 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. The
|
||||
// background answers once those have ended, so the lists drawn after it no
|
||||
// longer show the site.
|
||||
async function removeAllowedSite(origin) {
|
||||
forgetOrigin(state.allowedSites, origin);
|
||||
await saveState();
|
||||
await sendMessage({ 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() {
|
||||
const container = $("settings-tracked-tokens");
|
||||
if (state.trackedTokens.length === 0) {
|
||||
@@ -170,9 +210,14 @@ function renderWalletListSettings() {
|
||||
}
|
||||
|
||||
function show() {
|
||||
shownNetwork = currentNetwork();
|
||||
$("settings-rpc").value = state.rpcUrl;
|
||||
$("settings-blockscout").value = state.blockscoutUrl;
|
||||
$("settings-network").value = state.networkId;
|
||||
// Set on every show, not only at init: Welcome can change these two.
|
||||
$("settings-use-blockscout").checked = state.useBlockscout;
|
||||
$("settings-check-recipients-on-etherscan").checked =
|
||||
state.checkRecipientsOnEtherscan;
|
||||
renderTrackedTokens();
|
||||
renderSiteLists();
|
||||
renderWalletListSettings();
|
||||
@@ -191,36 +236,166 @@ function show() {
|
||||
versionClickCount = 0;
|
||||
|
||||
// Show debug well if debug mode is already enabled
|
||||
const debugWell = $("settings-debug-well");
|
||||
if (state.debugMode) {
|
||||
debugWell.style.display = "";
|
||||
} else {
|
||||
debugWell.style.display = "none";
|
||||
}
|
||||
$("settings-debug-well").classList.toggle("hidden", !state.debugMode);
|
||||
$("settings-debug-mode").checked = state.debugMode;
|
||||
|
||||
showView("settings");
|
||||
}
|
||||
|
||||
function renderSiteLists() {
|
||||
async function renderSiteLists() {
|
||||
renderSiteList(
|
||||
"settings-allowed-sites",
|
||||
state.allowedSites,
|
||||
"allowedSites",
|
||||
Object.values(state.allowedSites).flat(),
|
||||
removeAllowedSite,
|
||||
);
|
||||
renderSiteList(
|
||||
"settings-denied-sites",
|
||||
Object.values(state.deniedSites).flat(),
|
||||
removeDeniedSite,
|
||||
);
|
||||
// Sites allowed without "Remember" are held only in session storage, which
|
||||
// the background keeps, 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) {
|
||||
// A network switch, made by the user here or saved by the background once the
|
||||
// user approved a site's request (src/popup/index.js), leaves every screen on
|
||||
// the page showing the previous network. Two functions leave the page as
|
||||
// README's Navigation section describes:
|
||||
//
|
||||
// - cutViewStack(), before the switch is saved, so that save keeps the cut for
|
||||
// a popup closed straight after
|
||||
// - showNewSettings(), once `state` holds the new network
|
||||
//
|
||||
// The stack is cut at the first screen that cannot be rendered from saved
|
||||
// state, such as Send or AddToken, and at AddressToken unless it shows the
|
||||
// native token: any other token there was picked on the previous network, so
|
||||
// it is no longer selected either. Without a wallet the stack starts at
|
||||
// Welcome, which that cut would drop, and Welcome shows nothing from a network.
|
||||
// A change to "Use Blockscout" cuts the stack the same way (setUseBlockscout()
|
||||
// below).
|
||||
function cutViewStack() {
|
||||
if (!state.hasWallet) return;
|
||||
if (state.selectedToken !== "ETH") {
|
||||
const tokenView = state.viewStack.indexOf("address-token");
|
||||
if (tokenView !== -1) {
|
||||
state.viewStack = state.viewStack.slice(0, tokenView);
|
||||
}
|
||||
state.selectedToken = null;
|
||||
}
|
||||
state.viewStack = restorableStack(state.viewStack, state.currentView);
|
||||
}
|
||||
|
||||
// Sets "Use Blockscout" to `on` on this page. It is called inside a save,
|
||||
// holding the lock, for the user's change here or another page's
|
||||
// (followStoredSettings() in src/popup/index.js).
|
||||
//
|
||||
// Send's token dropdown was filled under the old setting, and Back cannot draw
|
||||
// Send again. So the stack is cut, as for a network switch. Turning the setting
|
||||
// off also drops every token the user has not tracked.
|
||||
function setUseBlockscout(on) {
|
||||
state.useBlockscout = on;
|
||||
cutViewStack();
|
||||
if (!on) dropUntrackedTokens(state.wallets, state.trackedTokens);
|
||||
}
|
||||
|
||||
// Draws the page again after a network switch, or after another page changed
|
||||
// "Use Blockscout" or "Check recipients on Etherscan". Every screen counts as
|
||||
// not yet rendered, so "Back" renders again any screen that a reopened popup
|
||||
// can render. Settings on top is brought up to date in place. Any other screen
|
||||
// is drawn again as a reopened popup draws it, which is Home for one that
|
||||
// cannot be rendered from saved state. Without a wallet any other screen is
|
||||
// Welcome or AddWallet. Back only unhides Welcome, so its two checkboxes are
|
||||
// set here.
|
||||
function showNewSettings() {
|
||||
resetRenderedViews();
|
||||
$("welcome-use-blockscout").checked = state.useBlockscout;
|
||||
$("welcome-check-recipients-on-etherscan").checked =
|
||||
state.checkRecipientsOnEtherscan;
|
||||
if (state.currentView === "settings") {
|
||||
markViewRendered("settings");
|
||||
shownNetwork = currentNetwork();
|
||||
$("settings-network").value = state.networkId;
|
||||
$("settings-rpc").value = state.rpcUrl;
|
||||
$("settings-blockscout").value = state.blockscoutUrl;
|
||||
$("settings-use-blockscout").checked = state.useBlockscout;
|
||||
$("settings-check-recipients-on-etherscan").checked =
|
||||
state.checkRecipientsOnEtherscan;
|
||||
renderTrackedTokens();
|
||||
} else if (state.hasWallet) {
|
||||
ctx.restoreView();
|
||||
}
|
||||
updateDebugBanner(state.currentView);
|
||||
}
|
||||
|
||||
// Saves `url` as `field` ("rpcUrl" or "blockscoutUrl") of the network
|
||||
// `networkId`, the one that Settings showed when the user tapped "Save". The
|
||||
// URL is set in `state` inside the save, holding the lock. So the page cannot
|
||||
// follow a switch saved elsewhere (followStoredSettings() in
|
||||
// src/popup/index.js) between setting the URL and writing it. If the active
|
||||
// network is no longer `networkId`, on this page or in the stored record,
|
||||
// nothing is saved. The flash message then says that the network changed.
|
||||
//
|
||||
// The save writes only the fields that differ from what this page last saved.
|
||||
// Another page may have stored a different URL since then. So `url` is set in
|
||||
// the stored record too. "Saved." then means that the stored record holds
|
||||
// `url`.
|
||||
//
|
||||
// `input` is the id of the input that the user typed `url` in. When another
|
||||
// page changes the network, "Use Blockscout" or "Check recipients on
|
||||
// Etherscan", this page draws Settings again. A redraw during the endpoint
|
||||
// check puts the URL from `state` back in the input. So if the input no longer
|
||||
// shows `url`, nothing is saved. The flash message then says that the URL was
|
||||
// changed during the check. The save compares the input and sets `url` in
|
||||
// `state` in one step. So a redraw after the comparison shows `url`.
|
||||
//
|
||||
// `tap` is the number of the "Save" tap that checked `url`. If the user taps
|
||||
// the same "Save" again before this save holds the lock, nothing is stored. If
|
||||
// the user taps the same "Save" again before the write ends, no flash message
|
||||
// is shown.
|
||||
async function saveEndpoint(field, input, url, networkId, tap) {
|
||||
let refused = false;
|
||||
let inputChanged = false;
|
||||
await saveState((stored) => {
|
||||
if (saveTaps[field] !== tap) return false;
|
||||
refused =
|
||||
state.networkId !== networkId || stored.networkId !== networkId;
|
||||
inputChanged = $(input).value.trim() !== url;
|
||||
if (refused || inputChanged) return false;
|
||||
state[field] = url;
|
||||
stored[field] = url;
|
||||
});
|
||||
if (saveTaps[field] !== tap) return;
|
||||
if (refused) {
|
||||
showFlash("The URL was not saved because the network changed.");
|
||||
} else if (inputChanged) {
|
||||
showFlash(
|
||||
"The URL was not saved because it was changed during the check.",
|
||||
);
|
||||
} else {
|
||||
showFlash("Saved.");
|
||||
}
|
||||
}
|
||||
|
||||
function init(_ctx) {
|
||||
ctx = _ctx;
|
||||
deleteWallet.init(ctx);
|
||||
showPhrase.init();
|
||||
|
||||
$("btn-save-rpc").addEventListener("click", async () => {
|
||||
saveTaps.rpcUrl++;
|
||||
const tap = saveTaps.rpcUrl;
|
||||
const url = $("settings-rpc").value.trim();
|
||||
if (!url) {
|
||||
showFlash("Please enter an RPC URL.");
|
||||
return;
|
||||
}
|
||||
// The URL is for the network that Settings shows when "Save" is tapped.
|
||||
const net = shownNetwork;
|
||||
showFlash("Testing endpoint...");
|
||||
try {
|
||||
const resp = await debugFetch(url, {
|
||||
@@ -234,61 +409,109 @@ function init(ctx) {
|
||||
}),
|
||||
});
|
||||
const json = await resp.json();
|
||||
// The user has tapped this "Save" again since the check began.
|
||||
if (tap !== saveTaps.rpcUrl) return;
|
||||
if (json.error) {
|
||||
log.errorf("RPC validation error:", json.error);
|
||||
showFlash("Endpoint returned error: " + json.error.message);
|
||||
showFlash("Endpoint returned an error.");
|
||||
return;
|
||||
}
|
||||
const net = currentNetwork();
|
||||
if (json.result !== net.chainId) {
|
||||
showFlash(
|
||||
"Wrong network (expected " +
|
||||
net.name +
|
||||
", got chain " +
|
||||
json.result +
|
||||
").",
|
||||
);
|
||||
showFlash("Wrong network: expected " + net.name + ".");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
log.errorf("RPC validation fetch failed:", e.message);
|
||||
} catch {
|
||||
// Cancelled by the popup closing, not failed: see pageClosed in
|
||||
// src/popup/index.js.
|
||||
if (ctx.pageClosed.aborted) return;
|
||||
if (tap !== saveTaps.rpcUrl) return;
|
||||
// Not the error's message: fetch puts the whole URL, password and
|
||||
// key included, in the message of the error it throws for a URL
|
||||
// with a user name and password or one it cannot parse.
|
||||
log.errorf("RPC validation fetch failed:", urlOrigin(url));
|
||||
showFlash("Could not reach endpoint.");
|
||||
return;
|
||||
}
|
||||
state.rpcUrl = url;
|
||||
await saveState();
|
||||
showFlash("Saved.");
|
||||
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id, tap);
|
||||
});
|
||||
|
||||
$("btn-save-blockscout").addEventListener("click", async () => {
|
||||
saveTaps.blockscoutUrl++;
|
||||
const tap = saveTaps.blockscoutUrl;
|
||||
const url = $("settings-blockscout").value.trim();
|
||||
if (!url) {
|
||||
showFlash("Please enter a Blockscout API URL.");
|
||||
return;
|
||||
}
|
||||
const net = shownNetwork;
|
||||
showFlash("Testing endpoint...");
|
||||
try {
|
||||
const resp = await debugFetch(url + "/stats");
|
||||
if (tap !== saveTaps.blockscoutUrl) return;
|
||||
if (!resp.ok) {
|
||||
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
log.errorf("Blockscout validation failed:", e.message);
|
||||
} catch {
|
||||
if (ctx.pageClosed.aborted) return;
|
||||
if (tap !== saveTaps.blockscoutUrl) return;
|
||||
// Not the error's message, as for the RPC check above.
|
||||
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
||||
showFlash("Could not reach endpoint.");
|
||||
return;
|
||||
}
|
||||
state.blockscoutUrl = url;
|
||||
await saveState();
|
||||
showFlash("Saved.");
|
||||
await saveEndpoint(
|
||||
"blockscoutUrl",
|
||||
"settings-blockscout",
|
||||
url,
|
||||
net.id,
|
||||
tap,
|
||||
);
|
||||
});
|
||||
|
||||
// Each change is made inside the save, holding the lock. So
|
||||
// followStoredSettings() in src/popup/index.js never mistakes this page's
|
||||
// unsaved change for one that another page saved.
|
||||
//
|
||||
// The screens under Settings were drawn under the old setting. So each
|
||||
// change makes every screen but Settings count as not yet drawn, as a
|
||||
// network switch does. Back then draws again any screen that a reopened
|
||||
// popup can draw. Back only unhides Welcome, so its boxes are set here too.
|
||||
$("settings-use-blockscout").addEventListener("change", async () => {
|
||||
const on = $("settings-use-blockscout").checked;
|
||||
await saveState(() => setUseBlockscout(on));
|
||||
$("welcome-use-blockscout").checked = on;
|
||||
resetRenderedViews();
|
||||
markViewRendered("settings");
|
||||
});
|
||||
|
||||
$("settings-check-recipients-on-etherscan").addEventListener(
|
||||
"change",
|
||||
async () => {
|
||||
const on = $("settings-check-recipients-on-etherscan").checked;
|
||||
await saveState(() => {
|
||||
state.checkRecipientsOnEtherscan = on;
|
||||
});
|
||||
$("welcome-check-recipients-on-etherscan").checked = on;
|
||||
resetRenderedViews();
|
||||
markViewRendered("settings");
|
||||
},
|
||||
);
|
||||
|
||||
const networkSelect = $("settings-network");
|
||||
networkSelect.addEventListener("change", async () => {
|
||||
// Whether `state` already has the network picked is asked only once
|
||||
// the switch holds the lock: while an earlier pick waits for it,
|
||||
// `state` still has the network before that pick, and a pick back to
|
||||
// that network must still be made.
|
||||
const newId = networkSelect.value;
|
||||
cutViewStack();
|
||||
const net = await onChainSwitch(newId);
|
||||
$("settings-rpc").value = state.rpcUrl;
|
||||
$("settings-blockscout").value = state.blockscoutUrl;
|
||||
if (!net) return;
|
||||
// Open pages are told by the background, as after a site's approved
|
||||
// switch request.
|
||||
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
|
||||
showNewSettings();
|
||||
showFlash("Switched to " + net.name + ".");
|
||||
});
|
||||
|
||||
@@ -351,6 +574,12 @@ function init(ctx) {
|
||||
await saveState();
|
||||
});
|
||||
|
||||
$("settings-allow-weak-passwords").checked = state.allowWeakPasswords;
|
||||
$("settings-allow-weak-passwords").addEventListener("change", async () => {
|
||||
state.allowWeakPasswords = $("settings-allow-weak-passwords").checked;
|
||||
await saveState();
|
||||
});
|
||||
|
||||
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
|
||||
|
||||
$("btn-settings-add-token").addEventListener(
|
||||
@@ -403,7 +632,7 @@ function init(ctx) {
|
||||
if (versionClickCount >= 10) {
|
||||
versionClickCount = 0;
|
||||
clearTimeout(versionClickTimer);
|
||||
$("settings-debug-well").style.display = "";
|
||||
$("settings-debug-well").classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -423,4 +652,11 @@ function init(ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show, renderSiteLists };
|
||||
module.exports = {
|
||||
init,
|
||||
show,
|
||||
renderSiteLists,
|
||||
cutViewStack,
|
||||
setUseBlockscout,
|
||||
showNewSettings,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
|
||||
const {
|
||||
$,
|
||||
showView,
|
||||
showFlash,
|
||||
escapeHtml,
|
||||
goBack,
|
||||
trackToken,
|
||||
} = require("./helpers");
|
||||
const { getTopTokens } = require("../../shared/tokenList");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||
const { lookupTokenInfo } = require("../../shared/balances");
|
||||
const { isScamAddress } = require("../../shared/scamlist");
|
||||
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
|
||||
const { log } = require("../../shared/log");
|
||||
|
||||
let ctx;
|
||||
@@ -12,7 +20,7 @@ function isTracked(address) {
|
||||
return state.trackedTokens.some((t) => t.address.toLowerCase() === lower);
|
||||
}
|
||||
|
||||
function tokenLabel(t) {
|
||||
function nameAndSymbol(t) {
|
||||
return t.name ? t.name + " (" + t.symbol + ")" : t.symbol;
|
||||
}
|
||||
|
||||
@@ -37,13 +45,18 @@ function renderTop10() {
|
||||
el.querySelectorAll(".settings-addtoken-quick:not([disabled])").forEach(
|
||||
(btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
// The button stays enabled until the first click's save ends.
|
||||
if (isTracked(btn.dataset.address)) {
|
||||
showFlash("Already tracked.");
|
||||
return;
|
||||
}
|
||||
const token = {
|
||||
address: btn.dataset.address,
|
||||
symbol: btn.dataset.symbol,
|
||||
decimals: parseInt(btn.dataset.decimals, 10),
|
||||
name: btn.dataset.name || btn.dataset.symbol,
|
||||
};
|
||||
state.trackedTokens.push(token);
|
||||
trackToken(token);
|
||||
await saveState();
|
||||
showFlash("Added " + token.symbol);
|
||||
renderTop10();
|
||||
@@ -60,7 +73,7 @@ function renderDropdown() {
|
||||
let html = '<option value="">-- select --</option>';
|
||||
for (const t of tokens) {
|
||||
const tracked = isTracked(t.address);
|
||||
const label = tokenLabel(t) + (tracked ? " (tracked)" : "");
|
||||
const label = nameAndSymbol(t) + (tracked ? " (tracked)" : "");
|
||||
html +=
|
||||
`<option value="${escapeHtml(t.address)}"` +
|
||||
` data-symbol="${escapeHtml(t.symbol)}"` +
|
||||
@@ -75,6 +88,12 @@ function show() {
|
||||
$("settings-addtoken-address").value = "";
|
||||
$("settings-addtoken-info").textContent = "";
|
||||
$("settings-addtoken-info").style.visibility = "hidden";
|
||||
// The bundled list holds Ethereum Mainnet contracts, which name no token
|
||||
// on any other network.
|
||||
$("settings-addtoken-common").classList.toggle(
|
||||
"hidden",
|
||||
state.networkId !== "mainnet",
|
||||
);
|
||||
renderTop10();
|
||||
renderDropdown();
|
||||
showView("settings-addtoken");
|
||||
@@ -104,7 +123,7 @@ function init(_ctx) {
|
||||
decimals: parseInt(opt.dataset.decimals, 10),
|
||||
name: opt.dataset.name || opt.dataset.symbol,
|
||||
};
|
||||
state.trackedTokens.push(token);
|
||||
trackToken(token);
|
||||
await saveState();
|
||||
showFlash("Added " + token.symbol);
|
||||
renderTop10();
|
||||
@@ -115,9 +134,7 @@ function init(_ctx) {
|
||||
$("btn-settings-addtoken-manual").addEventListener("click", async () => {
|
||||
const addr = $("settings-addtoken-address").value.trim();
|
||||
if (!addr || !addr.startsWith("0x")) {
|
||||
showFlash(
|
||||
"Please enter a valid contract address starting with 0x.",
|
||||
);
|
||||
showFlash("Enter a valid contract address starting with 0x.");
|
||||
return;
|
||||
}
|
||||
if (isTracked(addr)) {
|
||||
@@ -132,14 +149,45 @@ function init(_ctx) {
|
||||
infoEl.textContent = "Looking up token...";
|
||||
infoEl.style.visibility = "visible";
|
||||
log.debugf("Looking up token contract", addr);
|
||||
// The network the lookup asks, which Settings may switch away from
|
||||
// before it answers, as for a transaction in confirmTx.js.
|
||||
const chainId = currentNetwork().chainId;
|
||||
try {
|
||||
const info = await lookupTokenInfo(
|
||||
addr,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
ctx.pageClosed,
|
||||
);
|
||||
// A contract address names a token on one network only, so an
|
||||
// answer after a switch is not added to the new network's list.
|
||||
if (currentNetwork().chainId !== chainId) {
|
||||
showFlash(
|
||||
"The active network changed before the lookup finished," +
|
||||
" so the token was not added.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Another click on Add, or a "Top tokens:" button, may have
|
||||
// tracked this token while this lookup waited.
|
||||
if (isTracked(addr)) {
|
||||
showFlash("Already tracked.");
|
||||
infoEl.textContent = "";
|
||||
infoEl.style.visibility = "hidden";
|
||||
return;
|
||||
}
|
||||
// The balance list never shows a token that the spoof rule
|
||||
// refuses, so such a token is not tracked. The rule never refuses
|
||||
// a bundled token, so the "Top tokens:" buttons and the top 100
|
||||
// dropdown need no such check.
|
||||
if (isSpoofedSymbol(info.symbol, addr, chainId)) {
|
||||
showFlash("Not added: this token fakes a known symbol.");
|
||||
infoEl.textContent = "";
|
||||
infoEl.style.visibility = "hidden";
|
||||
return;
|
||||
}
|
||||
log.infof("Adding token", info.symbol, addr);
|
||||
state.trackedTokens.push({
|
||||
trackToken({
|
||||
address: addr,
|
||||
symbol: info.symbol,
|
||||
decimals: info.decimals,
|
||||
@@ -154,9 +202,19 @@ function init(_ctx) {
|
||||
renderDropdown();
|
||||
ctx.doRefreshAndRender();
|
||||
} 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);
|
||||
log.errorf("Token lookup failed for", addr, detail);
|
||||
showFlash(detail);
|
||||
log.errorf("Adding token failed for", addr, detail);
|
||||
// lookupTokenInfo() rejects a contract with a one-line message
|
||||
// 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.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ const {
|
||||
$,
|
||||
showView,
|
||||
showFlash,
|
||||
showError,
|
||||
hideError,
|
||||
flashCopyFeedback,
|
||||
goBack,
|
||||
onViewLeave,
|
||||
@@ -52,11 +54,6 @@ function isCurrentReveal(generation) {
|
||||
);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
$("show-phrase-flash").textContent = message;
|
||||
$("show-phrase-flash").style.visibility = "visible";
|
||||
}
|
||||
|
||||
// Wipe every trace of the phrase and drop the wallet selection. Safe to
|
||||
// call when nothing was ever revealed, and safe to call twice.
|
||||
function clear() {
|
||||
@@ -66,8 +63,7 @@ function clear() {
|
||||
$("show-phrase-password").value = "";
|
||||
$("show-phrase-result").classList.add("hidden");
|
||||
$("show-phrase-password-section").classList.remove("hidden");
|
||||
$("show-phrase-flash").textContent = "";
|
||||
$("show-phrase-flash").style.visibility = "hidden";
|
||||
hideError("show-phrase-password-error");
|
||||
}
|
||||
|
||||
function show(walletIdx) {
|
||||
@@ -90,19 +86,23 @@ function show(walletIdx) {
|
||||
async function reveal() {
|
||||
const password = $("show-phrase-password").value;
|
||||
if (!password) {
|
||||
fail("Please enter your password.");
|
||||
showError("show-phrase-password-error", "Please enter your password.");
|
||||
return;
|
||||
}
|
||||
if (walletIndex === null) {
|
||||
fail("No wallet is selected.");
|
||||
showError("show-phrase-password-error", "No wallet is selected.");
|
||||
return;
|
||||
}
|
||||
const wallet = state.wallets[walletIndex];
|
||||
if (!walletHasRecoveryPhrase(wallet)) {
|
||||
fail("This wallet does not have a recovery phrase.");
|
||||
showError(
|
||||
"show-phrase-password-error",
|
||||
"This wallet does not have a recovery phrase.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
hideError("show-phrase-password-error");
|
||||
const btn = $("btn-show-phrase-reveal");
|
||||
btn.disabled = true;
|
||||
btn.classList.add("text-muted");
|
||||
@@ -120,13 +120,14 @@ async function reveal() {
|
||||
$("show-phrase-password-section").classList.add("hidden");
|
||||
$("show-phrase-value").textContent = phrase;
|
||||
$("show-phrase-result").classList.remove("hidden");
|
||||
$("show-phrase-flash").textContent = "";
|
||||
$("show-phrase-flash").style.visibility = "hidden";
|
||||
} catch {
|
||||
if (!isCurrentReveal(generation)) return;
|
||||
// Deliberately not the caught error: the message is fixed so that
|
||||
// nothing derived from the ciphertext or the attempt can surface.
|
||||
fail("That password is incorrect. Please try again.");
|
||||
showError(
|
||||
"show-phrase-password-error",
|
||||
"That password is incorrect. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("text-muted");
|
||||
@@ -134,7 +135,16 @@ async function reveal() {
|
||||
}
|
||||
|
||||
function init() {
|
||||
onViewLeave(VIEW, clear);
|
||||
// Leaving drops the wallet selection, so the screen also comes off the
|
||||
// Back stack, where the settings gear has just put it: Back from Settings
|
||||
// must not land on a password prompt that can only fail. A reopened popup
|
||||
// drops it from the stack the same way
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/461).
|
||||
onViewLeave(VIEW, () => {
|
||||
clear();
|
||||
const stack = state.viewStack;
|
||||
if (stack[stack.length - 1] === VIEW) stack.pop();
|
||||
});
|
||||
|
||||
$("btn-show-phrase-back").addEventListener("click", () => {
|
||||
goBack();
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
// Everything else in the popup assumes a loaded profile: showView() reads and
|
||||
// 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
|
||||
// the time this runs, loadState() has REFUSED, deliberately, and reading the
|
||||
// singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
||||
// the time this runs, the stored record has been REFUSED, deliberately: at
|
||||
// open loadState() refused it and reading the singleton throws
|
||||
// (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
|
||||
// the one screen that must work when nothing else can, which is also why it
|
||||
@@ -22,7 +24,11 @@
|
||||
// than written again locally so that tests/popupElementIds.test.js sees these
|
||||
// lookups and holds every id below against the markup.
|
||||
const { $, VIEWS } = require("./helpers");
|
||||
const { storageGet, storageRemove } = require("../../shared/browserApi");
|
||||
const {
|
||||
storageGet,
|
||||
storageRemove,
|
||||
withLock,
|
||||
} = require("../../shared/browserApi");
|
||||
const { log } = require("../../shared/log");
|
||||
|
||||
// Typed in full before anything is erased, in the same spirit as the wallet
|
||||
@@ -135,7 +141,9 @@ async function resetProfile() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await storageRemove("autistmask");
|
||||
// Under the lock every write of the stored record takes, so the erase
|
||||
// cannot land between another write's read and its write and be undone.
|
||||
await withLock("autistmask", () => storageRemove("autistmask"));
|
||||
} catch (e) {
|
||||
log.errorf("state recovery: reset failed:", e);
|
||||
setFlash("The saved data could not be erased. Nothing was changed.");
|
||||
@@ -170,6 +178,11 @@ function wire() {
|
||||
* refused, or its sentence.
|
||||
*/
|
||||
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 =
|
||||
(problem && (problem.problem || problem.message)) || String(problem);
|
||||
|
||||
|
||||
@@ -7,22 +7,26 @@ const {
|
||||
showFlash,
|
||||
flashCopyFeedback,
|
||||
addressTitle,
|
||||
CONTRACT_CREATION_TEXT,
|
||||
addressDotHtml,
|
||||
escapeHtml,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
renderAddressHtml,
|
||||
blockieHtml,
|
||||
attachCopyHandlers,
|
||||
copyableHtml,
|
||||
etherscanLinkHtml,
|
||||
explorerUrl,
|
||||
endpointsByChainId,
|
||||
displaySymbol,
|
||||
goBack,
|
||||
} = require("./helpers");
|
||||
const { state } = require("../../shared/state");
|
||||
const { nativeCurrencyByChainId } = require("../../shared/networks");
|
||||
const { formatEther, formatUnits } = require("ethers");
|
||||
const makeBlockie = require("ethereum-blockies-base64");
|
||||
const { log, debugFetch } = require("../../shared/log");
|
||||
const { storedSettingIsOn } = require("../../shared/storedSettings");
|
||||
const { decodeCalldata } = require("./approval");
|
||||
|
||||
/**
|
||||
@@ -40,25 +44,22 @@ function getTransactionType(tx) {
|
||||
return "Token Approval";
|
||||
return "Contract Call";
|
||||
}
|
||||
if (tx.symbol && tx.symbol !== "ETH") return "ERC-20 Token Transfer";
|
||||
return "Native ETH Transfer";
|
||||
// By the token contract, not the symbol: a token chooses its own symbol
|
||||
// and can report the native token's, but only a token transfer has one.
|
||||
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, chainId) {
|
||||
const blockie = blockieHtml(address);
|
||||
return (
|
||||
`<div class="mb-1">${blockie}</div>` +
|
||||
renderAddressHtml(address, { title, ensName })
|
||||
renderAddressHtml(address, { title, ensName, chainId })
|
||||
);
|
||||
}
|
||||
|
||||
function txHashHtml(hash) {
|
||||
const link = explorerUrl("tx", hash);
|
||||
function txHashHtml(hash, chainId) {
|
||||
const link = explorerUrl("tx", hash, chainId);
|
||||
const extLink = etherscanLinkHtml(link);
|
||||
return copyableHtml(hash, "break-all") + extLink;
|
||||
}
|
||||
@@ -83,6 +84,13 @@ function show(tx) {
|
||||
isContractCall: tx.isContractCall || false,
|
||||
method: tx.method || 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,
|
||||
// the links open its explorer, and the block, network details and
|
||||
// calldata are read from its Blockscout: the active network can
|
||||
// change, in Settings or when the user approves a site's request,
|
||||
// before a later popup shows this screen again.
|
||||
chainId: tx.chainId,
|
||||
},
|
||||
};
|
||||
render();
|
||||
@@ -91,29 +99,43 @@ function show(tx) {
|
||||
function render() {
|
||||
const tx = state.viewData.tx;
|
||||
if (!tx) return;
|
||||
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
|
||||
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash, tx.chainId);
|
||||
|
||||
const fromTitle = addressTitle(tx.from, state.wallets);
|
||||
const toTitle = addressTitle(tx.to, state.wallets);
|
||||
$("tx-detail-from").innerHTML = txAddressHtml(
|
||||
tx.from,
|
||||
tx.fromEns,
|
||||
fromTitle,
|
||||
tx.chainId,
|
||||
);
|
||||
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
|
||||
// A contract creation has no recipient: transactions.js gives it `to: ""`.
|
||||
if (tx.to) {
|
||||
const toTitle = addressTitle(tx.to, state.wallets);
|
||||
$("tx-detail-to").innerHTML = txAddressHtml(
|
||||
tx.to,
|
||||
tx.toEns,
|
||||
toTitle,
|
||||
tx.chainId,
|
||||
);
|
||||
} else {
|
||||
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
|
||||
}
|
||||
|
||||
// Exact amount (full precision, copyable)
|
||||
const detailSym = displaySymbol(tx.symbol);
|
||||
const exactStr = tx.exactValue
|
||||
? tx.exactValue + " " + detailSym
|
||||
: tx.directionLabel + " " + detailSym;
|
||||
$("tx-detail-value").innerHTML = copyableHtml(exactStr, "font-bold");
|
||||
$("tx-detail-value").innerHTML = copyableHtml(
|
||||
exactStr,
|
||||
"font-bold break-words",
|
||||
);
|
||||
|
||||
// Native quantity (raw integer, copyable)
|
||||
const nativeEl = $("tx-detail-native");
|
||||
if (tx.rawAmount && tx.rawUnit) {
|
||||
const nativeStr = tx.rawAmount + " " + tx.rawUnit;
|
||||
nativeEl.innerHTML = copyableHtml(nativeStr, "");
|
||||
nativeEl.innerHTML = copyableHtml(nativeStr, "break-words");
|
||||
nativeEl.parentElement.classList.remove("hidden");
|
||||
} else {
|
||||
nativeEl.innerHTML = "";
|
||||
@@ -136,11 +158,17 @@ function render() {
|
||||
if (tokenContractSection && tokenContractEl) {
|
||||
if (tx.contractAddress) {
|
||||
const dot = addressDotHtml(tx.contractAddress);
|
||||
const link = explorerUrl("token", tx.contractAddress);
|
||||
const link = explorerUrl("token", tx.contractAddress, tx.chainId);
|
||||
// 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 =
|
||||
`<div class="flex items-center">${dot}` +
|
||||
copyableHtml(tx.contractAddress, "break-all") +
|
||||
etherscanLinkHtml(link) +
|
||||
`</div>` +
|
||||
`<div class="am-address">` +
|
||||
copyableHtml(tx.contractAddress) +
|
||||
`</div>`;
|
||||
tokenContractSection.classList.remove("hidden");
|
||||
} else {
|
||||
@@ -148,7 +176,8 @@ function render() {
|
||||
}
|
||||
}
|
||||
|
||||
// Hide calldata and raw data sections; always fetch full tx details
|
||||
// Hide the calldata and raw data sections. loadFullTxDetails() shows them
|
||||
// once it has their data.
|
||||
const calldataSection = $("tx-detail-calldata-section");
|
||||
if (calldataSection) calldataSection.classList.add("hidden");
|
||||
const rawDataSection = $("tx-detail-rawdata-section");
|
||||
@@ -167,7 +196,13 @@ function render() {
|
||||
if (el) el.classList.add("hidden");
|
||||
}
|
||||
|
||||
loadFullTxDetails(tx.hash, tx.to);
|
||||
// The block, network details, decoded details and raw data come from
|
||||
// Blockscout. While the setting is off, this line says why none is shown.
|
||||
$("tx-detail-blockscout-note").classList.toggle(
|
||||
"hidden",
|
||||
!!state.useBlockscout,
|
||||
);
|
||||
loadFullTxDetails(tx.hash, tx.chainId);
|
||||
|
||||
const isoStr = isoDate(tx.timestamp);
|
||||
$("tx-detail-time").innerHTML =
|
||||
@@ -185,10 +220,14 @@ function showDetailField(sectionId, contentId, value) {
|
||||
section.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function populateOnChainDetails(txData) {
|
||||
function populateOnChainDetails(txData, chainId) {
|
||||
// Block number
|
||||
if (txData.block_number != null) {
|
||||
const blockLink = explorerUrl("block", String(txData.block_number));
|
||||
const blockLink = explorerUrl(
|
||||
"block",
|
||||
String(txData.block_number),
|
||||
chainId,
|
||||
);
|
||||
const blockSection = $("tx-detail-block-section");
|
||||
const blockEl = $("tx-detail-block");
|
||||
if (blockSection && blockEl) {
|
||||
@@ -215,7 +254,7 @@ function populateOnChainDetails(txData) {
|
||||
showDetailField(
|
||||
"tx-detail-fee-section",
|
||||
"tx-detail-fee",
|
||||
feeEth + " ETH",
|
||||
feeEth + " " + nativeCurrencyByChainId(chainId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -275,7 +314,23 @@ function populateOnChainDetails(txData) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFullTxDetails(txHash, toAddress) {
|
||||
// Whether TransactionDetail still holds the transaction with `txHash` on
|
||||
// `chainId`. Opening another transaction replaces the one TransactionDetail
|
||||
// holds. A visit to Settings does not.
|
||||
function holdsTransaction(txHash, chainId) {
|
||||
const tx = state.viewData.tx;
|
||||
return tx?.hash === txHash && tx?.chainId === chainId;
|
||||
}
|
||||
|
||||
// Reads the block, network details and calldata from Blockscout, and decodes
|
||||
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not
|
||||
// asked. An answer that lands after the setting went off is dropped
|
||||
// (src/shared/storedSettings.js). Either way none of them is shown.
|
||||
//
|
||||
// Blockscout can answer after the user has opened another transaction. That
|
||||
// answer is dropped.
|
||||
async function loadFullTxDetails(txHash, chainId) {
|
||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
||||
const section = $("tx-detail-calldata-section");
|
||||
const actionEl = $("tx-detail-calldata-action");
|
||||
const detailsEl = $("tx-detail-calldata-details");
|
||||
@@ -285,25 +340,38 @@ async function loadFullTxDetails(txHash, toAddress) {
|
||||
if (!section || !actionEl || !detailsEl) return;
|
||||
|
||||
try {
|
||||
const { blockscoutUrl } = endpointsByChainId(chainId);
|
||||
const resp = await debugFetch(
|
||||
state.blockscoutUrl + "/transactions/" + txHash,
|
||||
blockscoutUrl + "/transactions/" + txHash,
|
||||
);
|
||||
if (!resp.ok) return;
|
||||
const txData = await resp.json();
|
||||
if (!(await storedSettingIsOn("useBlockscout"))) return;
|
||||
if (!holdsTransaction(txHash, chainId)) return;
|
||||
|
||||
// Populate on-chain detail fields (block, nonce, gas, fee)
|
||||
populateOnChainDetails(txData);
|
||||
populateOnChainDetails(txData, chainId);
|
||||
|
||||
const inputData = txData.raw_input || txData.input || null;
|
||||
if (!inputData || inputData === "0x") return;
|
||||
|
||||
const decoded = decodeCalldata(inputData, toAddress || "");
|
||||
// Decoded against the address the transaction was sent to, as the
|
||||
// explorer reports it, not the history entry's `to`: for a token
|
||||
// transfer that is the transfer's recipient, not the token contract.
|
||||
// On the network the transaction was sent on, whose Blockscout was
|
||||
// just asked, and from the sender it reports.
|
||||
const decoded = decodeCalldata(
|
||||
inputData,
|
||||
txData.to?.hash || "",
|
||||
chainId,
|
||||
txData.from?.hash || "",
|
||||
);
|
||||
if (decoded) {
|
||||
// Render decoded calldata matching approval view style
|
||||
actionEl.textContent = decoded.name;
|
||||
let detailsHtml = "";
|
||||
if (decoded.description) {
|
||||
detailsHtml += `<div class="mb-2">${escapeHtml(decoded.description)}</div>`;
|
||||
detailsHtml += `<div class="mb-2 break-words">${escapeHtml(decoded.description)}</div>`;
|
||||
}
|
||||
for (const d of decoded.details || []) {
|
||||
detailsHtml += `<div class="mb-2">`;
|
||||
@@ -312,13 +380,13 @@ async function loadFullTxDetails(txHash, toAddress) {
|
||||
// Token entry: show symbol on its own line, then address via shared renderer
|
||||
const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1];
|
||||
if (tokenSymbol) {
|
||||
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
|
||||
detailsHtml += `<div class="font-bold break-words">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
|
||||
}
|
||||
detailsHtml += renderAddressHtml(d.address);
|
||||
detailsHtml += renderAddressHtml(d.address, { chainId });
|
||||
} else if (d.address) {
|
||||
detailsHtml += renderAddressHtml(d.address);
|
||||
detailsHtml += renderAddressHtml(d.address, { chainId });
|
||||
} else {
|
||||
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
||||
detailsHtml += `<div class="font-bold break-words">${escapeHtml(d.value)}</div>`;
|
||||
}
|
||||
detailsHtml += `</div>`;
|
||||
}
|
||||
|
||||
+168
-70
@@ -4,6 +4,7 @@ const {
|
||||
$,
|
||||
showView,
|
||||
addressTitle,
|
||||
CONTRACT_CREATION_TEXT,
|
||||
escapeHtml,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
@@ -12,10 +13,15 @@ const {
|
||||
explorerUrl,
|
||||
displaySymbol,
|
||||
clearViewStack,
|
||||
tokenLabel,
|
||||
blockscoutOffTokenNoteHtml,
|
||||
reviewedNetworkProvider,
|
||||
} = require("./helpers");
|
||||
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
|
||||
const { state } = require("../../shared/state");
|
||||
const { getProvider } = require("../../shared/balances");
|
||||
const {
|
||||
SUPPORTED_CHAIN_IDS,
|
||||
nativeCurrencyByChainId,
|
||||
} = require("../../shared/networks");
|
||||
const { log } = require("../../shared/log");
|
||||
|
||||
// Receipt poll cadence and the deadline after which the wait is reported as
|
||||
@@ -58,19 +64,75 @@ function endWait() {
|
||||
}
|
||||
}
|
||||
|
||||
function toAddressHtml(address) {
|
||||
// A contract creation reaches these screens with `to` as "" (approval.js
|
||||
// writes `to: toAddr || ""`). The links below open the explorer of the network
|
||||
// with `chainId`, or of the active network when none is given.
|
||||
function toAddressHtml(address, chainId) {
|
||||
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
|
||||
const title = addressTitle(address, state.wallets);
|
||||
return renderAddressHtml(address, { title });
|
||||
return renderAddressHtml(address, { title, chainId });
|
||||
}
|
||||
|
||||
function txHashHtml(hash) {
|
||||
const link = explorerUrl("tx", hash);
|
||||
// A decoded record can come back from storage in any shape, so these screens
|
||||
// show only the lines whose label and value are text. An address that is not
|
||||
// text would throw in toAddressHtml().
|
||||
function isReadableLine(d) {
|
||||
return (
|
||||
d &&
|
||||
typeof d.label === "string" &&
|
||||
typeof d.value === "string" &&
|
||||
(!d.address || typeof d.address === "string")
|
||||
);
|
||||
}
|
||||
|
||||
// What a decoded line with an address shows: the address, under the token's
|
||||
// symbol when the line names a token.
|
||||
function decodedAddressHtml(d, chainId) {
|
||||
if (!d.isToken) return toAddressHtml(d.address, chainId);
|
||||
const sym = tokenLabel(d.address, chainId) || "Unknown token";
|
||||
return (
|
||||
`<div class="font-bold break-words">${escapeHtml(sym)}</div>` +
|
||||
blockscoutOffTokenNoteHtml(d.address, chainId) +
|
||||
toAddressHtml(d.address, chainId)
|
||||
);
|
||||
}
|
||||
|
||||
// Each `Moved from the sender`, `Permit2 allowance` and `Allowance token` line
|
||||
// of a decoded swap, for the wait and error screens to show under the Amount
|
||||
// figure. That figure can leave these amounts out, so alone it can understate
|
||||
// what can leave the wallet. The token line says which token an allowance
|
||||
// covers, which `Unlimited` alone does not.
|
||||
function movedOrAllowedHtml(decoded, chainId) {
|
||||
if (!decoded || !Array.isArray(decoded.details)) return "";
|
||||
let html = "";
|
||||
for (const d of decoded.details) {
|
||||
if (
|
||||
!isReadableLine(d) ||
|
||||
(d.label !== "Moved from the sender" &&
|
||||
d.label !== "Permit2 allowance" &&
|
||||
d.label !== "Allowance token")
|
||||
)
|
||||
continue;
|
||||
html += `<div class="mt-2">`;
|
||||
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
|
||||
if (d.address) {
|
||||
html += `<div class="text-xs">${decodedAddressHtml(d, chainId)}</div>`;
|
||||
} else {
|
||||
html += `<div class="font-bold break-words">${escapeHtml(d.value)}</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function txHashHtml(hash, chainId) {
|
||||
const link = explorerUrl("tx", hash, chainId);
|
||||
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
|
||||
}
|
||||
|
||||
function blockNumberHtml(blockNumber) {
|
||||
function blockNumberHtml(blockNumber, chainId) {
|
||||
const num = String(blockNumber);
|
||||
const link = explorerUrl("block", num);
|
||||
const link = explorerUrl("block", num, chainId);
|
||||
return copyableHtml(num) + etherscanLinkHtml(link);
|
||||
}
|
||||
|
||||
@@ -82,13 +144,28 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
endWait();
|
||||
const id = waitId;
|
||||
|
||||
const symbol =
|
||||
txInfo.token === "ETH"
|
||||
? "ETH"
|
||||
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
|
||||
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
|
||||
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
|
||||
// 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: the active network can change, in
|
||||
// Settings or when the user approves a site's request, while this screen
|
||||
// is open or before a later popup resumes it. The links on all three
|
||||
// screens open that network's explorer, and this screen's receipt is
|
||||
// asked of that network's node, for the same reason. A null token is a
|
||||
// swap whose amount was not decoded (see showTxApproval() in
|
||||
// approval.js): the amount says so, and no symbol follows it.
|
||||
let symbol = "";
|
||||
if (txInfo.token === "ETH") {
|
||||
symbol = nativeCurrencyByChainId(txInfo.chainId);
|
||||
} else if (txInfo.token !== null) {
|
||||
symbol = displaySymbol(txInfo.tokenSymbol || "?");
|
||||
}
|
||||
$("wait-tx-summary").textContent =
|
||||
txInfo.amount + (symbol ? " " + symbol : "");
|
||||
$("wait-tx-token-note").innerHTML =
|
||||
blockscoutOffTokenNoteHtml(txInfo.token, txInfo.chainId) +
|
||||
movedOrAllowedHtml(txInfo.decoded, txInfo.chainId);
|
||||
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
|
||||
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
|
||||
attachCopyHandlers("view-wait-tx");
|
||||
|
||||
// Persisted so closing and reopening the popup resumes this wait
|
||||
@@ -113,7 +190,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
renderElapsed();
|
||||
}, 1000);
|
||||
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const provider = reviewedNetworkProvider(txInfo);
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
async function poll() {
|
||||
@@ -123,13 +200,16 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
try {
|
||||
receipt = await provider.getTransactionReceipt(txHash);
|
||||
} 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
|
||||
// receipt": the RPC failed, the chain said nothing. Declaring
|
||||
// the timeout off it would report a confirmed transaction as
|
||||
// failed — which matters most on a resumed wait, where the
|
||||
// first poll is already past the deadline.
|
||||
answered = false;
|
||||
log.errorf("poll receipt failed:", e.message);
|
||||
log.errorf("poll receipt failed:", e.shortMessage || e.message);
|
||||
}
|
||||
// The lookup is async: the wait may have ended while it was in
|
||||
// flight, in which case this result must not touch the view.
|
||||
@@ -189,9 +269,12 @@ function showWait(txInfo, txHash) {
|
||||
// an object merely missing one of them throws a TypeError out of
|
||||
// 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
|
||||
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token and
|
||||
// txInfo.tokenSymbol are deliberately unchecked: they are compared and
|
||||
// coalesced rather than dereferenced, and tokenSymbol is null for ETH.
|
||||
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks
|
||||
// the node the receipt is asked of and the explorer the links open, so it has
|
||||
// to name a network. txInfo.token and txInfo.tokenSymbol are not checked here.
|
||||
// blockscoutOffTokenNoteHtml() shows no line for a token that is not text.
|
||||
// Everything else only compares and coalesces them, and tokenSymbol is null for
|
||||
// ETH. txInfo.decoded is not checked either: see isReadableLine().
|
||||
function restoreWait() {
|
||||
const d = state.viewData;
|
||||
if (!d || !d.pendingWait) return false;
|
||||
@@ -202,10 +285,12 @@ function restoreWait() {
|
||||
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
|
||||
// A string is the whole requirement: the empty string is what a
|
||||
// contract-deployment approval persists (approval.js writes `to: toAddr
|
||||
// || ""`), and both fields render harmlessly when empty, so refusing it
|
||||
// would abandon a wait the live path itself created.
|
||||
// || ""`), an empty `to` renders as a contract creation and an empty
|
||||
// amount renders harmlessly, so refusing it would abandon a wait the live
|
||||
// path itself created.
|
||||
if (typeof info.to !== "string") return false;
|
||||
if (typeof info.amount !== "string") return false;
|
||||
if (!SUPPORTED_CHAIN_IDS.has(info.chainId)) return false;
|
||||
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
|
||||
return false;
|
||||
}
|
||||
@@ -216,10 +301,13 @@ function restoreWait() {
|
||||
function showSuccess(txInfo, txHash, blockNumber) {
|
||||
endWait();
|
||||
|
||||
const symbol =
|
||||
txInfo.token === "ETH"
|
||||
? "ETH"
|
||||
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||
// As on the wait screen, a null token has no symbol.
|
||||
let symbol = "";
|
||||
if (txInfo.token === "ETH") {
|
||||
symbol = nativeCurrencyByChainId(txInfo.chainId);
|
||||
} else if (txInfo.token !== null) {
|
||||
symbol = displaySymbol(txInfo.tokenSymbol || "?");
|
||||
}
|
||||
state.viewData = {
|
||||
amount: txInfo.amount,
|
||||
symbol: symbol,
|
||||
@@ -227,40 +315,37 @@ function showSuccess(txInfo, txHash, blockNumber) {
|
||||
hash: txHash,
|
||||
blockNumber: blockNumber,
|
||||
decoded: txInfo.decoded || null,
|
||||
chainId: txInfo.chainId,
|
||||
};
|
||||
renderSuccess();
|
||||
ctx.doRefreshAndRender();
|
||||
}
|
||||
|
||||
function tokenLabel(address) {
|
||||
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
||||
return t ? t.symbol : null;
|
||||
function readableLines(decoded) {
|
||||
if (!decoded || !Array.isArray(decoded.details)) return [];
|
||||
return decoded.details.filter(isReadableLine);
|
||||
}
|
||||
|
||||
function decodedDetailsHtml(decoded) {
|
||||
if (!decoded || !decoded.details) return "";
|
||||
// The decoded action, or "" when no line of it can be read.
|
||||
function decodedDetailsHtml(decoded, chainId) {
|
||||
const lines = readableLines(decoded);
|
||||
if (lines.length === 0) return "";
|
||||
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
|
||||
if (decoded.name) {
|
||||
if (decoded.name && typeof decoded.name === "string") {
|
||||
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Action</div>`;
|
||||
html += `<div class="font-bold">${escapeHtml(decoded.name)}</div></div>`;
|
||||
html += `<div class="font-bold break-words">${escapeHtml(decoded.name)}</div></div>`;
|
||||
}
|
||||
if (decoded.description) {
|
||||
if (decoded.description && typeof decoded.description === "string") {
|
||||
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Description</div>`;
|
||||
html += `<div>${escapeHtml(decoded.description)}</div></div>`;
|
||||
html += `<div class="break-words">${escapeHtml(decoded.description)}</div></div>`;
|
||||
}
|
||||
for (const d of decoded.details) {
|
||||
for (const d of lines) {
|
||||
html += `<div class="mb-2">`;
|
||||
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
|
||||
if (d.address) {
|
||||
if (d.isToken) {
|
||||
const sym = tokenLabel(d.address) || "Unknown token";
|
||||
html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
|
||||
html += toAddressHtml(d.address);
|
||||
} else {
|
||||
html += toAddressHtml(d.address);
|
||||
}
|
||||
html += decodedAddressHtml(d, chainId);
|
||||
} else {
|
||||
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
||||
html += `<div class="font-bold break-words">${escapeHtml(d.value)}</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
@@ -272,29 +357,32 @@ function renderSuccess() {
|
||||
const d = state.viewData;
|
||||
if (!d || !d.hash) return;
|
||||
|
||||
const hasDecoded = d.decoded && d.decoded.details;
|
||||
const decodedHtml = decodedDetailsHtml(d.decoded, d.chainId);
|
||||
|
||||
// When decoded details are present, the Amount and To are already
|
||||
// shown inside the decoded well — hide the top-level duplicates.
|
||||
const summarySection = $("success-tx-summary").parentElement;
|
||||
const toSection = $("success-tx-to").parentElement;
|
||||
if (hasDecoded) {
|
||||
summarySection.classList.add("hidden");
|
||||
toSection.classList.add("hidden");
|
||||
} else {
|
||||
summarySection.classList.remove("hidden");
|
||||
toSection.classList.remove("hidden");
|
||||
$("success-tx-summary").textContent = d.amount + " " + d.symbol;
|
||||
$("success-tx-to").innerHTML = toAddressHtml(d.to);
|
||||
}
|
||||
// Amount and To are hidden only when a readable decoded line shows the same
|
||||
// thing: an `Amount` line, or a line with the address in `to`. Some swaps
|
||||
// have no `Amount` line, and a saved record can have unreadable lines. An
|
||||
// empty `to` is a contract creation, which no decoded line shows.
|
||||
const lines = readableLines(d.decoded);
|
||||
$("success-tx-summary").parentElement.classList.toggle(
|
||||
"hidden",
|
||||
lines.some((line) => line.label === "Amount"),
|
||||
);
|
||||
$("success-tx-to").parentElement.classList.toggle(
|
||||
"hidden",
|
||||
lines.some((line) => line.address && line.address === d.to),
|
||||
);
|
||||
$("success-tx-summary").textContent =
|
||||
d.amount + (d.symbol ? " " + d.symbol : "");
|
||||
$("success-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
|
||||
|
||||
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber);
|
||||
$("success-tx-hash").innerHTML = txHashHtml(d.hash);
|
||||
$("success-tx-block").innerHTML = blockNumberHtml(d.blockNumber, d.chainId);
|
||||
$("success-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
|
||||
|
||||
// Show decoded calldata details if present
|
||||
const decodedEl = $("success-tx-decoded");
|
||||
if (decodedEl && hasDecoded) {
|
||||
decodedEl.innerHTML = decodedDetailsHtml(d.decoded);
|
||||
if (decodedEl && decodedHtml) {
|
||||
decodedEl.innerHTML = decodedHtml;
|
||||
decodedEl.classList.remove("hidden");
|
||||
} else if (decodedEl) {
|
||||
decodedEl.classList.add("hidden");
|
||||
@@ -307,16 +395,22 @@ function renderSuccess() {
|
||||
function showError(txInfo, txHash, message) {
|
||||
endWait();
|
||||
|
||||
const symbol =
|
||||
txInfo.token === "ETH"
|
||||
? "ETH"
|
||||
: displaySymbol(txInfo.tokenSymbol || "?");
|
||||
// As on the wait screen, a null token has no symbol.
|
||||
let symbol = "";
|
||||
if (txInfo.token === "ETH") {
|
||||
symbol = nativeCurrencyByChainId(txInfo.chainId);
|
||||
} else if (txInfo.token !== null) {
|
||||
symbol = displaySymbol(txInfo.tokenSymbol || "?");
|
||||
}
|
||||
state.viewData = {
|
||||
amount: txInfo.amount,
|
||||
symbol: symbol,
|
||||
token: txInfo.token,
|
||||
to: txInfo.to,
|
||||
hash: txHash || null,
|
||||
message: message,
|
||||
decoded: txInfo.decoded || null,
|
||||
chainId: txInfo.chainId,
|
||||
};
|
||||
renderError();
|
||||
}
|
||||
@@ -324,18 +418,22 @@ function showError(txInfo, txHash, message) {
|
||||
function renderError() {
|
||||
const d = state.viewData;
|
||||
if (!d || !d.message) return;
|
||||
$("error-tx-summary").textContent = d.amount + " " + d.symbol;
|
||||
$("error-tx-to").innerHTML = toAddressHtml(d.to);
|
||||
$("error-tx-summary").textContent =
|
||||
d.amount + (d.symbol ? " " + d.symbol : "");
|
||||
$("error-tx-token-note").innerHTML =
|
||||
blockscoutOffTokenNoteHtml(d.token, d.chainId) +
|
||||
movedOrAllowedHtml(d.decoded, d.chainId);
|
||||
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
|
||||
$("error-tx-message").textContent = d.message;
|
||||
|
||||
if (d.hash) {
|
||||
$("error-tx-hash").innerHTML = txHashHtml(d.hash);
|
||||
$("error-tx-hash").innerHTML = txHashHtml(d.hash, d.chainId);
|
||||
$("error-tx-hash-section").classList.remove("hidden");
|
||||
attachCopyHandlers("view-error-tx");
|
||||
} else {
|
||||
$("error-tx-hash-section").classList.add("hidden");
|
||||
}
|
||||
|
||||
attachCopyHandlers("view-error-tx");
|
||||
showView("error-tx");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
const { $ } = require("./helpers");
|
||||
// The first screen, shown while there is no wallet. It offers "Use Blockscout"
|
||||
// and "Check recipients on Etherscan", which Settings offers too. Both are off
|
||||
// until the user ticks them.
|
||||
|
||||
function init(ctx) {
|
||||
$("btn-welcome-add").addEventListener("click", ctx.showAddWalletView);
|
||||
const { $, showView } = require("./helpers");
|
||||
const { state, saveState } = require("../../shared/state");
|
||||
|
||||
function show() {
|
||||
$("welcome-use-blockscout").checked = state.useBlockscout;
|
||||
$("welcome-check-recipients-on-etherscan").checked =
|
||||
state.checkRecipientsOnEtherscan;
|
||||
showView("welcome");
|
||||
}
|
||||
|
||||
module.exports = { init };
|
||||
// Each change is made inside the save, holding the lock, as Settings makes it.
|
||||
function init(ctx) {
|
||||
$("btn-welcome-add").addEventListener("click", ctx.showAddWalletView);
|
||||
|
||||
$("welcome-use-blockscout").addEventListener("change", async () => {
|
||||
const on = $("welcome-use-blockscout").checked;
|
||||
await saveState(() => {
|
||||
state.useBlockscout = on;
|
||||
});
|
||||
});
|
||||
|
||||
$("welcome-check-recipients-on-etherscan").addEventListener(
|
||||
"change",
|
||||
async () => {
|
||||
const on = $("welcome-check-recipients-on-etherscan").checked;
|
||||
await saveState(() => {
|
||||
state.checkRecipientsOnEtherscan = on;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { init, show };
|
||||
|
||||
@@ -51,12 +51,15 @@ function getLocalWarnings(address, options = {}) {
|
||||
|
||||
/**
|
||||
* Check an address against local lists AND via RPC queries.
|
||||
* Async — performs network calls to check contract status and tx history.
|
||||
* Async — performs network calls to check contract status, tx count and
|
||||
* balance.
|
||||
*
|
||||
* @param {string} address - The target address to check.
|
||||
* @param {object} provider - An ethers.js provider instance.
|
||||
* @param {object} [options] - Optional context.
|
||||
* @param {string} [options.fromAddress] - Sender address (for self-send check).
|
||||
* @param {string} [options.chainId] - Chain id of the network whose explorer
|
||||
* the Etherscan label check asks; the active network's when omitted.
|
||||
* @returns {Promise<Array<{type: string, message: string, severity: string}>>}
|
||||
*/
|
||||
async function getFullWarnings(address, provider, options = {}) {
|
||||
@@ -75,24 +78,33 @@ async function getFullWarnings(address, provider, options = {}) {
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
log.errorf("contract check failed:", e.message);
|
||||
log.errorf("contract check failed:", e.shortMessage || e.message);
|
||||
}
|
||||
|
||||
// Skip tx count check for contracts — they may legitimately have
|
||||
// zero inbound EOA transactions.
|
||||
// The transaction count is the number of transactions the address has
|
||||
// sent, so an address that has only ever received has a count of zero.
|
||||
// Native token it received stays in its balance until it sends a
|
||||
// transaction, so the warning also needs a zero balance. A contract is
|
||||
// skipped: it has its own warning above.
|
||||
if (!isContract) {
|
||||
try {
|
||||
const txCount = await provider.getTransactionCount(address);
|
||||
if (txCount === 0) {
|
||||
warnings.push({
|
||||
type: "new-address",
|
||||
message:
|
||||
"This address has never sent a transaction. Double-check it is correct.",
|
||||
severity: "info",
|
||||
});
|
||||
const balance = await provider.getBalance(address);
|
||||
if (balance === 0n) {
|
||||
warnings.push({
|
||||
type: "new-address",
|
||||
message:
|
||||
"This address has never sent a transaction and holds none of the network's native token. Double-check it is correct.",
|
||||
severity: "info",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.errorf("tx count check failed:", e.message);
|
||||
log.errorf(
|
||||
"tx count or balance check failed:",
|
||||
e.shortMessage || e.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +112,10 @@ async function getFullWarnings(address, provider, options = {}) {
|
||||
// Runs for ALL addresses including contracts, since many dangerous
|
||||
// flagged addresses on Etherscan (drainers, phishing contracts) are contracts.
|
||||
try {
|
||||
const etherscanWarning = await checkEtherscanLabel(address);
|
||||
const etherscanWarning = await checkEtherscanLabel(
|
||||
address,
|
||||
options.chainId,
|
||||
);
|
||||
if (etherscanWarning) {
|
||||
warnings.push(etherscanWarning);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// The 4-decimal amount rule from README.md's Display Consistency section, and
|
||||
// The 4-decimal amount rule from README.md's Amounts section, and
|
||||
// the one exception to it, in one place. Three call sites had grown their own
|
||||
// copy of the truncation — the history and balance lists
|
||||
// (`src/shared/transactions.js`), the approval screen's ERC-20 amount line
|
||||
@@ -6,10 +6,10 @@
|
||||
// (`src/shared/uniswap.js`) — and a fix applied to one of them left the other
|
||||
// two showing a different number for the same value.
|
||||
//
|
||||
// The two functions below are the two policies, not two implementations of
|
||||
// one: summary lists truncate, and the screens that state what is being
|
||||
// authorized truncate with a floor. Keeping them adjacent is the point, so a
|
||||
// change to the rule cannot reach one screen and miss another.
|
||||
// The two truncation functions below are the two policies, not two
|
||||
// implementations of one: summary lists truncate, and the screens that state
|
||||
// what is being authorized truncate with a floor. Keeping them adjacent is the
|
||||
// point, so a change to the rule cannot reach one screen and miss another.
|
||||
|
||||
// Truncate to exactly four decimal places. Truncation, never rounding: an
|
||||
// amount must never be displayed as larger than it is, so 0.99999 stays
|
||||
@@ -43,4 +43,18 @@ function truncateAmountNeverZero(val) {
|
||||
return parts[0] + "." + parts[1].slice(0, sig + 1);
|
||||
}
|
||||
|
||||
module.exports = { truncateAmount, truncateAmountNeverZero };
|
||||
// Whether a stored token balance is a holding below 0.000001. The balance
|
||||
// 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,13 +23,14 @@
|
||||
// disputed is refused rather than guessed at.
|
||||
|
||||
// Solidity's decimals() is a uint8, and every source here is ultimately
|
||||
// reporting that call's result. toDecimals() is that check, shared with the
|
||||
// send path rather than copied: the bundled list stores numbers, the
|
||||
// explorer's copy arrives as a string, and a token the user added by hand
|
||||
// carries whatever lookupTokenInfo() got back, so the accepted types are
|
||||
// enumerated rather than coerced.
|
||||
// reporting that call's result. toDecimals() is that check, stopping at the 80
|
||||
// places formatUnits() accepts, and shared with the send path rather than
|
||||
// copied: the bundled list stores numbers, the explorer's copy arrives as a
|
||||
// string, and a token the user added by hand carries whatever lookupTokenInfo()
|
||||
// got back, so the accepted types are enumerated rather than coerced.
|
||||
const { toDecimals } = require("./transferAmount");
|
||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { bundledToken } = require("./tokenList");
|
||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||
|
||||
// Every decimals the explorer reported for this contract, across all the
|
||||
// addresses whose balances have been fetched. They describe one contract, so
|
||||
@@ -52,12 +53,14 @@ function explorerDecimals(lower, wallets) {
|
||||
}
|
||||
|
||||
// The decimals to render a token amount with, or null when nothing knows.
|
||||
// `sources` is { trackedTokens, wallets }, both shaped as they are on `state`.
|
||||
// `sources` is { chainId, trackedTokens, wallets }. `chainId` is the chain id
|
||||
// of the token's network. The bundled list is read only when that network is
|
||||
// Ethereum Mainnet. `trackedTokens` and `wallets` are shaped as on `state`.
|
||||
function resolveTokenDecimals(tokenAddress, sources) {
|
||||
const lower = (tokenAddress || "").toLowerCase();
|
||||
if (!lower) return null;
|
||||
|
||||
const bundled = TOKEN_BY_ADDRESS.get(lower);
|
||||
const bundled = bundledToken(lower, sources && sources.chainId);
|
||||
if (bundled) {
|
||||
const d = toDecimals(bundled.decimals);
|
||||
if (d !== null) return d;
|
||||
@@ -74,6 +77,60 @@ function resolveTokenDecimals(tokenAddress, sources) {
|
||||
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 as for resolveTokenDecimals().
|
||||
//
|
||||
// A tracked or explorer-reported symbol is attacker-influenced text, so it is
|
||||
// held to the spoof rule (symbolSpoof.js): a candidate that the rule calls a
|
||||
// spoof is refused and the next source tried, so resolving a symbol never
|
||||
// becomes a new way to claim a known ticker. The bundled list is the wallet's
|
||||
// own data and is trusted as it is.
|
||||
function resolveTokenSymbol(tokenAddress, sources) {
|
||||
const lower = (tokenAddress || "").toLowerCase();
|
||||
if (!lower) return null;
|
||||
|
||||
const chainId = sources && sources.chainId;
|
||||
const bundled = bundledToken(lower, chainId);
|
||||
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, chainId)) return symbol;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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
|
||||
// cannot be mistaken for a token quantity, and it can never read as zero for a
|
||||
@@ -84,5 +141,6 @@ function unknownDecimalsAmount(rawAmount) {
|
||||
|
||||
module.exports = {
|
||||
resolveTokenDecimals,
|
||||
resolveTokenSymbol,
|
||||
unknownDecimalsAmount,
|
||||
};
|
||||
|
||||
+81
-19
@@ -1,13 +1,14 @@
|
||||
// Preparation of the transaction an approval screen displays.
|
||||
//
|
||||
// A dApp's eth_sendTransaction normally fixes only `to`, `value` and `data`.
|
||||
// The nonce, the gas limit and the fees have to be filled in from the network
|
||||
// before anything can be signed, and whoever fills them in decides what the
|
||||
// user is shown. That work used to happen in the popup, after the user had
|
||||
// already approved: the numbers on the approval screen came from the popup and
|
||||
// were compared against nothing, so a compromised popup could display one fee
|
||||
// and sign another, and the ceilings in approvalVerify.js were all that stood
|
||||
// between the user and a fee that hands the validator the balance.
|
||||
// A dApp's eth_sendTransaction normally fixes `to`, `value` and `data`, and
|
||||
// sometimes the gas limit. The nonce, the fees, and the gas limit when the
|
||||
// site gives none have to be filled in from the network before anything can
|
||||
// be signed, and whoever fills them in decides what the user is shown. That
|
||||
// work used to happen in the popup, after the user had already approved: the
|
||||
// numbers on the approval screen came from the popup and were compared against
|
||||
// nothing, so a compromised popup could display one fee and sign another, and
|
||||
// the ceilings in approvalVerify.js were all that stood between the user and a
|
||||
// fee that hands the validator the balance.
|
||||
//
|
||||
// So it happens here instead, in the background, before the approval window is
|
||||
// opened. The background populates the transaction, shows that object, and
|
||||
@@ -31,6 +32,7 @@ const {
|
||||
VoidSigner,
|
||||
accessListify,
|
||||
getAddress,
|
||||
getBigInt,
|
||||
getBytes,
|
||||
hexlify,
|
||||
toQuantity,
|
||||
@@ -39,6 +41,7 @@ const {
|
||||
ALLOWED_TX_TYPES,
|
||||
SERIALIZED_FIELDS,
|
||||
assertWithinCeilings,
|
||||
nodeError,
|
||||
} = require("./approvalVerify");
|
||||
|
||||
// How long the population may take before the request is failed back to the
|
||||
@@ -47,15 +50,20 @@ const {
|
||||
// minutes long, which is not a wait anyone will sit through.
|
||||
const POPULATE_TIMEOUT_MS = 20000;
|
||||
|
||||
// The request fields taken from the page. Anything else is dropped rather than
|
||||
// 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
|
||||
// it without this module knowing.
|
||||
// The request fields taken from the page, plus `gas`, which requestFrom()
|
||||
// takes as the gas limit. Anything else is dropped rather than 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 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 = [
|
||||
"to",
|
||||
"value",
|
||||
"data",
|
||||
"nonce",
|
||||
"gasLimit",
|
||||
"gasPrice",
|
||||
"maxFeePerGas",
|
||||
@@ -65,21 +73,43 @@ const REQUEST_FIELDS = [
|
||||
"type",
|
||||
];
|
||||
|
||||
// EIP-1474's code for invalid parameters: what the page is given when the
|
||||
// site asked for a transaction this wallet refuses as it was asked for.
|
||||
const INVALID_PARAMS_CODE = -32602;
|
||||
|
||||
// Why no approval can be raised. The message is the sentence the page is
|
||||
// shown. `code` and `data` are the JSON-RPC ones it is given with it: the
|
||||
// node's own, unchanged, when the node refused the transaction, so a site can
|
||||
// decode a revert; INVALID_PARAMS_CODE when the site's request is refused;
|
||||
// none when the failure is the wallet's own, which the background answers as
|
||||
// an internal error.
|
||||
class ApprovalPrepareError extends Error {
|
||||
constructor(message) {
|
||||
constructor(message, code, data) {
|
||||
super(message);
|
||||
this.name = "ApprovalPrepareError";
|
||||
if (code !== undefined) this.code = code;
|
||||
if (data !== undefined) this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
return new ApprovalPrepareError(message);
|
||||
function fail(message, code, data) {
|
||||
return new ApprovalPrepareError(message, code, data);
|
||||
}
|
||||
|
||||
function present(v) {
|
||||
return v !== null && v !== undefined && v !== "";
|
||||
}
|
||||
|
||||
// Whether two quantities from the page are the same number. One that is not a
|
||||
// number matches nothing.
|
||||
function sameQuantity(a, b) {
|
||||
try {
|
||||
return getBigInt(a) === getBigInt(b);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// These strings reach the user through the requesting page, so they are full
|
||||
// sentences even when the tail of one came from ethers or from the node.
|
||||
function sentence(text) {
|
||||
@@ -107,12 +137,28 @@ function requestFrom(txParams, from) {
|
||||
for (const key of REQUEST_FIELDS) {
|
||||
if (present(txParams[key])) request[key] = txParams[key];
|
||||
}
|
||||
// JSON-RPC calls the gas limit `gas`, and that is the name sites send.
|
||||
// ethers only reads `gasLimit`: left as `gas` it would be dropped and an
|
||||
// estimate signed in its place.
|
||||
if (present(txParams.gas)) {
|
||||
if (
|
||||
present(request.gasLimit) &&
|
||||
!sameQuantity(request.gasLimit, txParams.gas)
|
||||
) {
|
||||
throw fail(
|
||||
"The site asked for two different gas limits for this transaction.",
|
||||
INVALID_PARAMS_CODE,
|
||||
);
|
||||
}
|
||||
request.gasLimit = txParams.gas;
|
||||
}
|
||||
if (
|
||||
present(request.type) &&
|
||||
!ALLOWED_TX_TYPES.includes(Number(request.type))
|
||||
) {
|
||||
throw fail(
|
||||
"The site asked for a transaction of a type this wallet does not sign.",
|
||||
INVALID_PARAMS_CODE,
|
||||
);
|
||||
}
|
||||
return request;
|
||||
@@ -170,6 +216,7 @@ async function prepareApprovalTx(provider, from, txParams) {
|
||||
throw fail("There is no active address to send this transaction from.");
|
||||
}
|
||||
const request = requestFrom(txParams || {}, from);
|
||||
const signer = new VoidSigner(getAddress(from), provider);
|
||||
|
||||
let populated;
|
||||
try {
|
||||
@@ -177,15 +224,28 @@ async function prepareApprovalTx(provider, from, txParams) {
|
||||
// nonce, gas, fee and chain id are populated exactly as they were when
|
||||
// the popup did this. VoidSigner cannot sign, which is the point: the
|
||||
// background prepares, the popup signs.
|
||||
//
|
||||
// ethers asks the node for a gas estimate only when the request has
|
||||
// no gas limit, and that estimate is the only check that the
|
||||
// transaction does not revert. So a request carrying the site's gas
|
||||
// limit is estimated first, with that limit: one that reverts, or
|
||||
// needs more gas than the site gave it, fails here as well. The
|
||||
// site's gas limit is still the one populated.
|
||||
populated = await withTimeout(
|
||||
new VoidSigner(getAddress(from), provider).populateTransaction(
|
||||
request,
|
||||
),
|
||||
(async () => {
|
||||
if (present(request.gasLimit)) {
|
||||
await signer.estimateGas(request);
|
||||
}
|
||||
return signer.populateTransaction(request);
|
||||
})(),
|
||||
POPULATE_TIMEOUT_MS,
|
||||
"The transaction could not be prepared: the network did not answer in time.",
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof ApprovalPrepareError) throw e;
|
||||
// A refusal from the node, such as a reverting gas estimate, keeps
|
||||
// the node's code and revert data.
|
||||
const fromNode = nodeError(e);
|
||||
throw fail(
|
||||
sentence(
|
||||
"The transaction could not be prepared: " +
|
||||
@@ -193,6 +253,8 @@ async function prepareApprovalTx(provider, from, txParams) {
|
||||
e.message ||
|
||||
"the network did not answer"),
|
||||
),
|
||||
fromNode?.code,
|
||||
fromNode?.data,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,11 +51,16 @@
|
||||
const {
|
||||
Transaction,
|
||||
accessListify,
|
||||
formatEther,
|
||||
getAddress,
|
||||
getBigInt,
|
||||
getBytes,
|
||||
toQuantity,
|
||||
TypedDataEncoder,
|
||||
verifyMessage,
|
||||
verifyTypedData,
|
||||
} = require("ethers");
|
||||
const { nativeCurrencyByChainId } = require("./networks");
|
||||
|
||||
// The only transaction types this wallet signs: legacy, EIP-2930 and
|
||||
// EIP-1559. populateTransaction() produces nothing else, so nothing else can
|
||||
@@ -134,10 +139,19 @@ const FORBIDDEN_FIELDS = [
|
||||
const MAX_GAS_LIMIT = 100000000n;
|
||||
|
||||
// 100,000 gwei per gas: orders of magnitude above the highest fee either
|
||||
// supported network has produced, and low enough to catch a fee that would
|
||||
// hand the validator the balance.
|
||||
// supported network has produced.
|
||||
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
|
||||
// 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
|
||||
@@ -384,6 +398,37 @@ 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.
|
||||
@@ -592,6 +637,90 @@ function verifySignature(signParams, signature, expectedFrom) {
|
||||
return recovered;
|
||||
}
|
||||
|
||||
// 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 typed data message cannot be signed as the screen shows it, as a
|
||||
// sentence, or null when it can. ethers signs a `bool` field by whether its
|
||||
// value is truthy, so the string "false" is signed as true and a field left
|
||||
// out as false. Each field that `type` declares, at every level of nesting,
|
||||
// must be one of the value's own keys, and each `bool` must be true or false.
|
||||
// Called with the type ethers signs, the message, and a `path` of "".
|
||||
function typedDataMessageRefusal(types, type, value, path) {
|
||||
const itemType = type.replace(/\[\d*\]$/, "");
|
||||
if (itemType !== type) {
|
||||
// ethers refuses to sign a list field that is not a list.
|
||||
if (!Array.isArray(value)) return null;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const refusal = typedDataMessageRefusal(
|
||||
types,
|
||||
itemType,
|
||||
value[i],
|
||||
`${path}[${i}]`,
|
||||
);
|
||||
if (refusal) return refusal;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (type === "bool" && value !== true && value !== false) {
|
||||
return `The field "${path}" is not true or false, so this typed data cannot be signed.`;
|
||||
}
|
||||
for (const field of types[type] || []) {
|
||||
const at = path ? `${path}.${field.name}` : field.name;
|
||||
// An own key only: a plain lookup of `constructor` finds a function.
|
||||
const present =
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
Object.prototype.hasOwnProperty.call(value, field.name);
|
||||
if (!present) {
|
||||
return `The field "${at}" is missing from the message, so this typed data cannot be signed.`;
|
||||
}
|
||||
const refusal = typedDataMessageRefusal(
|
||||
types,
|
||||
field.type,
|
||||
value[field.name],
|
||||
at,
|
||||
);
|
||||
if (refusal) return refusal;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Why typed data cannot be signed while `network` is the active network, as a
|
||||
// sentence, or null when it can. EIP-712 has a wallet refuse typed data whose
|
||||
// domain names another chain, because the signature is valid on that chain.
|
||||
// ethers signs no chain id when the domain's is null or absent, and reads any
|
||||
// other with getBigInt(), so 1, "1" and "0x1" are the same chain here too.
|
||||
function typedDataChainRefusal(domain, network) {
|
||||
const chainId = domain?.chainId;
|
||||
if (chainId === undefined || chainId === null) return null;
|
||||
let named;
|
||||
try {
|
||||
named = getBigInt(chainId);
|
||||
} catch {
|
||||
return "This typed data cannot be read, so it cannot be signed.";
|
||||
}
|
||||
const active = getBigInt(network.chainId);
|
||||
if (named === active) return null;
|
||||
return (
|
||||
"This typed data is for chain id " +
|
||||
named +
|
||||
", but the active network is " +
|
||||
network.name +
|
||||
" (chain id " +
|
||||
active +
|
||||
"), so it cannot be signed."
|
||||
);
|
||||
}
|
||||
|
||||
// The stage a transaction approval failed at. Which stage it is decides
|
||||
// whether the approval survives the failure.
|
||||
const TX_STAGE_SIGN = "sign";
|
||||
@@ -617,6 +746,15 @@ function errorText(err) {
|
||||
return "The transaction could not be sent.";
|
||||
}
|
||||
|
||||
// The JSON-RPC error object the node answered with, `{ code, message, data }`,
|
||||
// out of the error ethers threw for that answer. ethers keeps it under
|
||||
// `info.error`, or under `error` when it could not classify it. Null when
|
||||
// there is none, as for a node that could not be reached.
|
||||
function nodeError(err) {
|
||||
if (!err || typeof err !== "object") return null;
|
||||
return err.error || (err.info && err.info.error) || null;
|
||||
}
|
||||
|
||||
// Every string a failure might carry its reason in. ethers reports the node's
|
||||
// own words in `shortMessage`, but a JSON-RPC error it could not classify is
|
||||
// nested under `error` or `info.error` with the node's message intact, and the
|
||||
@@ -628,7 +766,7 @@ function failureTexts(err) {
|
||||
for (const text of [err.shortMessage, err.message, err.reason]) {
|
||||
if (text) texts.push(String(text));
|
||||
}
|
||||
const nested = err.error || (err.info && err.info.error);
|
||||
const nested = nodeError(err);
|
||||
if (nested && nested.message) texts.push(String(nested.message));
|
||||
return texts;
|
||||
}
|
||||
@@ -753,12 +891,16 @@ function describeSigningFailure(response, fallbackMessage) {
|
||||
module.exports = {
|
||||
verifySignedTx,
|
||||
verifySignature,
|
||||
signedPrimaryType,
|
||||
typedDataMessageRefusal,
|
||||
typedDataChainRefusal,
|
||||
assertNoForbiddenFields,
|
||||
assertNothingUnchecked,
|
||||
assertCanonicalBytes,
|
||||
assertWithinCeilings,
|
||||
sameAddress,
|
||||
failureIsRetryable,
|
||||
nodeError,
|
||||
isNonceCollision,
|
||||
describeTxFailure,
|
||||
describeSigningFailure,
|
||||
@@ -775,4 +917,5 @@ module.exports = {
|
||||
TX_STAGE_NONCE,
|
||||
MAX_GAS_LIMIT,
|
||||
MAX_FEE_PER_GAS,
|
||||
MAX_TOTAL_FEE,
|
||||
};
|
||||
|
||||
+269
-64
@@ -1,5 +1,10 @@
|
||||
// Balance fetching: ETH balances via RPC, ERC-20 token balances via
|
||||
// Blockscout, ENS reverse lookup via RPC.
|
||||
// Balance fetching:
|
||||
//
|
||||
// - ETH balances, and the balances of the tokens the user tracks, via RPC
|
||||
// - the other ERC-20 token balances and the holder counts via Blockscout,
|
||||
// while the user has "Use Blockscout" on. Those balances are dropped when
|
||||
// the setting is off.
|
||||
// - ENS reverse lookup via RPC
|
||||
|
||||
const {
|
||||
JsonRpcProvider,
|
||||
@@ -10,13 +15,14 @@ const {
|
||||
} = require("ethers");
|
||||
const { ERC20_ABI } = require("./constants");
|
||||
const { NETWORKS } = require("./networks");
|
||||
const { log, debugFetch } = require("./log");
|
||||
const { log, debugFetch, urlOrigin } = require("./log");
|
||||
const { deriveAddressFromXpub } = require("./wallet");
|
||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { bundledToken } = require("./tokenList");
|
||||
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||
const { toDecimals } = require("./transferAmount");
|
||||
const { resolveTokenDecimals } = require("./approvalAmount");
|
||||
const { storedSettingIsOn } = require("./storedSettings");
|
||||
|
||||
// Use a static network to skip auto-detection (which can fail and cause
|
||||
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
||||
@@ -36,9 +42,18 @@ const { resolveTokenDecimals } = require("./approvalAmount");
|
||||
// ethers knows chains this wallet does not, so an id that is not one of ours
|
||||
// is a caller bug and must not resolve to a working provider for some other
|
||||
// chain.
|
||||
//
|
||||
// `disableCcipRead` keeps every ENS lookup on the RPC endpoint. A resolver can
|
||||
// say a name is resolved off-chain and give a web address for it. By default
|
||||
// ethers then fetches that address straight from the browser, sending the name
|
||||
// being looked up. For the reverse lookup of the user's addresses, the name sent
|
||||
// is the user's own (https://git.eeqj.de/sneak/AutistMask/issues/668). With the
|
||||
// flag set, such a name does not resolve.
|
||||
function getProvider(rpcUrl, networkId) {
|
||||
const net = Network.from(requireNetworkId(networkId).id);
|
||||
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
|
||||
const provider = new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
|
||||
provider.disableCcipRead = true;
|
||||
return provider;
|
||||
}
|
||||
|
||||
function requireNetworkId(networkId) {
|
||||
@@ -52,19 +67,16 @@ function requireNetworkId(networkId) {
|
||||
return net;
|
||||
}
|
||||
|
||||
function formatBalance(wei) {
|
||||
const eth = formatEther(wei);
|
||||
const parts = eth.split(".");
|
||||
if (parts.length === 1) return eth + ".0";
|
||||
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
|
||||
return parts[0] + "." + dec;
|
||||
}
|
||||
|
||||
// A token balance as an exact decimal string, never cut: a cut stores a small
|
||||
// nonzero holding as zero. fetchTokenBalances() stores every nonzero holding of
|
||||
// a token it admits, however small; the screens that leave out one below
|
||||
// 0.000001 decide that themselves, through isBelowOneMillionth() in
|
||||
// src/shared/amountDisplay.js.
|
||||
function formatTokenBalance(raw, decimals) {
|
||||
const val = formatUnits(raw, decimals);
|
||||
const parts = val.split(".");
|
||||
if (parts.length === 1) return val + ".0";
|
||||
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
|
||||
const dec = parts[1].replace(/0+$/, "") || "0";
|
||||
return parts[0] + "." + dec;
|
||||
}
|
||||
|
||||
@@ -83,14 +95,35 @@ function rawUnits(value) {
|
||||
|
||||
// Fetch token balances for a single address from Blockscout.
|
||||
// Returns [{ address, name, symbol, decimals, balance, holders }].
|
||||
//
|
||||
// While the stored "Use Blockscout" is off, Blockscout is not asked. An answer
|
||||
// that lands after the setting went off is dropped
|
||||
// (src/shared/storedSettings.js). Either way this returns an empty list, so
|
||||
// only tracked tokens are listed.
|
||||
//
|
||||
// Filters out spam: only shows tokens that are in the known token list,
|
||||
// explicitly tracked by the user, or have >= 1000 holders.
|
||||
// explicitly tracked by the user, or have >= 1000 holders. A tracked token's
|
||||
// balance is read through the RPC endpoint instead, and its row here gives that
|
||||
// entry its holder count and `decimals` (fetchTrackedTokenBalances()).
|
||||
//
|
||||
// `chainId` is the chain id of the network `blockscoutUrl` serves. The known
|
||||
// token list is used only when that network is Ethereum Mainnet.
|
||||
//
|
||||
// `decimals` and `balance` are each null when the answer is unknown, the same
|
||||
// way `holders` already is. Absence is never filled in here: this is the
|
||||
// upstream of every screen that displays a token amount, so a value invented
|
||||
// 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,
|
||||
chainId,
|
||||
signal,
|
||||
) {
|
||||
if (!(await storedSettingIsOn("useBlockscout"))) return [];
|
||||
try {
|
||||
const resp = await debugFetch(
|
||||
blockscoutUrl + "/addresses/" + address + "/token-balances",
|
||||
@@ -100,6 +133,7 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
return null;
|
||||
}
|
||||
const items = await resp.json();
|
||||
if (!(await storedSettingIsOn("useBlockscout"))) return [];
|
||||
if (!Array.isArray(items)) return null;
|
||||
|
||||
const trackedSet = new Set(
|
||||
@@ -145,29 +179,28 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
// either way: copying another source into it would make
|
||||
// explorerDecimals()'s disagreement check compare something other
|
||||
// than explorer values.
|
||||
const known = resolveTokenDecimals(tokenAddr, { trackedTokens });
|
||||
const known = resolveTokenDecimals(tokenAddr, {
|
||||
chainId,
|
||||
trackedTokens,
|
||||
});
|
||||
const scale = known !== null ? known : decimals;
|
||||
// null is a holding of an amount that cannot be stated, which is
|
||||
// not the same as a holding of zero, and must never render as one.
|
||||
// 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);
|
||||
if (bal === "0.0") continue;
|
||||
// null means the explorer reported no count, which is not the
|
||||
// same as a count of zero. This gate is not the low-holder
|
||||
// display filter: it has no user-facing off switch and governs
|
||||
// the whole balance list, so it stays strict and admits a token
|
||||
// only on a reported count — an unreported one is no evidence.
|
||||
// A legitimate token still reaches the list through the known
|
||||
// null means the explorer reported no readable count, which is
|
||||
// not the same as a count of zero. This gate is not the
|
||||
// low-holder display filter: it has no user-facing off switch and
|
||||
// governs the whole balance list, so it stays strict and admits a
|
||||
// token only on a reported count — an unreported one is no
|
||||
// evidence, and `null >= LOW_HOLDER_THRESHOLD` is false. A
|
||||
// legitimate token still reaches the list through the known
|
||||
// token list or by the user tracking it, and the null is carried
|
||||
// through to the views, where the two low-holder filters treat
|
||||
// an unknown count as "do not judge" rather than as zero.
|
||||
const holders = parseHoldersCount(item.token.holders_count);
|
||||
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
|
||||
const isKnown = bundledToken(tokenAddr, chainId) !== null;
|
||||
const isTracked = trackedSet.has(tokenAddr);
|
||||
const hasEnoughHolders =
|
||||
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
|
||||
const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD;
|
||||
|
||||
// Skip spam tokens the user never asked to see
|
||||
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
|
||||
@@ -176,7 +209,9 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
// Every row here is an ERC-20 the explorer reported, so it has a
|
||||
// contract address; the native ETH balance is fetched over RPC in
|
||||
// refreshBalances and never passes through this loop.
|
||||
if (isSpoofedSymbol(item.token.symbol, tokenAddr)) continue;
|
||||
if (isSpoofedSymbol(item.token.symbol, tokenAddr, chainId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
balances.push({
|
||||
address: item.token.address_hash,
|
||||
@@ -197,21 +232,130 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
}
|
||||
return balances;
|
||||
} catch (e) {
|
||||
log.errorf("fetchTokenBalances failed:", e.message);
|
||||
if (!signal?.aborted) {
|
||||
log.errorf("fetchTokenBalances failed:", e.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The entries at `address` of the tokens the user tracks, shaped as
|
||||
// fetchTokenBalances() returns them, for the tokens the address holds.
|
||||
//
|
||||
// The balance is read with balanceOf through the RPC endpoint. The name and
|
||||
// symbol are the tracked token's. `decimals` and `holders` are those of
|
||||
// Blockscout's entry for the token in `explorerRequest`, this refresh's
|
||||
// fetchTokenBalances() for the address; while Blockscout fails they are kept
|
||||
// from the token's entry in `previous`, the address's entries before this
|
||||
// refresh. The balance is stated at the scale the screens read the token at
|
||||
// (resolveTokenDecimals()): the bundled list's, then the one the tracked token
|
||||
// records, then Blockscout's, as in fetchTokenBalances().
|
||||
//
|
||||
// A token whose balanceOf fails keeps its entry in `previous`, as a failed ETH
|
||||
// balance keeps its figure, or takes Blockscout's entry from this refresh when
|
||||
// it has none. The failure is logged. A balance of zero gets no entry, as a
|
||||
// zero Blockscout reports gets none: the balance lists take a tracked token's
|
||||
// zero from the tracked list. A token whose symbol the spoof rule refuses gets
|
||||
// no entry either, as in fetchTokenBalances(), and its balance is not read.
|
||||
//
|
||||
// Resolves with `rows`, the entries, and `unread`, the tracked tokens whose
|
||||
// balance was not read, as lowercased contract addresses. A token is unread
|
||||
// when the spoof rule refuses it or its balanceOf fails.
|
||||
//
|
||||
// `chainId` and `signal` are as for fetchTokenBalances().
|
||||
async function fetchTrackedTokenBalances(
|
||||
provider,
|
||||
address,
|
||||
trackedTokens,
|
||||
previous,
|
||||
explorerRequest,
|
||||
chainId,
|
||||
signal,
|
||||
) {
|
||||
const listed = trackedTokens.filter(
|
||||
(token) => !isSpoofedSymbol(token.symbol, token.address, chainId),
|
||||
);
|
||||
// Each token's balanceOf answer, or null where it failed.
|
||||
const balances = await Promise.all(
|
||||
listed.map(async (token) => {
|
||||
try {
|
||||
const contract = new Contract(
|
||||
token.address,
|
||||
ERC20_ABI,
|
||||
provider,
|
||||
);
|
||||
return await contract.balanceOf(address);
|
||||
} catch (e) {
|
||||
if (!signal?.aborted) {
|
||||
log.errorf(
|
||||
"token balance failed",
|
||||
address,
|
||||
token.address,
|
||||
e.shortMessage || e.message,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const explorerRows = await explorerRequest;
|
||||
const reported = explorerRows ?? previous;
|
||||
|
||||
const rows = [];
|
||||
const unread = trackedTokens
|
||||
.filter((token) => !listed.includes(token))
|
||||
.map((token) => token.address.toLowerCase());
|
||||
listed.forEach((token, i) => {
|
||||
const lower = token.address.toLowerCase();
|
||||
const entryIn = (list) =>
|
||||
list.find((row) => String(row.address).toLowerCase() === lower);
|
||||
const raw = balances[i];
|
||||
if (raw === null) {
|
||||
unread.push(lower);
|
||||
const kept = entryIn(previous) || entryIn(explorerRows || []);
|
||||
if (kept) rows.push(kept);
|
||||
return;
|
||||
}
|
||||
if (raw === 0n) return;
|
||||
const entry = entryIn(reported);
|
||||
const decimals = entry ? entry.decimals : null;
|
||||
const known = resolveTokenDecimals(lower, { chainId, trackedTokens });
|
||||
const scale = known !== null ? known : decimals;
|
||||
rows.push({
|
||||
address: token.address,
|
||||
name: token.name || "",
|
||||
symbol: token.symbol || "???",
|
||||
decimals: decimals,
|
||||
// null when nothing knows the scale: a holding whose amount
|
||||
// cannot be stated, never a zero.
|
||||
balance: scale === null ? null : formatTokenBalance(raw, scale),
|
||||
holders: entry ? entry.holders : null,
|
||||
});
|
||||
});
|
||||
return { rows, unread };
|
||||
}
|
||||
|
||||
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
|
||||
// `trackedTokens` are the tokens tracked on the active network. `signal` is as
|
||||
// for fetchTokenBalances().
|
||||
//
|
||||
// Resolves with the lowercased contract addresses of the tracked tokens whose
|
||||
// balance it read at every address.
|
||||
async function refreshBalances(
|
||||
wallets,
|
||||
rpcUrl,
|
||||
blockscoutUrl,
|
||||
trackedTokens,
|
||||
networkId,
|
||||
signal,
|
||||
) {
|
||||
log.debugf("refreshBalances start, rpc:", rpcUrl);
|
||||
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
const { chainId } = requireNetworkId(networkId);
|
||||
const tracked = trackedTokens || [];
|
||||
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
|
||||
// The tracked tokens whose balance was not read at some address.
|
||||
const unread = new Set();
|
||||
const updates = [];
|
||||
|
||||
for (const wallet of wallets) {
|
||||
@@ -221,10 +365,13 @@ async function refreshBalances(
|
||||
provider
|
||||
.getBalance(addr.address)
|
||||
.then((bal) => {
|
||||
addr.balance = formatBalance(bal);
|
||||
// Exact, never cut: a cut here stores a small nonzero
|
||||
// balance as zero.
|
||||
addr.balance = formatEther(bal);
|
||||
log.debugf("ETH balance", addr.address, addr.balance);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (signal?.aborted) return;
|
||||
log.errorf(
|
||||
"ETH balance failed",
|
||||
addr.address,
|
||||
@@ -248,31 +395,57 @@ async function refreshBalances(
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (signal?.aborted) return;
|
||||
log.errorf(
|
||||
"ENS reverse failed",
|
||||
addr.address,
|
||||
e.message,
|
||||
e.shortMessage || e.message,
|
||||
);
|
||||
// Keep existing addr.ensName if we had one
|
||||
}),
|
||||
);
|
||||
|
||||
// ERC-20 token balances via Blockscout
|
||||
// ERC-20 token balances: the tracked tokens' through the RPC
|
||||
// endpoint, and from Blockscout the tokens the user has not
|
||||
// tracked. When Blockscout fails, the untracked rows from before
|
||||
// this refresh stay. While "Use Blockscout" is off, there are no
|
||||
// untracked rows. The Blockscout request is started first and
|
||||
// handed to fetchTrackedTokenBalances() too, which waits for it
|
||||
// after sending its balanceOf reads, so the two run side by side.
|
||||
const previous = addr.tokenBalances || [];
|
||||
const explorerRequest = fetchTokenBalances(
|
||||
addr.address,
|
||||
blockscoutUrl,
|
||||
tracked,
|
||||
chainId,
|
||||
signal,
|
||||
);
|
||||
updates.push(
|
||||
fetchTokenBalances(
|
||||
addr.address,
|
||||
blockscoutUrl,
|
||||
trackedTokens,
|
||||
).then((balances) => {
|
||||
if (balances !== null) {
|
||||
addr.tokenBalances = balances;
|
||||
log.debugf(
|
||||
"Token balances",
|
||||
addr.address,
|
||||
balances.length,
|
||||
"tokens",
|
||||
);
|
||||
}
|
||||
Promise.all([
|
||||
fetchTrackedTokenBalances(
|
||||
provider,
|
||||
addr.address,
|
||||
tracked,
|
||||
previous,
|
||||
explorerRequest,
|
||||
chainId,
|
||||
signal,
|
||||
),
|
||||
explorerRequest,
|
||||
]).then(([trackedResult, explorerRows]) => {
|
||||
for (const lower of trackedResult.unread) unread.add(lower);
|
||||
const untrackedRows = (explorerRows ?? previous).filter(
|
||||
(row) =>
|
||||
!trackedSet.has(String(row.address).toLowerCase()),
|
||||
);
|
||||
addr.tokenBalances =
|
||||
trackedResult.rows.concat(untrackedRows);
|
||||
log.debugf(
|
||||
"Token balances",
|
||||
addr.address,
|
||||
addr.tokenBalances.length,
|
||||
"tokens",
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -280,12 +453,28 @@ async function refreshBalances(
|
||||
|
||||
await Promise.all(updates);
|
||||
log.debugf("refreshBalances done");
|
||||
return [...trackedSet].filter((lower) => !unread.has(lower));
|
||||
}
|
||||
|
||||
// Drops the balance of each token not in `trackedTokens` from every address.
|
||||
// Callers run it while "Use Blockscout" is off, since only Blockscout reports
|
||||
// those tokens.
|
||||
function dropUntrackedTokens(wallets, trackedTokens) {
|
||||
const tracked = new Set(trackedTokens.map((t) => t.address.toLowerCase()));
|
||||
for (const wallet of wallets) {
|
||||
for (const addr of wallet.addresses || []) {
|
||||
addr.tokenBalances = (addr.tokenBalances || []).filter((row) =>
|
||||
tracked.has(String(row.address).toLowerCase()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look up token metadata from its contract.
|
||||
// Calls symbol() and decimals() to verify it implements ERC-20.
|
||||
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
||||
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
|
||||
// `signal` is as for fetchTokenBalances().
|
||||
async function lookupTokenInfo(contractAddress, rpcUrl, networkId, signal) {
|
||||
log.debugf("lookupTokenInfo", contractAddress, "rpc:", urlOrigin(rpcUrl));
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
const contract = new Contract(contractAddress, ERC20_ABI, provider);
|
||||
|
||||
@@ -294,7 +483,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
||||
symbol = await contract.symbol();
|
||||
log.debugf("symbol() =", symbol);
|
||||
} catch (e) {
|
||||
log.errorf("symbol() failed:", e.shortMessage || e.message);
|
||||
if (!signal?.aborted) {
|
||||
log.errorf("symbol() failed:", e.shortMessage || e.message);
|
||||
}
|
||||
throw new Error("Not a valid ERC-20 token (symbol() failed).");
|
||||
}
|
||||
|
||||
@@ -302,7 +493,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
||||
decimals = await contract.decimals();
|
||||
log.debugf("decimals() =", decimals);
|
||||
} catch (e) {
|
||||
log.errorf("decimals() failed:", e.shortMessage || e.message);
|
||||
if (!signal?.aborted) {
|
||||
log.errorf("decimals() failed:", e.shortMessage || e.message);
|
||||
}
|
||||
throw new Error("Not a valid ERC-20 token (decimals() failed).");
|
||||
}
|
||||
|
||||
@@ -310,13 +503,21 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
||||
name = await contract.name();
|
||||
log.debugf("name() =", name);
|
||||
} catch (e) {
|
||||
log.warnf("name() failed, using symbol as name:", e.message);
|
||||
if (!signal?.aborted) {
|
||||
log.warnf(
|
||||
"name() failed, using symbol as name:",
|
||||
e.shortMessage || e.message,
|
||||
);
|
||||
}
|
||||
name = symbol;
|
||||
}
|
||||
|
||||
// Truncate to prevent storage of excessively long values from RPC
|
||||
name = String(name).slice(0, 64);
|
||||
symbol = String(symbol).slice(0, 12);
|
||||
// Truncate to prevent storage of excessively long values from RPC.
|
||||
// Counted in code points, as displaySymbol() counts them, so the cut never
|
||||
// falls between the two halves of an emoji and stores a half that renders
|
||||
// as U+FFFD.
|
||||
name = Array.from(String(name)).slice(0, 64).join("");
|
||||
symbol = Array.from(String(symbol)).slice(0, 12).join("");
|
||||
|
||||
log.infof("Token resolved:", symbol, "decimals", Number(decimals));
|
||||
return { name, symbol, decimals: Number(decimals) };
|
||||
@@ -326,7 +527,8 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
||||
// Checks gapLimit addresses in parallel per batch. Stops when an entire
|
||||
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
|
||||
// Returns { addresses: [{ address, index }], nextIndex }.
|
||||
async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
|
||||
// `signal` is as for fetchTokenBalances().
|
||||
async function scanForAddresses(xpub, rpcUrl, networkId, signal, gapLimit = 5) {
|
||||
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
const used = [];
|
||||
@@ -349,11 +551,13 @@ async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
|
||||
]);
|
||||
return { addr, index, isUsed: balance > 0n || txCount > 0 };
|
||||
} catch (e) {
|
||||
log.errorf(
|
||||
"scanForAddresses check failed",
|
||||
addr,
|
||||
e.shortMessage || e.message,
|
||||
);
|
||||
if (!signal?.aborted) {
|
||||
log.errorf(
|
||||
"scanForAddresses check failed",
|
||||
addr,
|
||||
e.shortMessage || e.message,
|
||||
);
|
||||
}
|
||||
return { addr, index, isUsed: false };
|
||||
}
|
||||
}),
|
||||
@@ -384,6 +588,7 @@ async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
|
||||
module.exports = {
|
||||
fetchTokenBalances,
|
||||
refreshBalances,
|
||||
dropUntrackedTokens,
|
||||
lookupTokenInfo,
|
||||
getProvider,
|
||||
scanForAddresses,
|
||||
|
||||
+147
-5
@@ -31,6 +31,11 @@
|
||||
// Everything is resolved on use rather than captured at module load. The MV3
|
||||
// service worker is torn down and re-evaluated repeatedly, and the unit
|
||||
// suite installs its stubs on `global.chrome` around a require().
|
||||
//
|
||||
// The module also holds withLock(), a wrapper around the browser's
|
||||
// `navigator.locks`, which is in neither namespace. It sits beside
|
||||
// storageGet() and storageSet() because every write of the stored record
|
||||
// takes it.
|
||||
|
||||
// The extension API namespace, preferring `browser.*` where it exists.
|
||||
//
|
||||
@@ -145,6 +150,14 @@ function sendMessage(message) {
|
||||
return invoke(runtimeApi(), "sendMessage", message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Object>} the operating system and processor the browser
|
||||
* runs on.
|
||||
*/
|
||||
function getPlatformInfo() {
|
||||
return invoke(runtimeApi(), "getPlatformInfo");
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message nobody is expected to answer, and swallow the fact that
|
||||
* nobody did.
|
||||
@@ -166,9 +179,9 @@ function notify(message) {
|
||||
// default: resolving {} would make an existing wallet read back as no wallet,
|
||||
// and resolving a no-op write would discard the user's state with nothing
|
||||
// logged. A caller that wants to degrade takes storageLocal() directly.
|
||||
function storageUnavailable(method) {
|
||||
function storageUnavailable(area, method) {
|
||||
return Promise.reject(
|
||||
new Error("extension storage.local is not available: " + method),
|
||||
new Error("extension storage." + area + " is not available: " + method),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,7 +192,7 @@ function storageUnavailable(method) {
|
||||
*/
|
||||
function storageGet(keys) {
|
||||
const storage = storageLocal();
|
||||
if (!storage) return storageUnavailable("get");
|
||||
if (!storage) return storageUnavailable("local", "get");
|
||||
return Promise.resolve(storage.get(keys));
|
||||
}
|
||||
|
||||
@@ -190,7 +203,7 @@ function storageGet(keys) {
|
||||
*/
|
||||
function storageSet(items) {
|
||||
const storage = storageLocal();
|
||||
if (!storage) return storageUnavailable("set");
|
||||
if (!storage) return storageUnavailable("local", "set");
|
||||
return Promise.resolve(storage.set(items));
|
||||
}
|
||||
|
||||
@@ -207,7 +220,94 @@ function storageSet(items) {
|
||||
*/
|
||||
function storageRemove(keys) {
|
||||
const storage = storageLocal();
|
||||
if (!storage) return storageUnavailable("remove");
|
||||
if (!storage) return storageUnavailable("local", "remove");
|
||||
return Promise.resolve(storage.remove(keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `listener` after every write to `storage.local`, whether this page,
|
||||
* another page or the background made it, with what the write changed, as the
|
||||
* browser reports it: `{ [key]: { oldValue, newValue } }`.
|
||||
*
|
||||
* @param {function(Object): void} listener
|
||||
*/
|
||||
function onStorageChanged(listener) {
|
||||
namespaceMember("storage").onChanged.addListener((changes, areaName) => {
|
||||
if (areaName === "local") listener(changes);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` while holding the browser's lock called `name`
|
||||
* (`navigator.locks`), and settle as `fn` settles. Every page of the extension
|
||||
* and its background share these locks, so while one of them holds `name`
|
||||
* another asking for it waits, in the order they asked. The lock is let go when
|
||||
* `fn` settles, whether it resolved or rejected.
|
||||
*
|
||||
* Every write of the stored record holds the lock named after its key,
|
||||
* "autistmask", from its read to its write: saveState() in
|
||||
* src/shared/state.js, updateState() in src/background/state.js, and the
|
||||
* reset in src/popup/views/stateRecovery.js.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {function(): Promise<*>} fn
|
||||
* @returns {Promise<*>} what `fn` resolved with.
|
||||
* @throws rejects, without running `fn`, where `navigator.locks` is absent.
|
||||
*/
|
||||
function withLock(name, fn) {
|
||||
if (typeof navigator === "undefined" || !navigator.locks) {
|
||||
return Promise.reject(
|
||||
new Error("navigator.locks is not available in this context"),
|
||||
);
|
||||
}
|
||||
return navigator.locks.request(name, () => fn());
|
||||
}
|
||||
|
||||
// `storage.session`, the browser's session storage: what is kept there lasts
|
||||
// until the browser closes. It survives the background worker being stopped
|
||||
// and started again, which module-level variables do not (README, The
|
||||
// background worker). The background keeps the connections allowed without
|
||||
// "Remember" there; see src/background/index.js. Firefox has it from version
|
||||
// 115, the minimum manifest/firefox.json declares.
|
||||
//
|
||||
// The three below reject where it is absent, as the three above do where
|
||||
// `storage.local` is: a write that silently did nothing would tell a site it
|
||||
// is connected and then refuse its next request.
|
||||
function storageSession() {
|
||||
const storage = namespaceMember("storage");
|
||||
return (storage && storage.session) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|string[]|null} keys null for every item.
|
||||
* @returns {Promise<Object>} the stored items.
|
||||
* @throws rejects where `storage.session` is absent.
|
||||
*/
|
||||
function sessionStorageGet(keys) {
|
||||
const storage = storageSession();
|
||||
if (!storage) return storageUnavailable("session", "get");
|
||||
return Promise.resolve(storage.get(keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} items
|
||||
* @returns {Promise<void>}
|
||||
* @throws rejects where `storage.session` is absent.
|
||||
*/
|
||||
function sessionStorageSet(items) {
|
||||
const storage = storageSession();
|
||||
if (!storage) return storageUnavailable("session", "set");
|
||||
return Promise.resolve(storage.set(items));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|string[]} keys
|
||||
* @returns {Promise<void>}
|
||||
* @throws rejects where `storage.session` is absent.
|
||||
*/
|
||||
function sessionStorageRemove(keys) {
|
||||
const storage = storageSession();
|
||||
if (!storage) return storageUnavailable("session", "remove");
|
||||
return Promise.resolve(storage.remove(keys));
|
||||
}
|
||||
|
||||
@@ -235,6 +335,22 @@ function tabsSendMessage(tabId, message) {
|
||||
return invoke(tabsApi(), "sendMessage", tabId, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Object|undefined>} the tab the calling page is in, or
|
||||
* undefined for a page not in a tab, such as the toolbar popup.
|
||||
*/
|
||||
function tabsGetCurrent() {
|
||||
return invoke(tabsApi(), "getCurrent");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} tabId
|
||||
* @returns {Promise<number>} that tab's zoom: 1.5 at 150%.
|
||||
*/
|
||||
function tabsGetZoom(tabId) {
|
||||
return invoke(tabsApi(), "getZoom", tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} createData
|
||||
* @returns {Promise<Object>} the created window.
|
||||
@@ -250,6 +366,22 @@ function windowsGetLastFocused() {
|
||||
return invoke(windowsApi(), "getLastFocused");
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Object>} the window the calling page is in.
|
||||
*/
|
||||
function windowsGetCurrent() {
|
||||
return invoke(windowsApi(), "getCurrent");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} windowId
|
||||
* @param {Object} updateInfo
|
||||
* @returns {Promise<Object>} the updated window.
|
||||
*/
|
||||
function windowsUpdate(windowId, updateInfo) {
|
||||
return invoke(windowsApi(), "update", windowId, updateInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} windowId
|
||||
* @returns {Promise<void>}
|
||||
@@ -262,19 +394,29 @@ module.exports = {
|
||||
actionApi,
|
||||
alarmsApi,
|
||||
extensionApi,
|
||||
getPlatformInfo,
|
||||
hasBrowserNamespace,
|
||||
notify,
|
||||
onStorageChanged,
|
||||
runtimeApi,
|
||||
sendMessage,
|
||||
sessionStorageGet,
|
||||
sessionStorageRemove,
|
||||
sessionStorageSet,
|
||||
storageGet,
|
||||
storageLocal,
|
||||
storageRemove,
|
||||
storageSet,
|
||||
tabsApi,
|
||||
tabsGetCurrent,
|
||||
tabsGetZoom,
|
||||
tabsQuery,
|
||||
tabsSendMessage,
|
||||
windowsApi,
|
||||
windowsCreate,
|
||||
windowsGetCurrent,
|
||||
windowsGetLastFocused,
|
||||
windowsRemove,
|
||||
windowsUpdate,
|
||||
withLock,
|
||||
};
|
||||
|
||||
+89
-18
@@ -1,8 +1,18 @@
|
||||
// Consolidated chain-switch handler for the popup.
|
||||
// The popup's network switch.
|
||||
//
|
||||
// Every state change required when the active network changes is
|
||||
// performed here so that callers (settings UI, future chain additions) all go
|
||||
// through a single code path.
|
||||
// A network switch reaches the popup's `state` in one of two ways:
|
||||
//
|
||||
// - onChainSwitch() below makes the switch the user picks in Settings, with
|
||||
// applyChainSwitchFields() (chainSwitchFields.js).
|
||||
// - followStoredSettings() in src/popup/index.js takes a switch the background
|
||||
// or another popup page saved, from the stored record, with
|
||||
// takeStoredNetwork() (state.js), which copies it with
|
||||
// copyChainSwitchFields() (chainSwitchFields.js).
|
||||
//
|
||||
// Both change `state` inside a save, holding the lock, and both reset the
|
||||
// popup's own caches through clearPopupCaches(), so a reset added there
|
||||
// reaches both. A field applyChainSwitchFields() sets has to be copied by
|
||||
// copyChainSwitchFields() too.
|
||||
//
|
||||
// Adding a new chain (e.g. ETC) requires only a new entry in
|
||||
// networks.js — no per-caller wiring is needed.
|
||||
@@ -20,22 +30,83 @@
|
||||
const { applyChainSwitchFields } = require("./chainSwitchFields");
|
||||
const { clearPrices } = require("./prices");
|
||||
|
||||
// Switch the active chain and reset all chain-specific cached state.
|
||||
// Returns the network configuration object for the new chain.
|
||||
async function onChainSwitch(newNetworkId) {
|
||||
const { state, saveState } = require("./state");
|
||||
|
||||
const net = applyChainSwitchFields(state, newNetworkId);
|
||||
|
||||
// --- price cache ---
|
||||
// Prices are chain-specific (testnet tokens are worthless,
|
||||
// ETC has different pricing, etc.). In-memory and per bundle, so this is
|
||||
// the popup's own cache — the only context that ever fills it.
|
||||
// The popup's own caches a network switch resets. Prices are chain-specific
|
||||
// (testnet tokens are worthless, ETC has different pricing, etc.). In-memory
|
||||
// and per bundle, so this is the popup's own cache — the only context that
|
||||
// ever fills it.
|
||||
function clearPopupCaches() {
|
||||
clearPrices();
|
||||
}
|
||||
|
||||
await saveState();
|
||||
|
||||
// Switch the active chain and reset all chain-specific cached state.
|
||||
// Returns the network configuration object for the new chain, or null when
|
||||
// `state` already has that network once the save holds the lock, and nothing
|
||||
// is switched.
|
||||
//
|
||||
// `state` is switched inside the save that stores the switch, holding the
|
||||
// lock, so it is never switched while a follow of a switch saved elsewhere
|
||||
// waits for the lock (followStoredSettings() in src/popup/index.js): that
|
||||
// follow runs first, and this switch is made after it.
|
||||
//
|
||||
// The save writes only the fields that differ from what this page last saved.
|
||||
// When this page last saved a balance of null, clearing that balance is no
|
||||
// change. So the switch also resets these fields in the stored record:
|
||||
//
|
||||
// - every address's balance and token balances;
|
||||
// - the time of the last balance refresh;
|
||||
// - the fraud contracts.
|
||||
//
|
||||
// A balance or a fraud contract that the background or another page stored
|
||||
// since this page's last save is then not kept under the new network
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/653,
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/692).
|
||||
//
|
||||
// When the stored record is on another network, a switch saved elsewhere has
|
||||
// not reached this page yet. This page first takes that switch with
|
||||
// takeStoredNetwork() (state.js), as followStoredSettings() does. The switch to
|
||||
// `newNetworkId` is then made from the stored record's network. So every
|
||||
// network's list of tracked tokens in the stored record is kept
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/701). When the stored record is
|
||||
// already on `newNetworkId`, the switch made from there only resets the
|
||||
// balances and caches.
|
||||
//
|
||||
// When the stored record is on this page's network, this page first takes the
|
||||
// stored `networkTrackedTokens` (takeStoredNetworkTrackedTokens() in state.js),
|
||||
// because another page's save may have changed a list this page holds.
|
||||
//
|
||||
// Before the switch, `state` takes the tracked tokens that another page stored
|
||||
// since this page's last save (takeStoredTrackedTokens() in state.js). The
|
||||
// switch then puts them away under the previous network, the network they were
|
||||
// added on.
|
||||
async function onChainSwitch(newNetworkId) {
|
||||
const {
|
||||
state,
|
||||
saveState,
|
||||
takeStoredNetwork,
|
||||
takeStoredTrackedTokens,
|
||||
takeStoredNetworkTrackedTokens,
|
||||
} = require("./state");
|
||||
let net = null;
|
||||
await saveState((stored) => {
|
||||
if (state.networkId === newNetworkId) return false;
|
||||
if (stored.networkId !== state.networkId) {
|
||||
takeStoredNetwork(stored);
|
||||
} else {
|
||||
takeStoredNetworkTrackedTokens(stored);
|
||||
}
|
||||
takeStoredTrackedTokens(stored);
|
||||
net = applyChainSwitchFields(state, newNetworkId);
|
||||
stored.lastBalanceRefresh = 0;
|
||||
stored.fraudContracts = [];
|
||||
for (const wallet of stored.wallets) {
|
||||
for (const addr of wallet.addresses) {
|
||||
addr.balance = null;
|
||||
addr.tokenBalances = [];
|
||||
}
|
||||
}
|
||||
clearPopupCaches();
|
||||
});
|
||||
return net;
|
||||
}
|
||||
|
||||
module.exports = { onChainSwitch };
|
||||
module.exports = { onChainSwitch, clearPopupCaches };
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// the singleton and saves; the background applies it to the detached record of
|
||||
// its own read-modify-write (src/background/state.js).
|
||||
//
|
||||
// Everything here is synchronous and touches nothing but the object it is
|
||||
// Everything here is synchronous and touches nothing but the objects it is
|
||||
// given: no storage, no caches, no imports beyond the network table. That is
|
||||
// what makes it usable on a record that has been read fresh from storage
|
||||
// microseconds earlier and is about to be written back.
|
||||
@@ -38,6 +38,25 @@ function applyChainSwitchFields(s, newNetworkId) {
|
||||
blockscoutUrl: s.blockscoutUrl,
|
||||
};
|
||||
const remembered = s.networkEndpoints[net.id] || {};
|
||||
|
||||
// Tracked tokens are kept per network the same way, because a contract
|
||||
// address names a token on one network only. s.trackedTokens stays the
|
||||
// active network's list, so nothing that reads it changes: the outgoing
|
||||
// list is put away under its network, and a copy of the incoming network's
|
||||
// list, or an empty list, takes its place.
|
||||
//
|
||||
// It is a copy because a save writes a changed s.networkTrackedTokens whole,
|
||||
// over any list another page stored. An add or removal on the active network
|
||||
// must change only s.trackedTokens.
|
||||
//
|
||||
// Each token of the copy gets `balanceNotFetched`, because the balances are
|
||||
// cleared below and no refresh has read this network's yet. Each reads
|
||||
// `quantity unknown` until one has, not 0.0000.
|
||||
if (!s.networkTrackedTokens) s.networkTrackedTokens = {};
|
||||
s.networkTrackedTokens[s.networkId] = s.trackedTokens;
|
||||
s.trackedTokens = structuredClone(s.networkTrackedTokens[net.id] || []);
|
||||
for (const token of s.trackedTokens) token.balanceNotFetched = true;
|
||||
|
||||
s.networkId = net.id;
|
||||
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
|
||||
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
|
||||
@@ -49,10 +68,11 @@ function applyChainSwitchFields(s, newNetworkId) {
|
||||
|
||||
// Clear per-address balances and token balances so stale data
|
||||
// from the previous chain is never displayed while the first
|
||||
// refresh on the new chain is in flight.
|
||||
// refresh on the new chain is in flight. A balance of null is one no
|
||||
// refresh has read: it reads as unknown until one has, not as 0.0000.
|
||||
for (const wallet of s.wallets || []) {
|
||||
for (const addr of wallet.addresses || []) {
|
||||
addr.balance = "0";
|
||||
addr.balance = null;
|
||||
addr.tokenBalances = [];
|
||||
}
|
||||
}
|
||||
@@ -66,4 +86,33 @@ function applyChainSwitchFields(s, newNetworkId) {
|
||||
return net;
|
||||
}
|
||||
|
||||
module.exports = { applyChainSwitchFields };
|
||||
// Give `s` every field applyChainSwitchFields() sets, as `switched` holds it:
|
||||
// the popup takes a switch the background saved from the stored record this
|
||||
// way, rather than performing it again (takeStoredNetwork() in
|
||||
// src/shared/state.js). Balances are taken by address, and an address
|
||||
// `switched` does not have keeps none.
|
||||
function copyChainSwitchFields(s, switched) {
|
||||
s.networkEndpoints = switched.networkEndpoints;
|
||||
s.networkTrackedTokens = switched.networkTrackedTokens;
|
||||
s.trackedTokens = switched.trackedTokens;
|
||||
s.networkId = switched.networkId;
|
||||
s.rpcUrl = switched.rpcUrl;
|
||||
s.blockscoutUrl = switched.blockscoutUrl;
|
||||
s.lastBalanceRefresh = switched.lastBalanceRefresh;
|
||||
s.tokenHolderCache = switched.tokenHolderCache;
|
||||
s.fraudContracts = switched.fraudContracts;
|
||||
|
||||
const stored = new Map();
|
||||
for (const wallet of switched.wallets) {
|
||||
for (const addr of wallet.addresses) stored.set(addr.address, addr);
|
||||
}
|
||||
for (const wallet of s.wallets) {
|
||||
for (const addr of wallet.addresses) {
|
||||
const got = stored.get(addr.address);
|
||||
addr.balance = got ? got.balance : null;
|
||||
addr.tokenBalances = got ? got.tokenBalances : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { applyChainSwitchFields, copyChainSwitchFields };
|
||||
|
||||
@@ -22,11 +22,17 @@ const BUILD_DEBUG_MARKER = DEBUG
|
||||
? "autistmask-build-debug=on"
|
||||
: "autistmask-build-debug=off";
|
||||
|
||||
const DEBUG_MNEMONIC =
|
||||
"cube evolve unfold result inch risk jealous skill hotel bulb night wreck";
|
||||
// Behind DEBUG for the same reason BUILD_DEBUG_MARKER is above: in a release
|
||||
// build __BUILD_DEBUG__ is a compile-time false, esbuild drops this branch, and
|
||||
// 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_SEPOLIA_CHAIN_ID = "0xaa36a7";
|
||||
|
||||
const DEFAULT_RPC_URL = "https://ethereum-rpc.publicnode.com";
|
||||
|
||||
@@ -62,7 +68,6 @@ module.exports = {
|
||||
BUILD_DEBUG_MARKER,
|
||||
DEBUG_MNEMONIC,
|
||||
ETHEREUM_MAINNET_CHAIN_ID,
|
||||
ETHEREUM_SEPOLIA_CHAIN_ID,
|
||||
DEFAULT_RPC_URL,
|
||||
DEFAULT_BLOCKSCOUT_URL,
|
||||
BIP44_ETH_PATH,
|
||||
|
||||
+14
-4
@@ -32,7 +32,8 @@ function setCache(address, name) {
|
||||
localStorage.setItem(key, JSON.stringify({ name, ts: Date.now() }));
|
||||
}
|
||||
|
||||
async function resolveEnsName(address, rpcUrl, networkId) {
|
||||
// `signal` is as for fetchTokenBalances() in src/shared/balances.js.
|
||||
async function resolveEnsName(address, rpcUrl, networkId, signal) {
|
||||
const cached = getCached(address);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
@@ -42,17 +43,26 @@ async function resolveEnsName(address, rpcUrl, networkId) {
|
||||
setCache(address, name);
|
||||
return name;
|
||||
} catch (e) {
|
||||
log.errorf("ENS reverse lookup failed", address, e.message);
|
||||
if (!signal?.aborted) {
|
||||
log.errorf(
|
||||
"ENS reverse lookup failed",
|
||||
address,
|
||||
e.shortMessage || e.message,
|
||||
);
|
||||
}
|
||||
// Don't cache failures — let subsequent lookups retry
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveEnsNames(addresses, rpcUrl, networkId) {
|
||||
async function resolveEnsNames(addresses, rpcUrl, networkId, signal) {
|
||||
const results = new Map();
|
||||
await Promise.all(
|
||||
addresses.map(async (addr) => {
|
||||
results.set(addr, await resolveEnsName(addr, rpcUrl, networkId));
|
||||
results.set(
|
||||
addr,
|
||||
await resolveEnsName(addr, rpcUrl, networkId, signal),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
// Extension users make the requests directly to Etherscan — no proxy needed.
|
||||
// This is a best-effort enrichment: network failures return null silently.
|
||||
|
||||
const { networkByChainId } = require("./networks");
|
||||
const { storedSettingIsOn } = require("./storedSettings");
|
||||
|
||||
// Patterns in the page title that indicate a flagged address.
|
||||
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
|
||||
const PHISHING_LABEL_PATTERNS = [/^Fake_Phishing/i, /^Phish:/i, /^Exploiter/i];
|
||||
@@ -72,23 +75,36 @@ function parseEtherscanPage(html) {
|
||||
* Returns a warning object if the address is flagged, or null.
|
||||
* Network failures return null silently (best-effort check).
|
||||
*
|
||||
* Uses the current network's explorer URL so the lookup works on both
|
||||
* mainnet (etherscan.io) and Sepolia (sepolia.etherscan.io).
|
||||
* Uses the explorer of the network with `chainId`, or of the active network
|
||||
* when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on
|
||||
* Sepolia.
|
||||
*
|
||||
* While the stored "Check recipients on Etherscan" is off, Etherscan is not
|
||||
* asked. An answer that lands after the setting went off is dropped
|
||||
* (src/shared/storedSettings.js). Either way the answer is null.
|
||||
*
|
||||
* @param {string} address - Ethereum address to check.
|
||||
* @param {string} [chainId] - Chain id of the network to look it up on.
|
||||
* @returns {Promise<{type: string, message: string, severity: string}|null>}
|
||||
*/
|
||||
async function checkEtherscanLabel(address) {
|
||||
async function checkEtherscanLabel(address, chainId) {
|
||||
try {
|
||||
if (!(await storedSettingIsOn("checkRecipientsOnEtherscan"))) {
|
||||
return null;
|
||||
}
|
||||
// Lazy require to avoid pulling in chrome.storage at module scope
|
||||
// (which breaks unit tests that only exercise parseEtherscanPage).
|
||||
const { currentNetwork } = require("./state");
|
||||
const etherscanBase = currentNetwork().explorerUrl + "/address/";
|
||||
const network = chainId ? networkByChainId(chainId) : currentNetwork();
|
||||
const etherscanBase = network.explorerUrl + "/address/";
|
||||
const resp = await fetch(etherscanBase + address, {
|
||||
headers: { Accept: "text/html" },
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const html = await resp.text();
|
||||
if (!(await storedSettingIsOn("checkRecipientsOnEtherscan"))) {
|
||||
return null;
|
||||
}
|
||||
const result = parseEtherscanPage(html);
|
||||
if (result.isPhishing) {
|
||||
return {
|
||||
|
||||
+15
-6
@@ -9,13 +9,22 @@
|
||||
|
||||
const LOW_HOLDER_THRESHOLD = 1000;
|
||||
|
||||
// Parse an explorer-supplied holders_count into a number, or null when the
|
||||
// explorer did not report one. Anything unparseable is unknown too: a count
|
||||
// we cannot read is not a count of zero.
|
||||
// Parse an explorer-supplied holders_count into a number, or null when it is
|
||||
// not one. Only a whole number of zero or more, or a string made of nothing
|
||||
// but the digits 0-9, is a count. Anything else is null, never read in part:
|
||||
// "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) {
|
||||
if (raw === null || raw === undefined || raw === "") return null;
|
||||
const n = parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
if (typeof raw === "number") {
|
||||
return Number.isSafeInteger(raw) && raw >= 0 ? raw : 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
|
||||
|
||||
+25
-5
@@ -42,14 +42,34 @@ const log = {
|
||||
},
|
||||
};
|
||||
|
||||
// Fetch wrapper that debug-logs every request and response.
|
||||
// The origin (scheme, host and port) of a URL, for logging in place of the
|
||||
// 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) {
|
||||
const method = (opts && opts.method) || "GET";
|
||||
const body = opts && opts.body;
|
||||
log.debugf("fetch →", method, url, body || "");
|
||||
const origin = urlOrigin(url);
|
||||
let rpcMethod = "";
|
||||
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);
|
||||
log.debugf("fetch ←", resp.status, url);
|
||||
log.debugf("fetch ←", resp.status, origin);
|
||||
return resp;
|
||||
}
|
||||
|
||||
module.exports = { log, debugFetch, setRuntimeDebug, isDebug };
|
||||
module.exports = { log, debugFetch, urlOrigin, setRuntimeDebug, isDebug };
|
||||
|
||||
@@ -76,10 +76,13 @@ function networkByChainId(chainId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build a block explorer link for the given path type and value.
|
||||
// type: "address" | "tx" | "token" | "block"
|
||||
function explorerLink(network, type, value) {
|
||||
return `${network.explorerUrl}/${type}/${value}`;
|
||||
// The native currency of the network with this chain id. A transaction's
|
||||
// value and fee are labelled with the one of the chain the transaction is on,
|
||||
// which need not be the active network. `ETH` when the chain id is missing or
|
||||
// no network here has it.
|
||||
function nativeCurrencyByChainId(chainId) {
|
||||
const network = networkByChainId(chainId);
|
||||
return network ? network.nativeCurrency : "ETH";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -89,5 +92,5 @@ module.exports = {
|
||||
isKnownNetworkId,
|
||||
networkById,
|
||||
networkByChainId,
|
||||
explorerLink,
|
||||
nativeCurrencyByChainId,
|
||||
};
|
||||
|
||||
@@ -23,6 +23,11 @@ const DEFAULT_STATE = {
|
||||
hasWallet: false,
|
||||
wallets: [],
|
||||
trackedTokens: [],
|
||||
// Tracked tokens remembered per network: { [networkId]: [token, ...] }.
|
||||
// trackedTokens above is the active network's list; this is what the
|
||||
// others are restored from when the active network changes. See
|
||||
// applyChainSwitchFields().
|
||||
networkTrackedTokens: {},
|
||||
networkId: "mainnet",
|
||||
rpcUrl: DEFAULT_RPC_URL,
|
||||
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
|
||||
@@ -43,6 +48,17 @@ const DEFAULT_STATE = {
|
||||
hideDustTransactions: true,
|
||||
dustThresholdGwei: 100000,
|
||||
utcTimestamps: false,
|
||||
// "Use Blockscout" and "Check recipients on Etherscan" in Settings. Each
|
||||
// setting applies to every network. Both are off until the user turns them
|
||||
// on. With "Use Blockscout" on, Blockscout is sent the user's addresses and
|
||||
// the hash of each transaction opened. With "Check recipients on
|
||||
// Etherscan" on, Etherscan is sent the recipient's address and nothing
|
||||
// else.
|
||||
useBlockscout: false,
|
||||
checkRecipientsOnEtherscan: false,
|
||||
// "Allow weak passwords" in Settings. While it is off, AddWallet refuses a
|
||||
// password shorter than 12 characters.
|
||||
allowWeakPasswords: false,
|
||||
fraudContracts: [],
|
||||
tokenHolderCache: {},
|
||||
theme: "system",
|
||||
@@ -102,11 +118,11 @@ function tokenRefs(value) {
|
||||
|
||||
// A list of strings, for the fields whose entries are dereferenced as text:
|
||||
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
|
||||
// src/shared/transactions.js) and each address's hostname list in the site maps
|
||||
// below (`h !== host` filters, `list.includes(hostname)` in the background).
|
||||
// src/shared/transactions.js) and each address's origin list in the site maps
|
||||
// below (`o !== origin` filters, `list.includes(origin)` in the background).
|
||||
//
|
||||
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
|
||||
// with a malformed entry DROPPED rather than repaired. A number in a hostname
|
||||
// with a malformed entry DROPPED rather than repaired. A number in an origin
|
||||
// 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
|
||||
// survives. The result is a fresh array of primitives, so it shares no
|
||||
@@ -116,21 +132,22 @@ function textList(value) {
|
||||
return value.filter((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
// allowedSites / deniedSites: { [address]: [hostname, ...] }.
|
||||
// allowedSites / deniedSites: { [address]: [origin, ...] }, each origin the
|
||||
// full scheme://host[:port] of a site.
|
||||
//
|
||||
// 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
|
||||
// level below it. saveState() merges these maps per key and then per hostname
|
||||
// level below it. saveState() merges these maps per key and then per origin
|
||||
// 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
|
||||
// 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
|
||||
// revoke button (`list.filter()`), and the background's
|
||||
// `allowed.includes(hostname)` gate, dereference it the same way; on that last
|
||||
// `allowed.includes(origin)` gate, dereference it the same way; on that last
|
||||
// one a stored string would also answer a SUBSTRING match, so a corrupt map
|
||||
// could widen a site permission rather than merely throw.
|
||||
//
|
||||
// An address key whose value is not a list of hostnames is dropped entirely: it
|
||||
// An address key whose value is not a list of origins is dropped entirely: it
|
||||
// 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
|
||||
// can never be a wallet address, so it grants nothing either, and keeping it
|
||||
@@ -143,9 +160,9 @@ function siteMap(value) {
|
||||
if (!isRecord(value)) return out;
|
||||
for (const address of Object.keys(value)) {
|
||||
if (address === "__proto__") continue;
|
||||
const hostnames = textList(value[address]);
|
||||
if (hostnames.length === 0) continue;
|
||||
defineOwn(out, address, hostnames);
|
||||
const origins = textList(value[address]);
|
||||
if (origins.length === 0) continue;
|
||||
defineOwn(out, address, origins);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -288,6 +305,20 @@ function normalizePersisted(saved) {
|
||||
// profile whose wallets are perfectly fine. An empty list is a legitimate
|
||||
// value and survives.
|
||||
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
|
||||
// Each remembered list is floored like trackedTokens, because
|
||||
// applyChainSwitchFields() puts it straight back onto trackedTokens when
|
||||
// its network becomes the active one. A key that is not a network in
|
||||
// networks.js is dropped, which also keeps an own "__proto__" key from
|
||||
// reaching the plain assignment below.
|
||||
out.networkTrackedTokens = {};
|
||||
if (isRecord(saved.networkTrackedTokens)) {
|
||||
for (const netId of Object.keys(saved.networkTrackedTokens)) {
|
||||
if (!isKnownNetworkId(netId)) continue;
|
||||
out.networkTrackedTokens[netId] = structuredClone(
|
||||
tokenRefs(saved.networkTrackedTokens[netId]),
|
||||
);
|
||||
}
|
||||
}
|
||||
// The loud refusal for an unknown id is assertStateUsable(); this is the
|
||||
// floor under it. networkId is an object KEY into networkEndpoints below,
|
||||
// so a value that is not a network in networks.js must never get that far
|
||||
@@ -386,6 +417,13 @@ function normalizePersisted(saved) {
|
||||
: 100000;
|
||||
out.utcTimestamps =
|
||||
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
|
||||
// Anything but true is off: each setting lets a request reach another
|
||||
// service.
|
||||
out.useBlockscout = saved.useBlockscout === true;
|
||||
out.checkRecipientsOnEtherscan = saved.checkRecipientsOnEtherscan === true;
|
||||
// Anything but true is off: the setting lets AddWallet accept a short
|
||||
// password.
|
||||
out.allowWeakPasswords = saved.allowWeakPasswords === true;
|
||||
// A list of contract addresses, floored the same way: send.js builds its
|
||||
// fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())`
|
||||
// and filterTransactions() maps the same list through normalizeAddress(),
|
||||
|
||||
@@ -110,11 +110,14 @@ function hashListed(hash) {
|
||||
* Generate hostname variants for subdomain matching.
|
||||
* "sub.evil.com" yields ["sub.evil.com", "evil.com"].
|
||||
*
|
||||
* A trailing dot is dropped: "evil.com." is the fully qualified spelling of
|
||||
* "evil.com", and the browser loads the same site from either.
|
||||
*
|
||||
* @param {string} hostname
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function hostnameVariants(hostname) {
|
||||
const h = hostname.toLowerCase();
|
||||
const h = hostname.toLowerCase().replace(/\.$/, "");
|
||||
const variants = [h];
|
||||
const parts = h.split(".");
|
||||
// Parent domains: a.b.c.d -> b.c.d, c.d
|
||||
|
||||
+108
-45
@@ -1,29 +1,105 @@
|
||||
// Price fetching with 5-minute cache, USD formatting, value aggregation.
|
||||
// USD prices, read from Chainlink price feeds on Ethereum mainnet, with USD
|
||||
// formatting and value aggregation. The feeds are in src/shared/tokenList.js.
|
||||
|
||||
const { getTopTokenPrices } = require("./tokenList");
|
||||
const { Contract } = require("ethers");
|
||||
const { ETH_USD_FEED, PRICE_FEED_BY_ADDRESS } = require("./tokenList");
|
||||
const { getProvider } = require("./balances");
|
||||
const { isBelowOneMillionth } = require("./amountDisplay");
|
||||
const { networkByChainId } = require("./networks");
|
||||
const { log } = require("./log");
|
||||
|
||||
const PRICE_CACHE_TTL = 300000; // 5 minutes
|
||||
|
||||
// A reading older than its feed's heartbeat plus this many seconds is no price.
|
||||
const STALE_MARGIN = 1800; // 30 minutes
|
||||
|
||||
const FEED_ABI = [
|
||||
"function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)",
|
||||
];
|
||||
|
||||
// USD prices: ether's under "ETH", each token's under its lowercased contract
|
||||
// address.
|
||||
const prices = {};
|
||||
let lastFetchedAt = 0;
|
||||
|
||||
async function refreshPrices() {
|
||||
// Testnet tokens have no real market value — skip price fetching
|
||||
// and clear any stale mainnet prices so the UI shows no USD values.
|
||||
const { currentNetwork } = require("./state");
|
||||
if (currentNetwork().isTestnet) {
|
||||
// Reads the price of ether and of each token any address holds or the user
|
||||
// tracks, when that token has a feed. Mainnet only. The reads go to the RPC
|
||||
// endpoint as one batched request, at most every 5 minutes. A failed read is
|
||||
// logged, and that token then has no price.
|
||||
//
|
||||
// `signal`, passed by the popup, is aborted when the popup closes. A read that
|
||||
// fails once `signal` is aborted is not logged, because closing the popup
|
||||
// cancelled it.
|
||||
async function refreshPrices(signal) {
|
||||
const { state, currentNetwork } = require("./state");
|
||||
if (currentNetwork().id !== "mainnet") {
|
||||
clearPrices();
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - lastFetchedAt < PRICE_CACHE_TTL) return;
|
||||
try {
|
||||
const fetched = await getTopTokenPrices(25);
|
||||
Object.assign(prices, fetched);
|
||||
lastFetchedAt = now;
|
||||
} catch {
|
||||
// prices stay stale on error
|
||||
const feeds = new Map([["ETH", ETH_USD_FEED]]);
|
||||
for (const address of heldOrTrackedTokens(state)) {
|
||||
const feed = PRICE_FEED_BY_ADDRESS.get(address);
|
||||
if (feed) feeds.set(address, feed);
|
||||
}
|
||||
// ethers sends calls made together on one provider as one batch.
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const read = {};
|
||||
await Promise.all(
|
||||
[...feeds].map(async ([key, feed]) => {
|
||||
try {
|
||||
const contract = new Contract(feed.address, FEED_ABI, provider);
|
||||
const round = await contract.latestRoundData();
|
||||
const price = priceFromRound(round, feed, now);
|
||||
if (price === null) {
|
||||
log.warnf(
|
||||
"price feed reading is stale or not positive",
|
||||
key,
|
||||
);
|
||||
return;
|
||||
}
|
||||
read[key] = price;
|
||||
} catch (e) {
|
||||
if (signal?.aborted) return;
|
||||
log.errorf(
|
||||
"price feed read failed",
|
||||
key,
|
||||
e.shortMessage || e.message,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
clearPrices();
|
||||
Object.assign(prices, read);
|
||||
lastFetchedAt = now;
|
||||
}
|
||||
|
||||
// The lowercased contract address of each token that any address holds or
|
||||
// that the user tracks.
|
||||
function heldOrTrackedTokens(state) {
|
||||
const addresses = new Set();
|
||||
for (const wallet of state.wallets) {
|
||||
for (const addr of wallet.addresses) {
|
||||
for (const token of addr.tokenBalances || []) {
|
||||
addresses.add(token.address.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const token of state.trackedTokens || []) {
|
||||
addresses.add(token.address.toLowerCase());
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
// The USD price in a feed's latestRoundData() result, or null when the answer
|
||||
// is not positive or was written longer ago than the feed's heartbeat plus
|
||||
// STALE_MARGIN. `now` is in milliseconds.
|
||||
function priceFromRound(round, feed, now) {
|
||||
const ageSeconds = now / 1000 - Number(round.updatedAt);
|
||||
if (ageSeconds > feed.heartbeat + STALE_MARGIN) return null;
|
||||
if (round.answer <= 0n) return null;
|
||||
return Number(round.answer) / 10 ** feed.decimals;
|
||||
}
|
||||
|
||||
// Clear all cached prices and reset the fetch timestamp so the
|
||||
@@ -35,11 +111,15 @@ function clearPrices() {
|
||||
lastFetchedAt = 0;
|
||||
}
|
||||
|
||||
// Return the USD price for a symbol, or null on testnet / unknown.
|
||||
function getPrice(symbol) {
|
||||
// Return the USD price of the token at the contract address `token`, or of
|
||||
// ether when `token` is "ETH". The price is for the network with `chainId`, or
|
||||
// for the active network when no chain id is given. Returns null on testnet,
|
||||
// on a chain id that names no network, and for a token with no price.
|
||||
function getPrice(token, chainId) {
|
||||
const { currentNetwork } = require("./state");
|
||||
if (currentNetwork().isTestnet) return null;
|
||||
return prices[symbol] || null;
|
||||
const network = chainId ? networkByChainId(chainId) : currentNetwork();
|
||||
if (!network || network.isTestnet) return null;
|
||||
return prices[token === "ETH" ? "ETH" : token.toLowerCase()] || null;
|
||||
}
|
||||
|
||||
function formatUsd(amount) {
|
||||
@@ -57,7 +137,7 @@ function formatUsd(amount) {
|
||||
|
||||
// What an address is worth, as { usd, partial }.
|
||||
//
|
||||
// Prices are fetched for the top 25 tokens only, so an address can hold real
|
||||
// Only a token with a price feed has a price, so an address can hold real
|
||||
// assets this code has no price for. Adding up the priced ones and calling the
|
||||
// result the total states a number the holdings do not support: an address
|
||||
// holding nothing but unpriced tokens comes out at $0.00, which tells the user
|
||||
@@ -66,7 +146,8 @@ function formatUsd(amount) {
|
||||
// way an absent holders_count is not a count of zero.
|
||||
//
|
||||
// usd: the value of the holdings a price is known for, or null when
|
||||
// nothing is knowable at all — testnet, or before the first fetch.
|
||||
// no total can be stated — testnet, before the first fetch, or an
|
||||
// ETH balance no refresh has read.
|
||||
// partial: the address also holds a token with no price, so usd is a floor
|
||||
// and not the total.
|
||||
//
|
||||
@@ -75,9 +156,13 @@ function getAddressValue(addr) {
|
||||
const { currentNetwork } = require("./state");
|
||||
if (currentNetwork().isTestnet) return { usd: null, partial: false };
|
||||
if (!prices.ETH) return { usd: null, partial: false };
|
||||
let usd = parseFloat(addr.balance || "0") * prices.ETH;
|
||||
if (addr.balance == null) return { usd: null, partial: false };
|
||||
let usd = parseFloat(addr.balance) * prices.ETH;
|
||||
let partial = false;
|
||||
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
|
||||
// quantity to price — but it is still a holding, and a total that
|
||||
// silently omits it would read as complete. That is exactly what
|
||||
@@ -91,8 +176,9 @@ function getAddressValue(addr) {
|
||||
// nor make it incomplete. Anything that is not a number at all is not
|
||||
// a holding this can price either, and is left to the same rule.
|
||||
if (!(tokenBal > 0)) continue;
|
||||
if (prices[token.symbol]) {
|
||||
usd += tokenBal * prices[token.symbol];
|
||||
const price = prices[token.address.toLowerCase()];
|
||||
if (price) {
|
||||
usd += tokenBal * price;
|
||||
} else {
|
||||
partial = true;
|
||||
}
|
||||
@@ -100,27 +186,6 @@ function getAddressValue(addr) {
|
||||
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.
|
||||
//
|
||||
// A partial total is shown and named as partial: the figure is the ETH and
|
||||
@@ -145,6 +210,4 @@ module.exports = {
|
||||
formatUsd,
|
||||
formatAddressTotal,
|
||||
getAddressValue,
|
||||
getWalletValue,
|
||||
getTotalValue,
|
||||
};
|
||||
|
||||
+239
-51
@@ -23,6 +23,7 @@
|
||||
// guarantee.
|
||||
|
||||
const { networkById } = require("./networks");
|
||||
const { copyChainSwitchFields } = require("./chainSwitchFields");
|
||||
const {
|
||||
DEFAULT_STATE,
|
||||
PERSISTED_FIELDS,
|
||||
@@ -35,16 +36,18 @@ const {
|
||||
migrationNeeded,
|
||||
} = require("./stateSchema");
|
||||
|
||||
const { storageGet, storageSet } = require("./browserApi");
|
||||
const { storageGet, storageSet, withLock } = require("./browserApi");
|
||||
const { log } = require("./log");
|
||||
|
||||
// The live record the proxy below guards. Everything inside this module reads
|
||||
// and writes THIS object, never the proxy: the guard is for callers.
|
||||
const rawState = {
|
||||
...DEFAULT_STATE,
|
||||
// Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields()
|
||||
// mutates this map in place, and a spread copies the reference.
|
||||
// Their own objects, not the ones DEFAULT_STATE holds:
|
||||
// applyChainSwitchFields() mutates these maps in place, and a spread copies
|
||||
// the reference.
|
||||
networkEndpoints: {},
|
||||
networkTrackedTokens: {},
|
||||
currentView: null,
|
||||
selectedWallet: null,
|
||||
selectedAddress: null,
|
||||
@@ -122,9 +125,14 @@ function currentNetwork() {
|
||||
return networkById(state.networkId);
|
||||
}
|
||||
|
||||
// The persisted fields as they stood at the end of this page's last
|
||||
// loadState() or saveState(). saveState() diffs the live state against this
|
||||
// to find only the fields THIS page actually changed.
|
||||
// `baseline` holds the persisted fields as this page held them when its last
|
||||
// loadState() finished, or when its last successful saveState() began.
|
||||
// takeStoredNetwork(), takeStoredTrackedTokens() and
|
||||
// takeStoredNetworkTrackedTokens() also write into it what this page has taken
|
||||
// from the stored record since then. `baseline` also lacks any
|
||||
// `balanceNotFetched` field that a balance refresh has deleted since
|
||||
// (clearBalanceNotFetched()). saveState() diffs the live state against
|
||||
// `baseline` to find only the fields THIS page changed since.
|
||||
//
|
||||
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
|
||||
// in place (state.wallets.push(...)), and a reference baseline would mutate
|
||||
@@ -304,7 +312,7 @@ function mergeAddress(base, ours, theirs) {
|
||||
}
|
||||
|
||||
// Merge a plain object keyed by string (allowedSites/deniedSites: address ->
|
||||
// hostname list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
|
||||
// origin list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
|
||||
// same way mergeListByIdentity() merges an array — by key, not by whole-
|
||||
// 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
|
||||
@@ -353,25 +361,25 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// allowedSites/deniedSites: { [address]: [hostname, ...] }. The hostname
|
||||
// allowedSites/deniedSites: { [address]: [origin, ...] }. The origin
|
||||
// list is itself membership, not a leaf — the background appends a newly
|
||||
// approved/denied hostname to it, and the Settings "revoke" button
|
||||
// (src/popup/views/settings.js) filters a hostname out of it in place, from a
|
||||
// approved/denied origin to it, and the Settings "revoke" button
|
||||
// (src/popup/views/settings.js) filters an origin out of it in place, from a
|
||||
// different page. Merge it the same way wallets are merged: identity is the
|
||||
// hostname itself, so a merged pair is always equal and mergeItem is a no-op
|
||||
// origin itself, so a merged pair is always equal and mergeItem is a no-op
|
||||
// pick.
|
||||
function mergeHostnameList(base, ours, theirs) {
|
||||
function mergeOriginList(base, ours, theirs) {
|
||||
return mergeListByIdentity(
|
||||
base,
|
||||
ours,
|
||||
theirs,
|
||||
(hostname) => hostname,
|
||||
(origin) => origin,
|
||||
(b, o, t) => t,
|
||||
);
|
||||
}
|
||||
|
||||
function mergeSiteMap(base, ours, theirs) {
|
||||
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
|
||||
return mergeMapByKey(base, ours, theirs, mergeOriginList);
|
||||
}
|
||||
|
||||
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
|
||||
@@ -396,6 +404,51 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
return mergeMapByKey(base, ours, theirs, mergeEndpointEntry);
|
||||
}
|
||||
|
||||
// Merges one network's tracked tokens by contract address:
|
||||
//
|
||||
// - A token another page added or removed since this page's last save stays
|
||||
// added or removed.
|
||||
// - A token that this page and `theirs` both hold keeps this page's record.
|
||||
// - The result holds one record per contract address.
|
||||
//
|
||||
// mergeListByIdentity() keeps records that share an identity, on purpose, for
|
||||
// wallets. A contract address names one token, so this keeps one record for it.
|
||||
// Without that, a token that `ours` and `theirs` each hold twice comes back four
|
||||
// times, and each later merge multiplies it again.
|
||||
//
|
||||
// `theirs` takes this page's records first. mergeListByIdentity() would
|
||||
// otherwise log an error for two different records of one token.
|
||||
function mergeTrackedTokens(base, ours, theirs) {
|
||||
const contract = (token) => token.address.toLowerCase();
|
||||
const oursByContract = new Map(
|
||||
ours.map((token) => [contract(token), token]),
|
||||
);
|
||||
const merged = mergeListByIdentity(
|
||||
base,
|
||||
ours,
|
||||
theirs.map((token) => oursByContract.get(contract(token)) || token),
|
||||
contract,
|
||||
(baseToken, ourToken) => ourToken,
|
||||
);
|
||||
const byContract = new Map(merged.map((token) => [contract(token), token]));
|
||||
return [...byContract.values()];
|
||||
}
|
||||
|
||||
// Returns a copy of `stored.networkTrackedTokens` for when this page has not yet
|
||||
// taken the switch in `stored`. `page` is this page's state. A token that this
|
||||
// page tracked or removed since its last save is tracked or removed in the list
|
||||
// of `page.networkId`, by contract address.
|
||||
function networkTrackedTokensWithChange(stored, page) {
|
||||
return {
|
||||
...stored.networkTrackedTokens,
|
||||
[page.networkId]: mergeTrackedTokens(
|
||||
baseline.trackedTokens,
|
||||
page.trackedTokens,
|
||||
stored.networkTrackedTokens[page.networkId] || [],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Read-modify-write, merged per field, rather than one full-blob write.
|
||||
//
|
||||
// Every extension page (the toolbar popup, a dApp approval window) holds its
|
||||
@@ -422,8 +475,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
// address) apply independently instead of colliding as the same field.
|
||||
//
|
||||
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
|
||||
// by address key and then by hostname within each address's list), for the
|
||||
// identical reason: the background appends a newly approved/denied hostname
|
||||
// by address key and then by origin within each address's list), for the
|
||||
// identical reason: the background appends a newly approved/denied origin
|
||||
// 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
|
||||
// doesn't just lose data, it is a security defect — a stale page's save can
|
||||
@@ -436,12 +489,25 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
// comment at mergeEndpointEntry() for why the collision this closes is
|
||||
// milder than the other two.
|
||||
//
|
||||
// `trackedTokens` is merged by contract address (mergeTrackedTokens()) when this
|
||||
// page has changed it, so a token another page added or removed since this
|
||||
// page's last save stays added or removed. A token both lists hold keeps this
|
||||
// page's record. Two saves differ:
|
||||
//
|
||||
// - When the stored record is on another network than this page's, a save keeps
|
||||
// the stored `trackedTokens`. It merges this page's change into the list in
|
||||
// `networkTrackedTokens` for this page's network, by contract address.
|
||||
// - A save that switches networks writes this page's list whole. A switch in
|
||||
// Settings first merges that list by contract address
|
||||
// (takeStoredTrackedTokens()).
|
||||
//
|
||||
// Every other persisted field stays a whole-field diff:
|
||||
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
|
||||
// per-element identity to merge by; `tokenHolderCache` is a map shaped like
|
||||
// the ones above, but nothing in src/ ever writes an entry into it — it is
|
||||
// only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there is
|
||||
// no in-place mutation for a whole-field diff to collide with; `viewData` is
|
||||
// `fraudContracts`/`viewStack` are arrays of scalars with no per-element
|
||||
// identity to merge by; `networkTrackedTokens` is diffed whole like `networkId`,
|
||||
// which a network switch moves together with it; `tokenHolderCache` is a map
|
||||
// shaped like the ones above, but nothing in src/ ever writes an entry into it —
|
||||
// it is only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there
|
||||
// is no in-place mutation for a whole-field diff to collide with; `viewData` is
|
||||
// this page's own UI scratch space, not data another page has any reason to
|
||||
// share membership of.
|
||||
//
|
||||
@@ -454,17 +520,20 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
// This page's own live `state` is deliberately NOT rehydrated from a field
|
||||
// another page changed — only the record written to storage is merged.
|
||||
// showView() fires saveState() on every navigation without awaiting it,
|
||||
// which is what makes the queue above necessary in the first place, and a
|
||||
// save that is slow to come back has no way to tell whether the field it
|
||||
// which is why saves from one page wait for each other (see saveState()), and
|
||||
// a save that is slow to come back has no way to tell whether the field it
|
||||
// is about to hand back is still the current answer or has since been
|
||||
// overtaken by something this very page did in the meantime; writing it
|
||||
// into `state` regardless reintroduced exactly the clobber this function
|
||||
// exists to remove, just delayed and confined to one page instead of two
|
||||
// (caught by tests/txStatus.test.js). A page's live picture of a field it
|
||||
// does not own goes on being whatever its last loadState() saw, same as
|
||||
// before this fix; only the persisted record is guaranteed current.
|
||||
async function saveStateOnce() {
|
||||
const current = snapshotPersisted();
|
||||
// before this fix; only the persisted record is guaranteed current. Three
|
||||
// fields are the exceptions: the network, "Use Blockscout" and "Check
|
||||
// recipients on Etherscan". Through `beforeWrite`, the popup takes a change to
|
||||
// any of them that another context saved (followStoredSettings() in
|
||||
// src/popup/index.js).
|
||||
async function saveStateOnce(beforeWrite) {
|
||||
const result = await storageGet("autistmask");
|
||||
// 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,
|
||||
@@ -478,6 +547,12 @@ async function saveStateOnce() {
|
||||
// come from storage in its loaded (self-healed) shape. See
|
||||
// normalizePersisted() in persistedState.js.
|
||||
const fresh = normalizePersisted(result.autistmask);
|
||||
if (beforeWrite && beforeWrite(fresh) === false) return;
|
||||
|
||||
// A copy, so what this save compares and writes is the page's state as it
|
||||
// stood once the stored record was read. A change made while it waits on
|
||||
// the write is left for the next save, which compares against this copy.
|
||||
const current = structuredClone(snapshotPersisted());
|
||||
|
||||
const merged = { ...fresh };
|
||||
for (const key of PERSISTED_FIELDS) {
|
||||
@@ -508,6 +583,34 @@ async function saveStateOnce() {
|
||||
merged[key] = current[key];
|
||||
}
|
||||
}
|
||||
// When the merged record names a network other than this page's, this page
|
||||
// has not yet taken a switch saved elsewhere. This page's `trackedTokens`
|
||||
// is then the list of this page's network. The stored record keeps the
|
||||
// list of this page's network in `networkTrackedTokens`. So a token that
|
||||
// this page added or removed since its last save is added to or removed
|
||||
// from that stored list. The merged record keeps the stored `trackedTokens`
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/701).
|
||||
if (merged.networkId !== current.networkId) {
|
||||
merged.trackedTokens = fresh.trackedTokens;
|
||||
merged.networkTrackedTokens = networkTrackedTokensWithChange(
|
||||
fresh,
|
||||
current,
|
||||
);
|
||||
} else if (
|
||||
baseline !== null &&
|
||||
baseline.networkId === current.networkId &&
|
||||
!deepEqual(current.trackedTokens, baseline.trackedTokens)
|
||||
) {
|
||||
// This page's network is the one it last saved on, and the stored
|
||||
// record is on it too, so all three lists are of one network. This
|
||||
// page's change goes into the stored list, and another page's add or
|
||||
// removal stays (https://git.eeqj.de/sneak/AutistMask/issues/717).
|
||||
merged.trackedTokens = mergeTrackedTokens(
|
||||
baseline.trackedTokens,
|
||||
current.trackedTokens,
|
||||
fresh.trackedTokens,
|
||||
);
|
||||
}
|
||||
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
|
||||
// Stamped on every write, never merged or diffed: the record that goes to
|
||||
// storage is in THIS build's shape whatever shape it was read in, which is
|
||||
@@ -521,32 +624,22 @@ async function saveStateOnce() {
|
||||
// exactly as it stood; see the note above.
|
||||
rawState.hasWallet = rawState.wallets.length > 0;
|
||||
|
||||
baseline = structuredClone(snapshotPersisted());
|
||||
// What this save compared and wrote, not the page's state now: a change
|
||||
// made during the save must still differ from the baseline, or the next
|
||||
// save finds nothing to store
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/448).
|
||||
baseline = current;
|
||||
}
|
||||
|
||||
// showView() calls saveState() on every navigation without awaiting it, so
|
||||
// two saves from the SAME page can be in flight at once — e.g. a screen
|
||||
// shown, then immediately replaced before the first save's storageGet()
|
||||
// round trip has come back. Left concurrent, the first save's turn would
|
||||
// finish after the second's live-state mutation and then re-hydrate `state`
|
||||
// from what IT read, stomping the second, later change back to a stale
|
||||
// value — the same clobber this function exists to prevent, just between
|
||||
// two saves on one page instead of two pages. Queuing makes every save's
|
||||
// snapshot-diff-write-rehydrate run start to finish before the next one
|
||||
// begins, so each one only ever sees the true live state at its turn.
|
||||
let saveQueue = Promise.resolve();
|
||||
|
||||
// Where a failed save is REPORTED, set once by the context that has a screen
|
||||
// to say it on (src/popup/index.js).
|
||||
//
|
||||
// A save that fails must not fail silently. showView() fires saveState() on
|
||||
// every navigation without awaiting it, and the queue below has to attach a
|
||||
// rejection handler to keep advancing — so a failing save was swallowed
|
||||
// entirely: no throw, no message, nothing on screen. The wallet kept running
|
||||
// against storage that was rejecting every write, which is the data-loss half
|
||||
// of https://git.eeqj.de/sneak/AutistMask/issues/362. The awaited callers were
|
||||
// no better off: `await saveState()` inside an unguarded event handler surfaces
|
||||
// in the console and nowhere the user looks.
|
||||
// every navigation without awaiting it, and `await saveState()` inside an
|
||||
// unguarded event handler is no better: either way, without this, a failure
|
||||
// would reach only the console, where the user never looks, and the wallet
|
||||
// would keep running against storage that rejects every write. That is the
|
||||
// data-loss half of https://git.eeqj.de/sneak/AutistMask/issues/362.
|
||||
//
|
||||
// This is the "tell the user" half; the other half is the floor in
|
||||
// normalizePersisted(), which stops the malformed-record cause from arising in
|
||||
@@ -572,11 +665,25 @@ function reportSaveFailure(err) {
|
||||
}
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
const turn = saveQueue.then(saveStateOnce);
|
||||
// The queue must advance even when a save rejects, or every save after
|
||||
// it queues behind a promise that never settles.
|
||||
saveQueue = turn.catch(() => {});
|
||||
// Each save runs start to finish holding the lock every write of the stored
|
||||
// record takes (withLock() in src/shared/browserApi.js), so no write by
|
||||
// another page or the background lands between its read and its write and is
|
||||
// lost (https://git.eeqj.de/sneak/AutistMask/issues/641).
|
||||
//
|
||||
// The same lock orders this page's own saves. showView() calls saveState() on
|
||||
// every navigation without awaiting it, so two saves from one page can be
|
||||
// asked for at once; they take the lock in the order they were asked, and each
|
||||
// compares the page's state, as it stands once the stored record has been read
|
||||
// and `beforeWrite` has run, against the baseline the save before it left.
|
||||
//
|
||||
// `beforeWrite`, when given, is called inside the lock with the stored record
|
||||
// as just read, before this page's state is compared against it. It may change
|
||||
// `state`, and this save writes that change. It may also change the stored
|
||||
// record. A field that this page did not change is then written as
|
||||
// `beforeWrite` left it. When it returns false, nothing is written; any other
|
||||
// return lets the save go ahead.
|
||||
function saveState(beforeWrite) {
|
||||
const turn = withLock("autistmask", () => saveStateOnce(beforeWrite));
|
||||
// Every failed save is reported, whether or not the caller awaited this
|
||||
// one. The returned promise still rejects, so a caller that DOES await
|
||||
// keeps its own error handling.
|
||||
@@ -584,6 +691,83 @@ function saveState() {
|
||||
return turn;
|
||||
}
|
||||
|
||||
// Give this page the network switch held by `stored`, in `state` and in
|
||||
// `baseline` alike, as loadState() does with a whole record. `stored` is the
|
||||
// record that a save of this page has just read. followStoredSettings() in
|
||||
// src/popup/index.js and onChainSwitch() in src/shared/chainSwitch.js call this
|
||||
// from `beforeWrite`. The switch is then not this page's own change, even when
|
||||
// that save fails to write: a later switch back to the previous network still
|
||||
// differs from `baseline`, and is stored.
|
||||
//
|
||||
// A token that this page tracked or removed since its last save was tracked or
|
||||
// removed on this page's network before the switch. `state` keeps that change
|
||||
// in the list in `networkTrackedTokens` for that network, merged with the
|
||||
// stored list by contract address. `baseline` takes the stored list, so the
|
||||
// save stores the change (https://git.eeqj.de/sneak/AutistMask/issues/706).
|
||||
function takeStoredNetwork(stored) {
|
||||
const networkTrackedTokens = networkTrackedTokensWithChange(
|
||||
stored,
|
||||
rawState,
|
||||
);
|
||||
copyChainSwitchFields(rawState, stored);
|
||||
copyChainSwitchFields(baseline, structuredClone(stored));
|
||||
rawState.networkTrackedTokens = networkTrackedTokens;
|
||||
}
|
||||
|
||||
// Give this page the tracked tokens of `stored`, a record on this page's
|
||||
// network that a save of this page has just read (onChainSwitch() in
|
||||
// src/shared/chainSwitch.js calls this from `beforeWrite`):
|
||||
//
|
||||
// - A token that another page tracked or removed since this page's last save
|
||||
// is tracked or removed in `state` too.
|
||||
// - A token that this page tracked or removed since then stays as this page
|
||||
// has it.
|
||||
//
|
||||
// `baseline` takes the stored list, so the save stores the list in `state`
|
||||
// whenever the two differ.
|
||||
function takeStoredTrackedTokens(stored) {
|
||||
rawState.trackedTokens = mergeTrackedTokens(
|
||||
baseline.trackedTokens,
|
||||
rawState.trackedTokens,
|
||||
stored.trackedTokens,
|
||||
);
|
||||
baseline.trackedTokens = structuredClone(stored.trackedTokens);
|
||||
}
|
||||
|
||||
// Give `state` and `baseline` each their own copy of the stored
|
||||
// `networkTrackedTokens`. `stored` is a record on this page's network that a
|
||||
// save of this page has just read. onChainSwitch() in
|
||||
// src/shared/chainSwitch.js calls this from `beforeWrite`.
|
||||
function takeStoredNetworkTrackedTokens(stored) {
|
||||
rawState.networkTrackedTokens = structuredClone(
|
||||
stored.networkTrackedTokens,
|
||||
);
|
||||
baseline.networkTrackedTokens = structuredClone(
|
||||
stored.networkTrackedTokens,
|
||||
);
|
||||
}
|
||||
|
||||
// Deletes `balanceNotFetched` from each tracked token whose lowercased
|
||||
// contract address is in `addresses`. The field goes from `stored`, from
|
||||
// `state` and from `baseline` alike. `stored` is the record a save of this
|
||||
// page has just read (doRefreshAndRender() in src/popup/index.js calls this
|
||||
// from `beforeWrite`). So the deletion is not a change this page made to its
|
||||
// tracked tokens. Unless this page has changed its tracked tokens itself, the
|
||||
// save then writes the stored list with only the field gone.
|
||||
function clearBalanceNotFetched(stored, addresses) {
|
||||
for (const list of [
|
||||
stored.trackedTokens,
|
||||
rawState.trackedTokens,
|
||||
baseline.trackedTokens,
|
||||
]) {
|
||||
for (const token of list) {
|
||||
if (addresses.includes(token.address.toLowerCase())) {
|
||||
delete token.balanceNotFetched;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rejects with StateUnusableError for a stored record this build cannot make
|
||||
// sense of. Nothing is assigned and `loaded` stays false in that case, so a
|
||||
// caller that ignores the rejection gets StateNotLoadedError on the first
|
||||
@@ -629,6 +813,10 @@ module.exports = {
|
||||
state,
|
||||
saveState,
|
||||
onSaveFailure,
|
||||
takeStoredNetwork,
|
||||
takeStoredTrackedTokens,
|
||||
takeStoredNetworkTrackedTokens,
|
||||
clearBalanceNotFetched,
|
||||
loadState,
|
||||
currentAddress,
|
||||
currentNetwork,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Reads "Use Blockscout", "Check recipients on Etherscan" or the active network
|
||||
// from the stored profile.
|
||||
//
|
||||
// Before each request that sends the user's data to Blockscout or Etherscan, the
|
||||
// wallet reads that service's setting from storage. The wallet reads the setting
|
||||
// again when the answer lands. While the setting is off, no such request is
|
||||
// made, and an answer that lands after the setting went off is dropped. A page's
|
||||
// own copy of the setting is not used, because another page may have just
|
||||
// changed it.
|
||||
//
|
||||
// Before asking Blockscout for the transaction history, a screen remembers
|
||||
// which network is active. If another network is active on the page or in
|
||||
// storage when the answer lands, the screen drops the answer. A switch saved
|
||||
// elsewhere reaches storage first and the page later.
|
||||
|
||||
const { storageGet } = require("./browserApi");
|
||||
const { normalizePersisted } = require("./persistedState");
|
||||
|
||||
// Whether the setting `name` is on in the stored profile. `name` is
|
||||
// "useBlockscout" or "checkRecipientsOnEtherscan". The profile is read through
|
||||
// normalizePersisted(), as a load reads it, so only a stored true is on. A
|
||||
// failed read counts as off.
|
||||
async function storedSettingIsOn(name) {
|
||||
try {
|
||||
const { autistmask } = await storageGet("autistmask");
|
||||
return normalizePersisted(autistmask)[name];
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Whether `networkId` is the active network in the stored profile. A failed
|
||||
// read counts as no.
|
||||
async function storedNetworkIs(networkId) {
|
||||
try {
|
||||
const { autistmask } = await storageGet("autistmask");
|
||||
return normalizePersisted(autistmask).networkId === networkId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { storedSettingIsOn, storedNetworkIs };
|
||||
@@ -20,6 +20,10 @@
|
||||
// (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
|
||||
// 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;
|
||||
|
||||
@@ -32,8 +36,9 @@ const UNKNOWN_SYMBOL = "???";
|
||||
function displaySymbol(symbol) {
|
||||
const s = symbol === null || symbol === undefined ? "" : String(symbol);
|
||||
if (s.length === 0) return UNKNOWN_SYMBOL;
|
||||
if (s.length <= MAX_SYMBOL_LENGTH) return s;
|
||||
return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…";
|
||||
const chars = Array.from(s);
|
||||
if (chars.length <= MAX_SYMBOL_LENGTH) return s;
|
||||
return chars.slice(0, MAX_SYMBOL_LENGTH - 1).join("") + "…";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -11,15 +11,16 @@
|
||||
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
|
||||
// that may bear it, or to null. Null means the symbol belongs to the native
|
||||
// asset, which has no contract at all, so no contract may bear it and every
|
||||
// one that does is a spoof. "ETH" is the only such entry today; the rule is
|
||||
// written so that a second one needs no change here or at any call site.
|
||||
// one that does is a spoof. "ETH" is one such entry, and every network's
|
||||
// `nativeCurrency` in networks.js (`SepoliaETH`) is another, on every network.
|
||||
//
|
||||
// The value is a set because a ticker is not unique: seven symbols in the
|
||||
// bundled list belong to two real contracts each, and answering with one of
|
||||
// them hid the other one's holders' money (issue #276). Membership, not
|
||||
// equality, is therefore the question — but it is the same question, asked of
|
||||
// a table that can now state the truth. Every address in a set is one the
|
||||
// wallet ships as a real token; a contract outside the set is still a spoof.
|
||||
// wallet ships as a real token on Ethereum Mainnet. On mainnet, a contract
|
||||
// outside the set is still a spoof.
|
||||
//
|
||||
// The symbol is attacker-controlled — it is whatever the ERC-20 contract
|
||||
// returns — so the lookup is done on a normalized form (issue #260): the
|
||||
@@ -27,6 +28,7 @@
|
||||
// since that is what the user acts on.
|
||||
|
||||
const { KNOWN_SYMBOLS } = require("./tokenList");
|
||||
const { NETWORKS } = require("./networks");
|
||||
|
||||
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
||||
// over the address, not part of its identity.
|
||||
@@ -34,6 +36,11 @@ function normalizeAddress(addr) {
|
||||
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:
|
||||
//
|
||||
// NFKC collapses compatibility variants that render as the ASCII
|
||||
@@ -82,27 +89,34 @@ function normalizeAddress(addr) {
|
||||
function normalizeSymbol(symbol) {
|
||||
return String(symbol || "")
|
||||
.normalize("NFKC")
|
||||
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
|
||||
.replace(INVISIBLE_CHARACTERS, "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
// True when a token bearing `symbol` from contract `contractAddress` is
|
||||
// impersonating a known symbol.
|
||||
// impersonating a known symbol. `chainId` is the chain id of the token's
|
||||
// network.
|
||||
//
|
||||
// The native asset's labels are known symbols on every network. The bundled
|
||||
// list's symbols are known symbols only on Ethereum Mainnet, because the list
|
||||
// holds mainnet contracts.
|
||||
//
|
||||
// An empty contract address is the native asset, which is never a spoof:
|
||||
// this is what keeps the user's real ETH out of the rule, and it holds for
|
||||
// any symbol that becomes null-mapped later, not just for ETH.
|
||||
function isSpoofedSymbol(symbol, contractAddress) {
|
||||
function isSpoofedSymbol(symbol, contractAddress, chainId) {
|
||||
const contract = normalizeAddress(contractAddress);
|
||||
if (!contract) return false;
|
||||
const sym = normalizeSymbol(symbol);
|
||||
if (!KNOWN_SYMBOLS.has(sym)) return false;
|
||||
const legit = KNOWN_SYMBOLS.get(sym);
|
||||
if (legit === null) return true;
|
||||
if (chainId !== NETWORKS.mainnet.chainId) return false;
|
||||
return !legit.has(contract);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
INVISIBLE_CHARACTERS,
|
||||
isSpoofedSymbol,
|
||||
};
|
||||
|
||||
+376
-38
@@ -4,10 +4,11 @@
|
||||
// Addresses are EIP-55 checksummed.
|
||||
//
|
||||
// 511 tokens.
|
||||
//
|
||||
// Below the list, USD_PRICE_FEEDS gives the Chainlink USD price feed of each
|
||||
// token that has one.
|
||||
|
||||
const { debugFetch } = require("./log");
|
||||
|
||||
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
|
||||
const { NETWORKS } = require("./networks");
|
||||
|
||||
const TOKENS = [
|
||||
{
|
||||
@@ -575,7 +576,7 @@ const TOKENS = [
|
||||
symbol: "FLOKI",
|
||||
decimals: 9,
|
||||
name: "FLOKI",
|
||||
url: "",
|
||||
url: "https://floki.com",
|
||||
},
|
||||
{
|
||||
address: "0x7420B4b9a0110cdC71fB720908340C03F9Bc03EC",
|
||||
@@ -1289,7 +1290,7 @@ const TOKENS = [
|
||||
symbol: "TURBO",
|
||||
decimals: 18,
|
||||
name: "Turbo",
|
||||
url: "",
|
||||
url: "https://www.turbo-eth.com",
|
||||
},
|
||||
{
|
||||
address: "0xA27EC0006e59f245217Ff08CD52A7E8b169E62D2",
|
||||
@@ -3596,6 +3597,351 @@ const TOKENS = [
|
||||
},
|
||||
];
|
||||
|
||||
// Chainlink price feeds on Ethereum mainnet. Each feed is a contract whose
|
||||
// latestRoundData() returns a USD price and the time the price was written.
|
||||
// The fields of a feed:
|
||||
//
|
||||
// - address: the feed's contract
|
||||
// - heartbeat: the most seconds the feed goes without a new price
|
||||
// - decimals: the number of decimal places in the price
|
||||
//
|
||||
// Each feed's address and heartbeat come from Chainlink's published list of
|
||||
// mainnet feeds. Each feed was checked on mainnet by reading its description()
|
||||
// and decimals().
|
||||
|
||||
// The ETH/USD feed, which prices ether and WETH.
|
||||
const ETH_USD_FEED = {
|
||||
address: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419",
|
||||
heartbeat: 3600,
|
||||
decimals: 8,
|
||||
};
|
||||
|
||||
// The USD feed of each token in TOKENS that has one, keyed by the token's
|
||||
// contract address as TOKENS spells it. A token is paired with its feed by its
|
||||
// address alone, so a token that copies a listed token's symbol gets no feed.
|
||||
const USD_PRICE_FEEDS = {
|
||||
// WETH
|
||||
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": ETH_USD_FEED,
|
||||
// USDT
|
||||
"0xdAC17F958D2ee523a2206206994597C13D831ec7": {
|
||||
address: "0x3E7d1eAB13ad0104d2750B8863b489D65364e32D",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// BNB
|
||||
"0xB8c77482e45F1F44dE1745F52C74426C631bDD52": {
|
||||
address: "0x14e613AC84a31f709eadbdF89C6CC390fDc9540A",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// USDC
|
||||
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": {
|
||||
address: "0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6",
|
||||
heartbeat: 82800,
|
||||
decimals: 8,
|
||||
},
|
||||
// USDS
|
||||
"0xdC035D45d973E3EC169d2276DDab16f1e407384F": {
|
||||
address: "0xfF30586cD0F29eD462364C7e81375FC0C71219b1",
|
||||
heartbeat: 82800,
|
||||
decimals: 8,
|
||||
},
|
||||
// LINK
|
||||
"0x514910771AF9Ca656af840dff83E8264EcF986CA": {
|
||||
address: "0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c",
|
||||
heartbeat: 3600,
|
||||
decimals: 8,
|
||||
},
|
||||
// USDE
|
||||
"0x4c9EDD5852cd905f086C759E8383e09bff1E68B3": {
|
||||
address: "0xa569d910839Ae8865Da8F8e70FfFb0cBA869F961",
|
||||
heartbeat: 82800,
|
||||
decimals: 8,
|
||||
},
|
||||
// USD1
|
||||
"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d": {
|
||||
address: "0xF0d9bb015Cd7BfAb877B7156146dc09Bf461370d",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// DAI
|
||||
"0x6B175474E89094C44Da98b954EedeAC495271d0F": {
|
||||
address: "0xAed0c38402a5d19df6E4c03F4E2DceD6e29c1ee9",
|
||||
heartbeat: 3600,
|
||||
decimals: 8,
|
||||
},
|
||||
// PYUSD
|
||||
"0x6c3ea9036406852006290770BEdFcAbA0e23A0e8": {
|
||||
address: "0x8f1dF6D7F2db73eECE86a18b4381F4707b918FB1",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// WLFI
|
||||
"0xdA5e1988097297dCdc1f90D4dFE7909e847CBeF6": {
|
||||
address: "0x14E5FC91Ddb3f97C33013Cc9fA74F54062Ad1Aa1",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// UNI
|
||||
"0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984": {
|
||||
address: "0x553303d460EE0afB37EdFf9bE42922D8FF63220e",
|
||||
heartbeat: 3600,
|
||||
decimals: 8,
|
||||
},
|
||||
// PAXG
|
||||
"0x45804880De22913dAFE09f4980848ECE6EcbAf78": {
|
||||
address: "0x9944D86CEB9160aF5C5feB251FD671923323f8C3",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// AAVE
|
||||
"0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9": {
|
||||
address: "0x547a514d5e3769680Ce22B2361c10Ea13619e8a9",
|
||||
heartbeat: 3600,
|
||||
decimals: 8,
|
||||
},
|
||||
// USDF
|
||||
"0xFa2B947eEc368f42195f24F36d2aF29f7c24CeC2": {
|
||||
address: "0xb177857a1799aA5F7fEb5799Fdf12CbE8fdF78B1",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// USDG
|
||||
"0xe343167631d89B6Ffc58B88d6b7fB0228795491D": {
|
||||
address: "0x14f0737d6b705259e521EA6E9E3506AC78dBd311",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// SKY
|
||||
"0x56072C95FAA701256059aa122697B133aDEd9279": {
|
||||
address: "0xee10fE5E7aa92dd7b136597449c3d5813cFC5F18",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// RLUSD
|
||||
"0x8292Bb45bf1Ee4d140127049757C2E0fF06317eD": {
|
||||
address: "0x26C46B7aD0012cA71F2298ada567dC9Af14E7f2A",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// USDTB
|
||||
"0xC139190F447e929f090Edeb554D95AbB8b18aC1C": {
|
||||
address: "0x66704DAD467A7cA508B3be15865D9B9F3E186c90",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// ARB
|
||||
"0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1": {
|
||||
address: "0x31697852a68433DbCc2Ff612c516d69E3D9bd08F",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// USD0
|
||||
"0x73A15FeD60Bf67631dC6cd7Bc5B6e8da8190aCF5": {
|
||||
address: "0x7e891DEbD8FA0A4Cf6BE58Ddff5a8ca174FebDCB",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// GHO
|
||||
"0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f": {
|
||||
address: "0x3f12643D3f6f874d39C2a4c9f2Cd6f2DbAC877FC",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// TUSD
|
||||
"0x0000000000085d4780B73119b644AE5ecd22b376": {
|
||||
address: "0xec746eCF986E2927Abd291a2A1716c940100f8Ba",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// EURC
|
||||
"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c": {
|
||||
address: "0x04F84020Fdf10d9ee64D1dcC2986EDF2F556DA11",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// FDUSD
|
||||
"0xc5f0f7b66764F6ec8C8Dff7BA683102295E16409": {
|
||||
address: "0xfAA9147190c2C2cc5B8387B4f49016bDB3380572",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// CRV
|
||||
"0xD533a949740bb3306d119CC777fa900bA034cd52": {
|
||||
address: "0xCd627aA160A6fA45Eb793D19Ef54f5062F20f33f",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// GRT
|
||||
"0xc944E90C64B2c07662A292be6244BDf05Cda44a7": {
|
||||
address: "0x86cF33a451dE9dc61a2862FD94FF4ad4Bd65A5d2",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// CRVUSD
|
||||
"0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E": {
|
||||
address: "0xEEf0C605546958c1f899b6fB336C20671f9cD49F",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// FRAX, the Legacy Frax Dollar, priced by Chainlink's FRAX / USD feed
|
||||
"0x853d955aCEf822Db058eb8505911ED77F175b99e": {
|
||||
address: "0xB9E1E3A9feFf48998E45Fa90847ed4D467E8BcfD",
|
||||
heartbeat: 3600,
|
||||
decimals: 8,
|
||||
},
|
||||
// ENS
|
||||
"0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72": {
|
||||
address: "0x5C00128d4d1c2F4f652C267d7bcdD7aC99C16E16",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// SAND
|
||||
"0x3845badAde8e6dFF049820680d1F14bD3903a5d0": {
|
||||
address: "0x35E3f7E558C04cE7eEE1629258EcbbA03B36Ec56",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// AUSD
|
||||
"0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a": {
|
||||
address: "0xB00341502DfEA6Ced8A5786b4059d29dA5E4D1FD",
|
||||
heartbeat: 86400,
|
||||
decimals: 18,
|
||||
},
|
||||
// COMP
|
||||
"0xc00e94Cb662C3520282E6f5717214004A7f26888": {
|
||||
address: "0xdbd020CAeF83eFd542f4De03e3cF0C28A4428bd5",
|
||||
heartbeat: 3600,
|
||||
decimals: 8,
|
||||
},
|
||||
// XCN
|
||||
"0xA2cd3D43c775978A96BdBf12d733D5A1ED94fb18": {
|
||||
address: "0xeb988B77b94C186053282BfcD8B7ED55142D3cAB",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// CVX
|
||||
"0x4e3FBD56CD56c3e72c1403e103b45Db9da5B9D2B": {
|
||||
address: "0xd962fC30A72A84cE50161031391756Bf2876Af5D",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// IMX
|
||||
"0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF": {
|
||||
address: "0xBAEbEFc1D023c0feCcc047Bff42E75F15Ff213E6",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// 1INCH
|
||||
"0x111111111117dC0aa78b770fA6A738034120C302": {
|
||||
address: "0xc929ad75B72593967DE83E7F7Cda0493458261D9",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// SNX
|
||||
"0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F": {
|
||||
address: "0xDC3EA94CD0AC27d9A86C180091e7f78C683d3699",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// DOLA
|
||||
"0x865377367054516e17014CcdED1e7d814EDC9ce4": {
|
||||
address: "0xE33592594f72Cc7ec8a05788be8E8455746C3a32",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// FRXUSD
|
||||
"0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29": {
|
||||
address: "0x9B4a96210bc8D9D55b1908B465D8B0de68B7fF83",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// YFI
|
||||
"0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e": {
|
||||
address: "0xA027702dbb89fbd58938e4324ac03B58d812b0E1",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// RSR
|
||||
"0x320623b8E4fF03373931769A31Fc52A4E78B5d70": {
|
||||
address: "0x759bBC1be8F90eE6457C44abc7d443842a976d02",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// ZRX
|
||||
"0xE41d2489571d322189246DaFA5ebDe1F4699F498": {
|
||||
address: "0x2885d15b8Af22648b98B122b22FDF4D2a56c6023",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// FRAX, formerly FXS, priced by Chainlink's FXS / USD feed
|
||||
"0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0": {
|
||||
address: "0x6Ebc52C8C1089be9eB3945C4350B68B8E4C2233f",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// SXT
|
||||
"0xE6Bfd33F52d82Ccb5b37E16D3dD81f9FFDAbB195": {
|
||||
address: "0x2D27d9e1b74936D8E83c4BA118F09A4c4a897f62",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// SUSHI
|
||||
"0x6B3595068778DD592e39A122f4f5a5cF09C90fE2": {
|
||||
address: "0xCc70F09A6CC17553b2E31954cD36E4A2d89501f7",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// RPL
|
||||
"0xD33526068D116cE69F19A9ee46F0bd304F21A51f": {
|
||||
address: "0x4E155eD98aFE9034b7A5962f6C84c86d869daA9d",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// USDP
|
||||
"0x8E870D67F660D95d5be530380D0eC0bd388289E1": {
|
||||
address: "0x09023c0DA49Aaf8fc3fA3ADF34C6A7016D38D5e3",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// SPELL
|
||||
"0x090185f2135308BaD17527004364eBcC2D37e5F6": {
|
||||
address: "0x8c110B94C5f1d347fAcF5E1E938AB2db60E3c9a8",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// LUSD
|
||||
"0x5f98805A4E8be255a32880FDeC7F6728C6568bA0": {
|
||||
address: "0x3D7aE7E594f2f2091Ad8798313450130d0Aba3a0",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// STG
|
||||
"0xAf5191B0De278C7286d6C7CC6ab6BB8A73bA2Cd6": {
|
||||
address: "0x7A9f34a0Aa917D438e9b6E630067062B7F8f6f3d",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// KNC
|
||||
"0xdeFA4e8a7bcBA345F687a2f1456F5Edd9CE97202": {
|
||||
address: "0xf8fF43E991A81e6eC886a3D281A2C6cC19aE70Fc",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
// MUSD (the other MUSD in TOKENS has no feed)
|
||||
"0xacA92E438df0B2401fF60dA7E4337B687a2435DA": {
|
||||
address: "0xc90E3460424fb8ea79775089E9053113FEE34Ed0",
|
||||
heartbeat: 86400,
|
||||
decimals: 8,
|
||||
},
|
||||
};
|
||||
|
||||
// Build a map of token address (lowercased) -> its USD price feed.
|
||||
const PRICE_FEED_BY_ADDRESS = new Map();
|
||||
for (const [address, feed] of Object.entries(USD_PRICE_FEEDS)) {
|
||||
PRICE_FEED_BY_ADDRESS.set(address.toLowerCase(), feed);
|
||||
}
|
||||
|
||||
// Return the top N tokens from the list.
|
||||
function getTopTokens(n) {
|
||||
return TOKENS.slice(0, n);
|
||||
@@ -3607,10 +3953,21 @@ for (const t of TOKENS) {
|
||||
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
|
||||
}
|
||||
|
||||
// The bundled list's entry for the contract at `address` on the network with
|
||||
// chain id `chainId`, or null. The list holds Ethereum Mainnet contracts only.
|
||||
// A contract at the same address on another network is another token, so any
|
||||
// other chain id gets null.
|
||||
function bundledToken(address, chainId) {
|
||||
if (chainId !== NETWORKS.mainnet.chainId) return null;
|
||||
return TOKEN_BY_ADDRESS.get((address || "").toLowerCase()) || null;
|
||||
}
|
||||
|
||||
// Build a map of symbol (uppercased) -> the set of contract addresses
|
||||
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
|
||||
// "ETH" maps to null: the native asset has no contract, so no contract may
|
||||
// bear its symbol.
|
||||
// bear its symbol. So does every network's `nativeCurrency` in networks.js
|
||||
// (`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
|
||||
// and the list above proves it: seven of these 512 tokens share a symbol with
|
||||
@@ -3621,9 +3978,13 @@ for (const t of TOKENS) {
|
||||
// hides from the balance list, the history and the send selector at its own
|
||||
// address, so the user cannot spend it (issue #276). Naming every address
|
||||
// that bears the symbol is the only shape that says what is true; it does not
|
||||
// loosen the rule, because a contract outside the set is still a spoof.
|
||||
// loosen the rule. On Ethereum Mainnet, a contract outside the set is still a
|
||||
// spoof.
|
||||
const KNOWN_SYMBOLS = new Map();
|
||||
KNOWN_SYMBOLS.set("ETH", null);
|
||||
for (const network of Object.values(NETWORKS)) {
|
||||
KNOWN_SYMBOLS.set(network.nativeCurrency.toUpperCase(), null);
|
||||
}
|
||||
for (const t of TOKENS) {
|
||||
const upper = t.symbol.toUpperCase();
|
||||
if (!KNOWN_SYMBOLS.has(upper)) {
|
||||
@@ -3637,41 +3998,15 @@ for (const t of TOKENS) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch USD prices for the top N tokens from CoinDesk.
|
||||
// Also always includes ETH. Returns { ETH: 1234.56, LINK: 8.60, ... }.
|
||||
// n must be <= 30 (API and sanity limit).
|
||||
async function getTopTokenPrices(n) {
|
||||
if (n > 30) {
|
||||
throw new Error("getTopTokenPrices: n must be <= 30, got " + n);
|
||||
}
|
||||
const symbols = ["ETH", ...TOKENS.slice(0, n).map((t) => t.symbol)];
|
||||
const unique = [...new Set(symbols)];
|
||||
const instruments = unique.map((s) => s + "-USD").join(",");
|
||||
const url =
|
||||
COINDESK_API +
|
||||
"?market=cadli&instruments=" +
|
||||
instruments +
|
||||
"&apply_mapping=true";
|
||||
const resp = await debugFetch(url);
|
||||
const json = await resp.json();
|
||||
const prices = {};
|
||||
for (const [instrument, data] of Object.entries(json.Data || {})) {
|
||||
const symbol = instrument.split("-")[0];
|
||||
if (data.VALUE !== undefined) {
|
||||
prices[symbol] = data.VALUE;
|
||||
}
|
||||
}
|
||||
return prices;
|
||||
}
|
||||
|
||||
// Resolve a token symbol from multiple sources, never returning "?".
|
||||
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
|
||||
// Resolve a token symbol from multiple sources, never returning "?". The token
|
||||
// is a contract on the network with chain id `chainId`.
|
||||
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens, chainId) {
|
||||
const lower = (tokenAddress || "").toLowerCase();
|
||||
const tb = (tokenBalances || []).find(
|
||||
(t) => t.address.toLowerCase() === lower,
|
||||
);
|
||||
if (tb && tb.symbol) return tb.symbol;
|
||||
const known = TOKEN_BY_ADDRESS.get(lower);
|
||||
const known = bundledToken(lower, chainId);
|
||||
if (known && known.symbol) return known.symbol;
|
||||
const tracked = (trackedTokens || []).find(
|
||||
(t) => t.address.toLowerCase() === lower,
|
||||
@@ -3684,7 +4019,10 @@ module.exports = {
|
||||
TOKENS,
|
||||
TOKEN_BY_ADDRESS,
|
||||
KNOWN_SYMBOLS,
|
||||
ETH_USD_FEED,
|
||||
USD_PRICE_FEEDS,
|
||||
PRICE_FEED_BY_ADDRESS,
|
||||
getTopTokens,
|
||||
getTopTokenPrices,
|
||||
bundledToken,
|
||||
resolveSymbol,
|
||||
};
|
||||
|
||||
+46
-15
@@ -8,9 +8,10 @@
|
||||
|
||||
const { formatEther, formatUnits } = require("ethers");
|
||||
const { log, debugFetch } = require("./log");
|
||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
const { bundledToken } = require("./tokenList");
|
||||
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||
const { nativeCurrencyByChainId } = require("./networks");
|
||||
// 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
|
||||
// same thing here as it does on the screens that refuse to format one.
|
||||
@@ -19,6 +20,8 @@ const { toDecimals } = require("./transferAmount");
|
||||
// truncation without the approval screens' nonzero floor: the transaction
|
||||
// detail view is the authoritative record and already shows exact precision.
|
||||
const { truncateAmount: formatTxValue } = require("./amountDisplay");
|
||||
const { routerRelease } = require("./uniswap");
|
||||
const { storedSettingIsOn } = require("./storedSettings");
|
||||
|
||||
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
||||
// over the address, not part of its identity. Every address comparison in
|
||||
@@ -28,7 +31,7 @@ function normalizeAddress(addr) {
|
||||
return (addr || "").toLowerCase();
|
||||
}
|
||||
|
||||
function parseTx(tx, addrLower) {
|
||||
function parseTx(tx, addrLower, chainId) {
|
||||
const from = tx.from?.hash || "";
|
||||
const to = tx.to?.hash || "";
|
||||
const rawWei = tx.value || "0";
|
||||
@@ -36,7 +39,7 @@ function parseTx(tx, addrLower) {
|
||||
const method = tx.method || null;
|
||||
|
||||
// For contract calls, produce a meaningful label instead of "0.0000 ETH"
|
||||
let symbol = "ETH";
|
||||
let symbol = nativeCurrencyByChainId(chainId);
|
||||
let value = formatTxValue(formatEther(rawWei));
|
||||
let exactValue = formatEther(rawWei);
|
||||
let rawAmount = rawWei;
|
||||
@@ -44,13 +47,12 @@ function parseTx(tx, addrLower) {
|
||||
let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
|
||||
let directionLabel = direction === "sent" ? "Sent" : "Received";
|
||||
if (toIsContract && method && method !== "transfer") {
|
||||
const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
|
||||
const token = bundledToken(to, chainId);
|
||||
if (token) {
|
||||
symbol = token.symbol;
|
||||
}
|
||||
// Map known DEX methods to "Swap" for cleaner display
|
||||
const SWAP_METHODS = new Set([
|
||||
"execute",
|
||||
"swap",
|
||||
"swapExactTokensForTokens",
|
||||
"swapTokensForExactTokens",
|
||||
@@ -58,9 +60,16 @@ function parseTx(tx, addrLower) {
|
||||
"swapTokensForExactETH",
|
||||
"swapExactTokensForETH",
|
||||
"swapETHForExactTokens",
|
||||
"multicall",
|
||||
]);
|
||||
const label = SWAP_METHODS.has(method)
|
||||
// Multisigs, smart accounts and many other contracts also have an
|
||||
// `execute` or `multicall` method, so either is a swap only when sent
|
||||
// to a Universal Router on this transaction's network: the same
|
||||
// addresses the approval screen decodes a swap for.
|
||||
const isSwap =
|
||||
SWAP_METHODS.has(method) ||
|
||||
((method === "execute" || method === "multicall") &&
|
||||
routerRelease(to, chainId) !== null);
|
||||
const label = isSwap
|
||||
? "Swap"
|
||||
: method.charAt(0).toUpperCase() + method.slice(1);
|
||||
direction = "contract";
|
||||
@@ -90,10 +99,11 @@ function parseTx(tx, addrLower) {
|
||||
holders: null,
|
||||
isContractCall: toIsContract,
|
||||
method: method,
|
||||
chainId: chainId,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTokenTransfer(tt, addrLower) {
|
||||
function parseTokenTransfer(tt, addrLower, chainId) {
|
||||
const from = tt.from?.hash || "";
|
||||
const to = tt.to?.hash || "";
|
||||
// The explorer's own answer, or null. Never a default: a transfer of
|
||||
@@ -135,10 +145,12 @@ function parseTokenTransfer(tt, addrLower) {
|
||||
contractAddress: normalizeAddress(
|
||||
tt.token?.address_hash || tt.token?.address || "",
|
||||
),
|
||||
// null when the explorer reported no count: unknown, not zero. The
|
||||
// low-holder filter declines to judge a null, so a legitimate token
|
||||
// is not hidden because a field went missing upstream.
|
||||
// null when the explorer reported no readable count: unknown, not
|
||||
// zero. The low-holder filter declines to judge a null, so a
|
||||
// legitimate token is not hidden because a field went missing
|
||||
// upstream.
|
||||
holders: parseHoldersCount(tt.token?.holders_count),
|
||||
chainId: chainId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,7 +233,20 @@ function mergeTransactions(txs, tokenTransfers) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||
// `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry
|
||||
// carries it, and a native entry is labelled with that network's
|
||||
// `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`).
|
||||
//
|
||||
// While the stored "Use Blockscout" is off, Blockscout is not asked. An answer
|
||||
// that lands after the setting went off is dropped
|
||||
// (src/shared/storedSettings.js). Either way the answer is an empty list.
|
||||
async function fetchRecentTransactions(
|
||||
address,
|
||||
blockscoutUrl,
|
||||
chainId,
|
||||
count = 25,
|
||||
) {
|
||||
if (!(await storedSettingIsOn("useBlockscout"))) return [];
|
||||
log.debugf("fetchRecentTransactions", address);
|
||||
const addrLower = normalizeAddress(address);
|
||||
|
||||
@@ -252,10 +277,13 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||
|
||||
const txJson = txResp.ok ? await txResp.json() : {};
|
||||
const ttJson = ttResp.ok ? await ttResp.json() : {};
|
||||
if (!(await storedSettingIsOn("useBlockscout"))) return [];
|
||||
|
||||
const txs = mergeTransactions(
|
||||
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
|
||||
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
|
||||
(txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)),
|
||||
(ttJson.items || []).map((tt) =>
|
||||
parseTokenTransfer(tt, addrLower, chainId),
|
||||
),
|
||||
);
|
||||
|
||||
const result = txs.slice(0, count);
|
||||
@@ -289,7 +317,10 @@ function filterTransactions(txs, filters = {}) {
|
||||
const contract = normalizeAddress(tx.contractAddress);
|
||||
|
||||
// Filter spoofed known symbols and record the fraud contract
|
||||
if (hideSpoofed && isSpoofedSymbol(tx.symbol, tx.contractAddress)) {
|
||||
if (
|
||||
hideSpoofed &&
|
||||
isSpoofedSymbol(tx.symbol, tx.contractAddress, tx.chainId)
|
||||
) {
|
||||
if (contract && !fraudSet.has(contract)) {
|
||||
fraudSet.add(contract);
|
||||
newFraud.push(contract);
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// encoded with.
|
||||
//
|
||||
// A token amount is a decimal string plus a scale, and the two come from
|
||||
// different places. The confirmation screen renders the amount, the balance and
|
||||
// the symbol from the block explorer's cached metadata (see
|
||||
// fetchTokenBalances() in balances.js); the transfer used to be encoded from
|
||||
// decimals() read off the contract at signing time, and nothing compared the
|
||||
// two. A token whose on-chain scale differs from the cached one — an
|
||||
// different places. The confirmation screen renders the amount, the symbol and
|
||||
// the balance it reads from the node with the block explorer's cached metadata
|
||||
// (see fetchTokenBalances() in balances.js); the transfer used to be encoded
|
||||
// from decimals() read off the contract at signing time, and nothing compared
|
||||
// the two. A token whose on-chain scale differs from the cached one — an
|
||||
// upgradeable or proxy token, a caller-dependent one, a stale or wrong explorer
|
||||
// entry — therefore signed an amount that was never displayed, off by a power
|
||||
// of ten for every decimal place of disagreement.
|
||||
@@ -27,9 +27,11 @@
|
||||
|
||||
const { parseUnits } = require("ethers");
|
||||
|
||||
// Solidity's decimals() returns a uint8, so anything outside that range is not
|
||||
// an answer this wallet can use.
|
||||
const MAX_DECIMALS = 255;
|
||||
// Solidity's decimals() returns a uint8, but ethers' formatUnits() and
|
||||
// parseUnits() refuse more than 80 decimal places ("invalid FixedNumber
|
||||
// decimals (too large)"). A scale of 81 to 255 can be neither displayed nor
|
||||
// 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 =
|
||||
"The transfer was not sent, because the number of decimal places this" +
|
||||
@@ -55,7 +57,7 @@ function mismatchMessage(displayed, onChain) {
|
||||
// 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
|
||||
// as a string, so both of those are accepted alongside a plain number; anything
|
||||
// fractional, negative, out of uint8 range, or of any other type at all is not.
|
||||
// fractional, negative, above MAX_DECIMALS, or of any other type at all is not.
|
||||
//
|
||||
// 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
|
||||
|
||||
+70
-13
@@ -1,4 +1,5 @@
|
||||
// Balance arithmetic for the transaction confirmation screen.
|
||||
// The amount check and balance arithmetic for the Send and transaction
|
||||
// confirmation screens.
|
||||
//
|
||||
// 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
|
||||
@@ -10,7 +11,7 @@
|
||||
// the token balance arrive as human decimal strings, so comparing them at a
|
||||
// common scale is exact.
|
||||
|
||||
const { parseUnits } = require("ethers");
|
||||
const { parseUnits, formatEther } = require("ethers");
|
||||
|
||||
const SCALE_DECIMALS = 18;
|
||||
|
||||
@@ -20,7 +21,7 @@ const FEE_KNOWN = "known";
|
||||
const FEE_UNAVAILABLE = "unavailable";
|
||||
|
||||
const CODES = {
|
||||
// The amount is not a non-negative number we can do exact arithmetic on.
|
||||
// The amount is one isValidAmount() refuses.
|
||||
AMOUNT_INVALID: "amount-invalid",
|
||||
// ERC-20: the token amount exceeds the token balance.
|
||||
INSUFFICIENT_TOKEN: "insufficient-token",
|
||||
@@ -87,12 +88,57 @@ function toFixedPoint(value) {
|
||||
}
|
||||
}
|
||||
|
||||
// Whether `amount` can be sent: a number above zero, written as digits with
|
||||
// at most one decimal point ("0.5", "12", ".5"), with nothing but zeros after
|
||||
// the token's last decimal place, `tokenDecimals`, and nothing but zeros after
|
||||
// the 18th, the places the arithmetic here works in, which are ETH's. ETH, and
|
||||
// a token whose decimal places are unknown, pass null and are held to the 18
|
||||
// (the confirmation screen refuses to send such a token anyway). Zeros past
|
||||
// those places are accepted because parseUnits() drops them here and again
|
||||
// when the transfer is encoded, so "1.0000000" of a 6-place token is exactly
|
||||
// 1 everywhere. Refuses "1e-3", "1abc", "0" and "-1". The Send screen checks
|
||||
// it before the confirmation screen opens, and validateTransfer() checks it
|
||||
// again there.
|
||||
function isValidAmount(amount, tokenDecimals = null) {
|
||||
if (typeof amount !== "string") return false;
|
||||
const places = Math.min(tokenDecimals ?? SCALE_DECIMALS, SCALE_DECIMALS);
|
||||
try {
|
||||
return parseUnits(amount.trim(), places) > 0n;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 an exact decimal string as
|
||||
// formatEther() writes it, 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.
|
||||
//
|
||||
// isErc20 — token transfer rather than a native ETH transfer
|
||||
// amount — human decimal string being sent, non-negative. Anything
|
||||
// else, a negative value included, is an unusable amount
|
||||
// amount — human decimal string being sent. One isValidAmount()
|
||||
// refuses, a negative value included, is an unusable amount
|
||||
// rather than an amount that passes every comparison.
|
||||
// tokenDecimals — ERC-20: the token's decimal places, or null when they
|
||||
// are unknown. Passed to isValidAmount().
|
||||
// ethBalance — human decimal string, the sender's ETH balance
|
||||
// tokenBalance — human decimal string, the sender's token balance
|
||||
// feeStatus — FEE_PENDING, FEE_KNOWN or FEE_UNAVAILABLE. Anything else
|
||||
@@ -106,6 +152,7 @@ function toFixedPoint(value) {
|
||||
function validateTransfer({
|
||||
isErc20 = false,
|
||||
amount,
|
||||
tokenDecimals = null,
|
||||
ethBalance,
|
||||
tokenBalance,
|
||||
feeStatus = FEE_PENDING,
|
||||
@@ -113,17 +160,17 @@ function validateTransfer({
|
||||
} = {}) {
|
||||
const codes = [];
|
||||
|
||||
const amountFp = toFixedPoint(amount);
|
||||
const ethFp = toFixedPoint(ethBalance) ?? 0n;
|
||||
|
||||
// A negative amount parses to a valid bigint, so every comparison below
|
||||
// is trivially false and the send clears the screen — then dies at encode
|
||||
// time in parseEther(). Unusable, on the same footing as a malformed fee.
|
||||
if (amountFp === null || amountFp < 0n) {
|
||||
// An amount refused here never reaches the comparisons below: a negative
|
||||
// one would pass every one of them, then die at encode time in
|
||||
// parseEther().
|
||||
if (!isValidAmount(amount, isErc20 ? tokenDecimals : null)) {
|
||||
codes.push(CODES.AMOUNT_INVALID);
|
||||
return { canSend: false, codes };
|
||||
}
|
||||
|
||||
const amountFp = toFixedPoint(amount);
|
||||
const ethFp = toFixedPoint(ethBalance) ?? 0n;
|
||||
|
||||
// Fail closed. Anything that is not a usable fee under a recognised
|
||||
// status — a malformed feeWei, or a status this module does not know —
|
||||
// is an unavailable estimate, never a fee of zero. Every such input errs
|
||||
@@ -139,7 +186,14 @@ function validateTransfer({
|
||||
const feeFp = known ? feeWei : null;
|
||||
|
||||
if (isErc20) {
|
||||
const tokenFp = toFixedPoint(tokenBalance) ?? 0n;
|
||||
// Only the first 18 places of the balance are read: an amount with
|
||||
// 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 (feeFp !== null && feeFp > ethFp) {
|
||||
codes.push(CODES.INSUFFICIENT_ETH_FOR_FEE);
|
||||
@@ -166,6 +220,9 @@ module.exports = {
|
||||
SCALE_DECIMALS,
|
||||
feeReserveWei,
|
||||
feeEstimateWei,
|
||||
isValidAmount,
|
||||
maxEthAmount,
|
||||
maxTokenAmount,
|
||||
toFixedPoint,
|
||||
validateTransfer,
|
||||
};
|
||||
|
||||
+2271
-244
File diff suppressed because it is too large
Load Diff
+32
-6
@@ -110,16 +110,33 @@ function addressFromPrivateKey(key) {
|
||||
return w.address;
|
||||
}
|
||||
|
||||
function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
|
||||
// The signer for `addr`, one of the wallet's stored address records. An HD or
|
||||
// xprv address is derived at its own `index`, never at its position in the
|
||||
// wallet's list: the two differ once an import scan passes over an unused
|
||||
// index, or once an address that is not the last is removed. An HD or xprv
|
||||
// record without a valid `index` is refused before anything is derived.
|
||||
// Whatever the wallet type, the key is refused unless it is the key of
|
||||
// `addr.address`.
|
||||
function getSignerForAddress(walletData, addr, decryptedSecret) {
|
||||
if (
|
||||
(walletData.type === "hd" || walletData.type === "xprv") &&
|
||||
!(Number.isInteger(addr.index) && addr.index >= 0)
|
||||
) {
|
||||
throw new Error(
|
||||
"No derivation index is stored for " +
|
||||
addr.address +
|
||||
", so its key cannot be derived.",
|
||||
);
|
||||
}
|
||||
let signer;
|
||||
if (walletData.type === "hd") {
|
||||
const node = HDNodeWallet.fromPhrase(
|
||||
decryptedSecret,
|
||||
"",
|
||||
BIP44_ETH_PATH,
|
||||
);
|
||||
return node.deriveChild(addrIndex);
|
||||
}
|
||||
if (walletData.type === "xprv") {
|
||||
signer = node.deriveChild(addr.index);
|
||||
} else if (walletData.type === "xprv") {
|
||||
// Checked here rather than through masterXprvOrThrow so the message
|
||||
// fits the situation: nobody is importing anything at signing time,
|
||||
// and this wallet is already in storage. src/shared/walletDefects.js
|
||||
@@ -137,9 +154,18 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
|
||||
"is not a master key, so it cannot sign.",
|
||||
);
|
||||
}
|
||||
return node.derivePath(BIP44_ETH_PATH).deriveChild(addrIndex);
|
||||
signer = node.derivePath(BIP44_ETH_PATH).deriveChild(addr.index);
|
||||
} else {
|
||||
signer = new Wallet(decryptedSecret);
|
||||
}
|
||||
return new Wallet(decryptedSecret);
|
||||
if (signer.address.toLowerCase() !== addr.address.toLowerCase()) {
|
||||
throw new Error(
|
||||
"The key this wallet holds for " +
|
||||
addr.address +
|
||||
" belongs to a different address, so it was not used.",
|
||||
);
|
||||
}
|
||||
return signer;
|
||||
}
|
||||
|
||||
function isValidMnemonic(mnemonic) {
|
||||
|
||||
+22
-16
@@ -13,11 +13,17 @@ const NON_MASTER_XPRV = "non-master-xprv";
|
||||
|
||||
// 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
|
||||
// m/44'/60'/0'/0 from a depth-0 key, and the pre-#210 path derived the same
|
||||
// four levels as a relative path beneath whatever depth it was given. A master
|
||||
// import therefore stores a depth-4 xpub and a depth-d import stores depth
|
||||
// d + 4, which makes the stored xpub an exact read on the imported key's
|
||||
// depth — and it is readable without the password, unlike the key itself.
|
||||
// m/44'/60'/0'/0 from a depth-0 key, and the path before #210 (57959b7)
|
||||
// derived the same four levels as a relative path beneath whatever depth it
|
||||
// was given. A master import therefore stores a depth-4 xpub and a depth-d
|
||||
// import stores depth d + 4, which makes the stored xpub an exact read on the
|
||||
// imported key's depth — and it is readable without the password, unlike the
|
||||
// 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 DEFECTS = {
|
||||
@@ -30,23 +36,23 @@ const DEFECTS = {
|
||||
"derivation path beneath that key instead of from a master " +
|
||||
"key, so the addresses listed here are not the ones that key " +
|
||||
"produces under the standard path.",
|
||||
"Signing and sending are disabled for this wallet. The addresses " +
|
||||
"do descend from the extended private key you imported, so " +
|
||||
"anything they hold is still reachable by software that " +
|
||||
"repeats the same non-standard derivation. Check them in a " +
|
||||
"block explorer before deciding what to do.",
|
||||
"Signing, sending and private key export are disabled for this " +
|
||||
"wallet. Anything these addresses hold can be reached outside " +
|
||||
"this wallet with the extended private key you imported, if " +
|
||||
"you kept a copy of it, or with the master extended private " +
|
||||
"key or recovery phrase it came from, in software that " +
|
||||
"repeats the same non-standard derivation. Check the " +
|
||||
"addresses in a block explorer before deciding what to do.",
|
||||
"To see the addresses this key produces under the standard path, " +
|
||||
"import the master extended private key, or the recovery " +
|
||||
"phrase it came from, as a new wallet. Nothing here has been " +
|
||||
"changed or removed, and this wallet stays until you delete " +
|
||||
"it yourself.",
|
||||
],
|
||||
// One sentence for the places that have room for one: the flash on a
|
||||
// blocked Send, the inline error on the approval screens.
|
||||
shortMessage:
|
||||
"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.",
|
||||
// One line, for the flash on a blocked Send and the inline error on
|
||||
// the approval screens. It must fit on the flash line; see showFlash()
|
||||
// in src/popup/views/helpers.js.
|
||||
shortMessage: "This wallet cannot sign. See the wallet list.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -12,12 +12,15 @@ function sameAddress(a, b) {
|
||||
return String(a).toLowerCase() === String(b).toLowerCase();
|
||||
}
|
||||
|
||||
// Forget every site permission held against the given addresses.
|
||||
// Forget every site permission held against the given addresses: the
|
||||
// remembered ones in `state`, and the connections approved without
|
||||
// "Remember", which only the background holds, in session storage.
|
||||
function dropSitePermissions(state, addresses) {
|
||||
for (const addr of addresses) {
|
||||
delete state.allowedSites[addr];
|
||||
delete state.deniedSites[addr];
|
||||
}
|
||||
notify({ type: "AUTISTMASK_ADDRESSES_REMOVED", addresses });
|
||||
}
|
||||
|
||||
// Remove wallet `walletIdx` from `state` and repair the derived state.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// A page whose origin has no host is never told an address by accountsChanged
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/564).
|
||||
//
|
||||
// When the user switches address, the background tells each open tab the new
|
||||
// active address if the site at the tab's URL is connected. A page at a
|
||||
// connected site's URL can still have another origin: one its server
|
||||
// sandboxes with a Content-Security-Policy has the origin "null", and every
|
||||
// request it makes is refused. Each test here switches the address in the real
|
||||
// background, takes the event it sent to a tab at the site's URL, and hands
|
||||
// that event to the real content script in a page of the given origin.
|
||||
|
||||
const { networkById } = require("../src/shared/networks");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
const SITE = "https://dapp.example";
|
||||
|
||||
const EXT_URL = "chrome-extension://autistmask/";
|
||||
const FROM_POPUP = { url: EXT_URL + "src/popup/index.html" };
|
||||
|
||||
// One address, with SITE remembered as allowed for it.
|
||||
function storedProfile() {
|
||||
const mainnet = networkById("mainnet");
|
||||
return {
|
||||
hasWallet: true,
|
||||
wallets: [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
type: "hd",
|
||||
addresses: [
|
||||
{ address: ADDRESS, balance: "0", tokenBalances: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
activeAddress: ADDRESS,
|
||||
networkId: "mainnet",
|
||||
rpcUrl: mainnet.defaultRpcUrl,
|
||||
blockscoutUrl: mainnet.defaultBlockscoutUrl,
|
||||
allowedSites: { [ADDRESS]: [SITE] },
|
||||
deniedSites: {},
|
||||
trackedTokens: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete global.chrome;
|
||||
delete global.window;
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
// The popup tells the background the active address changed, and the one
|
||||
// open tab, at SITE's URL, is sent an event. That event is returned.
|
||||
async function eventSentToSiteTab() {
|
||||
jest.resetModules();
|
||||
jest.doMock("../src/shared/balances", () => ({
|
||||
getProvider: () => ({}),
|
||||
refreshBalances: jest.fn(async () => {}),
|
||||
}));
|
||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||
isPhishingDomain: () => false,
|
||||
}));
|
||||
jest.doMock("../src/shared/alarms", () => ({
|
||||
BALANCE_REFRESH_ALARM: "balance",
|
||||
BALANCE_REFRESH_PERIOD_MINUTES: 1,
|
||||
ensureRecurringAlarms: jest.fn(async () => {}),
|
||||
registerAlarmHandlers: jest.fn(),
|
||||
}));
|
||||
|
||||
let messageListener = null;
|
||||
const sentToTabs = [];
|
||||
global.chrome = {
|
||||
storage: makeStorageStub({ autistmask: storedProfile() }),
|
||||
runtime: {
|
||||
getURL: (path) => EXT_URL + path,
|
||||
onMessage: {
|
||||
addListener: (fn) => {
|
||||
messageListener = fn;
|
||||
},
|
||||
},
|
||||
onConnect: { addListener: () => {} },
|
||||
getPlatformInfo: (cb) => cb({ os: "linux" }),
|
||||
lastError: null,
|
||||
},
|
||||
windows: {
|
||||
remove: (id, cb) => {
|
||||
if (cb) cb();
|
||||
},
|
||||
onRemoved: { addListener: () => {} },
|
||||
},
|
||||
tabs: {
|
||||
query: (q, cb) => cb([{ id: 1, url: SITE + "/app" }]),
|
||||
sendMessage: (tabId, msg, cb) => {
|
||||
sentToTabs.push(msg);
|
||||
if (cb) cb();
|
||||
},
|
||||
},
|
||||
action: { setPopup: () => {} },
|
||||
};
|
||||
|
||||
require("../src/background/index");
|
||||
messageListener(
|
||||
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
|
||||
FROM_POPUP,
|
||||
() => {},
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(sentToTabs).toHaveLength(1);
|
||||
return sentToTabs[0];
|
||||
}
|
||||
|
||||
// The content script in a page whose origin is `pageOrigin` is handed `event`
|
||||
// from the background. What it posts to the page is returned.
|
||||
function postedToPage(event, pageOrigin) {
|
||||
jest.resetModules();
|
||||
let eventListener = null;
|
||||
const posted = [];
|
||||
global.chrome = {
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener: (fn) => {
|
||||
eventListener = fn;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
global.window = {
|
||||
origin: pageOrigin,
|
||||
addEventListener: () => {},
|
||||
postMessage: (data) => posted.push(data),
|
||||
};
|
||||
|
||||
require("../src/content/index");
|
||||
eventListener(event);
|
||||
|
||||
return posted;
|
||||
}
|
||||
|
||||
describe("accountsChanged after an address switch, at a connected site's URL", () => {
|
||||
test("the site's own page is told the active address", async () => {
|
||||
const event = await eventSentToSiteTab();
|
||||
|
||||
expect(postedToPage(event, SITE)).toEqual([
|
||||
{
|
||||
type: "AUTISTMASK_EVENT",
|
||||
eventName: "accountsChanged",
|
||||
data: [ADDRESS],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("a page whose origin has no host is told no address", async () => {
|
||||
const event = await eventSentToSiteTab();
|
||||
|
||||
expect(postedToPage(event, "null")).toEqual([
|
||||
{
|
||||
type: "AUTISTMASK_EVENT",
|
||||
eventName: "accountsChanged",
|
||||
data: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
// A contract lookup on AddToken can answer after the user has left AddToken.
|
||||
// The token is still added, and the screen the user is on does not change
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/603). An answer while AddToken
|
||||
// still shows goes to AddressDetail. On either add-token screen, an answer for a
|
||||
// token tracked while the lookup waited tracks nothing
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/727). A second click on a "Top
|
||||
// tokens:" button tracks nothing
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/729). Driven through the real
|
||||
// popup over a stored profile.
|
||||
|
||||
const {
|
||||
bootPopup,
|
||||
cleanupPopup,
|
||||
unversionedValidProfile,
|
||||
TOKEN_ADDRESS,
|
||||
} = require("./support/popupBoot");
|
||||
|
||||
const TOKEN = {
|
||||
address: TOKEN_ADDRESS,
|
||||
symbol: "TST",
|
||||
decimals: 18,
|
||||
name: "Test Token",
|
||||
balanceNotFetched: true,
|
||||
};
|
||||
|
||||
// A second contract address, typed after AddToken was opened again.
|
||||
const OTHER_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
|
||||
afterEach(() => {
|
||||
cleanupPopup();
|
||||
});
|
||||
|
||||
// The popup reopened onto AddressDetail, then AddToken opened from it with
|
||||
// TOKEN's contract address typed in.
|
||||
async function openAddToken() {
|
||||
const boot = await bootPopup(
|
||||
unversionedValidProfile({
|
||||
currentView: "address",
|
||||
selectedWallet: 0,
|
||||
selectedAddress: 0,
|
||||
}),
|
||||
);
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
await boot.click("btn-add-token");
|
||||
expect(boot.visibleViews()).toEqual(["add-token"]);
|
||||
boot.node("add-token-address").value = TOKEN.address;
|
||||
return boot;
|
||||
}
|
||||
|
||||
// Presses `button` with a contract lookup that answers only when the function
|
||||
// returned here is called. That function waits for the add to finish.
|
||||
function addWithPendingLookup(boot, button = "btn-add-token-confirm") {
|
||||
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||
let answer;
|
||||
lookupTokenInfo.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
);
|
||||
const adding = boot.click(button);
|
||||
return async () => {
|
||||
answer({
|
||||
symbol: TOKEN.symbol,
|
||||
decimals: TOKEN.decimals,
|
||||
name: TOKEN.name,
|
||||
});
|
||||
await adding;
|
||||
};
|
||||
}
|
||||
|
||||
test("an answer after the user opened Settings adds the token and Settings stays", async () => {
|
||||
const boot = await openAddToken();
|
||||
const answerLookup = addWithPendingLookup(boot);
|
||||
await boot.click("btn-settings");
|
||||
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||
|
||||
await answerLookup();
|
||||
expect(boot.visibleViews()).toEqual(["settings"]);
|
||||
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||
});
|
||||
|
||||
test("Back from Settings onto AddToken after the lookup answered shows no lookup status", async () => {
|
||||
const boot = await openAddToken();
|
||||
const answerLookup = addWithPendingLookup(boot);
|
||||
expect(boot.text("add-token-info")).toBe("Looking up token...");
|
||||
await boot.click("btn-settings");
|
||||
await answerLookup();
|
||||
|
||||
await boot.click("btn-settings-back");
|
||||
expect(boot.visibleViews()).toEqual(["add-token"]);
|
||||
expect(boot.text("add-token-info")).toBe("");
|
||||
expect(boot.node("add-token-info").style.visibility).toBe("hidden");
|
||||
});
|
||||
|
||||
test("an answer after AddToken was opened again adds the token and leaves AddToken and its status line as they are", async () => {
|
||||
const boot = await openAddToken();
|
||||
const answerFirstLookup = addWithPendingLookup(boot);
|
||||
await boot.click("btn-add-token-back");
|
||||
await boot.click("btn-add-token");
|
||||
expect(boot.visibleViews()).toEqual(["add-token"]);
|
||||
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
|
||||
// The second lookup is never answered.
|
||||
addWithPendingLookup(boot);
|
||||
|
||||
await answerFirstLookup();
|
||||
expect(boot.visibleViews()).toEqual(["add-token"]);
|
||||
expect(boot.text("add-token-info")).toBe("Looking up token...");
|
||||
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||
});
|
||||
|
||||
test("a lookup that fails after AddToken was opened again leaves the status line as it is", async () => {
|
||||
const boot = await openAddToken();
|
||||
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||
let failFirstLookup;
|
||||
lookupTokenInfo.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_, reject) => {
|
||||
failFirstLookup = reject;
|
||||
}),
|
||||
);
|
||||
const firstAdd = boot.click("btn-add-token-confirm");
|
||||
await boot.click("btn-add-token-back");
|
||||
await boot.click("btn-add-token");
|
||||
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
|
||||
// The second lookup is never answered.
|
||||
addWithPendingLookup(boot);
|
||||
|
||||
failFirstLookup(new Error("lookup failed"));
|
||||
await firstAdd;
|
||||
expect(boot.text("add-token-info")).toBe("Looking up token...");
|
||||
});
|
||||
|
||||
test("an answer while AddToken still shows goes to AddressDetail with the token listed", async () => {
|
||||
const boot = await openAddToken();
|
||||
const answerLookup = addWithPendingLookup(boot);
|
||||
|
||||
await answerLookup();
|
||||
expect(boot.visibleViews()).toEqual(["address"]);
|
||||
expect(boot.node("address-balances").innerHTML).toContain("TST");
|
||||
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||
});
|
||||
|
||||
test("an answer for a token tracked after AddToken was opened again leaves the status line as it is", async () => {
|
||||
const boot = await openAddToken();
|
||||
const answerFirstLookup = addWithPendingLookup(boot);
|
||||
await boot.click("btn-add-token-back");
|
||||
await boot.click("btn-add-token");
|
||||
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
|
||||
// The second lookup is never answered.
|
||||
addWithPendingLookup(boot);
|
||||
require("../src/popup/views/helpers").trackToken(TOKEN);
|
||||
|
||||
await answerFirstLookup();
|
||||
expect(boot.text("flash-msg")).toBe("TST is already being tracked.");
|
||||
expect(boot.text("add-token-info")).toBe("Looking up token...");
|
||||
});
|
||||
|
||||
// Each screen is opened from AddressDetail by the buttons named, in order.
|
||||
describe.each([
|
||||
[
|
||||
"AddToken",
|
||||
["btn-add-token"],
|
||||
"add-token-address",
|
||||
"btn-add-token-confirm",
|
||||
"add-token-info",
|
||||
"TST is already being tracked.",
|
||||
],
|
||||
[
|
||||
"SettingsAddToken",
|
||||
["btn-settings", "btn-settings-add-token"],
|
||||
"settings-addtoken-address",
|
||||
"btn-settings-addtoken-manual",
|
||||
"settings-addtoken-info",
|
||||
"Already tracked.",
|
||||
],
|
||||
])("%s", (_, opening, field, button, statusLine, alreadyTrackedFlash) => {
|
||||
// The screen open with TOKEN's contract address typed in.
|
||||
async function openScreen() {
|
||||
const boot = await bootPopup(
|
||||
unversionedValidProfile({
|
||||
currentView: "address",
|
||||
selectedWallet: 0,
|
||||
selectedAddress: 0,
|
||||
}),
|
||||
);
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
for (const id of opening) await boot.click(id);
|
||||
boot.node(field).value = TOKEN.address;
|
||||
return boot;
|
||||
}
|
||||
|
||||
test("a second click on Add while the lookup waits tracks the token once", async () => {
|
||||
const boot = await openScreen();
|
||||
const answerFirstClick = addWithPendingLookup(boot, button);
|
||||
const answerSecondClick = addWithPendingLookup(boot, button);
|
||||
|
||||
await answerFirstClick();
|
||||
await answerSecondClick();
|
||||
expect(boot.text("flash-msg")).toBe(alreadyTrackedFlash);
|
||||
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||
const { state } = require("../src/shared/state");
|
||||
expect(state.trackedTokens).toEqual([TOKEN]);
|
||||
});
|
||||
|
||||
test("an answer for a token tracked while the lookup waited clears the status line", async () => {
|
||||
const boot = await openScreen();
|
||||
const answerLookup = addWithPendingLookup(boot, button);
|
||||
expect(boot.text(statusLine)).toBe("Looking up token...");
|
||||
require("../src/popup/views/helpers").trackToken(TOKEN);
|
||||
|
||||
await answerLookup();
|
||||
expect(boot.text("flash-msg")).toBe(alreadyTrackedFlash);
|
||||
expect(boot.text(statusLine)).toBe("");
|
||||
expect(boot.node(statusLine).style.visibility).toBe("hidden");
|
||||
const { state } = require("../src/shared/state");
|
||||
expect(state.trackedTokens).toEqual([TOKEN]);
|
||||
});
|
||||
});
|
||||
|
||||
test('a double click on a "Top tokens:" button tracks the token once', async () => {
|
||||
const boot = await bootPopup(
|
||||
unversionedValidProfile({
|
||||
currentView: "address",
|
||||
selectedWallet: 0,
|
||||
selectedAddress: 0,
|
||||
}),
|
||||
);
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
// The page stub does not turn the markup the screen draws into elements.
|
||||
// So the list hands the screen a stand-in for TOKEN's button.
|
||||
let press;
|
||||
boot.node("settings-addtoken-top10").querySelectorAll = () => [
|
||||
{
|
||||
dataset: {
|
||||
address: TOKEN.address,
|
||||
symbol: TOKEN.symbol,
|
||||
decimals: String(TOKEN.decimals),
|
||||
name: TOKEN.name,
|
||||
},
|
||||
addEventListener: (name, fn) => {
|
||||
press = fn;
|
||||
},
|
||||
},
|
||||
];
|
||||
await boot.click("btn-settings");
|
||||
await boot.click("btn-settings-add-token");
|
||||
|
||||
const firstClick = press();
|
||||
const secondClick = press();
|
||||
const { state } = require("../src/shared/state");
|
||||
expect(state.trackedTokens).toEqual([TOKEN]);
|
||||
// Read before the first click's save ends, when "Added TST" replaces it.
|
||||
expect(boot.text("flash-msg")).toBe("Already tracked.");
|
||||
|
||||
await Promise.all([firstClick, secondClick]);
|
||||
await boot.settle();
|
||||
expect(state.trackedTokens).toEqual([TOKEN]);
|
||||
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
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