30 Commits
Author SHA1 Message Date
clawbot 64ec571a17 fix: long symbols wrap on TxApproval and TransactionDetail, and SuccessTx keeps its amount and recipient (closes #743)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
TxApproval and TransactionDetail cut off a long symbol, amount or description at
the edge of the screen. These lines now wrap.

SuccessTx hid its Amount and To lines whenever it had a decoded action, even one
that showed neither. Each line is now hidden only when a readable decoded line
shows the same thing. A swap with no Amount line now shows its amount on
SuccessTx, as WaitTx and ErrorTx do.

Model: opus-5-5
2026-11-03 12:27:20 +01:00
clawbot 1d40e2d7d5 fix: a saved SuccessTx in another shape reopens as itself, and long amounts wrap (closes #740)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The popup refused a saved SuccessTx whose decoded record was in a shape it
did not expect, and showed Home. SuccessTx already shows only the lines it
can read, so the popup no longer checks the record first.

A long amount or token symbol was cut off at the edge of the screen. These
now wrap:

- the Amount figure
- each decoded amount
- the token symbol
- the name and description of SuccessTx's decoded action

WaitTx and ErrorTx share the line that shows the symbol, so they gain this
too.

Model: opus-5-5
2026-11-03 11:38:35 +01:00
clawbot 9c691dfa6b fix: the ETH line reads unknown at an address no refresh has read (closes #736)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
An address's ETH balance is now null until a refresh has read it. That covers
a new address, a new wallet, addresses a wallet scan finds, and every address
after a network switch.

Its line reads `quantity unknown`, Home's balance `unknown (ETH)`, and Send
`Current balance: unknown (ETH)`. Max says the balance is unknown. Send is no
longer refused as zero. The address has no total, and DeleteAddress warns that
it may hold a balance.

A refresh also deletes `balanceNotFetched` only when it read every address in
the stored record.

Model: opus-5-5
2026-11-03 11:24:18 +01:00
clawbot 448afdb5ce fix: a saved wait in another shape reaches SuccessTx when its receipt arrives (closes #734)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A saved wait whose decoded record is in another shape threw when its
receipt arrived. WaitTx then stayed on screen with the counter stopped.
decodedDetailsHtml() now shows only the lines whose label and value are
text. SuccessTx shows the amount and recipient when no line can be read.

WaitTx and ErrorTx now show each allowance's Allowance token line. Long
amounts use break-words, so words stay whole. ErrorTx sets up copying
when it has no hash, so the token's address can be copied.

Model: opus-5-5
2026-11-03 10:14:05 +01:00
clawbot 49713e1cdb fix: Home's list drops the previous network's rows after a network switch (closes #718)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A network switch left the previous network's rows or failure message in Home's
list until the new network's answer landed. Home now replaces them with
"Loading..." when it first draws on a new network. A redraw on the same network
keeps its rows.

Home, AddressDetail and AddressToken compared the page's network before reading
storage. A switch made in Settings could change the page during that read, and
the answer was still drawn. They now compare after the read, for an answer and
for a failure.

Model: opus-5-5
2026-11-03 09:54:35 +01:00
clawbot 1ea0730a81 fix: the wait and error screens show a swap's Permit2 allowance amounts (closes #733)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A swap can give another spender an allowance that its Amount figure leaves
out, and these two screens did not show it. Both now show each Permit2
allowance amount under the figure, with the Moved from the sender amounts.
movedFromSenderHtml() is now movedOrAllowedHtml(), as it shows both.

An amount too long for one line now wraps on these screens, rather than losing
its last digits at the screen's edge.

Model: opus-5-5
2026-11-03 09:14:23 +01:00
clawbot 4531e8bce5 fix: a tracked token reads quantity unknown at an address no refresh has read (closes #720)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A new address, a new wallet and a network switch each leave tracked tokens
with no balance entry, so their lines read 0.0000 before any refresh had read
them.

Each of these now sets `balanceNotFetched` on the tracked tokens, and a refresh
already clears it. Their lines read `quantity unknown` until then. Addresses
that the wallet scan finds get the same. A refresh that did not read every
address the page now holds leaves the field set.

Model: opus-5-5
2026-11-03 08:39:40 +01:00
clawbot f79390cad5 fix: the wait and error screens show a swap's Moved from the sender amounts (closes #722)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A swap's Amount figure can leave out what its Moved from the sender lines
take, so these screens could read as if less left the wallet than can have
left. Both now show each of those amounts under the figure, using the decoded
details they already receive. The error screen saves those details too, so a
reopened popup keeps the lines. A saved record in another shape shows only the
lines it can read.

Model: opus-5-5
2026-11-03 08:12:41 +01:00
clawbot 21d5e42528 fix: a double click on a Top tokens button tracks the token once (closes #729)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A "Top tokens:" button had no already-tracked check and stays enabled until its
save ends, so a fast double click tracked the token twice in memory. Its handler
now checks first. A second click shows "Already tracked." and tracks nothing.
The top 100 dropdown already has this check, so it is unchanged.

Model: opus-5-5
2026-11-03 07:37:04 +01:00
clawbot ba61bedaaf fix: a chain switch gives trackedTokens its own copy of the network's list (closes #719)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
After a switch, `trackedTokens` and that network's list in
`networkTrackedTokens` were one array. So a page's next add or removal also
changed `networkTrackedTokens`, and the save wrote that page's older copy over
the stored one. Another page's add on the previous network was lost, and its
removal was undone.

`applyChainSwitchFields()` now copies the incoming network's list. An add or
removal on the active network changes only `trackedTokens`.

Model: opus-5-5
2026-11-03 07:10:33 +01:00
clawbot 42a5d94b49 fix: the swap decoder shows the ETH of a WRAP_ETH that does not set the input side (closes #721)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A later WRAP_ETH of a stated amount now counts as a take from the sender.
When it pays anyone but the router, it gets its own Moved from the sender
lines, with the recipient on Moved to. When it pays the router, it joins
the Amount line if Token In is ETH, and otherwise gets those lines too.
When the router may already hold ETH, the amount reads Up to.

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

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

Model: opus-5-5
2026-11-03 06:51:14 +01:00
clawbot 267b69382e fix: a second click on Add while the lookup waits tracks the token once (closes #727)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The "already tracked" check ran only before the contract lookup, so a second click while the first lookup waited tracked the token twice. AddToken and SettingsAddToken now check again when the lookup answers. An answer for a token tracked meanwhile shows the "already tracked" flash message and tracks nothing.

Model: opus-5-5
2026-11-03 06:24:14 +01:00
clawbot 1a4fb0803d fix: a save merges this page's tracked token change into the stored list (closes #717)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A save wrote `trackedTokens` whole, so two pages on one network dropped each
other's adds and brought back each other's removals.

A save that keeps the network now merges this page's change into the stored
list by contract address. A save that switches networks is unchanged.

`mergeTrackedTokens()` does that merge, and the two other merges of tracked
tokens now call it. It keeps one record per contract address, this page's for
a token both lists hold, so a token held twice no longer multiplies.

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

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

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

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

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

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

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

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

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

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

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

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

These steps drop earlier steps from the sum:

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

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

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

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

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

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

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

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

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

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

Model: opus-5-5
2026-11-02 06:04:07 +01:00
clawbot 3191577f73 test: make test fails when jest force-stops a worker (closes #711)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
When a test left a timer running, jest force-stopped the worker that ran the test and printed "A worker process has failed to exit gracefully", but the run still passed. Both test scripts in package.json now run jest through script/lib/jest.js. The helper runs jest inside its own process, so the terminal output is unchanged, and it exits 1 when jest writes that warning to stderr. Jest 30 has no option that fails the run on it. The eslint.config.js comment on script/lib/ now says those files run on plain node and are never bundled.

Model: opus-5-5
2026-11-02 01:44:22 +01:00
clawbot acb2577fec test: the test suite exits on its own, without --forceExit (closes #585)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Some tests left timers running when they ended, and --forceExit hid them; both test scripts drop it. Six background test files left the background's 20-second timer running while an approval was pending; each now stubs it for the whole file. Five popup test files left a flash message's two-second timer running; they now use fake timers, and a wait on real zero-delay timeouts became jest.advanceTimersByTimeAsync(0). Two ethers timers can still run for up to 250 ms after a test ends; they stop before jest checks for open handles.

Model: opus-5-5
2026-10-28 01:09:12 +01:00
clawbot a33a40a6fa build: say beside the install why yarn prints the workspaces warning (closes #595)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Yarn 1 prints "Workspaces can only be enabled in private projects." for each dependency whose package.json has a workspaces field without "private": true. Two do: eslint 10.8.1, and babel-plugin-istanbul 7.0.1, which jest pulls in. No update removes the field, and removing the warning would mean downgrading both below what this repo and jest require. A one-line comment beside the install in script/bootstrap and in the Dockerfile now names both packages and says why the warning stays; every Dockerfile install runs script/bootstrap, so one comment there is enough.

Model: opus-5-5
2026-10-28 00:28:27 +01:00
clawbot 5192419cac docs: README matches ConfirmTx's wrong-password message and fee-bound refusal (closes #610)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
README's ConfirmTx section quoted "Wrong password." for a wrong password; the screen says "That password is incorrect. Please try again.", and the README now quotes that. The other password screens describe their error without quoting it, and DeleteWallet already quoted it right. The fee-bound refusal no longer says the RPC endpoint always fills in the gas limit and fees at signing: for an ETH amount that Max filled in, they come from the screen's own fee estimate. The transition names `assertWithinCeilings()` and lists what it checks.

Model: opus-5-5
2026-10-28 00:05:20 +01:00
clawbot 41842b197e fix: a token lookup that answers after the user left AddToken no longer changes the screen (closes #603)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
When the contract lookup on AddToken answered, the popup went to AddressDetail even after the user had opened another screen. It now goes there only while AddToken shows and has not been opened again since Add was pressed; AddToken counts its openings as Send does. The token is still added as before unless the network changed. An answer, found or failed, from an earlier opening leaves the screen and the status line alone, so a later lookup's "Looking up token..." stays.

Judgement call: a user who went Back to AddressDetail during the lookup sees the new token once that screen is drawn again.

Model: opus-5-5
2026-10-27 23:16:10 +01:00
clawbot 8998d6c661 fix: a refresh asked for while another runs is no longer dropped (closes #581)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
doRefreshAndRender() returned at once when a refresh was already running. An import asks for a refresh after its address scan, and that request was dropped when it came during a refresh, so the addresses the scan found showed 0.0000 until the next ten-second refresh. A request made through ctx while a refresh runs now leads to one more refresh once the running one ends, however many such requests came in. A tick of the ten-second timer during a refresh is still dropped, so the wallet asks the node no more often than before.

Model: opus-5-5
2026-10-27 20:14:09 +01:00
79 changed files with 9140 additions and 4389 deletions
+1
View File
@@ -10,6 +10,7 @@ WORKDIR /app
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
COPY . .
+1761 -3487
View File
File diff suppressed because it is too large Load Diff
+644 -385
View File
File diff suppressed because it is too large Load Diff
+213 -75
View File
@@ -1,87 +1,225 @@
# Releasing AutistMask
This is the procedure that turns a green `main` into a tagged, packaged release.
It gathers into one place what is otherwise spread across the `Makefile` and
three `README.md` sections, so the person cutting a release does not have to
reconstruct the order from them.
A release is a tagged commit on the `main` branch and the two archives that
`make package` writes from that commit. This file is the procedure, in order.
There is one version, declared in three files (`package.json`,
`manifest/chrome.json`, `manifest/firefox.json`), and `make package` builds and
packages but publishes nothing. `make build` and `make package` can be run by
anyone; tagging, signing, packing a CRX and any upload need credentials only the
owner ([@sneak](https://sneak.berlin)) holds and are marked **owner-only**
below. Releases are tagged from `main` (see the Workflow section of `TODO.md`),
so the "release commit" throughout is the `main` commit the milestone PR merged.
Only the owner ([@sneak](https://sneak.berlin)):
- decides whether and when a release is tagged;
- creates, moves or deletes a tag;
- decides where the archives are published.
Anyone can run steps 1 to 4. No script in this repository uploads or publishes
the archives. None signs them, and none packs a CRX (Chrome's packaged extension
file).
## Terms
- The tracker is the repository's Gitea site (Gitea is the software that hosts
the repository): https://git.eeqj.de/sneak/AutistMask. The tracker's Actions
page, https://git.eeqj.de/sneak/AutistMask/actions, lists the workflow runs.
- A workflow is a file under `.gitea/workflows/` that lists jobs. The tracker
runs every workflow on each push. Each job runs one script on the tracker's
runner (the machine that runs jobs).
- The release commit is a commit on `main`. Releases are tagged from `main` only
(`TODO.md`, Workflow).
- A clean checkout is a working tree (the checked-out files) at the release
commit where `git status --porcelain` prints nothing. The build stamps the
commit hash (the commit's id) into the extension and appends `-dirty` when the
tree has local changes.
- A tag is a name that git attaches to one commit. Release tags read `vX.Y.Z`,
such as `v0.1.0`.
- A digest is the sha256 hash of a file (a fixed-length fingerprint of its
bytes), written as 64 hexadecimal characters.
## What you need
- A clean checkout of the release commit.
- `make`.
- Docker (a tool that runs programs in containers), for `script/cibuild` in
step 1. `make package` needs no Docker.
- Node and yarn, on the `PATH`. `make package` runs both directly.
`script/bootstrap` can install both through nvm (a Node version manager), but
it leaves them off the `PATH`.
## Procedure
1. **Confirm `main` is green in CI.** The `check` workflow
(`.gitea/workflows/check.yml`) runs `script/cibuild`, which runs
`script/check` and then builds the image uncached, so a green `check` run is
a green `make check`. Find the run for the exact release commit on the
tracker's Actions view. _Check:_ that commit's `check` run succeeded; running
`make check` on a clean checkout of the commit reproduces it and exits 0.
### 1. Confirm the release commit is green
2. **Confirm the version matches the intended tag.** `package.json`,
`manifest/chrome.json` and `manifest/firefox.json` must all declare the same
`X.Y.Z`. `make build` fails when they disagree, but nothing checks that they
equal the tag you mean to create — that is this manual step. _Check:_ all
three files read the same `X.Y.Z`, and it is the version you intend to tag
`vX.Y.Z`.
The `check` workflow (`.gitea/workflows/check.yml`) runs `script/cibuild` on
every push. `script/cibuild` runs:
3. **Build and package from a clean checkout of that commit.** From a fresh
clone, or a working tree with no local modifications (`git status` clean),
checked out at the release commit: run `make setup`, then `make package`.
`make package` runs `make build` first, so the archives can only be made from
a `dist/` verified against that build's own receipt as a release (not debug)
build. It writes three files into `release/`:
`autistmask-chrome-<version>.zip`, `autistmask-firefox-<version>.xpi`, and
`SHA256SUMS`. _Check:_ those three files exist and `<version>` in the archive
names is the version confirmed in step 2. The Firefox `.xpi` is **unsigned**
(see step 6 and "Installing on Firefox" in `README.md`).
- `script/bootstrap`, which installs the dependencies;
- `script/check`, which is what `make check` runs;
- the Docker image build (an image is Docker's packaged form of a program).
4. **Verify `SHA256SUMS`.** The archives are deterministic — sorted entries,
fixed timestamps, fixed compression — so a second `make package` from another
clean checkout of the same commit produces byte-identical files. Verify the
recorded digests against the files with `sha256sum -c SHA256SUMS`, run from
`release/`. To confirm reproducibility, run `make package` again on a
separate clean checkout and compare the digests. _Check:_ `sha256sum -c`
reports `OK` for every file, and an independent build's digests match.
`script/check` runs:
5. **Tag the release commit.** _(owner-only)_ Create an annotated tag `vX.Y.Z`
on the release commit and push it: `git tag -a vX.Y.Z` (with a message), then
`git push origin vX.Y.Z`. _Check:_ `git tag` lists `vX.Y.Z`, and
`git rev-parse vX.Y.Z^{commit}` resolves to the release commit.
- `script/test`: the tests, in Docker;
- `script/lint`, which runs three checks in Docker:
- ESLint, a code checker;
- `prettier --check .`, the formatting check;
- `script/check-censored`, which fails when the name that `RULES.md` bars
appears outside its allowed places;
- `script/fmt-check`: `prettier --check .` again, on the host.
6. **Distribute per browser.** _(owner-only; pending the owner's choice on
https://git.eeqj.de/sneak/AutistMask/issues/386)_ How 1.0.0 is distributed on
each browser is not yet decided; it is the open question on that issue, and
the concrete steps cannot be written until the owner records a choice there.
These steps need credentials only the owner holds. The options under
consideration are:
- **Firefox** — the packaged `.xpi` is unsigned, and release Firefox and ESR
refuse an unsigned add-on:
- (a) AMO self-distribution signing (unlisted): submit the `.xpi` to AMO
with the owner's credentials; AMO returns a signed `.xpi` installable
on every Firefox, with nothing listed publicly.
- (b) AMO listed: as (a), plus a public AMO listing and review.
- (c) Ship the unsigned `.xpi` and state that Firefox support means
Developer Edition, Nightly, or an Unbranded build with
`xpinstall.signatures.required` set to `false`.
- **Chrome** — the repo packs no CRX and publishes nothing; the extension id
is fixed by the `key` in `manifest/chrome.json`:
- (a) Chrome Web Store (unlisted): upload the `.zip` with the owner's
developer account; the store delivers installs and updates.
- (b) Self-hosted CRX signed with the private key the owner holds
(`chrome --pack-extension=dist/chrome --pack-extension-key=<path to the .pem>`),
installable only via enterprise policy on Windows and macOS, so
realistically Linux-only.
- (c) "Load unpacked" from `dist/chrome/` only, as today.
The `e2e` workflow (`.gitea/workflows/e2e.yml`) has two jobs. One runs
`script/test-e2e`, the browser tests in Chrome. The other runs
`script/test-e2e-firefox`, the browser tests in Firefox. A red `e2e` job does
not fail the `check` run.
Once the owner decides, the chosen steps — including which credentials they
need and who holds them — are written into this section and `README.md`'s
installation sections are updated to match, which is part of the definition
of done of https://git.eeqj.de/sneak/AutistMask/issues/386. _Check:_ for a
store or AMO route, the artifact installs from the store or AMO on a clean
browser profile; for the CRX or unpacked route, the documented load succeeds
and Chrome reports the extension id `gipbhkogfopeahplcjhipkgpcimdpkip`.
Run `script/cibuild` on the clean checkout. `script/cibuild` runs the same steps
as the `check` run, and must exit 0.
Check: on the Actions page, the `check` run for the release commit succeeded,
and both `e2e` jobs for that commit succeeded. `script/cibuild` exited 0 on the
clean checkout.
### 2. Confirm the version
Three files declare the version:
- `package.json`;
- `manifest/chrome.json`;
- `manifest/firefox.json`.
`script/lib/version.js` fails the build when the three disagree.
`tests/version.test.js` fails when the version is not three numbers separated by
dots. Nothing compares the version with a tag. That comparison is this step.
Check: all three files read the same `X.Y.Z`, and `vX.Y.Z` is the tag that the
owner creates in step 5.
### 3. Build and package
On the clean checkout, run:
```bash
make setup
make package
```
`make setup` installs two things:
- the dependencies, from the lockfile `yarn.lock`, which pins each one's
version;
- the git pre-commit hook, a script that git runs before each commit. Here the
hook runs `script/check`.
`make package` runs `make build`, then `script/lib/package.js`.
`make build`:
- runs `build.js`. `build.js` writes `dist/chrome/`, `dist/firefox/` and a build
receipt. The receipt lists every file that `build.js` wrote, with each file's
digest. The receipt lives outside the repository and is deleted when the build
ends;
- runs `script/verify-build --expect release` against the receipt. The check
fails when:
- `dist/` differs from the receipt;
- a bundle (one JavaScript file built from many source files) was compiled
with the build-time constant `DEBUG` on;
- any file carries the test recovery phrase, which a debug build gives every
new wallet;
- runs `script/check-censored --require-dist` against the built files;
- removes `dist/` when any of those steps fails.
`script/lib/package.js`:
- empties `release/`;
- refuses a symbolic link (a file that points at another file) under `dist/`;
- checks that the browser's directory (`dist/chrome/` or `dist/firefox/`) holds
every file named in `manifest.json` or in the popup's HTML page.
`manifest.json` is the file that describes the extension to the browser. The
popup is the window that the toolbar button opens;
- checks that the `version` in each `manifest.json` is the version from step 2;
- writes one archive per browser;
- reads each archive back;
- compares every member (a file inside the archive) with `dist/` byte for byte;
- writes `SHA256SUMS`, with one line per archive:
- the digest;
- two spaces;
- the file name.
`make package` writes three files:
```
release/autistmask-chrome-<version>.zip
release/autistmask-firefox-<version>.xpi
release/SHA256SUMS
```
The `.xpi` is Firefox's add-on package file (an add-on is Firefox's word for an
extension). The `.xpi` is not signed.
`make package` prints a line that starts `Not shipped` and names `styles.css`.
This is expected: each archive holds its own copy of the stylesheet.
Check: `release/` holds those three files, and `<version>` in their names is the
version from step 2.
### 4. Verify the digests
From `release/`, run `sha256sum -c SHA256SUMS`.
The archives are deterministic for a given `dist/`: the same `dist/` always
gives the same bytes. `script/lib/zip.js`:
- sorts the members;
- sets every member's timestamp (its recorded date) to 1980-01-01;
- compresses each member the same way every time.
`dist/` itself carries the build date. `build.js` compiles the build date, a UTC
(universal time) day, into the extension. So two builds of the same commit on
the same UTC day give byte-identical archives. Builds on different days differ.
Check: `sha256sum -c` prints `OK` for both archives. On the same UTC day, a
second `make package` from another clean checkout of the same commit gives the
same `SHA256SUMS`.
### 5. Tag the release commit
Owner only. Only the owner decides whether and when a release is tagged. Only
the owner creates, moves or deletes a tag. On the release commit, the owner
runs:
```bash
git tag -a vX.Y.Z -m "AutistMask X.Y.Z"
git push origin vX.Y.Z
```
`-a` makes an annotated tag: a tag that carries a message and records who made
the tag and when. `v0.1.0` is one.
Once the tag exists, `git describe --tags` on the release commit prints
`vX.Y.Z`. `script/cibuild` and `script/docker` pass that value to the Docker
image build as `VERSION`.
Check: `git rev-parse vX.Y.Z^{commit}` prints the release commit's hash, and the
tag is listed at https://git.eeqj.de/sneak/AutistMask/tags.
### 6. Publish the archives
Owner only. The owner decides where the archives are published. No script in
this repository uploads or publishes them.
Whoever receives an archive installs it as follows.
Chrome loads the extension from a directory:
- unzip the archive into a new, empty directory, for example
`unzip autistmask-chrome-0.1.0.zip -d autistmask-chrome`;
- open `chrome://extensions/` and turn on "Developer mode";
- click "Load unpacked" and pick that directory.
Chrome then reports the extension id `gipbhkogfopeahplcjhipkgpcimdpkip`. The id
is the fixed string that identifies the extension to Chrome; the `key` in
`manifest/chrome.json` sets the id.
Release builds of Firefox refuse to install an unsigned add-on. Desktop Firefox
115 or newer, in any edition, loads the `.xpi` as a temporary add-on from the
`about:debugging` page. Firefox unloads a temporary add-on when Firefox exits.
The section "Installing on Firefox" in `README.md` has the details.
Check: each archive installs on a fresh browser profile (a new, empty set of
browser data) by the route above.
+1 -2
View File
@@ -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: {
+2 -2
View File
@@ -6,8 +6,8 @@
"license": "GPL-3.0",
"private": true,
"scripts": {
"test": "jest --forceExit --maxWorkers=3",
"test:verbose": "jest --forceExit --maxWorkers=3 --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 .",
+1
View File
@@ -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\" && \
+40
View File
@@ -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");
+18 -6
View File
@@ -810,7 +810,10 @@
<h2 class="font-bold mb-2">Transaction Broadcast</h2>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="wait-tx-summary" class="font-bold"></div>
<div
id="wait-tx-summary"
class="font-bold break-words"
></div>
<div id="wait-tx-token-note"></div>
</div>
<div class="mb-3">
@@ -830,7 +833,10 @@
<div id="success-tx-decoded" class="mb-3 hidden text-xs"></div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="success-tx-summary" class="font-bold"></div>
<div
id="success-tx-summary"
class="font-bold break-words"
></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
@@ -857,7 +863,10 @@
<h2 class="font-bold mb-2">Transaction Failed</h2>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div>
<div id="error-tx-summary" class="font-bold"></div>
<div
id="error-tx-summary"
class="font-bold break-words"
></div>
<div id="error-tx-token-note"></div>
</div>
<div class="mb-3">
@@ -1637,7 +1646,7 @@
<div class="text-xs text-muted mb-1">Action</div>
<div
id="tx-detail-calldata-action"
class="text-xs font-bold mb-2"
class="text-xs font-bold mb-2 break-words"
></div>
<div
id="tx-detail-calldata-details"
@@ -1712,7 +1721,7 @@
<div class="text-xs text-muted mb-1">Action</div>
<div
id="approve-tx-action"
class="text-xs font-bold mb-2"
class="text-xs font-bold mb-2 break-words"
></div>
<div id="approve-tx-decoded-details" class="text-xs"></div>
</div>
@@ -1727,7 +1736,10 @@
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Value</div>
<div id="approve-tx-value" class="text-xs font-bold"></div>
<div
id="approve-tx-value"
class="text-xs font-bold break-words"
></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Network fee (max)</div>
+24 -1
View File
@@ -6,6 +6,7 @@ const {
saveState,
onSaveFailure,
takeStoredNetwork,
clearBalanceNotFetched,
loadState,
} = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema");
@@ -95,6 +96,19 @@ let refreshTimer = null;
// removed meanwhile is left as it is. Blockscout may have answered before the
// user turned "Use Blockscout" off. So the save drops every token the user has
// not tracked if the stored setting is off by then.
//
// The save also deletes `balanceNotFetched` from each tracked token whose
// balance this refresh read at every address. 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) {
refreshAgain = true;
@@ -105,7 +119,7 @@ async function doRefreshAndRender() {
await followStoredSettings();
const networkId = state.networkId;
const wallets = structuredClone(state.wallets);
await Promise.all([
const [, readTokens] = await Promise.all([
refreshPrices(pageClosed.signal),
refreshBalances(
wallets,
@@ -137,10 +151,19 @@ async function doRefreshAndRender() {
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) {
// No caller waits for this function, so it must never reject. An
// unhandled rejection reports nothing. The save inside it reports its
-16
View File
@@ -116,21 +116,6 @@ 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", `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings(), and `amount` reaches
@@ -209,7 +194,6 @@ 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)) {
+55 -5
View File
@@ -1,11 +1,29 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const {
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { 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";
@@ -37,9 +55,7 @@ function init(ctx) {
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;
@@ -55,6 +71,7 @@ function init(ctx) {
// 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,
@@ -71,8 +88,29 @@ function init(ctx) {
);
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,
@@ -80,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) {
@@ -100,6 +149,7 @@ function init(ctx) {
? detail
: "Could not add the token.",
);
if (addTokenScreenOpenings !== opening) return;
infoEl.textContent = "";
infoEl.style.visibility = "hidden";
}
+11 -5
View File
@@ -7,6 +7,7 @@ const {
goBack,
clearViewStack,
onViewLeave,
markTrackedTokensNotFetched,
} = require("./helpers");
const {
generateMnemonic,
@@ -189,12 +190,13 @@ async function importMnemonic(ctx) {
{
address: firstAddress,
index: 0,
balance: "0.0000",
balance: null,
tokenBalances: [],
},
],
};
state.wallets.push(wallet);
markTrackedTokensNotFetched();
state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = firstAddress;
@@ -216,10 +218,11 @@ async function importMnemonic(ctx) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
index: a.index,
balance: "0.0000",
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.
@@ -264,8 +267,9 @@ 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;
@@ -324,12 +328,13 @@ async function importXprvKey(ctx) {
{
address: firstAddress,
index: 0,
balance: "0.0000",
balance: null,
tokenBalances: [],
},
],
};
state.wallets.push(wallet);
markTrackedTokensNotFetched();
state.hasWallet = true;
if (state.activeAddress === null) {
state.activeAddress = firstAddress;
@@ -351,10 +356,11 @@ async function importXprvKey(ctx) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
index: a.index,
balance: "0.0000",
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 =
+69 -25
View File
@@ -22,6 +22,7 @@ const {
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const {
updateSendBalance,
renderSendTokenSelect,
@@ -66,11 +67,27 @@ function show() {
});
$("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden");
renderBalances();
// Only Blockscout reports the tokens the user has not tracked.
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
}
// The USD total and the balance list. Drawn again by each balance refresh
// that returns while this screen is up (doRefreshAndRender() in
// src/popup/index.js), which leaves the rest of the screen as it is.
function renderBalances() {
const addr =
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
$("address-balances").innerHTML = balanceLinesForAddress(
addr,
state.trackedTokens,
@@ -84,13 +101,6 @@ function show() {
ctx.showAddressToken();
});
});
// Only Blockscout reports the tokens the user has not tracked.
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
}
let loadedTxs = [];
@@ -98,6 +108,7 @@ let loadedTxs = [];
let ensNameMap = new Map();
async function loadTransactions(address) {
const networkId = state.networkId;
try {
const rawTxs = await fetchRecentTransactions(
address,
@@ -114,43 +125,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>';
}
@@ -230,7 +273,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) {
showFlash("Cannot send \u2014 zero balance.");
return;
}
@@ -287,4 +331,4 @@ function init(_ctx) {
exportPrivkey.init();
}
module.exports = { init, show };
module.exports = { init, show, renderBalances };
+106 -45
View File
@@ -22,13 +22,14 @@ const {
timeAgo,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { bundledToken, resolveSymbol } = require("../../shared/tokenList");
const { formatUsd, getPrice } = require("../../shared/prices");
const {
fetchRecentTransactions,
filterTransactions,
} = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const {
updateSendBalance,
renderSendTokenSelect,
@@ -49,37 +50,24 @@ function show() {
const addr = wallet.addresses[state.selectedAddress];
const ai = state.selectedAddress;
const tokenId = state.selectedToken;
// Determine token symbol and balance
let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") {
symbol = nativeCurrency();
amount = parseFloat(addr.balance || "0");
price = getPrice("ETH");
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
symbol = resolveSymbol(
const chainId = currentNetwork().chainId;
const knownToken = bundledToken(tokenId, chainId);
currentSymbol =
tokenId === "ETH"
? nativeCurrency()
: resolveSymbol(
tokenId,
addr.tokenBalances,
state.trackedTokens,
chainId,
);
// null when the scale is unknown: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000.
amount = tb ? unknownableAmount(tb.balance) : 0;
price = getPrice(tokenId);
}
currentSymbol = symbol;
$("address-token-title").textContent =
wallet.name +
" \u2014 Address " +
(ai + 1) +
" \u2014 " +
displaySymbol(symbol);
displaySymbol(currentSymbol);
// Blockie
const blockieEl = $("address-token-jazzicon");
@@ -101,13 +89,7 @@ function show() {
$("address-token-line").dataset.full = addr.address;
attachCopyHandlers($("address-token-line"));
// USD total for this token only
const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;";
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
renderBalance();
// Token contract details (ERC-20 only)
const contractInfo = $("address-token-contract-info");
@@ -169,7 +151,50 @@ 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) || "&nbsp;";
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(
currentSymbol,
amount,
price,
);
}
async function loadTransactions(address, tokenId) {
const networkId = state.networkId;
try {
const rawTxs = await fetchRecentTransactions(
address,
@@ -186,16 +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);
@@ -207,32 +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>';
}
@@ -313,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;
@@ -348,4 +409,4 @@ function init(_ctx) {
});
}
module.exports = { init, show };
module.exports = { init, show, renderBalance };
+4 -4
View File
@@ -361,7 +361,7 @@ function showTxApproval(details) {
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, approvedTx.chainId);
$("approve-tx-to").innerHTML = toHtml;
@@ -394,14 +394,14 @@ 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, approvedTx.chainId) || "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,
@@ -412,7 +412,7 @@ function showTxApproval(details) {
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>`;
}
+9 -2
View File
@@ -87,15 +87,22 @@ function recoveryPathText(wallet) {
// 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 "&nbsp;";
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
);
+45 -12
View File
@@ -33,6 +33,7 @@ const { networkByChainId } = require("../../shared/networks");
const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
const { markViewRendered } = require("../viewRouter");
@@ -282,9 +283,10 @@ function showFlash(msg, duration = 2000) {
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);
@@ -313,14 +315,17 @@ function nativeCurrency() {
// The wallets' balances hold what Blockscout reported for each token. So the
// wallets are returned only while "Use Blockscout" is on.
function tokenSourcesByChainId(chainId) {
const net = chainId ? networkByChainId(chainId) : currentNetwork();
const chainIdOrActive = chainId || currentNetwork().chainId;
const net = networkByChainId(chainIdOrActive);
if (net && net.id === state.networkId) {
return {
chainId: chainIdOrActive,
trackedTokens: state.trackedTokens,
wallets: state.useBlockscout ? state.wallets : [],
};
}
return {
chainId: chainIdOrActive,
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
wallets: [],
};
@@ -351,7 +356,7 @@ function blockscoutOffTokenNoteHtml(token, chainId) {
// The symbol shown for a token line, from these sources in order:
//
// - the bundled list
// - the bundled list, for a token on Ethereum Mainnet
// - the tokens the user tracks
// - the explorer's report, while "Use Blockscout" is on
//
@@ -390,10 +395,10 @@ function formatFee(wei, symbol, chainId) {
// 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 =
@@ -417,10 +422,26 @@ function balanceLine(symbol, amount, price, tokenId) {
);
}
// Tracks `token` on the active network, as every add path on AddToken and
// SettingsAddToken does. The tracked record carries `balanceNotFetched: true`
// until a balance refresh has read the token's balance at every address
// (doRefreshAndRender() in src/popup/index.js). Until then, an address with no
// balance row for the token shows `quantity unknown` for it, not 0.0000.
function trackToken(token) {
state.trackedTokens.push({ ...token, balanceNotFetched: true });
}
// Gives every tracked token `balanceNotFetched: true`, as trackToken() gives a
// new one. Called when an address is added, before the save: no refresh has
// read any token's balance at that address.
function markTrackedTokensNotFetched() {
for (const token of state.trackedTokens) token.balanceNotFetched = true;
}
function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine(
nativeCurrency(),
parseFloat(addr.balance || "0"),
unknownableAmount(addr.balance),
getPrice("ETH"),
"ETH",
);
@@ -447,9 +468,16 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
if (showZero && trackedTokens) {
for (const t of trackedTokens) {
if (seen.has(t.address.toLowerCase())) continue;
// A token that the spoof rule refuses has no stored balance, and
// gets no zero row either.
if (
isSpoofedSymbol(t.symbol, t.address, currentNetwork().chainId)
) {
continue;
}
html += balanceLine(
t.symbol,
0,
t.balanceNotFetched ? null : 0,
getPrice(t.address),
t.address.toLowerCase(),
);
@@ -466,7 +494,10 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
// 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 —
@@ -829,6 +860,8 @@ module.exports = {
flashCopyFeedback,
balanceLine,
balanceLinesForAddress,
trackToken,
markTrackedTokensNotFetched,
addressHoldsFunds,
keyMaterialName,
unknownableAmount,
+64 -6
View File
@@ -3,6 +3,8 @@ const {
showView,
showFlash,
balanceLinesForAddress,
unknownableAmount,
markTrackedTokensNotFetched,
isoDate,
timeAgo,
addressDotHtml,
@@ -44,6 +46,7 @@ const {
fetchRecentTransactions,
filterTransactions,
} = require("../../shared/transactions");
const { storedNetworkIs } = require("../../shared/storedSettings");
const { log } = require("../../shared/log");
function findActiveAddr() {
@@ -74,10 +77,16 @@ function renderTotalValue() {
if (subEl) subEl.innerHTML = "&nbsp;";
return;
}
const ethBal = parseFloat(addr.balance || "0");
const ethBal = unknownableAmount(addr.balance);
if (ethBal === null) {
el.textContent = "unknown (" + nativeCurrency() + ")";
} else {
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
const ethUsd = ethPrice
? " (" + formatUsd(ethBal * ethPrice) + ")"
: "";
el.textContent = ethStr + ethUsd;
}
if (subEl) {
subEl.innerHTML =
@@ -98,6 +107,9 @@ 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;
@@ -183,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,
@@ -202,6 +228,17 @@ async function loadHomeTxs(ctx) {
);
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 = [];
@@ -215,25 +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) {
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 =
@@ -362,10 +418,11 @@ function render(ctx) {
wallet.addresses.push({
address: newAddr,
index: wallet.nextIndex,
balance: "0.0000",
balance: null,
tokenBalances: [],
});
wallet.nextIndex++;
markTrackedTokensNotFetched();
await saveState();
render(ctx);
ctx.doRefreshAndRender();
@@ -448,7 +505,8 @@ 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;
}
+36 -12
View File
@@ -12,7 +12,7 @@ const {
goBack,
tokenSourcesByChainId,
} = require("./helpers");
const { state, currentAddress } = require("../../shared/state");
const { state, currentAddress, currentNetwork } = require("../../shared/state");
let ctx;
const { getProvider } = require("../../shared/balances");
const { resolveTokenDecimals } = require("../../shared/approvalAmount");
@@ -154,7 +154,9 @@ function renderSendTokenSelect(addr) {
// out. Its token's own screen can still send it: there
// state.selectedToken picks the token, not this list.
if (isBelowOneMillionth(t.balance)) continue;
if (isSpoofedSymbol(t.symbol, t.address)) continue;
if (isSpoofedSymbol(t.symbol, t.address, currentNetwork().chainId)) {
continue;
}
if (fraudSet.has(t.address.toLowerCase())) continue;
// An unknown holder count does not withhold a token the user holds:
// only a count the explorer actually reported as below the threshold
@@ -198,13 +200,21 @@ function renderSendTokenSelect(addr) {
// null rather than "0": this screen states it as unknown, as the confirmation
// screen does, and Max fills nothing in for it. Only a stored quantity is
// withdrawn: the "0" for a token that has no row at all is an absence of
// holdings, which is true at every scale.
// holdings, which is true at every scale. A token with no row whose tracked
// record carries `balanceNotFetched` gets null instead, because no refresh has
// read its balance.
function tokenBalanceAndDecimals(addr, token) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const tokenDecimals = resolveTokenDecimals(token, tokenSourcesByChainId());
if (!tb) return { tokenBalance: "0", tokenDecimals };
if (!tb) {
const tracked = state.trackedTokens.find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const notRead = tracked && tracked.balanceNotFetched;
return { tokenBalance: notRead ? null : "0", tokenDecimals };
}
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
return { tokenBalance: tb.balance ?? null, tokenDecimals };
}
@@ -220,9 +230,12 @@ 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: " +
truncateAmountNeverZero(addr.balance || "0") +
addr.balance == null
? "Current balance: unknown (" + nativeCurrency() + ")"
: "Current balance: " +
truncateAmountNeverZero(addr.balance) +
" " +
nativeCurrency();
} else {
@@ -230,11 +243,13 @@ function updateSendBalance() {
token,
addr.tokenBalances,
state.trackedTokens,
currentNetwork().chainId,
);
// A null balance is a holding whose scale is unknown. Saying a figure
// for it would be a claim about the amount, so it reads as the
// confirmation screen's balance line reads it; the send itself is
// refused later by transferAmountUnits() for the same missing scale.
// 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
@@ -277,6 +292,12 @@ async function fillMaxAmount() {
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();
@@ -294,7 +315,7 @@ async function fillMaxAmount() {
provider.estimateGas({
from: addr.address,
to,
value: parseEther(addr.balance || "0"),
value: parseEther(addr.balance),
}),
]);
feeWei = feeReserveWei(gasLimit, feeData);
@@ -309,8 +330,10 @@ async function fillMaxAmount() {
}
// 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.
// 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 ||
@@ -385,6 +408,7 @@ function init(_ctx) {
token,
addr.tokenBalances,
state.trackedTokens,
currentNetwork().chainId,
);
({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
}
+30 -3
View File
@@ -50,6 +50,11 @@ let versionClickTimer = null;
// not the one in `state`.
let shownNetwork = null;
// Each endpoint "Save" counts its taps here, under the field that it stores.
// Once the user taps the same "Save" again, the earlier tap shows no flash
// message. The earlier tap stores its URL only if its write had already begun.
const saveTaps = { rpcUrl: 0, blockscoutUrl: 0 };
// One row per site origin, however many addresses it appears under, each with
// an [x] that hands it to onRemove.
function renderSiteList(containerId, origins, onRemove) {
@@ -347,10 +352,16 @@ function showNewSettings() {
// shows `url`, nothing is saved. The flash message then says that the URL was
// changed during the check. The save compares the input and sets `url` in
// `state` in one step. So a redraw after the comparison shows `url`.
async function saveEndpoint(field, input, url, networkId) {
//
// `tap` is the number of the "Save" tap that checked `url`. If the user taps
// the same "Save" again before this save holds the lock, nothing is stored. If
// the user taps the same "Save" again before the write ends, no flash message
// is shown.
async function saveEndpoint(field, input, url, networkId, tap) {
let refused = false;
let inputChanged = false;
await saveState((stored) => {
if (saveTaps[field] !== tap) return false;
refused =
state.networkId !== networkId || stored.networkId !== networkId;
inputChanged = $(input).value.trim() !== url;
@@ -358,6 +369,7 @@ async function saveEndpoint(field, input, url, networkId) {
state[field] = url;
stored[field] = url;
});
if (saveTaps[field] !== tap) return;
if (refused) {
showFlash("The URL was not saved because the network changed.");
} else if (inputChanged) {
@@ -375,6 +387,8 @@ function init(_ctx) {
showPhrase.init();
$("btn-save-rpc").addEventListener("click", async () => {
saveTaps.rpcUrl++;
const tap = saveTaps.rpcUrl;
const url = $("settings-rpc").value.trim();
if (!url) {
showFlash("Please enter an RPC URL.");
@@ -395,6 +409,8 @@ function init(_ctx) {
}),
});
const json = await resp.json();
// The user has tapped this "Save" again since the check began.
if (tap !== saveTaps.rpcUrl) return;
if (json.error) {
log.errorf("RPC validation error:", json.error);
showFlash("Endpoint returned an error.");
@@ -408,6 +424,7 @@ function init(_ctx) {
// Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js.
if (ctx.pageClosed.aborted) return;
if (tap !== saveTaps.rpcUrl) return;
// Not the error's message: fetch puts the whole URL, password and
// key included, in the message of the error it throws for a URL
// with a user name and password or one it cannot parse.
@@ -415,10 +432,12 @@ function init(_ctx) {
showFlash("Could not reach endpoint.");
return;
}
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id);
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id, tap);
});
$("btn-save-blockscout").addEventListener("click", async () => {
saveTaps.blockscoutUrl++;
const tap = saveTaps.blockscoutUrl;
const url = $("settings-blockscout").value.trim();
if (!url) {
showFlash("Please enter a Blockscout API URL.");
@@ -428,18 +447,26 @@ function init(_ctx) {
showFlash("Testing endpoint...");
try {
const resp = await debugFetch(url + "/stats");
if (tap !== saveTaps.blockscoutUrl) return;
if (!resp.ok) {
showFlash("Endpoint returned HTTP " + resp.status + ".");
return;
}
} catch {
if (ctx.pageClosed.aborted) return;
if (tap !== saveTaps.blockscoutUrl) return;
// Not the error's message, as for the RPC check above.
log.errorf("Blockscout validation failed:", urlOrigin(url));
showFlash("Could not reach endpoint.");
return;
}
await saveEndpoint("blockscoutUrl", "settings-blockscout", url, net.id);
await saveEndpoint(
"blockscoutUrl",
"settings-blockscout",
url,
net.id,
tap,
);
});
// Each change is made inside the save, holding the lock. So
+35 -4
View File
@@ -1,8 +1,16 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers");
const {
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances");
const { isScamAddress } = require("../../shared/scamlist");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
const { log } = require("../../shared/log");
let ctx;
@@ -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();
@@ -110,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();
@@ -155,8 +168,26 @@ function init(_ctx) {
);
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,
+8 -5
View File
@@ -126,13 +126,16 @@ function render() {
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 = "";
@@ -368,7 +371,7 @@ async function loadFullTxDetails(txHash, chainId) {
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">`;
@@ -377,13 +380,13 @@ async function loadFullTxDetails(txHash, chainId) {
// 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, { chainId });
} else if (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>`;
}
+92 -39
View File
@@ -73,6 +73,58 @@ function toAddressHtml(address, chainId) {
return renderAddressHtml(address, { title, chainId });
}
// A decoded record can come back from storage in any shape, so these screens
// show only the lines whose label and value are text. An address that is not
// text would throw in toAddressHtml().
function isReadableLine(d) {
return (
d &&
typeof d.label === "string" &&
typeof d.value === "string" &&
(!d.address || typeof d.address === "string")
);
}
// What a decoded line with an address shows: the address, under the token's
// symbol when the line names a token.
function decodedAddressHtml(d, chainId) {
if (!d.isToken) return toAddressHtml(d.address, chainId);
const sym = tokenLabel(d.address, chainId) || "Unknown token";
return (
`<div class="font-bold break-words">${escapeHtml(sym)}</div>` +
blockscoutOffTokenNoteHtml(d.address, chainId) +
toAddressHtml(d.address, chainId)
);
}
// Each `Moved from the sender`, `Permit2 allowance` and `Allowance token` line
// of a decoded swap, for the wait and error screens to show under the Amount
// figure. That figure can leave these amounts out, so alone it can understate
// what can leave the wallet. The token line says which token an allowance
// covers, which `Unlimited` alone does not.
function movedOrAllowedHtml(decoded, chainId) {
if (!decoded || !Array.isArray(decoded.details)) return "";
let html = "";
for (const d of decoded.details) {
if (
!isReadableLine(d) ||
(d.label !== "Moved from the sender" &&
d.label !== "Permit2 allowance" &&
d.label !== "Allowance token")
)
continue;
html += `<div class="mt-2">`;
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
if (d.address) {
html += `<div class="text-xs">${decodedAddressHtml(d, chainId)}</div>`;
} else {
html += `<div class="font-bold break-words">${escapeHtml(d.value)}</div>`;
}
html += `</div>`;
}
return html;
}
function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash, chainId);
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
@@ -109,10 +161,9 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
}
$("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
txInfo.token,
txInfo.chainId,
);
$("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");
@@ -223,7 +274,7 @@ function showWait(txInfo, txHash) {
// 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.
// ETH. txInfo.decoded is not checked either: see isReadableLine().
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
@@ -270,31 +321,31 @@ function showSuccess(txInfo, txHash, blockNumber) {
ctx.doRefreshAndRender();
}
function readableLines(decoded) {
if (!decoded || !Array.isArray(decoded.details)) return [];
return decoded.details.filter(isReadableLine);
}
// The decoded action, or "" when no line of it can be read.
function decodedDetailsHtml(decoded, chainId) {
if (!decoded || !decoded.details) return "";
const lines = readableLines(decoded);
if (lines.length === 0) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
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, chainId) || "Unknown token";
html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
html += blockscoutOffTokenNoteHtml(d.address, chainId);
html += toAddressHtml(d.address, chainId);
html += decodedAddressHtml(d, chainId);
} else {
html += toAddressHtml(d.address, chainId);
}
} else {
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
html += `<div class="font-bold break-words">${escapeHtml(d.value)}</div>`;
}
html += `</div>`;
}
@@ -306,30 +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");
// 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, 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, d.chainId);
if (decodedEl && decodedHtml) {
decodedEl.innerHTML = decodedHtml;
decodedEl.classList.remove("hidden");
} else if (decodedEl) {
decodedEl.classList.add("hidden");
@@ -356,6 +409,7 @@ function showError(txInfo, txHash, message) {
to: txInfo.to,
hash: txHash || null,
message: message,
decoded: txInfo.decoded || null,
chainId: txInfo.chainId,
};
renderError();
@@ -366,21 +420,20 @@ function renderError() {
if (!d || !d.message) return;
$("error-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
$("error-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
d.token,
d.chainId,
);
$("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, 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");
}
+13 -10
View File
@@ -29,7 +29,7 @@
// string, and a token the user added by hand carries whatever lookupTokenInfo()
// got back, so the accepted types are enumerated rather than coerced.
const { toDecimals } = require("./transferAmount");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { bundledToken } = require("./tokenList");
const { isSpoofedSymbol } = require("./symbolSpoof");
// Every decimals the explorer reported for this contract, across all the
@@ -53,12 +53,14 @@ function explorerDecimals(lower, wallets) {
}
// The decimals to render a token amount with, or null when nothing knows.
// `sources` is { trackedTokens, wallets }, both shaped as they are on `state`.
// `sources` is { chainId, trackedTokens, wallets }. `chainId` is the chain id
// of the token's network. The bundled list is read only when that network is
// Ethereum Mainnet. `trackedTokens` and `wallets` are shaped as on `state`.
function resolveTokenDecimals(tokenAddress, sources) {
const lower = (tokenAddress || "").toLowerCase();
if (!lower) return null;
const bundled = TOKEN_BY_ADDRESS.get(lower);
const bundled = bundledToken(lower, sources && sources.chainId);
if (bundled) {
const d = toDecimals(bundled.decimals);
if (d !== null) return d;
@@ -100,18 +102,19 @@ function explorerSymbol(lower, wallets) {
// explorer reported: the same sources and the same precedence
// resolveTokenDecimals() uses, so a token's name and its scale are drawn from
// the same place and the two can no longer disagree about which sources they
// trust. `sources` is { trackedTokens, wallets }, shaped as on `state`.
// trust. `sources` is as for resolveTokenDecimals().
//
// A tracked or explorer-reported symbol is attacker-influenced text, so it is
// held to the spoof rule (symbolSpoof.js): a candidate that wears a bundled or
// native ticker from a contract not entitled to it is refused and the next
// source tried, so resolving a symbol never becomes a new way to claim a known
// ticker. The bundled list is the wallet's own data and is trusted as it is.
// held to the spoof rule (symbolSpoof.js): a candidate that the rule calls a
// spoof is refused and the next source tried, so resolving a symbol never
// becomes a new way to claim a known ticker. The bundled list is the wallet's
// own data and is trusted as it is.
function resolveTokenSymbol(tokenAddress, sources) {
const lower = (tokenAddress || "").toLowerCase();
if (!lower) return null;
const bundled = TOKEN_BY_ADDRESS.get(lower);
const chainId = sources && sources.chainId;
const bundled = bundledToken(lower, chainId);
if (bundled && bundled.symbol) return bundled.symbol;
const tracked = ((sources && sources.trackedTokens) || []).find(
@@ -123,7 +126,7 @@ function resolveTokenSymbol(tokenAddress, sources) {
if (reported) candidates.push(reported);
for (const symbol of candidates) {
if (!isSpoofedSymbol(symbol, tokenAddress)) return symbol;
if (!isSpoofedSymbol(symbol, tokenAddress, chainId)) return symbol;
}
return null;
}
+40 -11
View File
@@ -17,7 +17,7 @@ const { ERC20_ABI } = require("./constants");
const { NETWORKS } = require("./networks");
const { log, debugFetch, urlOrigin } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { bundledToken } = require("./tokenList");
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
const { toDecimals } = require("./transferAmount");
@@ -106,6 +106,9 @@ function rawUnits(value) {
// balance is read through the RPC endpoint instead, and its row here gives that
// entry its holder count and `decimals` (fetchTrackedTokenBalances()).
//
// `chainId` is the chain id of the network `blockscoutUrl` serves. The known
// token list is used only when that network is Ethereum Mainnet.
//
// `decimals` and `balance` are each null when the answer is unknown, the same
// way `holders` already is. Absence is never filled in here: this is the
// upstream of every screen that displays a token amount, so a value invented
@@ -117,6 +120,7 @@ async function fetchTokenBalances(
address,
blockscoutUrl,
trackedTokens,
chainId,
signal,
) {
if (!(await storedSettingIsOn("useBlockscout"))) return [];
@@ -175,7 +179,10 @@ async function fetchTokenBalances(
// either way: copying another source into it would make
// explorerDecimals()'s disagreement check compare something other
// than explorer values.
const known = resolveTokenDecimals(tokenAddr, { trackedTokens });
const known = resolveTokenDecimals(tokenAddr, {
chainId,
trackedTokens,
});
const scale = known !== null ? known : decimals;
// null is a holding of an amount that cannot be stated, which is
// not the same as a holding of zero, and must never render as one.
@@ -191,7 +198,7 @@ async function fetchTokenBalances(
// through to the views, where the two low-holder filters treat
// an unknown count as "do not judge" rather than as zero.
const holders = parseHoldersCount(item.token.holders_count);
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
const isKnown = bundledToken(tokenAddr, chainId) !== null;
const isTracked = trackedSet.has(tokenAddr);
const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD;
@@ -202,7 +209,9 @@ async function fetchTokenBalances(
// Every row here is an ERC-20 the explorer reported, so it has a
// contract address; the native ETH balance is fetched over RPC in
// refreshBalances and never passes through this loop.
if (isSpoofedSymbol(item.token.symbol, tokenAddr)) continue;
if (isSpoofedSymbol(item.token.symbol, tokenAddr, chainId)) {
continue;
}
balances.push({
address: item.token.address_hash,
@@ -247,19 +256,24 @@ async function fetchTokenBalances(
// it has none. The failure is logged. A balance of zero gets no entry, as a
// zero Blockscout reports gets none: the balance lists take a tracked token's
// zero from the tracked list. A token whose symbol the spoof rule refuses gets
// no entry either, as in fetchTokenBalances().
// no entry either, as in fetchTokenBalances(), and its balance is not read.
//
// `signal` is as for fetchTokenBalances().
// Resolves with `rows`, the entries, and `unread`, the tracked tokens whose
// balance was not read, as lowercased contract addresses. A token is unread
// when the spoof rule refuses it or its balanceOf fails.
//
// `chainId` and `signal` are as for fetchTokenBalances().
async function fetchTrackedTokenBalances(
provider,
address,
trackedTokens,
previous,
explorerRequest,
chainId,
signal,
) {
const listed = trackedTokens.filter(
(token) => !isSpoofedSymbol(token.symbol, token.address),
(token) => !isSpoofedSymbol(token.symbol, token.address, chainId),
);
// Each token's balanceOf answer, or null where it failed.
const balances = await Promise.all(
@@ -288,12 +302,16 @@ async function fetchTrackedTokenBalances(
const reported = explorerRows ?? previous;
const rows = [];
const unread = trackedTokens
.filter((token) => !listed.includes(token))
.map((token) => token.address.toLowerCase());
listed.forEach((token, i) => {
const lower = token.address.toLowerCase();
const entryIn = (list) =>
list.find((row) => String(row.address).toLowerCase() === lower);
const raw = balances[i];
if (raw === null) {
unread.push(lower);
const kept = entryIn(previous) || entryIn(explorerRows || []);
if (kept) rows.push(kept);
return;
@@ -301,7 +319,7 @@ async function fetchTrackedTokenBalances(
if (raw === 0n) return;
const entry = entryIn(reported);
const decimals = entry ? entry.decimals : null;
const known = resolveTokenDecimals(lower, { trackedTokens });
const known = resolveTokenDecimals(lower, { chainId, trackedTokens });
const scale = known !== null ? known : decimals;
rows.push({
address: token.address,
@@ -314,12 +332,15 @@ async function fetchTrackedTokenBalances(
holders: entry ? entry.holders : null,
});
});
return rows;
return { rows, unread };
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// `trackedTokens` are the tokens tracked on the active network. `signal` is as
// for fetchTokenBalances().
//
// Resolves with the lowercased contract addresses of the tracked tokens whose
// balance it read at every address.
async function refreshBalances(
wallets,
rpcUrl,
@@ -330,8 +351,11 @@ async function refreshBalances(
) {
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
const provider = getProvider(rpcUrl, networkId);
const { chainId } = requireNetworkId(networkId);
const tracked = trackedTokens || [];
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
// The tracked tokens whose balance was not read at some address.
const unread = new Set();
const updates = [];
for (const wallet of wallets) {
@@ -393,6 +417,7 @@ async function refreshBalances(
addr.address,
blockscoutUrl,
tracked,
chainId,
signal,
);
updates.push(
@@ -403,15 +428,18 @@ async function refreshBalances(
tracked,
previous,
explorerRequest,
chainId,
signal,
),
explorerRequest,
]).then(([trackedRows, explorerRows]) => {
]).then(([trackedResult, explorerRows]) => {
for (const lower of trackedResult.unread) unread.add(lower);
const untrackedRows = (explorerRows ?? previous).filter(
(row) =>
!trackedSet.has(String(row.address).toLowerCase()),
);
addr.tokenBalances = trackedRows.concat(untrackedRows);
addr.tokenBalances =
trackedResult.rows.concat(untrackedRows);
log.debugf(
"Token balances",
addr.address,
@@ -425,6 +453,7 @@ async function refreshBalances(
await Promise.all(updates);
log.debugf("refreshBalances done");
return [...trackedSet].filter((lower) => !unread.has(lower));
}
// Drops the balance of each token not in `trackedTokens` from every address.
+28 -8
View File
@@ -49,7 +49,7 @@ function clearPopupCaches() {
// 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 "0", clearing that balance is no
// 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;
@@ -61,26 +61,46 @@ function clearPopupCaches() {
// (https://git.eeqj.de/sneak/AutistMask/issues/653,
// https://git.eeqj.de/sneak/AutistMask/issues/692).
//
// When the stored record is on another network, a switch saved elsewhere has
// not reached this page yet. This page first takes that switch with
// takeStoredNetwork() (state.js), as followStoredSettings() does. The switch to
// `newNetworkId` is then made from the stored record's network. So every
// network's list of tracked tokens in the stored record is kept
// (https://git.eeqj.de/sneak/AutistMask/issues/701). When the stored record is
// already on `newNetworkId`, the switch made from there only resets the
// balances and caches.
//
// When the stored record is on this page's network, this page first takes the
// stored `networkTrackedTokens` (takeStoredNetworkTrackedTokens() in state.js),
// because another page's save may have changed a list this page holds.
//
// Before the switch, `state` takes the tracked tokens that another page stored
// since this page's last save (takeStoredTrackedTokens() in state.js). The
// switch then puts them away under the previous network, the network they were
// added on. When the stored record is on another network, a switch saved
// elsewhere has not reached this page yet. The stored record's tokens are then
// that network's, and none are taken.
// added on.
async function onChainSwitch(newNetworkId) {
const { state, saveState, takeStoredTrackedTokens } = require("./state");
const {
state,
saveState,
takeStoredNetwork,
takeStoredTrackedTokens,
takeStoredNetworkTrackedTokens,
} = require("./state");
let net = null;
await saveState((stored) => {
if (state.networkId === newNetworkId) return false;
if (stored.networkId === state.networkId) {
takeStoredTrackedTokens(stored);
if (stored.networkId !== state.networkId) {
takeStoredNetwork(stored);
} else {
takeStoredNetworkTrackedTokens(stored);
}
takeStoredTrackedTokens(stored);
net = applyChainSwitchFields(state, newNetworkId);
stored.lastBalanceRefresh = 0;
stored.fraudContracts = [];
for (const wallet of stored.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.balance = null;
addr.tokenBalances = [];
}
}
+16 -6
View File
@@ -42,11 +42,20 @@ function applyChainSwitchFields(s, newNetworkId) {
// 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 the incoming network's list, or
// an empty one, takes its place.
// 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 = s.networkTrackedTokens[net.id] || [];
s.trackedTokens = structuredClone(s.networkTrackedTokens[net.id] || []);
for (const token of s.trackedTokens) token.balanceNotFetched = true;
s.networkId = net.id;
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
@@ -59,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 = [];
}
}
@@ -99,7 +109,7 @@ function copyChainSwitchFields(s, switched) {
for (const wallet of s.wallets) {
for (const addr of wallet.addresses) {
const got = stored.get(addr.address);
addr.balance = got ? got.balance : "0";
addr.balance = got ? got.balance : null;
addr.tokenBalances = got ? got.tokenBalances : [];
}
}
+4 -2
View File
@@ -146,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.
//
@@ -155,7 +156,8 @@ 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
+151 -21
View File
@@ -127,10 +127,12 @@ function currentNetwork() {
// `baseline` holds the persisted fields as this page held them when its last
// loadState() finished, or when its last successful saveState() began.
// takeStoredNetwork() and takeStoredTrackedTokens() also write into it what
// this page has taken from the stored record since then. saveState() diffs the
// live state against `baseline` to find only the fields THIS page changed
// since.
// takeStoredNetwork(), takeStoredTrackedTokens() and
// takeStoredNetworkTrackedTokens() also write into it what this page has taken
// from the stored record since then. `baseline` also lacks any
// `balanceNotFetched` field that a balance refresh has deleted since
// (clearBalanceNotFetched()). saveState() diffs the live state against
// `baseline` to find only the fields THIS page changed since.
//
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -402,6 +404,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
@@ -442,18 +489,27 @@ 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:
// `fraudContracts`/`viewStack` are arrays of scalars with no per-element
// identity to merge by; a save diffs `trackedTokens` whole, and a switch in
// Settings first merges that list by contract address
// (takeStoredTrackedTokens()); `networkTrackedTokens` is diffed whole like
// `trackedTokens` and `networkId`, the two fields 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.
// 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.
//
// This does not make two pages that both change the SAME leaf concurrently
// safe: last write wins on that one leaf, same as before. What it removes
@@ -527,6 +583,34 @@ async function saveStateOnce(beforeWrite) {
merged[key] = current[key];
}
}
// When the merged record names a network other than this page's, this page
// has not yet taken a switch saved elsewhere. This page's `trackedTokens`
// is then the list of this page's network. The stored record keeps the
// list of this page's network in `networkTrackedTokens`. So a token that
// this page added or removed since its last save is added to or removed
// from that stored list. The merged record keeps the stored `trackedTokens`
// (https://git.eeqj.de/sneak/AutistMask/issues/701).
if (merged.networkId !== current.networkId) {
merged.trackedTokens = fresh.trackedTokens;
merged.networkTrackedTokens = networkTrackedTokensWithChange(
fresh,
current,
);
} 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
@@ -607,15 +691,27 @@ function saveState(beforeWrite) {
return turn;
}
// Give this page the network switch held by `stored`, a record a save of this
// page has just read (followStoredSettings() in src/popup/index.js calls this
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does
// with a whole record. The switch is then not this page's own change, even when
// Give this page the network switch held by `stored`, in `state` and in
// `baseline` alike, as loadState() does with a whole record. `stored` is the
// record that a save of this page has just read. followStoredSettings() in
// src/popup/index.js and onChainSwitch() in src/shared/chainSwitch.js call this
// from `beforeWrite`. The switch is then not this page's own change, even when
// that save fails to write: a later switch back to the previous network still
// differs from `baseline`, and is stored.
//
// A token that this page tracked or removed since its last save was tracked or
// removed on this page's network before the switch. `state` keeps that change
// in the list in `networkTrackedTokens` for that network, merged with the
// stored list by contract address. `baseline` takes the stored list, so the
// save stores the change (https://git.eeqj.de/sneak/AutistMask/issues/706).
function takeStoredNetwork(stored) {
const networkTrackedTokens = networkTrackedTokensWithChange(
stored,
rawState,
);
copyChainSwitchFields(rawState, stored);
copyChainSwitchFields(baseline, structuredClone(stored));
rawState.networkTrackedTokens = networkTrackedTokens;
}
// Give this page the tracked tokens of `stored`, a record on this page's
@@ -630,16 +726,48 @@ function takeStoredNetwork(stored) {
// `baseline` takes the stored list, so the save stores the list in `state`
// whenever the two differ.
function takeStoredTrackedTokens(stored) {
rawState.trackedTokens = mergeListByIdentity(
rawState.trackedTokens = mergeTrackedTokens(
baseline.trackedTokens,
rawState.trackedTokens,
stored.trackedTokens,
(token) => token.address.toLowerCase(),
(base, ours) => ours,
);
baseline.trackedTokens = structuredClone(stored.trackedTokens);
}
// Give `state` and `baseline` each their own copy of the stored
// `networkTrackedTokens`. `stored` is a record on this page's network that a
// save of this page has just read. onChainSwitch() in
// src/shared/chainSwitch.js calls this from `beforeWrite`.
function takeStoredNetworkTrackedTokens(stored) {
rawState.networkTrackedTokens = structuredClone(
stored.networkTrackedTokens,
);
baseline.networkTrackedTokens = structuredClone(
stored.networkTrackedTokens,
);
}
// Deletes `balanceNotFetched` from each tracked token whose lowercased
// contract address is in `addresses`. The field goes from `stored`, from
// `state` and from `baseline` alike. `stored` is the record a save of this
// page has just read (doRefreshAndRender() in src/popup/index.js calls this
// from `beforeWrite`). So the deletion is not a change this page made to its
// tracked tokens. Unless this page has changed its tracked tokens itself, the
// save then writes the stored list with only the field gone.
function clearBalanceNotFetched(stored, addresses) {
for (const list of [
stored.trackedTokens,
rawState.trackedTokens,
baseline.trackedTokens,
]) {
for (const token of list) {
if (addresses.includes(token.address.toLowerCase())) {
delete token.balanceNotFetched;
}
}
}
}
// Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first
@@ -687,6 +815,8 @@ module.exports = {
onSaveFailure,
takeStoredNetwork,
takeStoredTrackedTokens,
takeStoredNetworkTrackedTokens,
clearBalanceNotFetched,
loadState,
currentAddress,
currentNetwork,
+19 -3
View File
@@ -1,5 +1,5 @@
// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
// profile.
// Reads "Use Blockscout", "Check recipients on Etherscan" or the active network
// from the stored profile.
//
// Before each request that sends the user's data to Blockscout or Etherscan, the
// wallet reads that service's setting from storage. The wallet reads the setting
@@ -7,6 +7,11 @@
// made, and an answer that lands after the setting went off is dropped. A page's
// own copy of the setting is not used, because another page may have just
// changed it.
//
// Before asking Blockscout for the transaction history, a screen remembers
// which network is active. If another network is active on the page or in
// storage when the answer lands, the screen drops the answer. A switch saved
// elsewhere reaches storage first and the page later.
const { storageGet } = require("./browserApi");
const { normalizePersisted } = require("./persistedState");
@@ -24,4 +29,15 @@ async function storedSettingIsOn(name) {
}
}
module.exports = { storedSettingIsOn };
// Whether `networkId` is the active network in the stored profile. A failed
// read counts as no.
async function storedNetworkIs(networkId) {
try {
const { autistmask } = await storageGet("autistmask");
return normalizePersisted(autistmask).networkId === networkId;
} catch {
return false;
}
}
module.exports = { storedSettingIsOn, storedNetworkIs };
+11 -3
View File
@@ -19,7 +19,8 @@
// them hid the other one's holders' money (issue #276). Membership, not
// equality, is therefore the question — but it is the same question, asked of
// a table that can now state the truth. Every address in a set is one the
// wallet ships as a real token; a contract outside the set is still a spoof.
// wallet ships as a real token on Ethereum Mainnet. On mainnet, a contract
// outside the set is still a spoof.
//
// The symbol is attacker-controlled — it is whatever the ERC-20 contract
// returns — so the lookup is done on a normalized form (issue #260): the
@@ -27,6 +28,7 @@
// since that is what the user acts on.
const { KNOWN_SYMBOLS } = require("./tokenList");
const { NETWORKS } = require("./networks");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity.
@@ -93,18 +95,24 @@ function normalizeSymbol(symbol) {
}
// True when a token bearing `symbol` from contract `contractAddress` is
// impersonating a known symbol.
// impersonating a known symbol. `chainId` is the chain id of the token's
// network.
//
// The native asset's labels are known symbols on every network. The bundled
// list's symbols are known symbols only on Ethereum Mainnet, because the list
// holds mainnet contracts.
//
// An empty contract address is the native asset, which is never a spoof:
// this is what keeps the user's real ETH out of the rule, and it holds for
// any symbol that becomes null-mapped later, not just for ETH.
function isSpoofedSymbol(symbol, contractAddress) {
function isSpoofedSymbol(symbol, contractAddress, chainId) {
const contract = normalizeAddress(contractAddress);
if (!contract) return false;
const sym = normalizeSymbol(symbol);
if (!KNOWN_SYMBOLS.has(sym)) return false;
const legit = KNOWN_SYMBOLS.get(sym);
if (legit === null) return true;
if (chainId !== NETWORKS.mainnet.chainId) return false;
return !legit.has(contract);
}
+16 -4
View File
@@ -3953,6 +3953,15 @@ for (const t of TOKENS) {
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
}
// The bundled list's entry for the contract at `address` on the network with
// chain id `chainId`, or null. The list holds Ethereum Mainnet contracts only.
// A contract at the same address on another network is another token, so any
// other chain id gets null.
function bundledToken(address, chainId) {
if (chainId !== NETWORKS.mainnet.chainId) return null;
return TOKEN_BY_ADDRESS.get((address || "").toLowerCase()) || null;
}
// Build a map of symbol (uppercased) -> the set of contract addresses
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
// "ETH" maps to null: the native asset has no contract, so no contract may
@@ -3969,7 +3978,8 @@ for (const t of TOKENS) {
// hides from the balance list, the history and the send selector at its own
// address, so the user cannot spend it (issue #276). Naming every address
// that bears the symbol is the only shape that says what is true; it does not
// loosen the rule, because a contract outside the set is still a spoof.
// loosen the rule. On Ethereum Mainnet, a contract outside the set is still a
// spoof.
const KNOWN_SYMBOLS = new Map();
KNOWN_SYMBOLS.set("ETH", null);
for (const network of Object.values(NETWORKS)) {
@@ -3988,14 +3998,15 @@ for (const t of TOKENS) {
}
}
// Resolve a token symbol from multiple sources, never returning "?".
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
// Resolve a token symbol from multiple sources, never returning "?". The token
// is a contract on the network with chain id `chainId`.
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens, chainId) {
const lower = (tokenAddress || "").toLowerCase();
const tb = (tokenBalances || []).find(
(t) => t.address.toLowerCase() === lower,
);
if (tb && tb.symbol) return tb.symbol;
const known = TOKEN_BY_ADDRESS.get(lower);
const known = bundledToken(lower, chainId);
if (known && known.symbol) return known.symbol;
const tracked = (trackedTokens || []).find(
(t) => t.address.toLowerCase() === lower,
@@ -4012,5 +4023,6 @@ module.exports = {
USD_PRICE_FEEDS,
PRICE_FEED_BY_ADDRESS,
getTopTokens,
bundledToken,
resolveSymbol,
};
+6 -3
View File
@@ -8,7 +8,7 @@
const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { bundledToken } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
const { nativeCurrencyByChainId } = require("./networks");
@@ -47,7 +47,7 @@ function parseTx(tx, addrLower, chainId) {
let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
let directionLabel = direction === "sent" ? "Sent" : "Received";
if (toIsContract && method && method !== "transfer") {
const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
const token = bundledToken(to, chainId);
if (token) {
symbol = token.symbol;
}
@@ -317,7 +317,10 @@ function filterTransactions(txs, filters = {}) {
const contract = normalizeAddress(tx.contractAddress);
// Filter spoofed known symbols and record the fraud contract
if (hideSpoofed && isSpoofedSymbol(tx.symbol, tx.contractAddress)) {
if (
hideSpoofed &&
isSpoofedSymbol(tx.symbol, tx.contractAddress, tx.chainId)
) {
if (contract && !fraudSet.has(contract)) {
fraudSet.add(contract);
newFraud.push(contract);
+217 -43
View File
@@ -452,8 +452,8 @@ const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
// `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
// 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are
// on `state`; resolveTokenDecimals() reads the bundled list, then those.
// 0.000000000001. `sources` is as for resolveTokenDecimals(), which reads the
// bundled list on Ethereum Mainnet, then the tracked tokens and the wallets.
//
// A null `address` means UNDETERMINED — the calldata named no currency for
// that side — and is refused rather than named. It is not native ETH: Uniswap
@@ -988,6 +988,9 @@ function totalAmount(amounts) {
// pays it of Token In as the step's amount in place of the rules' Amount, or
// no amount when this decoder cannot tell what that is.
//
// `spent` lists the currency that each swap action spends, for each swap
// action that this decoder could read.
//
// `takes` is every TAKE, TAKE_ALL and TAKE_PORTION, as the currency it takes
// and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes
// a share of the currency, so it does not name the output side as TAKE does.
@@ -1163,6 +1166,8 @@ function decodeV4Swap(input) {
}
}
const spent = swaps.map((s) => s.tokenIn);
// Rule 3.
if (swapUndecoded) {
return {
@@ -1170,6 +1175,7 @@ function decodeV4Swap(input) {
tokenOut: takeToken,
amountIn: null,
amountOutMin: null,
spent,
takes,
settledFromRouter,
paidByUser,
@@ -1180,7 +1186,6 @@ function decodeV4Swap(input) {
};
}
const spent = swaps.map((s) => s.tokenIn);
const bought = swaps.map((s) => s.tokenOut);
// Rules 1 and 2 for Token In.
@@ -1216,6 +1221,7 @@ function decodeV4Swap(input) {
tokenOut,
amountIn,
amountOutMin,
spent,
takes,
settledFromRouter,
paidByUser,
@@ -1240,6 +1246,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const release = routerRelease(toAddress, chainId);
if (release === null) return null;
const { commandTypeMask, commands, allowRevert } = release;
// The swap's tokens are contracts on the network with `chainId`, so they
// are named and scaled for that network.
const tokenSources = { ...sources, chainId };
try {
const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null;
@@ -1258,6 +1267,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// ERC-20 token from the router takes none of that token from the
// sender.
let inputPaidByUser = false;
// The recipient of a WRAP_ETH that set the input side, as written,
// when it is not the router; otherwise null. That WRAP_ETH pays its
// WETH there, not to a swap step (see the Moved to lines).
let wrapEthRecipient = null;
// The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop).
let permit = null;
@@ -1307,8 +1320,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come
// from the same hop, or from hops that all spend that token. A figure
// is never rendered against a token that did not supply it.
// from the same hop, or from hops that all spend that token or all buy
// it. A figure is never rendered against a token that did not supply
// it.
//
// Both sides are therefore set as a PAIR — never field by field, and
// never on truthiness. An address is never falsy once set but an
@@ -1354,18 +1368,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// After the loop each joins the Amount line or gets lines of its own
// (see permitStands). A take with `atMost` set may take less than
// `amount`: it never joins the Amount line, and its line reads
// "Up to" and the amount.
// "Up to" and the amount. A take with a `recipient`, the ETH of a
// later WRAP_ETH that pays anyone but the router, never joins the
// Amount line either: its Moved to line names that recipient.
const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender. A V2 exact-in of ALREADY_PAID takes
// nothing.
const addUserPaid = (token, amount) => {
if (amount !== ALREADY_PAID) userPaid.push({ token, amount });
// of `token` from the sender, or at most that when `atMost` is set. A
// V2 exact-in of ALREADY_PAID takes nothing.
const addUserPaid = (token, amount, atMost = false) => {
if (amount !== ALREADY_PAID) {
userPaid.push({ token, amount, atMost });
}
};
// A step may take from the sender what this decoder did not read: a
// V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it
// cannot read; a V2 or V3 swap step or PERMIT2_TRANSFER_FROM whose
// input it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; an
// cannot read; a V2 or V3 swap step, PERMIT2_TRANSFER_FROM or WRAP_ETH
// whose input it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; an
// EXECUTE_SUB_PLAN; and a step it fails on. It is recorded with a null
// token and amount: one line says so, there is no Amount line, whose
// figure may then be too low, and the amount of a permit after it
@@ -1423,6 +1441,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const r = recipient.toLowerCase();
return r === ADDRESS_THIS || r === toAddress.toLowerCase();
};
const isSender = (recipient) => {
const r = recipient.toLowerCase();
return r === MSG_SENDER || r === sender;
};
// A swap step or V4 SETTLE pays `amount` of `token` from the router's
// own balance. CONTRACT_BALANCE pays all of it. Any other quantity
// counts against the amount a WRAP_ETH stated, and the steps pay all
@@ -1455,17 +1477,73 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// A step pays `recipient`. `output` is the token a swap step or a V4
// TAKE or TAKE_PORTION pays; other steps pass none.
const addPayee = (recipient, output) => {
const r = recipient.toLowerCase();
if (isRouter(recipient)) {
if (present(output)) {
heldByRouter.set(output.toLowerCase(), null);
}
} else if (r === MSG_SENDER || r === sender) {
} else if (isSender(recipient)) {
paidToSender = true;
} else {
addPaidElsewhere(recipient);
}
};
// Each swap step that paid the sender some of the token it bought,
// as { step, token, amount, onlyToSender }:
// - `step` is the step's index;
// - `token` is that token;
// - `amount` is the step's stated minimum;
// - `onlyToSender` is whether the step paid all of that token to the
// sender.
// A later step that spends that token takes the step off this list
// (see dropFromSum()).
let swapsPayingSender = [];
// The step being read spends `token`, so it may spend what an earlier
// swap step paid the sender. Each step on swapsPayingSender that
// bought `token` leaves the list. A step spends a token as follows:
// - a V2 or V3 swap step spends its input token;
// - a V4 step spends each token that one of its swap actions spends,
// and each token that one of its SETTLEs or SETTLE_ALLs pays;
// - a PERMIT2_TRANSFER_FROM spends the token it moves.
const dropFromSum = (token) => {
swapsPayingSender = swapsPayingSender.filter(
(s) => !sameAddress(s.token, token),
);
};
// The minimum the output side shows for the swap step at `step`. The
// step buys `token` with the stated minimum `amount`, and pays
// `token` to `recipients`. The minimum is the step's own, unless this
// step and an earlier one on swapsPayingSender both pay `token` to
// the sender, as a route split across steps does. The minimum is then
// the total of all such steps' minimums. There is none when this
// decoder reads no minimum from one of them, or when one of them pays
// some of `token` elsewhere. There is also none when this decoder did
// not read one of them, or a step between them, in full (see
// stepsUnread).
const swapMinimum = (step, token, amount, recipients) => {
const toSender = recipients.filter(
(r) => present(r) && isSender(r),
);
if (toSender.length === 0) return amount;
swapsPayingSender.push({
step,
token,
amount,
onlyToSender: toSender.length === recipients.length,
});
const split = swapsPayingSender.filter((s) =>
sameAddress(s.token, token),
);
if (split.length < 2) return amount;
if (
split.some(
(s) => !s.onlyToSender || typeof s.amount !== "bigint",
) ||
stepsUnread.some((unread) => unread >= split[0].step)
) {
return null;
}
return split.reduce((sum, s) => sum + s.amount, 0n);
};
// A step that pays `token` to `recipient` is a hop, not a payout,
// when the very next step is a V2 exact-in whose input is already
// paid, whose path starts at `token`, and whose pair (see v2PairFor())
@@ -1607,6 +1685,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
: null,
});
}
if (p) dropFromSum(p.token);
} else if (command === "BALANCE_CHECK_ERC20") {
const b = decodeBalanceCheck(inputs[i]);
if (!b) {
@@ -1649,10 +1728,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
dropFromSum(s.tokenIn);
// Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token
// and minimum received amount.
setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
// the last swap step determines the final output
// token. swapMinimum() gives the minimum.
setOutput(
i,
s.tokenOut,
swapMinimum(i, s.tokenOut, s.amountOutMin, [
s.recipient,
]),
[s.recipient],
);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
@@ -1673,7 +1760,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
dropFromSum(s.tokenIn);
setOutput(
i,
s.tokenOut,
swapMinimum(i, s.tokenOut, s.amountOutMin, [
s.recipient,
]),
[s.recipient],
);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
@@ -1705,7 +1800,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax);
}
setOutput(i, s.tokenOut, s.amountOut, [s.recipient]);
dropFromSum(s.tokenIn);
setOutput(
i,
s.tokenOut,
swapMinimum(i, s.tokenOut, s.amountOut, [
s.recipient,
]),
[s.recipient],
);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
@@ -1803,6 +1906,28 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount,
);
inputPaidByUser = true;
if (!isRouter(w.recipient)) {
wrapEthRecipient = w.recipient;
}
} else if (
w.amount > 0n &&
w.amount !== CONTRACT_BALANCE
) {
// The ETH of a stated amount comes from the
// transaction's value. ETH an earlier step left in
// the router may pay part of it, and the sender
// then pays at most that amount. CONTRACT_BALANCE
// states no amount to take.
userPaid.push({
token: "0x0000000000000000000000000000000000000000",
amount: w.amount,
atMost: heldByRouter.has(
"0x0000000000000000000000000000000000000000",
),
recipient: isRouter(w.recipient)
? null
: w.recipient,
});
}
addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH.
@@ -1827,6 +1952,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else {
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
}
} else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]);
@@ -1877,6 +2003,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
addPayee(t.recipient, t.currency);
}
}
for (const token of v4.spent) dropFromSum(token);
for (const s of v4.paidByUser) dropFromSum(s.currency);
for (const s of v4.settledFromRouter) {
dropFromSum(s.currency);
}
}
if (!v4 || v4.recipientUnread) {
payeeUnread = true;
@@ -1925,7 +2056,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
setInput(v4.tokenIn, amountIn);
}
outputToken = v4.tokenOut;
minOutput = v4.amountOutMin;
minOutput = swapMinimum(
i,
v4.tokenOut,
v4.amountOutMin,
takeRecipients,
);
outputEstablished = true;
outputUnwrapped = false;
unwrapIntoRouterStep = null;
@@ -1953,9 +2089,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router still runs it.
const setInputHere = inputEstablished && !inputSetBefore;
if (setInputHere) inputPaidByUser = paidIn !== undefined;
// ETH an earlier step left in the router may pay part of
// the ETH that the user pays this step. The sender then
// pays at most that amount.
for (const p of paidByUser) {
if (setInputHere && p === paidIn) continue;
addUserPaid(p.currency, p.amount);
addUserPaid(
p.currency,
p.amount,
routerHeldEth &&
p.currency ===
"0x0000000000000000000000000000000000000000",
);
}
// A SETTLE of ETH from the router that `paidByUser` leaves
// out is a take of its own, even on the step that sets
@@ -2080,8 +2225,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// added to it while both are quantities. A take that cannot be added,
// being of another token, coming after an amount that is not a
// quantity (a V4 open delta, ALREADY_PAID, or none stated), or having
// `atMost` set, gets lines of its own. One whose amount is not known
// also leaves the total unknown, with no figure.
// `atMost` or a `recipient` set, gets lines of its own. One whose
// amount is not known also leaves the total unknown, with no figure.
//
// A PERMIT2_PERMIT names Token In only when its amount stands as the
// Amount figure in place of the total, which it does only in the
@@ -2119,15 +2264,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
permit.beforeEveryTake &&
!stepsUnread.some((step) => step > permit.step);
// What the steps take that the Amount line does not give, as
// { token, amount }. A null token is a step this decoder did not read
// (see spentUnread()), or a token it does not know.
// { token, amount, atMost, recipient }. A null token is a step this
// decoder did not read (see spentUnread()), or a token it does not
// know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH that
// pays elsewhere has a recipient (see wrapEthRecipient and userPaid).
const spentFromSender = [];
let total = null;
// The takes from the sender that `total` adds up, each a quantity, as
// { token, amount }. When there is no Amount line, each gets lines of
// its own (see below). The amount of the step that set the input side
// counts in `total` whoever pays that step. That amount is among them
// only when the user pays the step (see inputPaidByUser).
// The takes from the sender that `total` adds up, each a quantity, in
// the shape of `spentFromSender`. When there is no Amount line, each
// gets lines of its own (see below). The amount of the step that set
// the input side counts in `total` whoever pays that step. That amount
// is among them only when the user pays the step (see inputPaidByUser).
const inTotal = [];
// Whether the step that set the input side counts as a take from the
// sender, which may get lines of its own. The amount it stated counts
@@ -2141,7 +2288,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount;
if (typeof inputAmount === "bigint" && inputPaidByUser) {
inTotal.push({ token: inputToken, amount: inputAmount });
inTotal.push({
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
}
if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null };
@@ -2149,7 +2300,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (hasFirstTake) {
// The step that set the input side spent a token other than the
// permit's, or one not known, so its amount gets lines of its own.
spentFromSender.push({ token: inputToken, amount: inputAmount });
spentFromSender.push({
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
}
if (permitStands) inputToken = permit.token;
for (const t of userPaid) {
@@ -2159,7 +2314,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
ofTokenIn &&
typeof total === "bigint" &&
typeof t.amount === "bigint" &&
!t.atMost
!t.atMost &&
!present(t.recipient)
) {
total += t.amount;
inTotal.push(t);
@@ -2208,11 +2364,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// Any other output keeps its own token and figure: a swap that pays
// its WETH to the user and then unwraps receives WETH, and one that
// buys USDC and then unwraps the WETH it did not spend receives USDC.
const inInfo = tokenInfo(inputToken, sources);
const inInfo = tokenInfo(inputToken, tokenSources);
const outInfo =
outputUnwrapped || (hasUnwrapWeth && !outputEstablished)
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
: tokenInfo(outputToken, tokenSources);
const inSymbol = inInfo.symbol;
const outSymbol = outInfo.symbol;
@@ -2332,30 +2488,48 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What steps take from the sender beyond the Amount line, in the shape
// of the lines below for a PERMIT2_TRANSFER_FROM. An amount this
// decoder does not know is said so, and a step it did not read gets
// that one line alone.
// that one line alone. A take with no recipient goes to the swap step
// that spends it. A take with a recipient goes to that recipient;
// `MSG_SENDER` means the sender.
for (const s of spentFromSender) {
details.push({
label: "Moved from the sender",
value: present(s.amount)
? spentText(s.amount, tokenInfo(s.token, sources), s.atMost)
.display
? spentText(
s.amount,
tokenInfo(s.token, tokenSources),
s.atMost,
).display
: UNDECODED_CURRENCY,
});
if (present(s.token)) {
details.push(
tokenMovedLine("Token moved", tokenInfo(s.token, sources)),
tokenMovedLine(
"Token moved",
tokenInfo(s.token, tokenSources),
),
);
if (!present(s.recipient)) {
details.push({
label: "Moved to",
value: "The swap step that spends it",
});
} else if (s.recipient.toLowerCase() === MSG_SENDER) {
details.push({ label: "Moved to", value: "The sender" });
} else {
details.push({
label: "Moved to",
value: s.recipient,
address: s.recipient,
});
}
}
}
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own.
for (const m of movedFromSender) {
const info = permit2TokenInfo(m.token, sources);
const info = permit2TokenInfo(m.token, tokenSources);
details.push({
label: "Moved from the sender",
value: amountText(m.amount, info).display,
@@ -2390,7 +2564,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
continue;
}
const info = permit2TokenInfo(p.token, sources);
const info = permit2TokenInfo(p.token, tokenSources);
details.push({
label: "Permit2 allowance",
value:
@@ -2456,7 +2630,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
}
for (const m of movedIntoRouter) {
const info = permit2TokenInfo(m.token, sources);
const info = permit2TokenInfo(m.token, tokenSources);
paidTo.push({
label: "Paid to",
value:
@@ -2481,7 +2655,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push(
tokenMovedLine(
"Token moved into the router",
tokenInfo(getAddress(token), sources),
tokenInfo(getAddress(token), tokenSources),
),
);
}
+260
View File
@@ -0,0 +1,260 @@
// A contract lookup on AddToken can answer after the user has left AddToken.
// The token is still added, and the screen the user is on does not change
// (https://git.eeqj.de/sneak/AutistMask/issues/603). An answer while AddToken
// still shows goes to AddressDetail. On either add-token screen, an answer for a
// token tracked while the lookup waited tracks nothing
// (https://git.eeqj.de/sneak/AutistMask/issues/727). A second click on a "Top
// tokens:" button tracks nothing
// (https://git.eeqj.de/sneak/AutistMask/issues/729). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
balanceNotFetched: true,
};
// A second contract address, typed after AddToken was opened again.
const OTHER_TOKEN_ADDRESS = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressDetail, then AddToken opened from it with
// TOKEN's contract address typed in.
async function openAddToken() {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-add-token");
expect(boot.visibleViews()).toEqual(["add-token"]);
boot.node("add-token-address").value = TOKEN.address;
return boot;
}
// Presses `button` with a contract lookup that answers only when the function
// returned here is called. That function waits for the add to finish.
function addWithPendingLookup(boot, button = "btn-add-token-confirm") {
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
const adding = boot.click(button);
return async () => {
answer({
symbol: TOKEN.symbol,
decimals: TOKEN.decimals,
name: TOKEN.name,
});
await adding;
};
}
test("an answer after the user opened Settings adds the token and Settings stays", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
await boot.click("btn-settings");
expect(boot.visibleViews()).toEqual(["settings"]);
await answerLookup();
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
test("Back from Settings onto AddToken after the lookup answered shows no lookup status", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
expect(boot.text("add-token-info")).toBe("Looking up token...");
await boot.click("btn-settings");
await answerLookup();
await boot.click("btn-settings-back");
expect(boot.visibleViews()).toEqual(["add-token"]);
expect(boot.text("add-token-info")).toBe("");
expect(boot.node("add-token-info").style.visibility).toBe("hidden");
});
test("an answer after AddToken was opened again adds the token and leaves AddToken and its status line as they are", async () => {
const boot = await openAddToken();
const answerFirstLookup = addWithPendingLookup(boot);
await boot.click("btn-add-token-back");
await boot.click("btn-add-token");
expect(boot.visibleViews()).toEqual(["add-token"]);
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
// The second lookup is never answered.
addWithPendingLookup(boot);
await answerFirstLookup();
expect(boot.visibleViews()).toEqual(["add-token"]);
expect(boot.text("add-token-info")).toBe("Looking up token...");
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
test("a lookup that fails after AddToken was opened again leaves the status line as it is", async () => {
const boot = await openAddToken();
const { lookupTokenInfo } = require("../src/shared/balances");
let failFirstLookup;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((_, reject) => {
failFirstLookup = reject;
}),
);
const firstAdd = boot.click("btn-add-token-confirm");
await boot.click("btn-add-token-back");
await boot.click("btn-add-token");
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
// The second lookup is never answered.
addWithPendingLookup(boot);
failFirstLookup(new Error("lookup failed"));
await firstAdd;
expect(boot.text("add-token-info")).toBe("Looking up token...");
});
test("an answer while AddToken still shows goes to AddressDetail with the token listed", async () => {
const boot = await openAddToken();
const answerLookup = addWithPendingLookup(boot);
await answerLookup();
expect(boot.visibleViews()).toEqual(["address"]);
expect(boot.node("address-balances").innerHTML).toContain("TST");
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
test("an answer for a token tracked after AddToken was opened again leaves the status line as it is", async () => {
const boot = await openAddToken();
const answerFirstLookup = addWithPendingLookup(boot);
await boot.click("btn-add-token-back");
await boot.click("btn-add-token");
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
// The second lookup is never answered.
addWithPendingLookup(boot);
require("../src/popup/views/helpers").trackToken(TOKEN);
await answerFirstLookup();
expect(boot.text("flash-msg")).toBe("TST is already being tracked.");
expect(boot.text("add-token-info")).toBe("Looking up token...");
});
// Each screen is opened from AddressDetail by the buttons named, in order.
describe.each([
[
"AddToken",
["btn-add-token"],
"add-token-address",
"btn-add-token-confirm",
"add-token-info",
"TST is already being tracked.",
],
[
"SettingsAddToken",
["btn-settings", "btn-settings-add-token"],
"settings-addtoken-address",
"btn-settings-addtoken-manual",
"settings-addtoken-info",
"Already tracked.",
],
])("%s", (_, opening, field, button, statusLine, alreadyTrackedFlash) => {
// The screen open with TOKEN's contract address typed in.
async function openScreen() {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
expect(boot.pageErrors).toEqual([]);
for (const id of opening) await boot.click(id);
boot.node(field).value = TOKEN.address;
return boot;
}
test("a second click on Add while the lookup waits tracks the token once", async () => {
const boot = await openScreen();
const answerFirstClick = addWithPendingLookup(boot, button);
const answerSecondClick = addWithPendingLookup(boot, button);
await answerFirstClick();
await answerSecondClick();
expect(boot.text("flash-msg")).toBe(alreadyTrackedFlash);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
const { state } = require("../src/shared/state");
expect(state.trackedTokens).toEqual([TOKEN]);
});
test("an answer for a token tracked while the lookup waited clears the status line", async () => {
const boot = await openScreen();
const answerLookup = addWithPendingLookup(boot, button);
expect(boot.text(statusLine)).toBe("Looking up token...");
require("../src/popup/views/helpers").trackToken(TOKEN);
await answerLookup();
expect(boot.text("flash-msg")).toBe(alreadyTrackedFlash);
expect(boot.text(statusLine)).toBe("");
expect(boot.node(statusLine).style.visibility).toBe("hidden");
const { state } = require("../src/shared/state");
expect(state.trackedTokens).toEqual([TOKEN]);
});
});
test('a double click on a "Top tokens:" button tracks the token once', async () => {
const boot = await bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
expect(boot.pageErrors).toEqual([]);
// The page stub does not turn the markup the screen draws into elements.
// So the list hands the screen a stand-in for TOKEN's button.
let press;
boot.node("settings-addtoken-top10").querySelectorAll = () => [
{
dataset: {
address: TOKEN.address,
symbol: TOKEN.symbol,
decimals: String(TOKEN.decimals),
name: TOKEN.name,
},
addEventListener: (name, fn) => {
press = fn;
},
},
];
await boot.click("btn-settings");
await boot.click("btn-settings-add-token");
const firstClick = press();
const secondClick = press();
const { state } = require("../src/shared/state");
expect(state.trackedTokens).toEqual([TOKEN]);
// Read before the first click's save ends, when "Added TST" replaces it.
expect(boot.text("flash-msg")).toBe("Already tracked.");
await Promise.all([firstClick, secondClick]);
await boot.settle();
expect(state.trackedTokens).toEqual([TOKEN]);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
});
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -161,7 +161,7 @@ function click(element) {
// show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
}
// ------------------------------------------------------------ harness
@@ -188,7 +188,11 @@ let sent;
// handler to the buttons it drew, as the browser's fresh list would have.
let plusButton;
// showFlash() starts a timer that clears the message one or two seconds later.
// Each test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
approvalDetails = null;
sent = [];
mockNode.broadcast = [];
@@ -239,6 +243,7 @@ beforeEach(() => {
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
+1
View File
@@ -44,6 +44,7 @@ jest.doMock("../src/popup/views/helpers", () => ({
goBack: () => {},
clearViewStack: () => {},
onViewLeave: () => {},
markTrackedTokensNotFetched: () => {},
}));
// state.js reads chrome.storage.local at load, and saveState() writes it.
+8
View File
@@ -125,6 +125,14 @@ describe("the value of an address, and whether it is the whole value", () => {
expect(getAddressValue(addr)).toEqual({ usd: 0, partial: false });
});
// No total can include an ETH balance no refresh has read, and "unpriced
// tokens only" would be untrue of an address with nothing known at all.
test("an ETH balance no refresh has read means no value", () => {
const addr = { address: "0x1", balance: null, tokenBalances: [] };
expect(getAddressValue(addr)).toEqual({ usd: null, partial: false });
expect(formatAddressTotal(getAddressValue(addr))).toBe("");
});
// Before the first price fetch, and on testnet, nothing is knowable: that
// is a third state, and it stays distinct from both of the others.
test("no prices at all means no value, not an incomplete one", () => {
+5 -2
View File
@@ -90,9 +90,11 @@ beforeEach(() => {
});
describe("resolveTokenDecimals", () => {
// The bundled list is read only for a token on Ethereum Mainnet.
test("prefers the bundled list", () => {
state.trackedTokens = [{ address: USDC, symbol: "USDC", decimals: 2 }];
expect(resolveTokenDecimals(USDC, state)).toBe(6);
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
expect(resolveTokenDecimals(USDC, sources)).toBe(6);
});
test("reads a token the user tracks", () => {
@@ -124,7 +126,8 @@ describe("resolveTokenDecimals", () => {
// answers 0 is used rather than fallen past like the unusable entry above.
test("uses a bundled scale of zero", () => {
state.trackedTokens = [{ address: SLP, symbol: "SLP", decimals: 18 }];
expect(resolveTokenDecimals(SLP, state)).toBe(0);
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
expect(resolveTokenDecimals(SLP, sources)).toBe(0);
});
test("uses a tracked scale of zero", () => {
+171
View File
@@ -0,0 +1,171 @@
// The approval screen for a site's Sepolia transfer of the contract at USDC's
// mainnet address, while Sepolia is active.
//
// The bundled token list holds Ethereum Mainnet contracts. The contract at the
// same address on Sepolia is another token, but the screen named it USDC and
// scaled its amount at USDC's decimals
// (https://git.eeqj.de/sneak/AutistMask/issues/632). The bundled list now names
// a token only on Ethereum Mainnet. The same screen for a mainnet transfer of
// USDC, while Sepolia is active, still names USDC.
//
// Driven through the real approval screen, against the minimal DOM stub
// tests/approvalTokenNetwork.test.js uses.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const approval = require("../src/popup/views/approval");
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// USDC's address on Ethereum Mainnet, where the bundled list names it.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// FROM sends 1234500000 base units to RECIPIENT.
const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// One line of the decoded details: its label and what is drawn under it.
function decodedLine(label) {
return node("approve-tx-decoded-details")
.innerHTML.split('<div class="mb-2">')
.find((line) =>
line.startsWith(`<div class="text-muted">${label}</div>`),
);
}
// Open the approval screen, with Sepolia active, for the site's transaction
// sending TRANSFER_DATA to the contract at USDC's address on the network with
// chain id `chainId`.
async function showTransfer(chainId) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId,
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: USDC,
value: "0x0",
data: TRANSFER_DATA,
accessList: [],
},
});
},
};
state.networkId = "sepolia";
state.trackedTokens = [];
state.networkTrackedTokens = {};
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances: [] },
],
},
];
state.currentView = null;
approval.init({});
await approval.show(1);
}
test("names no token, and states the amount in base units", async () => {
await showTransfer(NETWORKS.sepolia.chainId);
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-to").innerHTML).not.toContain("USDC");
expect(decodedLine("Token")).toContain(
'<div class="font-bold break-words">Unknown token</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold break-words">1234500000 base units (decimals unknown)</div>',
);
});
// A transaction is named for its own network, not the active one.
test("a mainnet transfer of USDC is still named and scaled as USDC", async () => {
await showTransfer(NETWORKS.mainnet.chainId);
expect(state.currentView).toBe("approve-tx");
expect(decodedLine("Token")).toContain(
'<div class="font-bold break-words">USDC</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold break-words">1234.5000 USDC</div>',
);
});
+45 -5
View File
@@ -24,6 +24,8 @@ globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const fs = require("fs");
const path = require("path");
const { Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
@@ -193,13 +195,13 @@ describe("a token tracked on Sepolia only", () => {
test("is named on the Contract line and the decoded Token line, with its amount at its scale", async () => {
await showTokenTransfer();
expect(node("approve-tx-to").innerHTML).toContain(
'<div class="font-bold mb-1">TDOL</div>',
'<div class="font-bold mb-1 break-words">TDOL</div>',
);
expect(decodedLine("Token")).toContain(
'<div class="font-bold">TDOL</div>',
'<div class="font-bold break-words">TDOL</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
'<div class="font-bold break-words">1234.5000 TDOL</div>',
);
});
@@ -220,13 +222,51 @@ test("a token tracked and held on mainnet only is not named, and its amount is i
]);
expect(node("approve-tx-to").innerHTML).not.toContain("MDOL");
expect(decodedLine("Token")).toContain(
'<div class="font-bold">Unknown token</div>',
'<div class="font-bold break-words">Unknown token</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
'<div class="font-bold break-words">1234500000 base units (decimals unknown)</div>',
);
});
// Nothing limits the length of a token's symbol. Without the break-words class
// a long one runs past the right edge of the screen and is cut off.
describe("a long token symbol", () => {
const LONG = "L".repeat(60);
test("wraps on every decoded line and on the Contract line", async () => {
state.networkTrackedTokens = {
sepolia: [{ address: TOKEN, symbol: LONG, decimals: 6 }],
};
await showTokenTransfer();
expect(node("approve-tx-to").innerHTML).toContain(
`<div class="font-bold mb-1 break-words">${LONG}</div>`,
);
expect(node("approve-tx-decoded-details").innerHTML).toContain(
`<div class="mb-2 break-words">Transfer ${LONG}</div>`,
);
expect(decodedLine("Token")).toContain(
`<div class="font-bold break-words">${LONG}</div>`,
);
expect(decodedLine("Amount")).toContain(
`<div class="font-bold break-words">1234.5000 ${LONG}</div>`,
);
});
test("wraps on the Action line, and a long amount wraps on the Value line", () => {
const html = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
);
expect(html).toMatch(
/id="approve-tx-action"\s+class="text-xs font-bold mb-2 break-words"/,
);
expect(html).toMatch(
/id="approve-tx-value"\s+class="text-xs font-bold break-words"/,
);
});
});
// The From and Contract lines, and the decoded Token and Recipient lines.
test("every address links to the Sepolia explorer", async () => {
await showTokenTransfer();
+11 -12
View File
@@ -537,7 +537,18 @@ async function settleIncludingRejections() {
await new Promise((resolve) => setImmediate(resolve));
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
jest.resetModules();
});
@@ -1883,18 +1894,6 @@ describe("a closed approval window leaves the request pending", () => {
return bg.actionPopups[bg.actionPopups.length - 1];
}
// The timer that keeps the worker running while an approval is pending is
// stubbed, so no test here leaves that timer running when it ends.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
});
test("the toolbar popup opens a closed transaction approval, and approving it there sends", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
+12 -1
View File
@@ -103,7 +103,18 @@ function deferred() {
return { promise, resolve };
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
@@ -434,7 +445,7 @@ describe("a balance refresh under a network switch", () => {
const after = bg.persisted();
expect(after.networkId).toBe("mainnet");
expect(after.wallets[0].addresses[0].balance).toBe("0");
expect(after.wallets[0].addresses[0].balance).toBeNull();
expect(after.lastBalanceRefresh).toBe(0);
await bg.fireBalanceAlarm();
+7 -6
View File
@@ -45,6 +45,7 @@ async function holdRefresh(page) {
holders: null,
},
];
return [];
});
const ticking = page.tick();
await page.settle();
@@ -69,7 +70,7 @@ async function chooseNetwork(page, networkId) {
function expectNothingStored(page) {
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.wallets[0].addresses[0].balance).toBe("0");
expect(stored.wallets[0].addresses[0].balance).toBeNull();
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([]);
expect(stored.lastBalanceRefresh).toBe(0);
}
@@ -85,13 +86,13 @@ test("a refresh that returns after a switch in Settings writes nothing, and the
await chooseNetwork(page, "sepolia");
await refresh.finish();
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].balance).toBeNull();
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
expect(page.text("total-value")).toBe("unknown (SepoliaETH)");
await page.tick();
@@ -137,7 +138,7 @@ test("a refresh whose save is reading the stored record when the user switches i
await page.settle();
expect(state.networkId).toBe("sepolia");
expect(state.wallets[0].addresses[0].balance).toBe("0");
expect(state.wallets[0].addresses[0].balance).toBeNull();
expect(state.wallets[0].addresses[0].tokenBalances).toEqual([]);
expectNothingStored(page);
});
@@ -165,11 +166,11 @@ test("a refresh that returns after the background switched the network writes no
});
// The background's refresh saves Ethereum Mainnet's balances while the switch
// in Settings waits for the lock. This page last saved a balance of "0" and no
// in Settings waits for the lock. This page last saved a balance of null and no
// token balances, the values that the switch sets.
test("a background refresh saved while a switch in Settings waits for the lock leaves none of its balances stored", async () => {
const profile = unversionedValidProfile();
profile.wallets[0].addresses[0].balance = "0";
profile.wallets[0].addresses[0].balance = null;
const page = await bootPopup(profile);
expect(page.pageErrors).toEqual([]);
const background = require("../src/background/state");
@@ -720,6 +720,7 @@ describe("the popup", () => {
tokenRow(TRACKED, "TRK"),
tokenRow(UNTRACKED, "TST"),
];
return [];
});
const ticking = page.tick();
await page.settle();
@@ -764,6 +765,7 @@ describe("the popup", () => {
addr.tokenBalances = after
? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")]
: [tokenRow(TRACKED, "TRK")];
return [];
});
anotherPageSaves(page, { useBlockscout: after });
+81
View File
@@ -0,0 +1,81 @@
// The token screen, the Send screen and the confirmation screen for the
// contract at USDC's mainnet address. No address holds it and the user does
// not track it, so only the bundled token list can name it.
//
// The bundled token list holds Ethereum Mainnet contracts, so these screens
// name the token from it on Ethereum Mainnet only
// (https://git.eeqj.de/sneak/AutistMask/issues/632). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressToken for USDC's address, on `networkId`.
function bootOnToken(networkId) {
return bootPopup(
unversionedValidProfile({
networkId,
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: USDC,
}),
);
}
// AddressToken's Send, then Review for 1 token to RECIPIENT.
async function reviewSend(boot) {
await boot.click("btn-address-token-send");
expect(boot.visibleViews()).toEqual(["send"]);
boot.node("send-to").value = RECIPIENT;
boot.node("send-amount").value = "1";
await boot.click("btn-send-review");
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
}
describe("on Ethereum Mainnet, the bundled list names the token", () => {
test("on the token screen", async () => {
const boot = await bootOnToken("mainnet");
expect(boot.visibleViews()).toEqual(["address-token"]);
expect(boot.text("address-token-title")).toContain("USDC");
const info = boot.node("address-token-contract-info").innerHTML;
expect(info).toContain(
'<span class="text-muted">Decimals:</span> 6</div>',
);
});
test("on the Send screen and the confirmation screen", async () => {
const boot = await bootOnToken("mainnet");
await reviewSend(boot);
expect(boot.text("send-balance")).toContain("USDC");
expect(boot.text("confirm-amount")).toContain("USDC");
});
});
describe("with Sepolia active, the bundled list does not name the token", () => {
test("on the token screen", async () => {
const boot = await bootOnToken("sepolia");
expect(boot.visibleViews()).toEqual(["address-token"]);
expect(boot.text("address-token-title")).not.toContain("USDC");
const info = boot.node("address-token-contract-info").innerHTML;
expect(info).not.toContain("Decimals:");
});
test("on the Send screen and the confirmation screen", async () => {
const boot = await bootOnToken("sepolia");
await reviewSend(boot);
expect(boot.text("send-balance")).not.toContain("USDC");
expect(boot.text("confirm-amount")).not.toContain("USDC");
});
});
+12 -1
View File
@@ -56,7 +56,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
@@ -317,7 +328,7 @@ describe("only the user switches the network", () => {
const after = bg.walletState();
expect(after.networkId).toBe("sepolia");
expect(after.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(after.wallets[0].addresses[0].balance).toBe("0");
expect(after.wallets[0].addresses[0].balance).toBeNull();
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
+18 -2
View File
@@ -39,7 +39,7 @@ function walletFixture() {
{
name: "Wallet 1",
type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
addresses: [{ address: ADDRESS, balance: null, tokenBalances: [] }],
},
];
}
@@ -64,7 +64,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
@@ -231,7 +242,12 @@ describe("a chain switch on a worker that never loaded state", () => {
expect(bg.persisted().blockscoutUrl).toBe(CUSTOM_BLOCKSCOUT);
expect(bg.persisted().wallets).toEqual(walletFixture());
expect(bg.persisted().trackedTokens).toEqual([
{ address: TOKEN, symbol: "DAI", decimals: 18 },
{
address: TOKEN,
symbol: "DAI",
decimals: 18,
balanceNotFetched: true,
},
]);
});
+11
View File
@@ -76,7 +76,18 @@ async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
});
+11
View File
@@ -57,7 +57,18 @@ async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
// While an approval is pending, the background starts a 20-second timer to
// keep itself running. The timer is stubbed and never starts, so no test in
// this file leaves it running.
let keepRunningTimer;
beforeEach(() => {
keepRunningTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
});
afterEach(() => {
keepRunningTimer.mockRestore();
delete global.chrome;
jest.resetModules();
});
+26 -2
View File
@@ -33,6 +33,7 @@ const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
const EMPTY = { address: "0x1", balance: "0.0000", tokenBalances: [] };
const ETH_ONLY = { address: "0x1", balance: "1.5", tokenBalances: [] };
const DUST = { address: "0x1", balance: "0.00001", tokenBalances: [] };
const NOT_READ = { address: "0x1", balance: null, tokenBalances: [] };
const TOKEN_ONLY = {
address: "0x1",
balance: "0.0000",
@@ -106,14 +107,23 @@ describe("whether an address holds anything", () => {
expect(addressHoldsFunds(DUST)).toBe(true);
});
// No refresh has read it, so the address may hold funds.
test("an ETH balance no refresh has read counts", () => {
expect(addressHoldsFunds(NOT_READ)).toBe(true);
});
test("an address holding nothing does not", () => {
expect(addressHoldsFunds(EMPTY)).toBe(false);
expect(addressHoldsFunds(ZERO_TOKEN)).toBe(false);
});
test("a missing address or missing fields do not", () => {
test("a missing address does not", () => {
expect(addressHoldsFunds(undefined)).toBe(false);
expect(addressHoldsFunds({ address: "0x1" })).toBe(false);
});
// It reads as unknown, as a null balance does.
test("an address with no ETH balance field counts", () => {
expect(addressHoldsFunds({ address: "0x1" })).toBe(true);
});
});
@@ -140,6 +150,20 @@ describe("the balance warning on the removal confirmation", () => {
expect(html).toContain("<span>0.0000</span>");
});
test("an ETH balance no refresh has read is warned about, and reads as unknown", () => {
const html = balanceWarningHtml(NOT_READ);
expect(html).toContain("This address may hold a balance.");
expect(html).not.toContain("This address holds a balance.");
expect(html).toContain("quantity unknown");
expect(html).not.toContain("0.0000");
});
test("an address with no ETH balance field reads the same", () => {
expect(balanceWarningHtml({ address: "0x1" })).toContain(
"This address may hold a balance.",
);
});
// The sentence must not assert an amount, because any amount it could
// assert has been rounded: "This address holds 0.0000 ETH." is what the
// rounded form produces for an address that holds real money.
+11
View File
@@ -102,6 +102,17 @@ function makeDocument() {
// ------------------------------------------------------------ harness
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
function wallet(name, secret, addresses) {
return {
type: "hd",
+2 -1
View File
@@ -969,7 +969,8 @@ test("a token added by its contract address is listed on the address screen (#15
await visible(env.page, "#view-address");
// No wait: the confirm renders the balance list before it shows the
// screen, and nothing renders the list again while the screen is up.
// screen. The refresh it starts draws the list again, with the row still
// in it.
const row = env.page.locator(
'#address-balances [data-token="' + STUB_TOKEN.address + '"]',
{ hasText: STUB_TOKEN.symbol },
+3 -1
View File
@@ -111,13 +111,15 @@ function respondWith(items) {
}
// Fetch and place the result exactly where refreshBalances() places it, so the
// approval screens read what a real refresh would have left on state.
// approval screens read what a real refresh on Ethereum Mainnet would have
// left on state.
async function fetchOnto(items, trackedTokens = []) {
respondWith(items);
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
trackedTokens,
"0x1",
);
state.trackedTokens = trackedTokens;
state.wallets = [
+1
View File
@@ -48,6 +48,7 @@ async function boot(stored) {
for (const wallet of wallets) {
for (const addr of wallet.addresses) addr.balance = chainBalance;
}
return [];
});
return { page, refreshBalances };
}
+1
View File
@@ -88,6 +88,7 @@ describe.each([
showFlash: (msg) => flashes.push(msg),
escapeHtml: (s) => s,
goBack: () => {},
trackToken: () => {},
}));
require("../src/popup/views/" + view).init({
+634
View File
@@ -0,0 +1,634 @@
// Home, AddressDetail and AddressToken drop a transaction history answer that
// lands after a network switch. A dropped answer draws no rows and saves no
// fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657). Home's list
// shows nothing of the previous network once it is drawn after a switch
// (https://git.eeqj.de/sneak/AutistMask/issues/718).
//
// Most tests hold the history request open, switch the network or what the
// screen shows, and then let the answer land. The views run against a minimal
// DOM stub, as in tests/transactionListCancelled.test.js, and over
// tests/support/storageStub.js.
const { makeStorageStub } = require("./support/storageStub");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: jest.fn(async () => []),
}));
jest.mock("../src/shared/ens", () => ({
resolveEnsNames: jest.fn(async () => new Map()),
}));
const { fetchRecentTransactions } = require("../src/shared/transactions");
const { resolveEnsNames } = require("../src/shared/ens");
const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
const { storedNetworkIs } = require("../src/shared/storedSettings");
const home = require("../src/popup/views/home");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const ADDRESS = "0x1111111111111111111111111111111111111111";
const OTHER_ADDRESS = "0x2222222222222222222222222222222222222222";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// A contract that sends a token claiming USDT's symbol. Spotting the spoof
// adds this contract to the fraud contracts.
const SPOOF_CONTRACT = "0x3333333333333333333333333333333333333333";
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
// A profile on Ethereum Mainnet with two addresses, "Use Blockscout" on and no
// fraud contracts.
function profile() {
return {
schemaVersion: STATE_SCHEMA_VERSION,
hasWallet: true,
wallets: [
{
type: "hd",
name: "Main",
xpub: "xpub-main",
encryptedSecret: "encrypted-secret",
nextIndex: 2,
addresses: [
{ address: ADDRESS, balance: "1.0", tokenBalances: [] },
{
address: OTHER_ADDRESS,
balance: "0.0",
tokenBalances: [],
},
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
useBlockscout: true,
fraudContracts: [],
};
}
// What Blockscout answers for ADDRESS on Ethereum Mainnet:
//
// - a transfer of ETH from ADDRESS;
// - a transfer of the fake USDT from SPOOF_CONTRACT.
function history() {
return [
{
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
blockNumber: 2,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
rawAmount: "1000000000000000000",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
chainId: "0x1",
},
{
hash: "0x0d6a6e0a0d1b7d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c",
blockNumber: 1,
from: SPOOF_CONTRACT,
to: ADDRESS,
value: "100.0000",
exactValue: "100.0",
rawAmount: "100000000",
rawUnit: "base units",
symbol: "USDT",
timestamp: 1790000000,
isError: false,
directionLabel: "Received",
direction: "received",
contractAddress: SPOOF_CONTRACT,
chainId: "0x1",
},
];
}
// The next history request waits until the returned function is called, and
// then answers with `answer`.
function holdHistory(answer = history()) {
let land;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve) => {
land = () => resolve(answer);
}),
);
return () => land();
}
// The next history request waits until the returned function is called, and
// then fails.
function holdFailure() {
let fail;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve, reject) => {
fail = () => reject(new Error("Blockscout is down"));
}),
);
return () => fail();
}
// Another write holds the lock until the returned function is called. Every
// save waits until then.
function holdLock() {
let release;
const held = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
release = resolve;
}),
);
return async () => {
release();
await held;
};
}
// Every read of storage waits until the returned function is called. Each read
// then answers with what storage holds at that moment, the oldest read first.
function holdReads() {
const read = storage.local.get;
const waiting = [];
storage.local.get = (key) =>
new Promise((resolve) => {
waiting.push(() => resolve(read(key)));
});
return () => {
storage.local.get = read;
for (const answer of waiting) answer();
};
}
function makeElement(id) {
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: () => {},
remove: () => {},
contains: () => false,
toggle: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Lets every promise that is not held open settle.
function settle() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
let storage;
beforeEach(async () => {
storage = makeStorageStub({ autistmask: profile() });
globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged },
};
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
await loadState();
state.viewStack = [];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
const ctx = { pageClosed: new AbortController().signal };
addressDetail.init(ctx);
addressToken.init(ctx);
});
afterEach(() => {
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
});
const HOME = {
name: "Home",
listId: "home-tx-list",
open: () => home.render({ pageClosed: new AbortController().signal }),
};
const ADDRESS_DETAIL = {
name: "AddressDetail",
listId: "tx-list",
open: () => addressDetail.show(),
};
const ADDRESS_TOKEN = {
name: "AddressToken",
listId: "address-token-tx-list",
open: () => addressToken.show(),
};
// Three ways a test switches the network:
//
// - made in Settings: the page and storage have both switched;
// - saved elsewhere: storage has switched and the page has not;
// - not yet stored: the page has switched and storage has not.
const SWITCHES = [
{
how: "made in Settings",
to: (networkId) => onChainSwitch(networkId),
},
{
// Another page or the background stored the switch, and this page has
// not followed it yet.
how: "saved elsewhere",
to: async (networkId) => {
const record = storage.read("autistmask");
applyChainSwitchFields(record, networkId);
storage.write("autistmask", record);
},
},
{
// The switch's own save has changed the page and has not yet written
// storage.
how: "not yet stored",
to: async (networkId) => {
applyChainSwitchFields(state, networkId);
},
},
];
// The list still shows `before`, and neither the page nor storage holds a
// fraud contract.
function expectNothingAdded(screen, before) {
expect(node(screen.listId).innerHTML).toBe(before);
expect(state.fraudContracts).toEqual([]);
expect(storage.read("autistmask").fraudContracts).toEqual([]);
}
describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands on the same network is drawn and saved", async () => {
const land = holdHistory();
screen.open();
land();
await settle();
expect(node(screen.listId).innerHTML).toContain("tx-row");
expect(state.fraudContracts).toEqual([SPOOF_CONTRACT]);
expect(storage.read("autistmask").fraudContracts).toEqual([
SPOOF_CONTRACT,
]);
});
test.each(SWITCHES)(
"an answer that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
// The answer is the transfer of ETH alone, which adds no fraud contract.
// Nothing is saved, so only the check before the save can drop the answer.
test.each(SWITCHES)(
"an answer without a fraud contract that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory(history().slice(0, 1));
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
test.each(SWITCHES)(
"a request that fails after a switch $how shows no message",
async ({ to }) => {
const fail = holdFailure();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
},
);
// The user picks Sepolia in Settings while another write holds the lock.
// The answer lands, and its save waits behind the switch.
test("an answer whose save waits behind a switch made in Settings adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
const switching = onChainSwitch("sepolia");
land();
await settle();
await releaseLock();
await switching;
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. Another page or the
// background then stores a switch.
test("an answer whose save waits while a switch is saved elsewhere adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. The page then switches,
// and storage has not.
test("an answer whose save waits while the page switches adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
applyChainSwitchFields(state, "sepolia");
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails after a switch leaves the new network's rows", async () => {
const fail = holdFailure();
screen.open();
await onChainSwitch("sepolia");
fetchRecentTransactions.mockImplementationOnce(async () => history());
screen.open();
await settle();
const drawn = node(screen.listId).innerHTML;
fail();
await settle();
expect(drawn).toContain("tx-row");
expect(node(screen.listId).innerHTML).toBe(drawn);
});
// The user picks Sepolia in Settings, and the switch reads storage. The
// answer lands during that read, so the screen's own read of storage still
// finds Ethereum Mainnet. The switch changes the page before the screen's
// read ends. The answer is the transfer of ETH alone, so nothing is saved,
// and only a check after the screen's read can drop the answer.
test("an answer that lands during the first read of a switch made in Settings adds nothing", async () => {
const land = holdHistory(history().slice(0, 1));
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
const answerReads = holdReads();
const switching = onChainSwitch("sepolia");
await settle();
land();
await settle();
answerReads();
await switching;
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails during the first read of a switch made in Settings shows no message", async () => {
const fail = holdFailure();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
const answerReads = holdReads();
const switching = onChainSwitch("sepolia");
await settle();
fail();
await settle();
answerReads();
await switching;
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
});
});
describe("Home's list", () => {
test.each([
{ what: "rows", hold: holdHistory, shown: "tx-row" },
{
what: "failure message",
hold: holdFailure,
shown: "Failed to load transactions.",
},
])(
"shows the previous network's $what neither before nor after the new network's answer lands",
async ({ hold, shown }) => {
const answer = hold();
HOME.open();
answer();
await settle();
expect(node(HOME.listId).innerHTML).toContain(shown);
await onChainSwitch("sepolia");
const land = holdHistory([]);
HOME.open();
await settle();
expect(node(HOME.listId).innerHTML).toContain("Loading...");
land();
await settle();
expect(node(HOME.listId).innerHTML).toContain(
"No transactions found.",
);
},
);
// The user leaves Ethereum Mainnet with a request waiting, comes back, and
// that request lands. The list then holds Ethereum Mainnet's answer, so the
// next draw on Sepolia must replace it.
test.each([
{ what: "rows", hold: holdHistory },
{ what: "failure message", hold: holdFailure },
])(
"shows no $what of an older request that lands after a switch back",
async ({ hold }) => {
const answer = hold();
HOME.open();
await onChainSwitch("sepolia");
holdHistory();
HOME.open();
await onChainSwitch("mainnet");
answer();
await settle();
await onChainSwitch("sepolia");
holdHistory();
HOME.open();
await settle();
const list = node(HOME.listId).innerHTML;
expect(list).toContain("Loading...");
expect(list).not.toContain("tx-row");
expect(list).not.toContain("Failed to load transactions.");
},
);
// Each balance refresh draws Home again, so the rows must not blink.
test("keeps its rows while a redraw on the same network waits for the answer", async () => {
fetchRecentTransactions.mockImplementationOnce(async () => history());
HOME.open();
await settle();
const drawn = node(HOME.listId).innerHTML;
holdHistory();
HOME.open();
await settle();
expect(drawn).toContain("tx-row");
expect(node(HOME.listId).innerHTML).toBe(drawn);
});
});
describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands once the screen shows another address adds nothing", async () => {
const land = holdHistory();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
land();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails once the screen shows another address shows no message", async () => {
const fail = holdFailure();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
});
// The answer is the transfer of ETH alone, which adds no fraud contract.
// Nothing is saved, so only the check after the name lookup can drop the
// answer.
test("an answer whose name lookup lands after a switch adds nothing", async () => {
let landNames;
resolveEnsNames.mockImplementationOnce(
() =>
new Promise((resolve) => {
landNames = () => resolve(new Map());
}),
);
fetchRecentTransactions.mockImplementationOnce(async () =>
history().slice(0, 1),
);
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await onChainSwitch("sepolia");
landNames();
await settle();
expectNothingAdded(screen, before);
});
});
test("AddressToken: an answer that lands once the screen shows another token adds nothing", async () => {
const land = holdHistory();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
land();
await settle();
expectNothingAdded(ADDRESS_TOKEN, before);
});
test("AddressToken: a request that fails once the screen shows another token shows no message", async () => {
const fail = holdFailure();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
fail();
await settle();
expect(node(ADDRESS_TOKEN.listId).innerHTML).toBe(before);
});
test("storedNetworkIs(): a failed read counts as no", async () => {
globalThis.chrome.storage.local = {
get: async () => {
throw new Error("storage is down");
},
};
await expect(storedNetworkIs("mainnet")).resolves.toBe(false);
});
+7 -1
View File
@@ -192,7 +192,13 @@ describe("the balance-list spam gate", () => {
},
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
// On Ethereum Mainnet, the only network the known-token list covers.
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
[],
"0x1",
);
expect(balances).toHaveLength(1);
expect(balances[0].holders).toBeNull();
});
+54 -14
View File
@@ -416,20 +416,6 @@ const CONTRACT = [
views: ["success-tx"],
restored: false,
},
{
value: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } },
views: ["success-tx"],
restored: false,
},
{
value: {
hash: "0x1",
to: ADDRESS,
decoded: { details: [{ address: 42 }] },
},
views: ["success-tx"],
restored: false,
},
// error-tx passes on `data.message`, same dereference.
{
value: { message: "boom" },
@@ -1082,3 +1068,57 @@ describe("a restore-only hostile value onto", () => {
}
}
});
// A saved decoded record comes back from storage, in whatever shape it is in.
// SuccessTx reopens as itself and shows only the lines it can read.
describe("a popup reopened on a SuccessTx with a decoded record in another shape", () => {
function savedSuccess(decoded) {
return restoringOnto("success-tx", {
viewData: {
amount: "1.0000",
symbol: "ETH",
to: ADDRESS,
hash: "0x1",
chainId: "0x1",
decoded,
},
});
}
test.each([
7,
"text",
[1, 2],
{ details: 7 },
{ details: [null] },
{ details: [7] },
{ details: [{ address: 42 }] },
{ details: [{ label: "Steps", value: "text", address: 42 }] },
])("%j shows the amount and the recipient", async (decoded) => {
const page = await bootPopup(savedSuccess(decoded));
expect(page.pageErrors).toEqual([]);
expect(page.visibleViews()).toEqual(["success-tx"]);
expect(page.hidden("success-tx-decoded")).toBe(true);
expect(page.text("success-tx-summary")).toBe("1.0000 ETH");
expect(page.node("success-tx-to").innerHTML).toContain(ADDRESS);
});
test("a line it can read is shown, with the amount and the recipient", async () => {
const page = await bootPopup(
savedSuccess({
details: [null, { label: "Steps", value: "V3_SWAP_EXACT_IN" }],
}),
);
expect(page.pageErrors).toEqual([]);
expect(page.visibleViews()).toEqual(["success-tx"]);
expect(page.node("success-tx-decoded").innerHTML).toContain(
"V3_SWAP_EXACT_IN",
);
const summary = page.node("success-tx-summary");
expect(summary.parentElement.classList.contains("hidden")).toBe(false);
expect(summary.textContent).toBe("1.0000 ETH");
const to = page.node("success-tx-to");
expect(to.parentElement.classList.contains("hidden")).toBe(false);
expect(to.innerHTML).toContain(ADDRESS);
});
});
+58 -2
View File
@@ -134,9 +134,63 @@ test.each([
await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
expect(page.text("total-value")).toBe("unknown (SepoliaETH)");
});
// A request on Ethereum Mainnet fails, so Home's list holds the failure message.
// The list shows it neither before nor after Sepolia's answer lands.
test.each([
{
how: "saved by the background",
switchToSepolia: (page) => backgroundSwitch(page, "sepolia"),
},
{
how: "made in Settings",
switchToSepolia: async (page) => {
await page.click("btn-settings");
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) await fn();
await page.settle();
await page.click("btn-settings-back");
},
},
])(
"Home's list drops the previous network's message after a switch $how",
async ({ switchToSepolia }) => {
const page = await bootPopup(
unversionedValidProfile({ useBlockscout: true }),
);
const {
fetchRecentTransactions,
} = require("../src/shared/transactions");
fetchRecentTransactions.mockRejectedValueOnce(
new Error("Blockscout is down"),
);
await page.tick();
expect(page.node("home-tx-list").innerHTML).toContain(
"Failed to load transactions.",
);
let land;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve) => {
land = () => resolve([]);
}),
);
await switchToSepolia(page);
expect(page.visibleViews()).toEqual(["main"]);
expect(page.node("home-tx-list").innerHTML).toContain("Loading...");
land();
await page.settle();
expect(page.node("home-tx-list").innerHTML).toContain(
"No transactions found.",
);
},
);
// The report of the switch's own save names the network the page already has.
test("a switch in Settings is not taken a second time when its save is reported", async () => {
const page = await bootOnAddress();
@@ -245,7 +299,9 @@ test("a switch back in Settings after a follow whose write failed is stored", as
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.rpcUrl).toBe(NETWORKS.mainnet.defaultRpcUrl);
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
expect(stored.trackedTokens).toEqual([
{ ...MAINNET_TOKEN, balanceNotFetched: true },
]);
expect(page.text("flash-msg")).toBe("Switched to Ethereum Mainnet.");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
+17 -3
View File
@@ -67,13 +67,15 @@ beforeEach(() => {
});
describe("resolveTokenSymbol", () => {
// The bundled list is read only for a token on Ethereum Mainnet.
test("reads the bundled list", () => {
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
expect(resolveTokenSymbol(USDC, { chainId: "0x1" })).toBe("USDC");
});
test("prefers the bundled list over a tracked entry", () => {
state.trackedTokens = [{ address: USDC, symbol: "NOTUSDC" }];
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
expect(resolveTokenSymbol(USDC, sources)).toBe("USDC");
});
test("reads a token the user tracks", () => {
@@ -104,7 +106,19 @@ describe("resolveTokenSymbol", () => {
test("rejects a tracked entry claiming a bundled ticker it is not", () => {
// NOVEL_TOKEN is not the real USDC contract, so it may not wear USDC.
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }];
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
expect(resolveTokenSymbol(NOVEL_TOKEN, sources)).toBeNull();
});
// The bundled list holds mainnet contracts, so on Sepolia any contract may
// carry one of its symbols.
test("on Sepolia, reads a tracked entry carrying a bundled ticker", () => {
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }];
const sources = {
chainId: "0xaa36a7",
trackedTokens: state.trackedTokens,
};
expect(resolveTokenSymbol(NOVEL_TOKEN, sources)).toBe("USDC");
});
test("rejects an explorer entry claiming the native ETH ticker", () => {
+9 -1
View File
@@ -131,10 +131,14 @@ async function review(amount, token) {
async function confirm(amount, token) {
const txInfo = await review("0.5", token);
confirmTx.show({ ...txInfo, amount });
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
}
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
elements.clear();
state.wallets = [
{
@@ -167,6 +171,10 @@ beforeEach(() => {
state.currentView = null;
});
afterEach(() => {
jest.useRealTimers();
});
describe.each([
["1e-3", "ETH"],
["1e-3", "TOK"],
+30 -1
View File
@@ -277,14 +277,18 @@ async function review() {
// show() starts the fee estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
for (let i = 0; i < 10; i++) await jest.advanceTimersByTimeAsync(0);
}
function canSend() {
return !el("btn-confirm-send").disabled;
}
// showFlash() starts a timer that clears the message two seconds later. Each
// test here runs on fake timers and drops them in afterEach, so that timer
// never outlives the test.
beforeEach(() => {
jest.useFakeTimers();
elements.clear();
log.errorf.mockClear();
pageClosed = new AbortController();
@@ -297,6 +301,10 @@ beforeEach(() => {
mockNode.broadcast = [];
});
afterEach(() => {
jest.useRealTimers();
});
describe("Max on an ETH send", () => {
test("fills in the exact balance minus the fee reserve", async () => {
await refreshWith(BALANCE_WEI);
@@ -424,6 +432,27 @@ describe("Max on an ETH send", () => {
},
);
test("says so when no refresh has read the ETH balance", async () => {
await refreshWith(BALANCE_WEI);
state.wallets[0].addresses[0].balance = null;
openSend();
await pressMax();
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("This token's balance is unknown.");
});
test("fills nothing in and says nothing when a network switch cleared the balance while the fee was estimated", async () => {
await refreshWith(BALANCE_WEI);
openSend();
const release = holdFeeEstimate();
const pressed = pressMax();
state.wallets[0].addresses[0].balance = null;
release();
await pressed;
expect(el("send-amount").value).toBe("");
expect(text("flash-msg")).toBe("");
});
test("fills nothing in and says nothing once Send was left while the fee was estimated", async () => {
// 0.0001 ETH, which does not cover the fee: a result that landed
// would say so on whichever screen is shown.
+269
View File
@@ -167,3 +167,272 @@ test.each([
);
},
);
// This answer passes both endpoint checks on Ethereum Mainnet.
const PASSING_ANSWER = {
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
};
// Both endpoint checks read this answer as an error. The RPC check reads the
// answer's `error`. The Blockscout check reads the answer's `ok`.
const ERROR_ANSWER = {
ok: false,
status: 500,
json: async () => ({ error: { code: -32603, message: "Internal error" } }),
};
// The user types a URL in Settings and taps "Save". Before the endpoint answers,
// the user types a second URL and taps "Save" again. The second check passes
// first. The second URL is stored, and the flash message reads "Saved.". The
// first check then ends in one of three ways:
//
// - It passes.
// - It fails, because fetch cannot reach the endpoint.
// - It gets an answer that holds an error.
//
// None of the three changes the stored URL or the flash message
// (https://git.eeqj.de/sneak/AutistMask/issues/698).
test.each([
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "passes",
},
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "fails",
},
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
first: "answers with an error",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "passes",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "fails",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
first: "answers with an error",
},
])(
"$button keeps the second tap's result when the first check $first last",
async ({ button, input, field, first }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve, reject) => {
checks.push({ resolve, reject });
}),
);
const secondUrl = "https://second.example.invalid";
page.node(input).value = "https://first.example.invalid";
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
page.node(input).value = secondUrl;
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
checks[1].resolve(PASSING_ANSWER);
await Promise.all(secondTap);
await page.settle();
expect(page.text("flash-msg")).toBe("Saved.");
if (first === "passes") {
checks[0].resolve(PASSING_ANSWER);
} else if (first === "answers with an error") {
checks[0].resolve(ERROR_ANSWER);
} else {
// fetch rejects when it cannot reach the endpoint.
checks[0].reject(new TypeError("Failed to fetch"));
}
await Promise.all(firstTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(secondUrl);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
// The user types a URL in Settings and taps "Save". Before the endpoint answers,
// the user empties the input and taps "Save" again. The flash message asks for
// a URL. The first check then passes. The flash message still asks for a URL,
// and the stored URL is unchanged.
test.each([
{
button: "btn-save-rpc",
input: "settings-rpc",
field: "rpcUrl",
message: "Please enter an RPC URL.",
},
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
message: "Please enter a Blockscout API URL.",
},
])(
"$button keeps the empty-input message when the first check passes last",
async ({ button, input, field, message }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
page.node(input).value = "https://typed.example.invalid";
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
page.node(input).value = "";
await page.click(button);
expect(page.text("flash-msg")).toBe(message);
answer(PASSING_ANSWER);
await Promise.all(firstTap);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(message);
},
);
// The user types a URL in Settings and taps "Save". Another write holds the
// lock. So the save waits for the lock once the check passes. The user then
// taps "Save" again on the same URL. When the lock is free, the first save
// stores nothing and shows no flash message. The second check then passes, and
// the second save stores the URL.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing for an earlier tap still waiting for the lock",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve) => {
checks.push(resolve);
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
checks[0](PASSING_ANSWER);
await page.settle();
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
releaseLock();
await lockHeld;
await Promise.all(firstTap);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.text("flash-msg")).toBe("Testing endpoint...");
checks[1](PASSING_ANSWER);
await Promise.all(secondTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
expect(page.text("flash-msg")).toBe("Saved.");
},
);
// The user taps "Save", and the check passes. The save then begins its storage
// write, and this test keeps the write waiting. The user taps "Save" again, and
// the second check cannot reach the endpoint. When the write ends, the flash
// message still reads "Could not reach endpoint.". The write stores the URL
// anyway, because the write began before the second tap.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button keeps the second tap's message when tapped again during the write",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const checks = [];
globalThis.fetch.mockImplementation(
() =>
new Promise((resolve, reject) => {
checks.push({ resolve, reject });
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const firstTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
const { set } = page.storage.local;
const write = set.getMockImplementation();
let writeBegan = false;
let releaseWrite;
const writeHeld = new Promise((resolve) => {
releaseWrite = resolve;
});
set.mockImplementationOnce(async (items) => {
writeBegan = true;
await writeHeld;
await write(items);
});
checks[0].resolve(PASSING_ANSWER);
await page.settle();
expect(writeBegan).toBe(true);
const secondTap = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
// fetch rejects when it cannot reach the endpoint.
checks[1].reject(new TypeError("Failed to fetch"));
await Promise.all(secondTap);
await page.settle();
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
releaseWrite();
await Promise.all(firstTap);
await page.settle();
expect(checks).toHaveLength(2);
expect(page.storage.read("autistmask")[field]).toBe(typedUrl);
expect(page.text("flash-msg")).toBe("Could not reach endpoint.");
},
);
+134
View File
@@ -0,0 +1,134 @@
// A token that the spoof rule refuses never shows in the balance list, even
// tracked at zero balance with "Show tracked tokens with zero balance" on, and
// the two add-token screens refuse to track one
// (https://git.eeqj.de/sneak/AutistMask/issues/705). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
// Claims the symbol of the bundled USDT from another contract.
const FAKE_USDT = {
address: TOKEN_ADDRESS,
symbol: "USDT",
decimals: 6,
name: "Tether USD",
};
// A Sepolia contract that reports USDT. The bundled list holds Ethereum Mainnet
// contracts, so the spoof rule does not check this symbol on Sepolia
// (https://git.eeqj.de/sneak/AutistMask/issues/632).
const SEPOLIA_USDT = {
address: TOKEN_ADDRESS,
symbol: "USDT",
decimals: 6,
name: "Test USDT",
};
// A symbol the bundled list does not have, so the spoof rule passes it.
const TEST_TOKEN = {
address: "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const REFUSED_FLASH = "Not added: this token fakes a known symbol.";
afterEach(() => {
cleanupPopup();
});
test("on Ethereum Mainnet, a fake token tracked at zero balance is not listed while zero balances are shown", async () => {
const boot = await bootPopup(
unversionedValidProfile({
networkId: "mainnet",
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [FAKE_USDT, TEST_TOKEN],
showZeroBalanceTokens: true,
}),
);
const rows = boot.node("address-balances").innerHTML;
expect(rows).toContain(`data-token="${TEST_TOKEN.address.toLowerCase()}"`);
expect(rows).not.toContain(
`data-token="${FAKE_USDT.address.toLowerCase()}"`,
);
});
// Each screen is opened from AddressDetail by the buttons named, in order.
describe.each([
[
"AddToken",
["btn-add-token"],
"add-token",
"add-token-address",
"btn-add-token-confirm",
],
[
"SettingsAddToken",
["btn-settings", "btn-settings-add-token"],
"settings-addtoken",
"settings-addtoken-address",
"btn-settings-addtoken-manual",
],
])("%s", (_, opening, view, field, button) => {
test("on Ethereum Mainnet, refuses a fake token by contract address", async () => {
const boot = await bootPopup(
unversionedValidProfile({
networkId: "mainnet",
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
}),
);
for (const id of opening) await boot.click(id);
expect(boot.visibleViews()).toEqual([view]);
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: FAKE_USDT.symbol,
decimals: FAKE_USDT.decimals,
name: FAKE_USDT.name,
});
boot.node(field).value = FAKE_USDT.address;
await boot.click(button);
expect(boot.text("flash-msg")).toBe(REFUSED_FLASH);
expect(boot.visibleViews()).toEqual([view]);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([]);
});
test("on Sepolia, adds a contract that reports USDT and lists it", async () => {
const boot = await bootPopup(
unversionedValidProfile({
networkId: "sepolia",
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
showZeroBalanceTokens: true,
}),
);
for (const id of opening) await boot.click(id);
expect(boot.visibleViews()).toEqual([view]);
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: SEPOLIA_USDT.symbol,
decimals: SEPOLIA_USDT.decimals,
name: SEPOLIA_USDT.name,
});
boot.node(field).value = SEPOLIA_USDT.address;
await boot.click(button);
expect(boot.text("flash-msg")).not.toBe(REFUSED_FLASH);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
expect.objectContaining(SEPOLIA_USDT),
]);
// SettingsAddToken stays up after adding, so this draws AddressDetail.
require("../src/popup/views/addressDetail").show();
expect(boot.node("address-balances").innerHTML).toContain(
`data-token="${SEPOLIA_USDT.address.toLowerCase()}"`,
);
});
});
+3 -2
View File
@@ -279,10 +279,11 @@ async function bootPopup(stored, options) {
getAddressValue: () => ({ usd: null, partial: false }),
}));
// scanForAddresses() answers in the real one's shape, a scan that found
// no used address: AddWallet reads `addresses` from it.
// no used address: AddWallet reads `addresses` from it. refreshBalances()
// answers as a refresh that read no tracked token's balance.
jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => {}),
refreshBalances: jest.fn(async () => []),
dropUntrackedTokens: jest.requireActual("../../src/shared/balances")
.dropUntrackedTokens,
lookupTokenInfo: jest.fn(async () => null),
+184 -11
View File
@@ -231,6 +231,15 @@ async function approveSwap(data, response, value = 0n) {
await click("btn-approve-tx");
}
// A later popup resumes the wait, and the receipt is there.
async function receiptArrives() {
mockReceipt = { blockNumber: 1 };
txStatus.restoreWait();
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 0));
}
}
afterEach(() => {
txStatus.endWait();
mockReceipt = null;
@@ -287,20 +296,27 @@ describe.each([
expect(node("error-tx-summary").textContent).toBe(expected);
});
// The success screen shows the approval screen's decoded lines in place
// of the amount, so the ETH value is not on it either.
test("the success screen has no amount line of its own", async () => {
// The success screen has an Amount line of its own only when the approval
// screen's decoded lines have none. The ETH value is on neither.
test("the success screen's amount", async () => {
await approveSwap(v4SwapData(actions), { txHash: TX_HASH });
mockReceipt = { blockNumber: 1 };
// A later popup resumes the wait, and the receipt is there.
txStatus.restoreWait();
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 0));
}
const summary = node("success-tx-summary").parentElement;
expect(summary.classList.contains("hidden")).toBe(true);
await receiptArrives();
const summary = node("success-tx-summary");
expect(summary.parentElement.classList.contains("hidden")).toBe(
!expected.startsWith("Unknown"),
);
expect(summary.textContent).toBe(expected);
expect(node("success-tx-decoded").innerHTML).not.toContain("0.0000");
});
// The Protocol line of the decoded lines shows the router.
test("the success screen has no To line of its own", async () => {
await approveSwap(v4SwapData(actions), { txHash: TX_HASH });
await receiptArrives();
const to = node("success-tx-to").parentElement;
expect(to.classList.contains("hidden")).toBe(true);
expect(node("success-tx-decoded").innerHTML).toContain(ROUTER);
});
});
// A swap paid in native ETH has a Token In of "ETH (native)" and no Amount
@@ -339,4 +355,161 @@ describe.each([
);
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
});
test("the success screen shows the ETH the transaction sends", async () => {
await approveSwap(v4SwapData(actions), { txHash: TX_HASH }, ONE_ETH);
await receiptArrives();
const summary = node("success-tx-summary");
expect(summary.parentElement.classList.contains("hidden")).toBe(false);
expect(summary.textContent).toBe("1.0000 ETH");
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/722: a WRAP_ETH of 1 ETH into the
// router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH the user
// settles. The Amount line reads 1.0000 ETH, though up to 2 ETH can leave. The
// rest is on the swap's own Moved from the sender line.
describe("a swap with a Moved from the sender line of its own", () => {
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
const TWO_ETH = 2n * ONE_ETH;
const SEND_FAILS = {
error: "The transaction could not be sent.",
retryable: false,
};
const data = routerIface.encodeFunctionData("execute", [
"0x0b0c10",
[
// WRAP_ETH and UNWRAP_WETH have the same layout.
coder.encode(["address", "uint256"], [ADDRESS_THIS, ONE_ETH]),
coder.encode(["address", "uint256"], [ADDRESS_THIS, 0n]),
coder.encode(
["bytes", "bytes[]"],
[
new Uint8Array([SWAP_EXACT_IN_SINGLE, SETTLE, TAKE_ALL]),
[
coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
[[ETH_USDC_POOL, true, ONE_ETH, 990000n, "0x"]],
),
// The user pays this one, not the router.
coder.encode(
["address", "uint256", "bool"],
[ZERO, 0n, true],
),
takeAllUsdc,
],
],
),
],
9999999999n,
]);
function expectMovedLine(id) {
const html = node(id).innerHTML;
expect(html).toContain("Moved from the sender");
expect(html).toContain("Up to 1.0000 ETH");
}
test("the wait screen shows that line's amount under the Amount figure", async () => {
await approveSwap(data, { txHash: TX_HASH }, TWO_ETH);
expect(node("wait-tx-summary").textContent).toBe("1.0000 ETH");
expectMovedLine("wait-tx-token-note");
});
test("the error screen shows that line's amount under the Amount figure", async () => {
await approveSwap(data, SEND_FAILS, TWO_ETH);
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
expectMovedLine("error-tx-token-note");
});
test("a reopened popup's wait screen still shows it", async () => {
await approveSwap(data, { txHash: TX_HASH }, TWO_ETH);
node("wait-tx-token-note").innerHTML = "";
txStatus.restoreWait();
expectMovedLine("wait-tx-token-note");
});
test("a reopened popup's error screen still shows it", async () => {
await approveSwap(data, SEND_FAILS, TWO_ETH);
node("error-tx-token-note").innerHTML = "";
txStatus.renderError();
expectMovedLine("error-tx-token-note");
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/733: a PERMIT2_PERMIT that gives
// another spender an unlimited USDC allowance, then a V4 swap of 1 ETH. The
// Amount line reads 1.0000 ETH and says nothing of the allowance.
describe("a swap that gives another spender an allowance", () => {
const OTHER_SPENDER = "0x1111111111111111111111111111111111111111";
const MAX_UINT160 = (1n << 160n) - 1n;
const SEND_FAILS = {
error: "The transaction could not be sent.",
retryable: false,
};
const data = routerIface.encodeFunctionData("execute", [
"0x0a10",
[
coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
"bytes",
],
[
[[USDC, MAX_UINT160, 0, 0], OTHER_SPENDER, 9999999999n],
"0x1234",
],
),
coder.encode(
["bytes", "bytes[]"],
[
new Uint8Array([SWAP_EXACT_IN_SINGLE, SETTLE, TAKE]),
[
coder.encode(
[`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`],
[[ETH_USDC_POOL, true, ONE_ETH, 990000n, "0x"]],
),
settleEth,
takeUsdc,
],
],
),
],
9999999999n,
]);
function expectAllowanceLine(id) {
const html = node(id).innerHTML;
expect(html).toContain("Permit2 allowance");
expect(html).toContain("Unlimited");
expect(html).toContain("Allowance token");
expect(html).toContain('<div class="font-bold break-words">USDC</div>');
expect(html).toContain(`data-copy="${USDC}"`);
}
test("the wait screen shows the allowance under the Amount figure", async () => {
await approveSwap(data, { txHash: TX_HASH }, ONE_ETH);
expect(node("wait-tx-summary").textContent).toBe("1.0000 ETH");
expectAllowanceLine("wait-tx-token-note");
});
test("the error screen shows the allowance under the Amount figure", async () => {
await approveSwap(data, SEND_FAILS, ONE_ETH);
expect(node("error-tx-summary").textContent).toBe("1.0000 ETH");
expectAllowanceLine("error-tx-token-note");
});
test("a reopened popup's wait screen still shows it", async () => {
await approveSwap(data, { txHash: TX_HASH }, ONE_ETH);
node("wait-tx-token-note").innerHTML = "";
txStatus.restoreWait();
expectAllowanceLine("wait-tx-token-note");
});
test("a reopened popup's error screen still shows it", async () => {
await approveSwap(data, SEND_FAILS, ONE_ETH);
node("error-tx-token-note").innerHTML = "";
txStatus.renderError();
expectAllowanceLine("error-tx-token-note");
});
});
+110 -16
View File
@@ -72,6 +72,11 @@ const {
const { renderSendTokenSelect } = require("../src/popup/views/send");
const { state } = require("../src/shared/state");
const { debugFetch } = require("../src/shared/log");
const { NETWORKS } = require("../src/shared/networks");
// The bundled list's symbols are known symbols only on Ethereum Mainnet.
const MAINNET = NETWORKS.mainnet.chainId;
const SEPOLIA = NETWORKS.sepolia.chainId;
// The fake "Ethereum" token with symbol "ETH" from the attack documented in
// README.md, given a holder count high enough to clear every other filter so
@@ -116,14 +121,29 @@ describe("the shared rule", () => {
});
test("a known symbol from its own contract is not a spoof", () => {
expect(isSpoofedSymbol("USDC", USDC_CONTRACT)).toBe(false);
expect(isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase())).toBe(
false,
);
expect(isSpoofedSymbol("USDC", USDC_CONTRACT, MAINNET)).toBe(false);
expect(
isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase(), MAINNET),
).toBe(false);
});
test("a known symbol from another contract is a spoof", () => {
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT, MAINNET)).toBe(true);
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so on Sepolia any contract may carry one of its
// symbols.
test("on Sepolia, a bundled symbol from any contract is not a spoof", () => {
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT, SEPOLIA)).toBe(false);
expect(isSpoofedSymbol("USDC", USDC_CONTRACT, SEPOLIA)).toBe(false);
});
test("on Sepolia, the native asset's labels are still reserved", () => {
expect(isSpoofedSymbol("ETH", FAKE_ETH_CONTRACT, SEPOLIA)).toBe(true);
expect(isSpoofedSymbol("SepoliaETH", FAKE_ETH_CONTRACT, SEPOLIA)).toBe(
true,
);
});
test("a symbol that is not in the table is not judged here", () => {
@@ -164,7 +184,9 @@ describe("the shared rule: symbols that render as a known symbol", () => {
test("ASCII padding does not buy a pass", () => {
expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("\tETH\n", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT, MAINNET)).toBe(
true,
);
});
test("non-breaking and other Unicode spaces do not either", () => {
@@ -243,7 +265,9 @@ describe("the shared rule: symbols that render as a known symbol", () => {
test("compatibility forms fold onto the symbol they imitate", () => {
expect(isSpoofedSymbol(FULLWIDTH_ETH, FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT)).toBe(true);
expect(
isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT, MAINNET),
).toBe(true);
});
// The two knowingly open classes, asserted here so that the boundary is
@@ -271,8 +295,10 @@ describe("the shared rule: symbols that render as a known symbol", () => {
});
test("a genuine contract still bears its own padded symbol", () => {
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT)).toBe(false);
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT)).toBe(false);
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT, MAINNET)).toBe(false);
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT, MAINNET)).toBe(
false,
);
});
// Normalization must not invent a match. Interior ASCII whitespace is
@@ -296,7 +322,7 @@ describe("the shared rule: symbols that render as a known symbol", () => {
expect(symbol).toMatch(/^[!-~]+$/);
if (addresses === null) continue;
for (const address of addresses) {
expect(isSpoofedSymbol(symbol, address)).toBe(false);
expect(isSpoofedSymbol(symbol, address, MAINNET)).toBe(false);
}
}
});
@@ -317,7 +343,7 @@ describe("the shared rule: symbols that render as a known symbol", () => {
describe("the shipped token list", () => {
test("no bundled token is filtered at its own address", () => {
const filtered = TOKENS.filter((t) =>
isSpoofedSymbol(t.symbol, t.address),
isSpoofedSymbol(t.symbol, t.address, MAINNET),
).map((t) => t.symbol + " @ " + t.address);
expect(filtered).toEqual([]);
});
@@ -360,7 +386,9 @@ describe("the shipped token list", () => {
expect(shared.length).toBeGreaterThan(0);
for (const [, list] of shared) {
for (const t of list) {
expect(isSpoofedSymbol(t.symbol, t.address)).toBe(false);
expect(isSpoofedSymbol(t.symbol, t.address, MAINNET)).toBe(
false,
);
}
}
});
@@ -404,7 +432,7 @@ describe("the shipped token list", () => {
[...addresses].sort(),
);
for (const address of addresses) {
expect(isSpoofedSymbol(symbol, address)).toBe(false);
expect(isSpoofedSymbol(symbol, address, MAINNET)).toBe(false);
}
}
});
@@ -421,7 +449,9 @@ describe("the shipped token list", () => {
}
for (const [symbol, list] of bySymbol) {
if (list.length < 2) continue;
expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT, MAINNET)).toBe(
true,
);
}
});
});
@@ -506,10 +536,40 @@ describe("surface 2: the Send token selector", () => {
}
beforeEach(() => {
state.networkId = "mainnet";
state.fraudContracts = [];
state.hideLowHolderTokens = true;
});
afterAll(() => {
state.networkId = "mainnet";
});
// A token reporting USDC's symbol from another contract.
function otherUsdc() {
return {
address: FAKE_ETH_CONTRACT,
symbol: "USDC",
decimals: 6,
balance: "12.5",
holders: 900000,
};
}
test("a bundled symbol from another contract is not selectable", () => {
render([otherUsdc()]);
expect(select.children).toEqual([]);
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so on Sepolia any contract may carry its symbols.
test("with Sepolia active, a bundled symbol from another contract is selectable", () => {
state.networkId = "sepolia";
render([otherUsdc()]);
expect(select.children).toHaveLength(1);
expect(select.children[0].value).toBe(FAKE_ETH_CONTRACT);
});
test("a fake ETH token is not selectable", () => {
render([
{
@@ -627,7 +687,12 @@ describe("surface 3: the balance list", () => {
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
[],
MAINNET,
);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe(" USDC ");
});
@@ -641,11 +706,40 @@ describe("surface 3: the balance list", () => {
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
[],
MAINNET,
);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC");
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so on Sepolia any contract may carry its symbols.
test("a bundled symbol from another contract is listed on Sepolia only", async () => {
respondWith([fakeEthItem({ symbol: "USDC", decimals: "6" })]);
expect(
await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], MAINNET),
).toEqual([]);
const sepolia = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
[],
SEPOLIA,
);
expect(sepolia).toHaveLength(1);
expect(sepolia[0].symbol).toBe("USDC");
});
test("a fake ETH token is filtered on Sepolia too", async () => {
respondWith([fakeEthItem()]);
expect(
await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], SEPOLIA),
).toEqual([]);
});
// The trap in this change: the user's real ETH balance is not an ERC-20
// and is fetched over RPC in refreshBalances, so it never passes through
// this loop at all. An explorer row that is not an ERC-20 is dropped
+58 -5
View File
@@ -21,6 +21,8 @@
// calls. Driven against a minimal DOM stub in the shape
// tests/contractCreation.test.js uses.
const fs = require("fs");
const path = require("path");
const { Interface } = require("ethers");
jest.mock("../src/shared/log", () => ({
@@ -51,6 +53,7 @@ globalThis.chrome = {
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const { displaySymbol } = require("../src/shared/symbolDisplay");
const transactionDetail = require("../src/popup/views/transactionDetail");
const EXPLORER = "https://explorer.example/api/v2";
@@ -197,15 +200,63 @@ test("a token transfer's decoded details name the token contract and the amount
"Token Transfer",
);
const token = decodedLine("Token");
expect(token).toContain('<div class="font-bold">TDOL</div>');
expect(token).toContain('<div class="font-bold break-words">TDOL</div>');
expect(token).toContain(TOKEN);
expect(token).not.toContain(RECIPIENT);
expect(decodedLine("Recipient")).toContain(RECIPIENT);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
'<div class="font-bold break-words">1234.5000 TDOL</div>',
);
});
// Nothing limits the length of a symbol the explorer reports. Without the
// break-words class a long one runs past the right edge of the screen and is
// cut off.
describe("a long token symbol", () => {
const LONG = "L".repeat(60);
test("wraps on the amount, native quantity and decoded lines", async () => {
state.trackedTokens = [{ address: TOKEN, symbol: LONG, decimals: 6 }];
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.mainnet.chainId,
);
transactionDetail.show({
...row,
symbol: LONG,
rawUnit: LONG + " base units (10^-6)",
});
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
expect(node("tx-detail-value").innerHTML).toContain(
`font-bold break-words" data-copy="1234.5 ${displaySymbol(LONG)}"`,
);
expect(node("tx-detail-native").innerHTML).toContain(
`break-words" data-copy="1234500000 ${LONG} base units (10^-6)"`,
);
expect(node("tx-detail-calldata-details").innerHTML).toContain(
`<div class="mb-2 break-words">Transfer ${LONG}</div>`,
);
expect(decodedLine("Token")).toContain(
`<div class="font-bold break-words">${displaySymbol(LONG)}</div>`,
);
expect(decodedLine("Amount")).toContain(
`<div class="font-bold break-words">1234.5000 ${LONG}</div>`,
);
});
test("wraps on the Action line", () => {
const html = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
);
expect(html).toMatch(
/id="tx-detail-calldata-action"\s+class="text-xs font-bold mb-2 break-words"/,
);
});
});
// Mainnet is active, and its Blockscout knows nothing of the transaction. The
// row was read from the Blockscout Settings remembers for Sepolia, and the
// screen reads the transaction from there too, so every link on it is drawn.
@@ -278,9 +329,11 @@ test("with mainnet active, a Sepolia token transfer's decoded details name the t
};
await showSepoliaTransferWithMainnetActive();
expect(decodedLine("Token")).toContain('<div class="font-bold">TDOL</div>');
expect(decodedLine("Token")).toContain(
'<div class="font-bold break-words">TDOL</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 TDOL</div>',
'<div class="font-bold break-words">1234.5000 TDOL</div>',
);
});
@@ -295,6 +348,6 @@ test("with mainnet active, a Sepolia token transfer's decoded details take nothi
expect(decodedLine("Token")).not.toContain("MDOL");
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
'<div class="font-bold break-words">1234500000 base units (decimals unknown)</div>',
);
});
+103 -2
View File
@@ -133,13 +133,13 @@ function trackedRow(balance, decimals = null, holders = null) {
};
}
async function refresh(addresses, trackedTokens) {
async function refresh(addresses, trackedTokens, networkId = "mainnet") {
await refreshBalances(
[{ addresses }],
RPC_URL,
EXPLORER_URL,
trackedTokens,
"mainnet",
networkId,
new AbortController().signal,
);
return addresses;
@@ -307,3 +307,104 @@ test("a tracked token wearing a known symbol from another contract is not listed
);
expect(first.tokenBalances).toEqual([]);
});
test("the refresh resolves with each tracked token whose balance it read at every address", async () => {
const flaky = "0x9999999999999999999999999999999999999999";
holdings[TOKEN] = { [FIRST]: 2000000n, [SECOND]: 0n };
// balanceOf answers at FIRST and fails at SECOND.
holdings[flaky] = { [FIRST]: 2000000n };
const read = await refreshBalances(
[
{
addresses: [
{ address: FIRST, tokenBalances: [] },
{ address: SECOND, tokenBalances: [] },
],
},
],
RPC_URL,
EXPLORER_URL,
[
TRACKED,
{ address: flaky, symbol: "FLK", name: "Flaky", decimals: 6 },
// A known symbol from another contract: its balance is not read.
{ address: OTHER, symbol: "USDC", name: "Fake", decimals: 6 },
],
"mainnet",
new AbortController().signal,
);
expect(read).toEqual([TOKEN]);
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// Ethereum Mainnet contracts. A token outside the tracked tokens with an
// unknown holder count is listed only when the bundled list names it.
test("an untracked bundled token with an unknown holder count is listed on Ethereum Mainnet only", async () => {
explorerRows = [explorerRow(USDC, "USDC", 5000000, null)];
const [mainnet] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[],
);
expect(mainnet.tokenBalances).toEqual([
{
address: USDC,
name: "USDC",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: null,
},
]);
const [sepolia] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[],
"sepolia",
);
expect(sepolia.tokenBalances).toEqual([]);
});
test("a tracked token at a bundled token's address takes the bundled scale on Ethereum Mainnet only", async () => {
holdings[USDC] = { [FIRST]: 5000000n };
// The tracked entry records no decimals, and Blockscout fails.
const tracked = [
{ address: USDC, symbol: "USDC", name: "USD Coin", decimals: null },
];
const row = (balance) => ({
address: USDC,
name: "USD Coin",
symbol: "USDC",
decimals: null,
balance: balance,
holders: null,
});
const [mainnet] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
tracked,
);
expect(mainnet.tokenBalances).toEqual([row("5.0")]);
const [sepolia] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
tracked,
"sepolia",
);
expect(sepolia.tokenBalances).toEqual([row(null)]);
});
test("with Sepolia active, a tracked token wearing a bundled symbol from another contract is listed", async () => {
holdings[OTHER] = { [FIRST]: 5000000n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[{ address: OTHER, symbol: "USDC", name: "Other", decimals: 6 }],
"sepolia",
);
expect(first.tokenBalances).toEqual([
{
address: OTHER,
name: "Other",
symbol: "USDC",
decimals: null,
balance: "5.0",
holders: null,
},
]);
});
+538 -5
View File
@@ -117,6 +117,17 @@ async function chooseNetwork(boot, networkId) {
await boot.settle();
}
// Adds NEW_TOKEN_ADDRESS by contract address on SettingsAddToken, as the user
// does. Returns the record the add tracks.
async function addNewToken(boot) {
const { lookupTokenInfo } = require("../src/shared/balances");
const info = { symbol: "NEW", decimals: 18, name: "New Token" };
lookupTokenInfo.mockResolvedValueOnce(info);
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
await boot.click("btn-settings-addtoken-manual");
return { address: NEW_TOKEN_ADDRESS, ...info, balanceNotFetched: true };
}
// The Settings gear, a switch to Sepolia, and Back.
async function gearSwitchBack(boot) {
await boot.click("btn-settings");
@@ -162,19 +173,24 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
"sepolia",
expect.anything(),
);
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
// No refresh has read the new token's balance yet.
const added = { ...SEPOLIA_TOKEN, balanceNotFetched: true };
expect(stored().trackedTokens).toEqual([added]);
await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet");
expect(stored().networkId).toBe("mainnet");
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
// No refresh has read a balance on this network since the switch.
expect(stored().trackedTokens).toEqual([
{ ...MAINNET_TOKEN, balanceNotFetched: true },
]);
const onMainnet = boot.node("settings-tracked-tokens").innerHTML;
expect(onMainnet).toContain("DAI");
expect(onMainnet).not.toContain("TST");
await chooseNetwork(boot, "sepolia");
expect(stored().networkId).toBe("sepolia");
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored().trackedTokens).toEqual([added]);
const onSepolia = boot.node("settings-tracked-tokens").innerHTML;
expect(onSepolia).toContain("TST");
expect(onSepolia).not.toContain("DAI");
@@ -261,12 +277,259 @@ test("a token tracked on Sepolia when another page switched there is not tracked
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// Another popup page saves a switch to Sepolia, where TST is tracked. Before
// taking that switch, this page adds a token on Ethereum Mainnet. The add's
// save adds the token to Ethereum Mainnet's list and leaves Sepolia's list as
// it was.
test("a token added before this page takes another page's switch is tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet", {
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
});
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [SEPOLIA_TOKEN],
networkTrackedTokens: {
mainnet: [MAINNET_TOKEN],
sepolia: [SEPOLIA_TOKEN],
},
});
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
await boot.click("btn-settings-addtoken-manual");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
balanceNotFetched: true,
},
]);
});
// Another popup page tracks DAI on Ethereum Mainnet and then saves a switch to
// Sepolia. This page does not track DAI. Before taking that switch, this page
// adds a token on Ethereum Mainnet. The add's save puts both DAI and the new
// token on Ethereum Mainnet's list.
test("a token added before this page takes another page's switch joins that page's tokens on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
await boot.click("btn-settings-addtoken-manual");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
balanceNotFetched: true,
},
]);
});
// Another popup page saves a switch to Sepolia, where TST is tracked. Before
// taking that switch, this page removes DAI on Ethereum Mainnet, as the `[x]`
// on a tracked token in Settings does. The removal's save drops DAI from
// Ethereum Mainnet's list and leaves Sepolia's list as it was.
test("a token removed before this page takes another page's switch is gone from Ethereum Mainnet", async () => {
const boot = await bootPopup(
unversionedValidProfile({
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
}),
);
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [SEPOLIA_TOKEN],
networkTrackedTokens: {
mainnet: [MAINNET_TOKEN],
sepolia: [SEPOLIA_TOKEN],
},
});
const { state, saveState } = require("../src/shared/state");
state.trackedTokens.splice(0, 1);
await saveState();
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// Another popup page tracks DAI on Ethereum Mainnet and then saves a switch to
// Sepolia. That switch puts DAI away under Ethereum Mainnet. This page has not
// taken the switch yet. A switch to Sepolia in this page's Settings keeps DAI
// on Ethereum Mainnet's list.
test("a token another page tracked before its own switch stays tracked on Ethereum Mainnet", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// This page switches to Sepolia. Another popup page has not taken that switch,
// and its save adds a token to Ethereum Mainnet's list. This page switches back
// and tracks that token.
test("a token another page added on Ethereum Mainnet is tracked when this page switches back there", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const newToken = {
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
};
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkTrackedTokens: { mainnet: [MAINNET_TOKEN, newToken] },
});
await chooseNetwork(boot, "mainnet");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.trackedTokens).toEqual([
{ ...MAINNET_TOKEN, balanceNotFetched: true },
{ ...newToken, balanceNotFetched: true },
]);
});
// This page tracks DAI on Ethereum Mainnet and switches to Sepolia. Another
// popup page has not taken that switch, and its save removes DAI from Ethereum
// Mainnet's list. This page switches back and does not track DAI again.
test("a token another page removed on Ethereum Mainnet stays removed when this page switches back there", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkTrackedTokens: { mainnet: [] },
});
await chooseNetwork(boot, "mainnet");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.trackedTokens).toEqual([]);
});
// This page switches to Sepolia, where TST is tracked. Another popup page has
// not taken that switch, and its save tracks DAI on Ethereum Mainnet. This page
// then adds a token on Sepolia. Its save changes only `trackedTokens`, so DAI
// stays on Ethereum Mainnet's list.
test("a token another page added on Ethereum Mainnet stays when this page adds a token on Sepolia", async () => {
const boot = await bootOnAddToken("mainnet", {
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
});
expect(boot.pageErrors).toEqual([]);
await gearSwitchBack(boot);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkTrackedTokens: {
mainnet: [MAINNET_TOKEN],
sepolia: [SEPOLIA_TOKEN],
},
});
const added = await addNewToken(boot);
const stored = boot.storage.read("autistmask");
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
expect(stored.networkTrackedTokens.sepolia).toEqual([SEPOLIA_TOKEN]);
expect(stored.trackedTokens).toEqual([
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
added,
]);
});
// This page tracks DAI on Ethereum Mainnet and switches to Sepolia, where TST is
// tracked. Another popup page has not taken that switch, and its save removes
// DAI from Ethereum Mainnet's list. This page then adds a token on Sepolia. Its
// save changes only `trackedTokens`, so DAI does not come back.
test("a token another page removed on Ethereum Mainnet stays removed when this page adds a token on Sepolia", async () => {
const boot = await bootOnAddToken("mainnet", {
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
});
expect(boot.pageErrors).toEqual([]);
await gearSwitchBack(boot);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkTrackedTokens: { mainnet: [], sepolia: [SEPOLIA_TOKEN] },
});
const added = await addNewToken(boot);
const stored = boot.storage.read("autistmask");
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
expect(stored.networkTrackedTokens.sepolia).toEqual([SEPOLIA_TOKEN]);
expect(stored.trackedTokens).toEqual([
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
added,
]);
});
// Another popup page records a fraud contract on Ethereum Mainnet after this
// page's last save. The switch to Sepolia drops that fraud contract, as the
// switch drops this page's own.
@@ -334,10 +597,276 @@ test("a token added while a switch in Settings waits for the lock stays tracked
symbol: "NEW",
decimals: 18,
name: "New Token",
balanceNotFetched: true,
},
]);
});
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while
// this page's switch to Sepolia waits for the lock. Another popup page holds
// the lock and stores its own switch to Sepolia, with DAI tracked on Ethereum
// Mainnet. This page's switch keeps DAI and the new token on Ethereum Mainnet's
// list.
test("a token added while a switch in Settings waits for another page's switch stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
const { lookupTokenInfo } = require("../src/shared/balances");
let answer;
lookupTokenInfo.mockImplementationOnce(
() =>
new Promise((resolve) => {
answer = resolve;
}),
);
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.click("btn-settings");
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
const select = boot.node("settings-network");
select.value = "sepolia";
const switching = Promise.all(select.listeners.change.map((fn) => fn()));
await boot.settle();
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
});
answer({ symbol: "NEW", decimals: 18, name: "New Token" });
await boot.settle();
releaseLock();
await lockHeld;
await switching;
await adding;
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
balanceNotFetched: true,
},
]);
});
// Another popup page holds the lock and stores its switch to Sepolia, with DAI
// tracked on Ethereum Mainnet. This page waits for the lock to take that
// switch. Meanwhile, the contract lookup on SettingsAddToken answers on
// Ethereum Mainnet. When this page takes that switch, DAI and the new token
// stay on Ethereum Mainnet's list.
test("a token added while this page waits to take another page's switch stays tracked on Ethereum Mainnet", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
await boot.storage.local.set({
autistmask: {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
},
});
await boot.settle();
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
const adding = boot.click("btn-settings-addtoken-manual");
await boot.settle();
releaseLock();
await lockHeld;
await adding;
await boot.settle();
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
balanceNotFetched: true,
},
]);
});
// This page tracks DAI on Ethereum Mainnet. Another popup page holds the lock
// and stores its switch to Sepolia, with DAI on Ethereum Mainnet's list. This
// page waits for the lock to take that switch. Meanwhile, the user removes DAI,
// as the `[x]` on a tracked token in Settings does. When this page takes that
// switch, DAI is gone from Ethereum Mainnet's list.
test("a token removed while this page waits to take another page's switch is gone from Ethereum Mainnet", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
await boot.storage.local.set({
autistmask: {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [],
networkTrackedTokens: { mainnet: [MAINNET_TOKEN] },
},
});
await boot.settle();
const { state, saveState } = require("../src/shared/state");
state.trackedTokens.splice(0, 1);
const removing = saveState();
releaseLock();
await lockHeld;
await removing;
await boot.settle();
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// Another popup page tracks DAI after this page's last save. This page then
// adds a token. Its save keeps DAI.
test("a token another page added stays when this page adds a token", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [MAINNET_TOKEN],
});
const added = await addNewToken(boot);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
MAINNET_TOKEN,
added,
]);
});
// Another popup page tracks a new token after this page's last save. This page
// then removes DAI, as the `[x]` on a tracked token in Settings does. Its save
// keeps the new token.
test("a token another page added stays when this page removes a token", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
const otherPageToken = {
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
};
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [MAINNET_TOKEN, otherPageToken],
});
const { state, saveState } = require("../src/shared/state");
state.trackedTokens.splice(0, 1);
await saveState();
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
otherPageToken,
]);
});
// Another popup page stops tracking DAI after this page's last save. This page
// still holds DAI and adds a token. Its save does not store DAI again.
test("a token another page removed stays removed when this page adds a token", async () => {
const boot = await bootOnAddToken("mainnet", {
trackedTokens: [MAINNET_TOKEN],
});
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [],
});
const added = await addNewToken(boot);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([added]);
});
// Another popup page tracks DAI and the new token after this page's last save,
// and its refresh has read the new token's balance. This page adds the new token
// too, so the two pages hold different records for it. The save stores one
// record for the new token, this page's, and keeps DAI.
test("a token both pages added is stored once, with this page's record", async () => {
const boot = await bootOnAddToken("mainnet");
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [
MAINNET_TOKEN,
{
address: NEW_TOKEN_ADDRESS,
symbol: "NEW",
decimals: 18,
name: "New Token",
},
],
});
const added = await addNewToken(boot);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
MAINNET_TOKEN,
added,
]);
});
// This page and the stored list each hold DAI twice. This page then adds a
// token. Its save stores DAI once.
test("a token held twice is stored once when this page adds a token", async () => {
const boot = await bootOnAddToken("mainnet", {
trackedTokens: [MAINNET_TOKEN, MAINNET_TOKEN],
});
expect(boot.pageErrors).toEqual([]);
const added = await addNewToken(boot);
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
MAINNET_TOKEN,
added,
]);
});
describe("Back after a switch from the Settings gear", () => {
test("SettingsAddToken no longer offers the common tokens", async () => {
const boot = await bootOnAddToken("mainnet");
@@ -516,7 +1045,9 @@ describe("a contract lookup that answers after a switch adds nothing", () => {
await addAcrossSwitch(boot, "btn-settings-addtoken-manual", "mainnet");
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
expect(stored().trackedTokens).toEqual([
{ ...MAINNET_TOKEN, balanceNotFetched: true },
]);
expect(stored().networkTrackedTokens.sepolia).toEqual([]);
});
@@ -529,7 +1060,9 @@ describe("a contract lookup that answers after a switch adds nothing", () => {
await addAcrossSwitch(boot, "btn-add-token-confirm", "sepolia");
expect(boot.visibleViews()).toEqual(["settings"]);
expect(boot.text("flash-msg")).toBe(NETWORK_CHANGED_FLASH);
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored().trackedTokens).toEqual([
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
]);
expect(stored().networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
});
+62
View File
@@ -144,6 +144,7 @@ function tokenTx(overrides = {}) {
isError: false,
contractAddress: USDC_CONTRACT,
holders: 3500000,
chainId: "0x1",
...overrides,
};
}
@@ -259,6 +260,32 @@ describe("known-symbol spoof verification", () => {
expect(result.newFraudContracts).toEqual([NOVEL_SPAM_CONTRACT]);
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so its symbols are not known symbols on Sepolia.
test("on Sepolia, a bundled symbol from another contract is kept", () => {
const sepoliaToken = tokenTx({
symbol: "USDC",
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 5000000,
chainId: "0xaa36a7",
});
const result = filterTransactions([sepoliaToken], filters());
expect(result.transactions).toEqual([sepoliaToken]);
expect(result.newFraudContracts).toEqual([]);
});
test('on Sepolia, "ETH" as an ERC-20 is still spoofed', () => {
const fakeEth = tokenTx({
symbol: "ETH",
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 5000000,
chainId: "0xaa36a7",
});
expect(filterTransactions([fakeEth], filters()).transactions).toEqual(
[],
);
});
test("the genuine contract for that symbol is not spoofed", () => {
const genuine = tokenTx();
const result = filterTransactions([genuine], filters());
@@ -1495,6 +1522,41 @@ describe("fetchRecentTransactions merge and dedup", () => {
expect(mainnet[0].chainId).toBe("0x1");
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled token list
// names a contract only on Ethereum Mainnet. On Sepolia, a call to the
// contract at USDC's mainnet address is labelled with the network's native
// currency. A call to any contract the wallet does not name gets that label.
test("a contract call on Sepolia is not named by the bundled list", async () => {
respondWith(
[
{
hash: "0x" + "b".repeat(64),
block_number: 21000095,
timestamp: TS,
from: { hash: VICTIM },
to: { hash: USDC_CONTRACT, is_contract: true },
value: "0",
method: "approve",
status: "ok",
},
],
[],
);
const sepolia = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
"0xaa36a7",
);
expect(sepolia[0].directionLabel).toBe("Approve");
expect(sepolia[0].symbol).toBe("SepoliaETH");
const mainnet = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
"0x1",
);
expect(mainnet[0].symbol).toBe("USDC");
});
test("the fake token transfer survives fetching and is then filtered", async () => {
respondWith(
[],
+416 -4
View File
@@ -101,6 +101,9 @@ global.document = {
global.window = { location: { search: "" } };
const fs = require("fs");
const path = require("path");
// Clones in both directions, as the real chrome.storage.local does; the stub
// here used to hand back the live stored object, so an in-memory mutation
// looked like a write that had reached storage. See
@@ -115,6 +118,7 @@ const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const { log } = require("../src/shared/log");
const { CONTRACT_CREATION_TEXT } = require("../src/popup/views/helpers");
const TX_HASH =
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
@@ -614,8 +618,8 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
const decoded = {
name: "Transfer",
details: [
{ label: "Token", address: TOKEN, isToken: true },
{ label: "Recipient", address: RECIPIENT },
{ label: "Token", value: TOKEN, address: TOKEN, isToken: true },
{ label: "Recipient", value: RECIPIENT, address: RECIPIENT },
],
};
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
@@ -633,7 +637,7 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
test("SuccessTx's decoded action names the token from the tokens tracked on Sepolia", async () => {
function expectTokenNamed() {
expect(getElement("success-tx-decoded").innerHTML).toContain(
'<div class="font-bold">TDOL</div>',
'<div class="font-bold break-words">TDOL</div>',
);
}
state.networkTrackedTokens = {
@@ -641,7 +645,9 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
};
const decoded = {
name: "Transfer",
details: [{ label: "Token", address: TOKEN, isToken: true }],
details: [
{ label: "Token", value: TOKEN, address: TOKEN, isToken: true },
],
};
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
@@ -672,6 +678,412 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
});
});
describe("the Moved from the sender lines of a swap on WaitTx and ErrorTx", () => {
const moved = (value) => ({ label: "Moved from the sender", value });
const decoded = {
details: [
moved("Up to 1.0000 ETH"),
{ label: "Token moved", value: "ETH" },
moved("<b>400.0000</b> DAI"),
],
};
function expectEveryLine(id) {
const html = getElement(id).innerHTML;
expect(html).toContain("Up to 1.0000 ETH");
expect(html).toContain("&lt;b&gt;400.0000&lt;/b&gt; DAI");
expect(html).not.toContain("Token moved");
}
test("WaitTx shows each line's amount, escaped", () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
expectEveryLine("wait-tx-token-note");
});
test("ErrorTx shows each line's amount, escaped", () => {
txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed.");
expectEveryLine("error-tx-token-note");
});
test("a wait that ends in ErrorTx keeps them", async () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
await jest.advanceTimersByTimeAsync(60000);
expect(state.currentView).toBe("error-tx");
expectEveryLine("error-tx-token-note");
});
// A saved record comes back from storage, in whatever shape it is in. A
// throw here would leave the popup's restore unfinished.
test("a saved record in another shape shows only the lines it can read", () => {
mockReceiptLookup.mockResolvedValue(null);
function noteOnReopen(saved) {
state.viewData = {
pendingWait: {
hash: TX_HASH,
txInfo: { ...TX_INFO, decoded: saved },
broadcastTime: Date.now(),
},
};
expect(txStatus.restoreWait()).toBe(true);
const waitNote = getElement("wait-tx-token-note").innerHTML;
txStatus.endWait();
state.viewData = {
message: "It failed.",
to: RECIPIENT,
chainId: TX_INFO.chainId,
decoded: saved,
};
txStatus.renderError();
expect(getElement("error-tx-token-note").innerHTML).toBe(waitNote);
return waitNote;
}
for (const saved of [
7,
"text",
{ details: 7 },
{ details: [null] },
{ details: [{ label: "Moved from the sender" }] },
{ details: [moved(7)] },
{ details: [moved({ toString: 1 })] },
{ details: [{ label: "Permit2 allowance", value: 7 }] },
{
details: [
{ label: "Permit2 allowance", value: "text", address: 7 },
],
},
{
details: [
{
label: "Allowance token",
value: "text",
address: 7,
isToken: true,
},
],
},
]) {
expect(noteOnReopen(saved)).toBe("");
}
expect(
noteOnReopen({ details: [null, moved("Up to 1.0000 ETH")] }),
).toContain("Up to 1.0000 ETH");
});
});
describe("the Permit2 allowance lines of a swap on WaitTx and ErrorTx", () => {
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const allowance = (value) => ({ label: "Permit2 allowance", value });
const decoded = {
details: [
allowance("Unlimited"),
{
label: "Allowance token",
value: "USDC (" + USDC + ")",
address: USDC,
isToken: true,
},
{
label: "Allowance spender",
value: RECIPIENT,
address: RECIPIENT,
},
allowance("<b>400.0000</b> DAI"),
],
};
function expectEveryLine(id) {
const html = getElement(id).innerHTML;
expect(html).toContain("Permit2 allowance");
expect(html).toContain("Unlimited");
expect(html).toContain("&lt;b&gt;400.0000&lt;/b&gt; DAI");
expect(html).not.toContain("Allowance spender");
expect(html).not.toContain(RECIPIENT);
}
// `Unlimited` alone does not say which token it covers. The address gets
// the row of its own that every address gets, in the transaction's network.
function expectTokenLine(id) {
const html = getElement(id).innerHTML;
expect(html.indexOf("Unlimited")).toBeLessThan(
html.indexOf("Allowance token"),
);
expect(html).toContain('<div class="font-bold break-words">USDC</div>');
expect(html).toContain(`data-copy="${USDC}"`);
expect(html).toContain(`href="https://etherscan.io/address/${USDC}"`);
expect(html).toMatch(/<div class="text-xs">.*class="am-address"/);
}
test("WaitTx says which token an allowance covers", () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
expectTokenLine("wait-tx-token-note");
});
test("ErrorTx says which token an allowance covers", () => {
txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed.");
expectTokenLine("error-tx-token-note");
});
test("the token's address links to the transaction's network", () => {
const sepoliaTx = {
...TX_INFO,
chainId: NETWORKS.sepolia.chainId,
decoded,
};
txStatus.showError(sepoliaTx, TX_HASH, "It failed.");
expect(getElement("error-tx-token-note").innerHTML).toContain(
`href="https://sepolia.etherscan.io/address/${USDC}"`,
);
});
test("the token's address can be copied on an ErrorTx with no hash", () => {
const view = getElement("view-error-tx");
view.querySelectorAll = jest.fn(() => []);
txStatus.showError({ ...TX_INFO, decoded }, null, "It failed.");
expect(view.querySelectorAll).toHaveBeenCalledWith("[data-copy]");
});
test("WaitTx shows each line's amount, escaped", () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
expectEveryLine("wait-tx-token-note");
});
test("ErrorTx shows each line's amount, escaped", () => {
txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed.");
expectEveryLine("error-tx-token-note");
});
test("a wait that ends in ErrorTx keeps them", async () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait({ ...TX_INFO, decoded }, TX_HASH);
await jest.advanceTimersByTimeAsync(60000);
expect(state.currentView).toBe("error-tx");
expectEveryLine("error-tx-token-note");
});
});
// An amount of about 47 digits or more is wider than the screen. Without the
// break-words class its last digits are cut off at the edge. That class keeps
// the words of a line such as `(decimals unknown)` whole.
describe("a long amount on WaitTx, ErrorTx and SuccessTx", () => {
test("each amount under the Amount figure wraps", () => {
const decoded = {
details: [
{ label: "Moved from the sender", value: "1.0000 ETH" },
{ label: "Permit2 allowance", value: "Unlimited" },
],
};
txStatus.showError({ ...TX_INFO, decoded }, TX_HASH, "It failed.");
const html = getElement("error-tx-token-note").innerHTML;
expect(html).toContain(
'<div class="font-bold break-words">1.0000 ETH<',
);
expect(html).toContain('<div class="font-bold break-words">Unlimited<');
});
test("the Amount figure wraps", () => {
const html = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
);
expect(html).toMatch(
/id="wait-tx-summary"\s+class="font-bold break-words"/,
);
expect(html).toMatch(
/id="error-tx-summary"\s+class="font-bold break-words"/,
);
expect(html).toMatch(
/id="success-tx-summary"\s+class="font-bold break-words"/,
);
});
test("each amount of SuccessTx's decoded action wraps", () => {
state.viewData = {
hash: TX_HASH,
to: RECIPIENT,
chainId: TX_INFO.chainId,
decoded: { details: [{ label: "Amount", value: "1.0000 ETH" }] },
};
txStatus.renderSuccess();
expect(getElement("success-tx-decoded").innerHTML).toContain(
'<div class="font-bold break-words">1.0000 ETH<',
);
});
test("the name and description of SuccessTx's decoded action wrap", () => {
state.viewData = {
hash: TX_HASH,
to: RECIPIENT,
chainId: TX_INFO.chainId,
decoded: {
name: "Transfer LONGSYMBOL",
description: "Transfer LONGSYMBOL to a recipient",
details: [{ label: "Amount", value: "1.0000 ETH" }],
},
};
txStatus.renderSuccess();
const html = getElement("success-tx-decoded").innerHTML;
expect(html).toContain(
'<div class="font-bold break-words">Transfer LONGSYMBOL<',
);
expect(html).toContain(
'<div class="break-words">Transfer LONGSYMBOL to a recipient<',
);
});
});
// A saved wait comes back from storage, in whatever shape it is in. A throw when
// its receipt arrives would leave the popup on WaitTx with the counter stopped.
describe("a saved wait in another shape when its receipt arrives", () => {
const LINE = { label: "Steps", value: "V3_SWAP_EXACT_IN" };
async function receiptForSavedWait(decoded) {
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
state.viewData = {
pendingWait: {
hash: TX_HASH,
txInfo: { ...TX_INFO, decoded },
broadcastTime: Date.now(),
},
};
expect(txStatus.restoreWait()).toBe(true);
await jest.advanceTimersByTimeAsync(0);
}
test.each([
{ details: 7 },
{ details: "text" },
{ details: { label: "Steps", value: "text" } },
{ details: [null] },
{ details: [7] },
{ details: ["text"] },
{ details: [{ value: "text" }] },
{ details: [{ label: 7, value: "text" }] },
{ details: [{ label: { toString: 1 }, value: "text" }] },
{ details: [{ label: "Steps" }] },
{ details: [{ label: "Steps", value: 7 }] },
{ details: [{ label: "Steps", value: { toString: 1 } }] },
{ details: [{ label: "Steps", address: RECIPIENT }] },
{ details: [{ label: "Steps", value: "text", address: 7 }] },
{
details: [
{ label: "Steps", value: "text", address: 7, isToken: true },
],
},
{ name: "Swap", description: "A swap", details: [null] },
])("%j shows SuccessTx with the amount and recipient", async (decoded) => {
await receiptForSavedWait(decoded);
expect(state.currentView).toBe("success-tx");
expect(
getElement("success-tx-decoded").classList.contains("hidden"),
).toBe(true);
expect(
getElement("success-tx-summary-parent").classList.contains(
"hidden",
),
).toBe(false);
expect(getElement("success-tx-summary").textContent).toBe("0.0050 ETH");
expect(
getElement("success-tx-to-parent").classList.contains("hidden"),
).toBe(false);
expect(getElement("success-tx-to").innerHTML).toContain(RECIPIENT);
});
test.each([
{ details: [null, LINE, 7] },
{ name: 7, details: [LINE] },
{ name: { toString: 1 }, details: [LINE] },
{ description: 7, details: [LINE] },
{ description: { toString: 1 }, details: [LINE] },
])("%j shows SuccessTx with the line it can read", async (decoded) => {
await receiptForSavedWait(decoded);
expect(state.currentView).toBe("success-tx");
const html = getElement("success-tx-decoded").innerHTML;
expect(html).toContain("V3_SWAP_EXACT_IN");
expect(html).not.toContain("Action");
expect(html).not.toContain("Description");
});
});
// The decoded action stands in for the Amount and To lines only where it shows
// the same thing. A saved record with unreadable lines can leave out either.
describe("SuccessTx's Amount and To lines beside a decoded action", () => {
const OTHER = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
const STEPS = { label: "Steps", value: "V3_SWAP_EXACT_IN" };
const AMOUNT = { label: "Amount", value: "0.0050 ETH" };
const RECIPIENT_LINE = {
label: "Recipient",
value: RECIPIENT,
address: RECIPIENT,
};
const SPENDER = { label: "Spender", value: OTHER, address: OTHER };
function renderSuccessWith(details, to) {
state.viewData = {
amount: "0.0050",
symbol: "ETH",
to,
hash: TX_HASH,
chainId: TX_INFO.chainId,
decoded: { details },
};
txStatus.renderSuccess();
}
function lineHidden(id) {
return getElement(id + "-parent").classList.contains("hidden");
}
test.each([
["no line for either", [STEPS], false, false],
["a line of another address", [AMOUNT, SPENDER], true, false],
["an Amount line only", [AMOUNT], true, false],
["a Recipient line only", [RECIPIENT_LINE], false, true],
[
"an unreadable Amount line",
[{ label: "Amount" }, RECIPIENT_LINE],
false,
true,
],
[
"an unreadable Recipient line",
[AMOUNT, { address: RECIPIENT }],
true,
false,
],
["a line for each", [AMOUNT, RECIPIENT_LINE], true, true],
])("%s", (_name, details, amountHidden, toHidden) => {
renderSuccessWith(details, RECIPIENT);
expect(state.currentView).toBe("success-tx");
expect(lineHidden("success-tx-summary")).toBe(amountHidden);
expect(lineHidden("success-tx-to")).toBe(toHidden);
});
test("the lines read the amount and the recipient", () => {
renderSuccessWith([STEPS], RECIPIENT);
expect(getElement("success-tx-summary").textContent).toBe("0.0050 ETH");
expect(getElement("success-tx-to").innerHTML).toContain(RECIPIENT);
});
// A contract creation's data can decode as an ERC-20 call, with an empty
// token address.
test("a contract creation keeps its To line", () => {
renderSuccessWith(
[{ label: "Token", value: "", address: "", isToken: true }],
"",
);
expect(lineHidden("success-tx-to")).toBe(false);
expect(getElement("success-tx-to").innerHTML).toBe(
CONTRACT_CREATION_TEXT,
);
});
});
describe("wait-tx is a view the popup may reopen onto", () => {
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
// refuses any view not in the set, so dropping "wait-tx" from it kills
+855 -5
View File
@@ -1428,8 +1428,8 @@ describe("uniswap decoder", () => {
];
// execute() of these command bytes and inputs, decoded as sent by
// the user.
function decodeSteps(commands, inputs) {
// the user to `router`.
function decodeSteps(commands, inputs, router = ROUTER_ADDR) {
const data = buildExecute(
solidityPacked(
commands.map(() => "uint8"),
@@ -1438,7 +1438,7 @@ describe("uniswap decoder", () => {
inputs,
9999999999n,
);
return uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
return uniswap.decode(data, router, "0x1", {}, USER_ADDR);
}
// The first step: a V3 exact-in of 600 USDT for USDC, paid by the
@@ -2424,6 +2424,505 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/665 — a WRAP_ETH that
// sets the input side and pays its WETH to anyone but the router
// names that recipient on its Moved to line. On next each row read
// The swap step that spends it.
const ONE_ETH = 1000000000000000000n;
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
// The lines of a WRAP_ETH of 1 ETH whose Moved to line reads `to`.
const wrapLines = (to) => [
["Moved from the sender", "1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", to],
];
test.each([
{
kind: "a WRAP_ETH of 1 ETH to another address, then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x21],
inputs: [encodeWrapEth(OTHER_ADDR, ONE_ETH), SUB_PLAN],
lines: [
...wrapLines(OTHER_ADDR),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a WRAP_ETH of 1 ETH to address(1), then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x21],
inputs: [encodeWrapEth(MSG_SENDER, ONE_ETH), SUB_PLAN],
lines: [
...wrapLines("The sender"),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a WRAP_ETH of 1 ETH to another address, then a V3 exact-in of 600 USDT",
commands: [0x0a, 0x0b, 0x00],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
encodeWrapEth(OTHER_ADDR, ONE_ETH),
V3_600,
],
lines: [["Amount", "5000.0000 USDT"], ...wrapLines(OTHER_ADDR)],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/665 — V4Router pays a
// SETTLE of ETH from the router's own ETH, whichever payer it names.
// After an earlier step that may leave ETH in the router, the ETH that
// the user pays a V4 step reads as a maximum, on lines of its own.
// Every row reads at least the ETH that can leave the wallet. The two
// rows marked "reads as on next" read the same on next. On next every
// other row added that ETH to the Amount line, or read it as an exact
// amount.
//
// ADDRESS_THIS is the router on every release.
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
const WRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, ONE_ETH);
// UNWRAP_WETH has the layout of WRAP_ETH.
const UNWRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, 0n);
// A V4 step swapping `amount` of ETH for USDC, with a SETTLE of
// OPEN_DELTA by the user.
const v4EthForUsdc = (amount) =>
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(ZERO_ADDR, USDC_ADDR, amount, 1n),
settle(ZERO_ADDR, 0n, true),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const V4_ONE_ETH = v4EthForUsdc(ONE_ETH);
// A TRANSFER or PAY_PORTION of `amount` of `token` to another
// address.
const payOut = (token, amount) =>
coder.encode(
["address", "address", "uint256"],
[token, OTHER_ADDR, amount],
);
// An ACROSS_V4_DEPOSIT_V3 of `amount` of ETH (`useNative` set), from
// the sender to the sender on chain id 8453.
const acrossEth = (amount) =>
coder.encode(
[
"tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)",
],
[
[
USER_ADDR,
USER_ADDR,
WETH_ADDR,
WETH_ADDR,
amount,
1n,
8453n,
ZERO_ADDR,
0,
0,
0,
"0x",
true,
],
],
);
// The lines of ETH the sender pays a V4 step, of `amount`.
const ethLines = (amount) => [
["Moved from the sender", amount],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
];
test.each([
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 ETH that the user settles, reads as on next",
commands: [0x0b, 0x10],
inputs: [WRAP_INTO_ROUTER, V4_ONE_ETH],
lines: [["Amount", "2.0000 ETH"]],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x0c, 0x10],
inputs: [WRAP_INTO_ROUTER, UNWRAP_INTO_ROUTER, V4_ONE_ETH],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a TRANSFER of 1 ETH to another address after the UNWRAP_WETH",
commands: [0x0b, 0x0c, 0x05, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
payOut(ZERO_ADDR, ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a PAY_PORTION of 5,000 bips of WETH to another address before the UNWRAP_WETH",
commands: [0x0b, 0x06, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
payOut(WETH_ADDR, 5000n),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a WRAP_ETH of 1 ETH to another address after the UNWRAP_WETH",
commands: [0x0b, 0x0c, 0x0b, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
encodeWrapEth(OTHER_ADDR, ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", OTHER_ADDR],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same on UniversalRouterV2_1_2 with an ACROSS_V4_DEPOSIT_V3 of 1 ETH after the UNWRAP_WETH",
router: ROUTER_V2_1_2_ADDR,
commands: [0x0b, 0x0c, 0x40, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
acrossEth(ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V3 exact-in of 600 USDT that the user pays, a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x00, 0x0b, 0x0c, 0x10],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V3 exact-in of 600 USDT for WETH paid to the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x00, 0x0c, 0x10],
inputs: [
encodeV3SwapExactIn(ADDRESS_THIS, USDT_600, 1n, [
USDT_ADDR,
WETH_ADDR,
]),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V4 swap of 600 USDT that takes ETH into the router, then a V4 swap of 5 ETH that the user settles",
commands: [0x10, 0x10],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
ZERO_ADDR,
USDT_600,
1n,
),
settle(USDT_ADDR, 0n, true),
take(ZERO_ADDR, ADDRESS_THIS, 0n),
],
),
v4EthForUsdc(5n * ONE_ETH),
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 5.0000 ETH"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 exact-out paid in ETH with a SETTLE_ALL of at most 3 ETH, reads as on next",
commands: [0x0b, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
390000000n,
3n * ONE_ETH,
),
encodeV4SettleAll(ZERO_ADDR, 3n * ONE_ETH),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
],
lines: [
...wrapLines("The swap step that spends it"),
...ethLines(UNDECODED),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, a V3 exact-out of at most 1 WETH paid from the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x01, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
encodeV3SwapExactOut(
USER_ADDR,
390000000n,
ONE_ETH,
[USDC_ADDR, WETH_ADDR],
false,
),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "Up to 1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, a V4 exact-out paid from the router's WETH, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x10, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
390000000n,
ONE_ETH,
),
settle(WETH_ADDR, 0n, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
])("$kind", ({ router, commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs, router);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/721 — a WRAP_ETH that
// does not set the input side takes the amount it states from the
// sender. On next no line gave that ETH. The rows marked "reads as on
// next" state no amount to take from the sender. Uniswap's own routes
// wrap CONTRACT_BALANCE after their first step, as the last two rows
// do.
const CONTRACT_BALANCE = 2n ** 255n;
const WRAP_TO_OTHER = encodeWrapEth(OTHER_ADDR, ONE_ETH);
test.each([
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to another address",
commands: [0x00, 0x0b],
inputs: [V3_600, WRAP_TO_OTHER],
lines: [["Amount", "600.0000 USDT"], ...wrapLines(OTHER_ADDR)],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to address(1)",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(MSG_SENDER, ONE_ETH)],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The sender"),
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to address(2)",
commands: [0x00, 0x0b],
inputs: [V3_600, WRAP_INTO_ROUTER],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to the router by its own address",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(ROUTER_ADDR, ONE_ETH)],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a WRAP_ETH of 1 ETH to another address",
commands: [0x0b, 0x0b],
inputs: [WRAP_INTO_ROUTER, WRAP_TO_OTHER],
lines: [["Amount", "1.0000 ETH"], ...wrapLines(OTHER_ADDR)],
},
{
kind: "two WRAP_ETHs of 1 ETH into the router, which the Amount line adds up",
commands: [0x0b, 0x0b],
inputs: [WRAP_INTO_ROUTER, WRAP_INTO_ROUTER],
lines: [["Amount", "2.0000 ETH"]],
},
{
kind: "a V3 exact-in of 600 USDT for WETH paid to the router, an UNWRAP_WETH into the router, then a WRAP_ETH of 1 ETH to another address",
commands: [0x00, 0x0c, 0x0b],
inputs: [
encodeV3SwapExactIn(ADDRESS_THIS, USDT_600, 1n, [
USDT_ADDR,
WETH_ADDR,
]),
UNWRAP_INTO_ROUTER,
WRAP_TO_OTHER,
],
lines: [
["Amount", "600.0000 USDT"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", OTHER_ADDR],
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a WRAP_ETH of 1 ETH into the router",
commands: [0x0b, 0x0c, 0x0b],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
WRAP_INTO_ROUTER,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to another address",
commands: [0x0a, 0x00, 0x0b],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
V3_600,
WRAP_TO_OTHER,
],
lines: [["Amount", "5000.0000 USDT"], ...wrapLines(OTHER_ADDR)],
},
{
kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH to another address, then an EXECUTE_SUB_PLAN",
commands: [0x00, 0x0b, 0x21],
inputs: [V3_600, WRAP_TO_OTHER, SUB_PLAN],
lines: [
...USDT_600_GROUP,
...wrapLines(OTHER_ADDR),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH whose input this decoder cannot read",
commands: [0x00, 0x0b],
inputs: [V3_600, "0x1234"],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of nothing to another address, reads as on next",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(OTHER_ADDR, 0n)],
lines: [["Amount", "600.0000 USDT"]],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to the sender, reads as on next",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(MSG_SENDER, CONTRACT_BALANCE)],
lines: [["Amount", "600.0000 USDT"]],
},
{
kind: "a V4 swap of 600 USDT that takes ETH into the router, a WRAP_ETH of CONTRACT_BALANCE into the router, then a V3 exact-in of it, reads as on next",
commands: [0x10, 0x0b, 0x00],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
ZERO_ADDR,
USDT_600,
1n,
),
settle(USDT_ADDR, 0n, true),
take(ZERO_ADDR, ADDRESS_THIS, 0n),
],
),
encodeWrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
CONTRACT_BALANCE,
1n,
encodeV3Path([WETH_ADDR, USDC_ADDR]),
false,
],
),
],
lines: [["Amount", "600.0000 USDT"]],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax`
// USDT for USDC.
const v3ExactOut = (amountInMax) =>
@@ -2871,8 +3370,9 @@ describe("uniswap decoder", () => {
...USDT_400_GROUP,
...ONE_USDT_LINES(OTHER_ADDR),
],
// The last swap step's minimum.
minimum: "390.0000 USDC",
// Both swap steps pay USDC to the user, so the minimum
// is the sum of their two minimums.
minimum: "980.0000 USDC",
},
])(
"$kind shows each take's own lines and the allowances, and no Amount figure or Min. received",
@@ -3638,6 +4138,356 @@ describe("uniswap decoder", () => {
]);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/647 — on next at
// f550d8c, a split whose steps each pay WETH to the user showed only
// the last step's minimum.
describe("the minimum of a split that pays WETH to the user", () => {
const WETH_01 = 100000000000000000n;
const WETH_02 = 200000000000000000n;
const WETH_03 = 300000000000000000n;
// A V3 exact-in of 600 USDT for at least 0.3 WETH, and a V2
// exact-in of 400 USDT for at least 0.2 WETH, each paid to the
// user.
const V3_TO_WETH = encodeV3SwapExactIn(
USER_ADDR,
USDT_600,
WETH_03,
[USDT_ADDR, WETH_ADDR],
);
const V2_TO_WETH = encodeV2SwapExactIn(
USER_ADDR,
USDT_400,
WETH_02,
[USDT_ADDR, WETH_ADDR],
);
// A V4 step that swaps 400 USDT for at least 0.2 WETH, with these
// TAKEs of the WETH.
const v4ToWeth = (...takes) =>
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
...takes.map(() => V4_TAKE_ID),
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
USDT_400,
WETH_02,
),
encodeV4Settle(USDT_ADDR), // paid by the user
...takes,
],
);
// The swap's minimum line as [label, value], or undefined.
const minimum = (result) =>
result.details
.filter(
(d) =>
d.label === "Min. received" ||
d.label === "Min. paid out",
)
.map((d) => [d.label, d.value])[0];
test("a V3 and a V2 exact-in show the total of their minimums", () => {
const result = decodeSteps(
[0x00, 0x08],
[V3_TO_WETH, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.5000 WETH",
]);
});
test("a V3 exact-in and a V4 step show the total of their minimums", () => {
const result = decodeSteps(
[0x00, 0x10],
[V3_TO_WETH, v4ToWeth(encodeV4Take(WETH_ADDR))],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.5000 WETH",
]);
});
// The second route buys DAI for the router, then swaps all of it
// from the router for at least 0.2 WETH.
test("a route that hops through DAI counts its last hop's minimum", () => {
const result = decodeSteps(
[0x00, 0x08, 0x00],
[
V3_TO_WETH,
encodeV2SwapExactIn(ROUTER_ADDR, USDT_400, DAI_400, [
USDT_ADDR,
DAI_ADDR,
]),
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
2n ** 255n, // CONTRACT_BALANCE
WETH_02,
encodeV3Path([DAI_ADDR, WETH_ADDR]),
false, // paid from the router
],
),
],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.5000 WETH",
]);
});
// The middle step swaps 0.3 of the user's WETH for USDC, so the
// sum of minimums starts again after it.
test.each([
{
kind: "a V3 exact-in",
command: 0x00,
input: encodeV3SwapExactIn(USER_ADDR, WETH_03, 1n, [
WETH_ADDR,
USDC_ADDR,
]),
},
{
kind: "a V2 exact-in",
command: 0x08,
input: encodeV2SwapExactIn(USER_ADDR, WETH_03, 1n, [
WETH_ADDR,
USDC_ADDR,
]),
},
{
kind: "a V2 exact-out",
command: 0x09,
input: encodeV2SwapExactOut(USER_ADDR, 1n, WETH_03, [
WETH_ADDR,
USDC_ADDR,
]),
},
{
kind: "a V3 exact-out",
command: 0x01,
input: encodeV3SwapExactOut(USER_ADDR, 1n, WETH_03, [
USDC_ADDR,
WETH_ADDR,
]),
},
])(
"$kind that spends WETH between them leaves the last step's minimum",
({ command, input }) => {
const result = decodeSteps(
[0x00, command, 0x08],
[V3_TO_WETH, input, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.2000 WETH",
]);
},
);
// The middle step swaps 0.3 WETH and 400 DAI for USDC, both paid
// by the user. It spends WETH whichever SETTLE comes last.
test.each([
{ order: "WETH first", settled: [WETH_ADDR, DAI_ADDR] },
{ order: "DAI first", settled: [DAI_ADDR, WETH_ADDR] },
])(
"a V4 step that spends WETH and DAI between them leaves the last step's minimum, with the SETTLE of $order",
({ settled }) => {
const wethAndDaiForUsdc = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
WETH_03,
1n,
),
encodeV4ExactInSingle(
DAI_ADDR,
USDC_ADDR,
DAI_400,
1n,
),
...settled.map((token) => encodeV4Settle(token)),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const result = decodeSteps(
[0x00, 0x10, 0x08],
[V3_TO_WETH, wethAndDaiForUsdc, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.2000 WETH",
]);
},
);
// The middle step's swap actions buy WETH with the user's USDT,
// then swap all of that WETH for USDC.
test("a V4 step that swaps through WETH between them leaves the last step's minimum", () => {
const usdtToUsdcThroughWeth = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
WETH_ADDR,
USDT_400,
WETH_01,
),
encodeV4ExactInSingle(WETH_ADDR, USDC_ADDR, 0n, 1n),
encodeV4Settle(USDT_ADDR), // paid by the user
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const result = decodeSteps(
[0x00, 0x10, 0x08],
[V3_TO_WETH, usdtToUsdcThroughWeth, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.2000 WETH",
]);
});
// The middle step has no swap action. Its SETTLE or SETTLE_ALL
// pays 0.3 WETH, and its TAKE pays that WETH to another address.
test.each([
{
payment: "a SETTLE of WETH paid by the user",
actions: [V4_SETTLE_ID, V4_TAKE_ID],
params: [
settle(WETH_ADDR, WETH_03, true),
take(WETH_ADDR, OTHER_ADDR, 0n),
],
},
{
payment: "a SETTLE of WETH paid from the router",
actions: [V4_SETTLE_ID, V4_TAKE_ID],
params: [
settle(WETH_ADDR, WETH_03, false),
take(WETH_ADDR, OTHER_ADDR, 0n),
],
},
{
payment: "a SETTLE_ALL of WETH",
actions: [V4_TAKE_ID, V4_SETTLE_ALL_ID],
params: [
take(WETH_ADDR, OTHER_ADDR, WETH_03),
encodeV4SettleAll(WETH_ADDR, WETH_03),
],
},
])(
"a V4 step with $payment between them leaves the last step's minimum",
({ actions, params }) => {
const wethToOther = encodeV4Swap(
new Uint8Array(actions),
params,
);
const result = decodeSteps(
[0x00, 0x10, 0x08],
[V3_TO_WETH, wethToOther, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. paid out",
"0.2000 WETH",
]);
},
);
test("a PERMIT2_TRANSFER_FROM of WETH between them leaves the last step's minimum", () => {
const result = decodeSteps(
[0x00, 0x02, 0x08],
[
V3_TO_WETH,
permit2TransferFrom(WETH_ADDR, OTHER_ADDR, WETH_03),
V2_TO_WETH,
],
);
expect(minimum(result)).toEqual([
"Min. paid out",
"0.2000 WETH",
]);
});
// The decoder reads no minimum from a V4 exact-out action.
test("a step that states no minimum leaves no figure", () => {
const exactOut = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
USDT_ADDR,
WETH_ADDR,
WETH_02,
USDT_400,
),
encodeV4SettleAll(USDT_ADDR, USDT_400),
encodeV4TakeAll(WETH_ADDR, 0n),
],
);
const result = decodeSteps(
[0x10, 0x00],
[exactOut, V3_TO_WETH],
);
expect(minimum(result)).toBeUndefined();
expect(detail(result, "Paid to")).toBeUndefined();
});
test("a step that pays some of its WETH to the router leaves no figure", () => {
const result = decodeSteps(
[0x00, 0x10],
[
V3_TO_WETH,
v4ToWeth(
take(WETH_ADDR, USER_ADDR, WETH_01),
take(WETH_ADDR, ROUTER_ADDR, 0n),
),
],
);
expect(minimum(result)).toBeUndefined();
});
// The router still runs a swap step whose recipient cannot be
// read, and that step may spend the WETH the first step paid.
test("a step between them that cannot be read leaves no figure", () => {
const result = decodeSteps(
[0x00, 0x00, 0x08],
[
V3_TO_WETH,
dirtyWord(
encodeV3SwapExactIn(USER_ADDR, WETH_03, 1n, [
WETH_ADDR,
USDC_ADDR,
]),
0,
),
V2_TO_WETH,
],
);
expect(minimum(result)).toBeUndefined();
});
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
+18 -7
View File
@@ -370,7 +370,8 @@ const PAID_ELSEWHERE = [
"Steps",
"Deadline",
];
// `lines` with the lines of one PERMIT2_TRANSFER_FROM after Amount.
// `lines` with the lines of one PERMIT2_TRANSFER_FROM or later WRAP_ETH after
// Amount.
const withMove = (lines) =>
lines.flatMap((l) =>
l === "Amount"
@@ -985,7 +986,7 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
);
// The SWEEP pays the user the swap's WETH, so the UNWRAP_WETH unwraps the
// WRAP_ETH's.
// WRAP_ETH's. The WRAP_ETH's 1 ETH gets lines of its own.
test("a SWEEP of the swap's WETH to the user, then a WRAP_ETH into the router, reads as ETH with no minimum", () => {
const result = decode(
execute(
@@ -996,7 +997,10 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
expect(labels(result)).toEqual(withMove(NO_MINIMUM_TO_USER));
expect(detail(result, "Moved from the sender").value).toBe(
"1.0000 ETH",
);
});
test("an UNWRAP_WETH into the router, then a WRAP_ETH of all its ETH to the user, reads as WETH with no minimum", () => {
@@ -1097,7 +1101,9 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
},
);
// The user receives 0.5 WETH and the rest of the swap's output as ETH.
// The user receives 0.5 WETH and the rest of the swap's output as ETH. The
// router holds ETH when the WRAP_ETH runs, so the sender pays at most its
// 0.5 ETH.
test("an UNWRAP_WETH into the router, then a WRAP_ETH of 0.5 ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
@@ -1108,7 +1114,11 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
expect(labels(result)).toEqual(withMove(NO_MINIMUM_TO_USER));
expect(detail(result, "Moved from the sender").value).toBe(
"Up to 0.5000 ETH",
);
expect(detail(result, "Moved to").value).toBe("The sender");
});
// The UNWRAP_WETH pays the user the swap's WETH as ETH; the WRAP_ETH wraps
@@ -1348,6 +1358,7 @@ describe("any token left in the router is not received", () => {
),
),
left: tokenLine(WETH, "WETH"),
spent: "1.0000 ETH",
},
// A SETTLE of OPEN_DELTA from the router pays only what the step
// still owes of its currency when it runs.
@@ -1516,8 +1527,8 @@ describe("any token left in the router is not received", () => {
"Moved from the sender",
]
: ["Amount"]),
// A later swap step the user pays for spends `spent` of a token
// other than Token In.
// A later swap step or WRAP_ETH the user pays for spends `spent`
// of a token other than Token In.
...(spent
? ["Moved from the sender", "Token moved", "Moved to"]
: []),
+3 -2
View File
@@ -167,7 +167,8 @@ function row(token = {}, value = FIVE_WETH) {
}
// Fetch the explorer's rows through the real fetcher and put them exactly where
// refreshBalances() puts them. The node reports the holding the explorer does.
// refreshBalances() puts them on Ethereum Mainnet. The node reports the holding
// the explorer does.
async function fetchOnto(items) {
mockProvider.tokenUnits = BigInt(items[0].value);
debugFetch.mockImplementation(async () => ({
@@ -176,7 +177,7 @@ async function fetchOnto(items) {
statusText: "OK",
json: async () => items,
}));
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], "0x1");
state.wallets = [
{
name: "Wallet 1",
+6
View File
@@ -357,6 +357,11 @@ describe("every way to a signature or the private key refuses a defective wallet
});
beforeEach(() => {
// showFlash() starts a timer that clears the message two seconds
// later. Each test here runs on fake timers and drops them in
// afterEach, so that timer never outlives the test.
jest.useFakeTimers();
const broken = brokenXprvWallet();
// A balance, so that no Send button's zero-balance refusal can stand
// in for the defect check.
@@ -398,6 +403,7 @@ describe("every way to a signature or the private key refuses a defective wallet
});
afterEach(() => {
jest.useRealTimers();
state.wallets = [];
state.activeAddress = null;
state.selectedWallet = null;