Compare commits

..
Author SHA1 Message Date
sneak fd53737403 docs: second rework of the release procedure after review
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Apply the five wording findings of the second review verbatim: the owner's
rule as one lead-in with three acts, the yarn source without a pronoun, the
workflow definition with its file first, the two e2e jobs named with their
scripts, and the manifest version tied to step 2. The same three faults
were fixed elsewhere: pronouns with two candidates, phrases that arrive
after the words they belong to, and terms used before they were defined.

Model: fable-5-1
2026-11-02 03:34:38 +00:00
sneak c45cf628a9 docs: rework the release procedure after review
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Fix the eight findings of the first review: step 1 now runs script/cibuild
on the clean checkout instead of make check; the owner's rule on tagging and
publishing is stated in the opening and in steps 5 and 6; the Chrome unzip
goes into a new directory; yarn comes from corepack or npm; the comma lists
are bullets; the prettier check is named once with its two places; the
re-read sentences are rewritten; the tracker and its Actions page are named
with their links. A fact pass ran every command of steps 2 to 4 and the
unzip on a fresh clone, and a definition pass glossed each technical word.

Model: fable-5-1
2026-11-02 03:16:17 +00:00
sneak 69ce8fd6d6 docs: rewrite the release procedure sentence by sentence against the prose rules
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Rewrite docs/RELEASE.md from scratch: a terms list, what is needed, and six
numbered steps, each with its check. Every statement is checked against the
Makefile, script/, build.js, the two workflows and the tests. The step that
described distribution routes not built is cut; installing from the archives
replaces it. The reproducibility claim now names the build date that build.js
compiles in, so it holds only within one UTC day.

Model: fable-5-1
2026-11-02 02:49:45 +00:00
9 changed files with 238 additions and 513 deletions
+15 -21
View File
@@ -1473,20 +1473,21 @@ tracked.
A tracked token is shown when its balance is nonzero, and any other ERC-20 when A tracked token is shown when its balance is nonzero, and any other ERC-20 when
its balance is nonzero and it is in the bundled known-token list or has 1,000 or its balance is nonzero and it is in the bundled known-token list or has 1,000 or
more holders. The balance list always drops a token that claims a bundled-list more holders. A token claiming a symbol from the bundled list from any other
symbol from another contract address, whatever the settings. The list also drops contract address is always dropped, tracked or not, and so is any token claiming
every token that claims the native asset's symbol: `"ETH"`, or any network's a symbol that belongs to the native asset and therefore has no legitimate
`nativeCurrency`, such as `"SepoliaETH"`. That filter is unconditional — the contract at all (`"ETH"`, and every network's `nativeCurrency`, such as
"Hide tokens with fewer than 1,000 holders" setting governs the transaction `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
history and the send-screen token selector, not this list. A token's holder tokens with fewer than 1,000 holders" setting governs the transaction history
count is unknown when the explorer reports none, or reports anything other than and the send-screen token selector, not this list. A token's holder count is
a whole number written in digits alone, such as `1,000` or `1e3` unknown when the explorer reports none, or reports anything other than a whole
(`parseHoldersCount()` in `src/shared/holders.js`). This list does not take an number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()`
unknown count as 1,000 or more, so such a token is shown only when it is on the in `src/shared/holders.js`). This list does not take an unknown count as 1,000
bundled list or tracked. Every nonzero holding of a token shown is stored or more, so such a token is shown only when it is on the bundled list or
exactly, however small, but a holding below 0.000001 is left out of the balance tracked. Every nonzero holding of a token shown is stored exactly, however
lists, the send-screen token selector, the address total and the remove-address small, but a holding below 0.000001 is left out of the balance lists, the
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and send-screen token selector, the address total and the remove-address warning
(`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked confirmation screens show it when its token is the one being sent. Tracked
tokens with a zero balance are listed as well while "Show tracked tokens with tokens with a zero balance are listed as well while "Show tracked tokens with
zero balance" is on. zero balance" is on.
@@ -2238,8 +2239,6 @@ view would leave a wallet one click from deletion.
**AddressDetail** **AddressDetail**
- "Add" with a token already tracked, a scam-listed address, or a failed - "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change contract lookup → flash message, no screen change
- "Add" with a token that the spoof rule refuses → nothing is tracked; "Not
added: this token fakes a known symbol." flash message, no screen change
- "Add", then the contract lookup answers while another screen shows, or - "Add", then the contract lookup answers while another screen shows, or
after AddToken was opened again → tracks the token, no screen change after AddToken was opened again → tracks the token, no screen change
- "Add", then a network switch in Settings before the contract lookup - "Add", then a network switch in Settings before the contract lookup
@@ -2291,8 +2290,6 @@ view would leave a wallet one click from deletion.
- Either "Save" stores its URL only if the input still shows that URL when - Either "Save" stores its URL only if the input still shows that URL when
the endpoint answers. Otherwise a flash message says that the URL was not the endpoint answers. Otherwise a flash message says that the URL was not
saved saved
- If the user taps the same "Save" again before the endpoint answers, the
earlier tap stores nothing and shows no flash message
- Etherscan: - Etherscan:
- "Check recipients on Etherscan" checkbox, unticked until the user - "Check recipients on Etherscan" checkbox, unticked until the user
ticks it ticks it
@@ -2550,9 +2547,6 @@ view would leave a wallet one click from deletion.
"Added SYMBOL" flash message (no screen change) "Added SYMBOL" flash message (no screen change)
- A duplicate, a scam-listed address, or a failed contract lookup → flash - A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change message, no screen change
- "Add" by contract address with a token that the spoof rule refuses →
nothing is tracked; "Not added: this token fakes a known symbol." flash
message, no screen change
- "Add" by contract address, then a network switch in Settings before the - "Add" by contract address, then a network switch in Settings before the
lookup answers → nothing is tracked; "The active network changed before lookup answers → nothing is tracked; "The active network changed before
the lookup finished, so the token was not added." flash message, no screen the lookup finished, so the token was not added." flash message, no screen
+4 -2
View File
@@ -380,8 +380,10 @@ but comes from an unrecognized contract, it is identified as a spoof and hidden.
In your transaction history this is the "Hide fake tokens impersonating a known In your transaction history this is the "Hide fake tokens impersonating a known
symbol" setting, which you can switch off; doing so also stops new entries being symbol" setting, which you can switch off; doing so also stops new entries being
added to the fraud contract blocklist below, since detecting a spoof is what added to the fraud contract blocklist below, since detecting a spoof is what
fills it. The send token list always applies the check. Your balance list always fills it. The send token list always applies the check. Your balances apply it
applies the check too. too, with one exception: a token claiming the symbol "ETH" is not filtered
there, so a fake "ETH" token can still show up in your balance list even though
it is hidden from your transaction history and from the send token list.
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden **Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden
from transaction history and the send token list, and are left out of your from transaction history and the send token list, and are left out of your
+216 -75
View File
@@ -1,87 +1,228 @@
# Releasing AutistMask # Releasing AutistMask
This is the procedure that turns a green `main` into a tagged, packaged release. A release is a tagged commit on the `main` branch and the two archives that
It gathers into one place what is otherwise spread across the `Makefile` and `make package` writes from that commit. This file is the procedure, in order.
three `README.md` sections, so the person cutting a release does not have to
reconstruct the order from them.
There is one version, declared in three files (`package.json`, Only the owner ([@sneak](https://sneak.berlin)):
`manifest/chrome.json`, `manifest/firefox.json`), and `make package` builds and
packages but publishes nothing. `make build` and `make package` can be run by - decides whether and when a release is tagged;
anyone; tagging, signing, packing a CRX and any upload need credentials only the - creates, moves or deletes a tag;
owner ([@sneak](https://sneak.berlin)) holds and are marked **owner-only** - decides where the archives are published.
below. Releases are tagged from `main` (see the Workflow section of `TODO.md`),
so the "release commit" throughout is the `main` commit the milestone PR merged. Anyone can run steps 1 to 4. No script in this repository uploads or publishes
the archives. None signs them, and none packs a CRX (Chrome's packaged extension
file).
## Terms
- The tracker is the repository's Gitea site (Gitea is the software that hosts
the repository): https://git.eeqj.de/sneak/AutistMask. The tracker's Actions
page, https://git.eeqj.de/sneak/AutistMask/actions, lists the workflow runs.
- A workflow is a file under `.gitea/workflows/` that lists jobs. The tracker
runs every workflow on each push. Each job runs one script on the tracker's
runner (the machine that runs jobs).
- The release commit is a commit on `main`. Releases are tagged from `main` only
(`TODO.md`, Workflow).
- A clean checkout is a working tree (the checked-out files) at the release
commit where `git status --porcelain` prints nothing. The build stamps the
commit hash (the commit's id) into the extension and appends `-dirty` when the
tree has local changes.
- A tag is a name that git attaches to one commit. Release tags read `vX.Y.Z`,
such as `v0.1.0`.
- A digest is the sha256 hash of a file (a fixed-length fingerprint of its
bytes), written as 64 hexadecimal characters.
## What you need
- A clean checkout of the release commit.
- `make`.
- Docker (a tool that runs programs in containers), for `script/cibuild` in
step 1. `make package` needs no Docker.
- Node and yarn. Both come from the system when present. Otherwise
`script/bootstrap` installs Node through a pinned (fixed-version) install of
nvm, a Node version manager. `script/bootstrap` installs yarn through corepack
(Node's package manager installer). When no corepack is found,
`script/bootstrap` installs yarn through `npm` (Node's package manager).
## Procedure ## Procedure
1. **Confirm `main` is green in CI.** The `check` workflow ### 1. Confirm the release commit is green
(`.gitea/workflows/check.yml`) runs `script/cibuild`, which runs
`script/check` and then builds the image uncached, so a green `check` run is
a green `make check`. Find the run for the exact release commit on the
tracker's Actions view. _Check:_ that commit's `check` run succeeded; running
`make check` on a clean checkout of the commit reproduces it and exits 0.
2. **Confirm the version matches the intended tag.** `package.json`, The `check` workflow (`.gitea/workflows/check.yml`) runs `script/cibuild` on
`manifest/chrome.json` and `manifest/firefox.json` must all declare the same every push. `script/cibuild` runs:
`X.Y.Z`. `make build` fails when they disagree, but nothing checks that they
equal the tag you mean to create — that is this manual step. _Check:_ all
three files read the same `X.Y.Z`, and it is the version you intend to tag
`vX.Y.Z`.
3. **Build and package from a clean checkout of that commit.** From a fresh - `script/bootstrap`, which installs the dependencies;
clone, or a working tree with no local modifications (`git status` clean), - `script/check`, which is what `make check` runs;
checked out at the release commit: run `make setup`, then `make package`. - the Docker image build (an image is Docker's packaged form of a program).
`make package` runs `make build` first, so the archives can only be made from
a `dist/` verified against that build's own receipt as a release (not debug)
build. It writes three files into `release/`:
`autistmask-chrome-<version>.zip`, `autistmask-firefox-<version>.xpi`, and
`SHA256SUMS`. _Check:_ those three files exist and `<version>` in the archive
names is the version confirmed in step 2. The Firefox `.xpi` is **unsigned**
(see step 6 and "Installing on Firefox" in `README.md`).
4. **Verify `SHA256SUMS`.** The archives are deterministic — sorted entries, `script/check` runs:
fixed timestamps, fixed compression — so a second `make package` from another
clean checkout of the same commit produces byte-identical files. Verify the
recorded digests against the files with `sha256sum -c SHA256SUMS`, run from
`release/`. To confirm reproducibility, run `make package` again on a
separate clean checkout and compare the digests. _Check:_ `sha256sum -c`
reports `OK` for every file, and an independent build's digests match.
5. **Tag the release commit.** _(owner-only)_ Create an annotated tag `vX.Y.Z` - `script/test`: the tests, in Docker;
on the release commit and push it: `git tag -a vX.Y.Z` (with a message), then - `script/lint`, which runs three checks in Docker:
`git push origin vX.Y.Z`. _Check:_ `git tag` lists `vX.Y.Z`, and - ESLint, a code checker;
`git rev-parse vX.Y.Z^{commit}` resolves to the release commit. - `prettier --check .`, the formatting check;
- `script/check-censored`, which fails when the name that `RULES.md` bars
appears outside its allowed places;
- `script/fmt-check`: `prettier --check .` again, on the host.
6. **Distribute per browser.** _(owner-only; pending the owner's choice on The `e2e` workflow (`.gitea/workflows/e2e.yml`) has two jobs. One runs
https://git.eeqj.de/sneak/AutistMask/issues/386)_ How 1.0.0 is distributed on `script/test-e2e`, the browser tests in Chrome. The other runs
each browser is not yet decided; it is the open question on that issue, and `script/test-e2e-firefox`, the browser tests in Firefox. A red `e2e` job does
the concrete steps cannot be written until the owner records a choice there. not fail the `check` run.
These steps need credentials only the owner holds. The options under
consideration are:
- **Firefox** — the packaged `.xpi` is unsigned, and release Firefox and ESR
refuse an unsigned add-on:
- (a) AMO self-distribution signing (unlisted): submit the `.xpi` to AMO
with the owner's credentials; AMO returns a signed `.xpi` installable
on every Firefox, with nothing listed publicly.
- (b) AMO listed: as (a), plus a public AMO listing and review.
- (c) Ship the unsigned `.xpi` and state that Firefox support means
Developer Edition, Nightly, or an Unbranded build with
`xpinstall.signatures.required` set to `false`.
- **Chrome** — the repo packs no CRX and publishes nothing; the extension id
is fixed by the `key` in `manifest/chrome.json`:
- (a) Chrome Web Store (unlisted): upload the `.zip` with the owner's
developer account; the store delivers installs and updates.
- (b) Self-hosted CRX signed with the private key the owner holds
(`chrome --pack-extension=dist/chrome --pack-extension-key=<path to the .pem>`),
installable only via enterprise policy on Windows and macOS, so
realistically Linux-only.
- (c) "Load unpacked" from `dist/chrome/` only, as today.
Once the owner decides, the chosen steps — including which credentials they Run `script/cibuild` on the clean checkout. `script/cibuild` runs the same steps
need and who holds them — are written into this section and `README.md`'s as the `check` run, and must exit 0.
installation sections are updated to match, which is part of the definition
of done of https://git.eeqj.de/sneak/AutistMask/issues/386. _Check:_ for a Check: on the Actions page, the `check` run for the release commit succeeded,
store or AMO route, the artifact installs from the store or AMO on a clean and both `e2e` jobs for that commit succeeded. `script/cibuild` exited 0 on the
browser profile; for the CRX or unpacked route, the documented load succeeds clean checkout.
and Chrome reports the extension id `gipbhkogfopeahplcjhipkgpcimdpkip`.
### 2. Confirm the version
Three files declare the version:
- `package.json`;
- `manifest/chrome.json`;
- `manifest/firefox.json`.
`script/lib/version.js` fails the build when the three disagree.
`tests/version.test.js` fails when the version is not three numbers separated by
dots. Nothing compares the version with a tag. That comparison is this step.
Check: all three files read the same `X.Y.Z`, and `vX.Y.Z` is the tag that the
owner creates in step 5.
### 3. Build and package
On the clean checkout, run:
```bash
make setup
make package
```
`make setup` installs two things:
- the dependencies, from the lockfile `yarn.lock`, which pins each one's
version;
- the git pre-commit hook, a script that git runs before each commit. Here the
hook runs `script/check`.
`make package` runs `make build`, then `script/lib/package.js`.
`make build`:
- runs `build.js`. `build.js` writes `dist/chrome/`, `dist/firefox/` and a build
receipt. The receipt lists every file that `build.js` wrote, with each file's
digest. The receipt lives outside the repository and is deleted when the build
ends;
- runs `script/verify-build --expect release` against the receipt. The check
fails when:
- `dist/` differs from the receipt;
- a bundle (one JavaScript file built from many source files) was compiled
with the build-time constant `DEBUG` on;
- any file carries the test recovery phrase, which a debug build gives every
new wallet;
- runs `script/check-censored --require-dist` against the built files;
- removes `dist/` when any of those steps fails.
`script/lib/package.js`:
- empties `release/`;
- refuses a symbolic link (a file that points at another file) under `dist/`;
- checks that the archive is self-contained: every file that the manifest or the
popup's HTML page names is inside the browser's directory (`dist/chrome/` or
`dist/firefox/`). The manifest is `manifest.json`, 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.
-10
View File
@@ -3,7 +3,6 @@ const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist"); const { isScamAddress } = require("../../shared/scamlist");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
// Counts the times AddToken has opened. A contract lookup started before // Counts the times AddToken has opened. A contract lookup started before
@@ -78,15 +77,6 @@ function init(ctx) {
); );
return; 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)) {
showFlash("Not added: this token fakes a known symbol.");
if (addTokenScreenOpenings !== opening) return;
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
return;
}
log.infof("Adding token", info.symbol, contractAddr); log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({ state.trackedTokens.push({
address: contractAddr, address: contractAddr,
-4
View File
@@ -33,7 +33,6 @@ const { networkByChainId } = require("../../shared/networks");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay"); const { displaySymbol } = require("../../shared/symbolDisplay");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { walletHasRecoveryPhrase } = require("../../shared/wallet"); const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { markViewRendered } = require("../viewRouter"); const { markViewRendered } = require("../viewRouter");
@@ -448,9 +447,6 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
if (showZero && trackedTokens) { if (showZero && trackedTokens) {
for (const t of trackedTokens) { for (const t of trackedTokens) {
if (seen.has(t.address.toLowerCase())) continue; if (seen.has(t.address.toLowerCase())) continue;
// A token that the spoof rule refuses has no stored balance, and
// gets no zero row either.
if (isSpoofedSymbol(t.symbol, t.address)) continue;
html += balanceLine( html += balanceLine(
t.symbol, t.symbol,
0, 0,
+3 -30
View File
@@ -50,11 +50,6 @@ let versionClickTimer = null;
// not the one in `state`. // not the one in `state`.
let shownNetwork = null; let shownNetwork = null;
// Each endpoint "Save" counts its taps here, under the field that it stores.
// Once the user taps the same "Save" again, the earlier tap shows no flash
// message. The earlier tap stores its URL only if its write had already begun.
const saveTaps = { rpcUrl: 0, blockscoutUrl: 0 };
// One row per site origin, however many addresses it appears under, each with // One row per site origin, however many addresses it appears under, each with
// an [x] that hands it to onRemove. // an [x] that hands it to onRemove.
function renderSiteList(containerId, origins, onRemove) { function renderSiteList(containerId, origins, onRemove) {
@@ -352,16 +347,10 @@ function showNewSettings() {
// shows `url`, nothing is saved. The flash message then says that the URL was // 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 // 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`. // `state` in one step. So a redraw after the comparison shows `url`.
// async function saveEndpoint(field, input, url, networkId) {
// `tap` is the number of the "Save" tap that checked `url`. If the user taps
// the same "Save" again before this save holds the lock, nothing is stored. If
// the user taps the same "Save" again before the write ends, no flash message
// is shown.
async function saveEndpoint(field, input, url, networkId, tap) {
let refused = false; let refused = false;
let inputChanged = false; let inputChanged = false;
await saveState((stored) => { await saveState((stored) => {
if (saveTaps[field] !== tap) return false;
refused = refused =
state.networkId !== networkId || stored.networkId !== networkId; state.networkId !== networkId || stored.networkId !== networkId;
inputChanged = $(input).value.trim() !== url; inputChanged = $(input).value.trim() !== url;
@@ -369,7 +358,6 @@ async function saveEndpoint(field, input, url, networkId, tap) {
state[field] = url; state[field] = url;
stored[field] = url; stored[field] = url;
}); });
if (saveTaps[field] !== tap) return;
if (refused) { if (refused) {
showFlash("The URL was not saved because the network changed."); showFlash("The URL was not saved because the network changed.");
} else if (inputChanged) { } else if (inputChanged) {
@@ -387,8 +375,6 @@ function init(_ctx) {
showPhrase.init(); showPhrase.init();
$("btn-save-rpc").addEventListener("click", async () => { $("btn-save-rpc").addEventListener("click", async () => {
saveTaps.rpcUrl++;
const tap = saveTaps.rpcUrl;
const url = $("settings-rpc").value.trim(); const url = $("settings-rpc").value.trim();
if (!url) { if (!url) {
showFlash("Please enter an RPC URL."); showFlash("Please enter an RPC URL.");
@@ -409,8 +395,6 @@ function init(_ctx) {
}), }),
}); });
const json = await resp.json(); const json = await resp.json();
// The user has tapped this "Save" again since the check began.
if (tap !== saveTaps.rpcUrl) return;
if (json.error) { if (json.error) {
log.errorf("RPC validation error:", json.error); log.errorf("RPC validation error:", json.error);
showFlash("Endpoint returned an error."); showFlash("Endpoint returned an error.");
@@ -424,7 +408,6 @@ function init(_ctx) {
// Cancelled by the popup closing, not failed: see pageClosed in // Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js. // src/popup/index.js.
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
if (tap !== saveTaps.rpcUrl) return;
// Not the error's message: fetch puts the whole URL, password and // Not the error's message: fetch puts the whole URL, password and
// key included, in the message of the error it throws for a URL // key included, in the message of the error it throws for a URL
// with a user name and password or one it cannot parse. // with a user name and password or one it cannot parse.
@@ -432,12 +415,10 @@ function init(_ctx) {
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id, tap); await saveEndpoint("rpcUrl", "settings-rpc", url, net.id);
}); });
$("btn-save-blockscout").addEventListener("click", async () => { $("btn-save-blockscout").addEventListener("click", async () => {
saveTaps.blockscoutUrl++;
const tap = saveTaps.blockscoutUrl;
const url = $("settings-blockscout").value.trim(); const url = $("settings-blockscout").value.trim();
if (!url) { if (!url) {
showFlash("Please enter a Blockscout API URL."); showFlash("Please enter a Blockscout API URL.");
@@ -447,26 +428,18 @@ function init(_ctx) {
showFlash("Testing endpoint..."); showFlash("Testing endpoint...");
try { try {
const resp = await debugFetch(url + "/stats"); const resp = await debugFetch(url + "/stats");
if (tap !== saveTaps.blockscoutUrl) return;
if (!resp.ok) { if (!resp.ok) {
showFlash("Endpoint returned HTTP " + resp.status + "."); showFlash("Endpoint returned HTTP " + resp.status + ".");
return; return;
} }
} catch { } catch {
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
if (tap !== saveTaps.blockscoutUrl) return;
// Not the error's message, as for the RPC check above. // Not the error's message, as for the RPC check above.
log.errorf("Blockscout validation failed:", urlOrigin(url)); log.errorf("Blockscout validation failed:", urlOrigin(url));
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
await saveEndpoint( await saveEndpoint("blockscoutUrl", "settings-blockscout", url, net.id);
"blockscoutUrl",
"settings-blockscout",
url,
net.id,
tap,
);
}); });
// Each change is made inside the save, holding the lock. So // Each change is made inside the save, holding the lock. So
-11
View File
@@ -3,7 +3,6 @@ const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist"); const { isScamAddress } = require("../../shared/scamlist");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
let ctx; let ctx;
@@ -156,16 +155,6 @@ function init(_ctx) {
); );
return; 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)) {
showFlash("Not added: this token fakes a known symbol.");
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
return;
}
log.infof("Adding token", info.symbol, addr); log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({ state.trackedTokens.push({
address: addr, address: addr,
-269
View File
@@ -167,272 +167,3 @@ test.each([
); );
}, },
); );
// This answer passes both endpoint checks on Ethereum Mainnet.
const PASSING_ANSWER = {
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
};
// Both endpoint checks read this answer as an error. The RPC check reads the
// answer's `error`. The Blockscout check reads the answer's `ok`.
const ERROR_ANSWER = {
ok: false,
status: 500,
json: async () => ({ error: { code: -32603, message: "Internal error" } }),
};
// The user types a URL in Settings and taps "Save". Before the endpoint answers,
// the user types a second URL and taps "Save" again. The second check passes
// first. The second URL is stored, and the flash message reads "Saved.". The
// first check then ends in one of three ways:
//
// - It passes.
// - It fails, because fetch cannot reach the endpoint.
// - It gets an answer that holds an error.
//
// None of the three changes the stored URL or the flash message
// (https://git.eeqj.de/sneak/AutistMask/issues/698).
test.each([
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "passes",
},
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "fails",
},
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "answers with an error",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "passes",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "fails",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "answers with an error",
},
])(
"$button keeps the second tap's result when the first check $first last",
async ({ button, input, field, first }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve, reject) => {
checks.push({ resolve, reject });
}),
);
const secondUrl = "https://second.example.invalid";
page.node(input).value = "https://first.example.invalid";
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
page.node(input).value = secondUrl;
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
checks[1].resolve(PASSING_ANSWER);
await Promise.all(secondTap);
await page.settle();
expect(page.text("flash-msg")).toBe("Saved.");
if (first === "passes") {
checks[0].resolve(PASSING_ANSWER);
} else if (first === "answers with an error") {
checks[0].resolve(ERROR_ANSWER);
} else {
// fetch rejects when it cannot reach the endpoint.
checks[0].reject(new TypeError("Failed to fetch"));
}
await Promise.all(firstTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(secondUrl);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
// The user types a URL in Settings and taps "Save". Before the endpoint answers,
// the user empties the input and taps "Save" again. The flash message asks for
// a URL. The first check then passes. The flash message still asks for a URL,
// and the stored URL is unchanged.
test.each([
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
message: "Please enter an RPC URL.",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
message: "Please enter a Blockscout API URL.",
},
])(
"$button keeps the empty-input message when the first check passes last",
async ({ button, input, field, message }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
page.node(input).value = "https://typed.example.invalid";
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
page.node(input).value = "";
await page.click(button);
expect(page.text("flash-msg")).toBe(message);
answer(PASSING_ANSWER);
await Promise.all(firstTap);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(message);
},
);
// The user types a URL in Settings and taps "Save". Another write holds the
// lock. So the save waits for the lock once the check passes. The user then
// taps "Save" again on the same URL. When the lock is free, the first save
// stores nothing and shows no flash message. The second check then passes, and
// the second save stores the URL.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing for an earlier tap still waiting for the lock",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve) => {
checks.push(resolve);
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
checks[0](PASSING_ANSWER);
await page.settle();
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
releaseLock();
await lockHeld;
await Promise.all(firstTap);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.text("flash-msg")).toBe("Testing endpoint...");
checks[1](PASSING_ANSWER);
await Promise.all(secondTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
// The user taps "Save", and the check passes. The save then begins its storage
// write, and this test keeps the write waiting. The user taps "Save" again, and
// the second check cannot reach the endpoint. When the write ends, the flash
// message still reads "Could not reach endpoint.". The write stores the URL
// anyway, because the write began before the second tap.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button keeps the second tap's message when tapped again during the write",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve, reject) => {
checks.push({ resolve, reject });
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
const { set } = page.storage.local;
const write = set.getMockImplementation();
let writeBegan = false;
let releaseWrite;
const writeHeld = new Promise((resolve) => {
releaseWrite = resolve;
});
set.mockImplementationOnce(async (items) => {
writeBegan = true;
await writeHeld;
await write(items);
});
checks[0].resolve(PASSING_ANSWER);
await page.settle();
expect(writeBegan).toBe(true);
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
// fetch rejects when it cannot reach the endpoint.
checks[1].reject(new TypeError("Failed to fetch"));
await Promise.all(secondTap);
await page.settle();
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
releaseWrite();
await Promise.all(firstTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
},
);
-91
View File
@@ -1,91 +0,0 @@
// A token that the spoof rule refuses never shows in the balance list, even
// tracked at zero balance with "Show tracked tokens with zero balance" on, and
// the two add-token screens refuse to track one
// (https://git.eeqj.de/sneak/AutistMask/issues/705). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
// Claims the symbol of the bundled USDT from another contract.
const FAKE_USDT = {
address: TOKEN_ADDRESS,
symbol: "USDT",
decimals: 6,
name: "Tether USD",
};
// A symbol the bundled list does not have, so the spoof rule passes it.
const TEST_TOKEN = {
address: "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const REFUSED_FLASH = "Not added: this token fakes a known symbol.";
afterEach(() => {
cleanupPopup();
});
test("a fake token tracked at zero balance is not listed while zero balances are shown", async () => {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [FAKE_USDT, TEST_TOKEN],
showZeroBalanceTokens: true,
}),
);
const rows = boot.node("address-balances").innerHTML;
expect(rows).toContain(`data-token="${TEST_TOKEN.address.toLowerCase()}"`);
expect(rows).not.toContain(
`data-token="${FAKE_USDT.address.toLowerCase()}"`,
);
});
// Each screen is opened from AddressDetail by the buttons named, in order.
describe.each([
[
"AddToken",
["btn-add-token"],
"add-token",
"add-token-address",
"btn-add-token-confirm",
],
[
"SettingsAddToken",
["btn-settings", "btn-settings-add-token"],
"settings-addtoken",
"settings-addtoken-address",
"btn-settings-addtoken-manual",
],
])("%s", (_, opening, view, field, button) => {
test("refuses a fake token by contract address", async () => {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
for (const id of opening) await boot.click(id);
expect(boot.visibleViews()).toEqual([view]);
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: FAKE_USDT.symbol,
decimals: FAKE_USDT.decimals,
name: FAKE_USDT.name,
});
boot.node(field).value = FAKE_USDT.address;
await boot.click(button);
expect(boot.text("flash-msg")).toBe(REFUSED_FLASH);
expect(boot.visibleViews()).toEqual([view]);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([]);
});
});