15 Commits
Author SHA1 Message Date
clawbot c150b83322 fix: Home's Send and Receive clear the selected token (closes #753)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Three routes reach Home with a token still selected. Send used that token
whatever its dropdown showed, and Receive warned about it. Both now clear it
when they open, and Send's dropdown starts on ETH.

Model: opus-5-5
2026-11-03 16:09:42 +01:00
clawbot ee370b5ef7 fix: a WRAP_ETH says where its ETH goes (closes #747)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A WRAP_ETH into the router now always reads `Moved to: The router`. That is
true whichever step then takes the WETH. Other takes keep
`The swap step that spends it`.

A later WRAP_ETH of CONTRACT_BALANCE to anyone but the router now gets a
`Moved from the sender` line reading `All the ETH the transaction carries`,
and `Moved to` names the recipient. It gets none after an UNWRAP_WETH into
the router, or while the router holds ETH from a V4 step.

README states both rules.

Model: opus-5-5
2026-11-03 15:44:15 +01:00
clawbot e438272125 fix: Back from AddressToken clears the selected token (closes #751)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
AddressDetail's show() clears the token, but "Back" onto a screen already drawn
only unhides it. Send and Receive then kept using the token from AddressToken,
whatever Send's dropdown showed. "Back" now clears the token and puts the
dropdown on ETH.

Model: opus-5-5
2026-11-03 15:27:40 +01:00
clawbot 39c58b8775 fix: Send's balance line is drawn again when a refresh returns (closes #748)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Send opened at an address no refresh had read kept reading
"Current balance: unknown" after a refresh had read the balance, until the
screen was opened again. A token just added read the same way.

A refresh that returns while Send is up now runs updateSendBalance(). The
token list is not rebuilt, so the recipient, amount and token stay as the
user left them. README says so.

Model: opus-5-5
2026-11-03 15:05:11 +01:00
clawbot 39c1ad09c0 docs: code comments name the headings README has now (closes #724)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The README rewrite renamed or merged several headings that comments still
cited. Each comment now names the current heading. Line number citations of
README become headings too, and one test name drops its line numbers.

The e2e workflow's citations of "In CI" are dropped, because no section has what
they pointed at. Its comment also corrects what it says about required jobs and
open reports.

The comment on withLock() no longer states a Firefox private window exception,
because the manifest keeps the extension out of those windows.

Two comments also take corrections from reviews: the cases with no total in
DeleteAddress's warning, and the screens that say a swap's amount could not be
decoded.

Model: opus-5-5
2026-11-03 14:03:31 +01:00
clawbot a65e84c2dd docs: the sentences the rewrite reviews left as follow-up notes (closes #723)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Each sentence named on #723 now
matches the code on `next`:

- the Firefox load steps are numbered;
- the AddToken refusal lists name a token that fakes a known symbol;
- the worker's in-memory state is listed in full;
- `script/bootstrap` falls back to `npm install -g` for yarn;
- the Send line under a `null` ETH balance holds with ETH selected, and
  "Max" has a line of its own;
- the Home and Send lines there name `SepoliaETH` on Sepolia;
- the well-known symbols are given per network;
- a network switch empties the fraud contract list;
- the cases where an import leaves out a removed address are spelled out;
- a new token's line depends on "Show tracked tokens with zero balance";
- wallet drainer is defined at first use only.

Model: fable-5-1
2026-11-03 13:06:17 +01:00
clawbot 64ec571a17 fix: long symbols wrap on TxApproval and TransactionDetail, and SuccessTx keeps its amount and recipient (closes #743)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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
56 changed files with 3034 additions and 394 deletions
+6 -11
View File
@@ -19,13 +19,10 @@ on: [push]
# by the host daemon and mounts an empty directory, and the runner image's
# node is too old to install this repo's dependencies.
#
# These jobs REPORT, they do not gate. Whether a check blocks a merge is
# Gitea branch protection, which this repo does not configure, so a failure
# here is a red mark a reviewer has to account for rather than a hard
# block. Making e2e-chrome a required check is blocked while reports of the
# Chrome suite failing under load are still open; the "In CI" section of
# README.md names them. A gate that fails at random teaches people to merge
# past red.
# These jobs REPORT, they do not gate. No branch requires these two jobs, so
# a failure is a red mark a reviewer has to account for. The Chrome suite has
# failed under load before, and no such report is open. A gate that fails at
# random teaches people to merge past red.
#
# Nothing here may pass vacuously. There is no continue-on-error and no
# `|| true`. Both scripts exit non-zero when docker is missing, when the
@@ -36,8 +33,7 @@ jobs:
e2e-chrome:
runs-on: ubuntu-latest
# Bounds the image build, a cold cache included, and the three Chrome
# programs, so a hung browser frees the shared runner. README.md
# "In CI" has the measured times.
# programs, so a hung browser frees the shared runner.
timeout-minutes: 40
steps:
# actions/checkout v4.2.2, 2026-02-22
@@ -47,8 +43,7 @@ jobs:
e2e-firefox:
runs-on: ubuntu-latest
# Bounds the image build, a cold cache included, and both Firefox
# programs, so a hung browser frees the shared runner. README.md
# "In CI" has the measured times.
# programs, so a hung browser frees the shared runner.
timeout-minutes: 15
steps:
# actions/checkout v4.2.2, 2026-02-22
+90 -34
View File
@@ -89,9 +89,13 @@ Load it:
2. Turn on "Developer mode".
3. Click "Load unpacked".
4. Pick `dist/chrome/`.
- Firefox: open `about:debugging#/runtime/this-firefox`, click "Load Temporary
Add-on" and pick `dist/firefox/manifest.json`. A temporary add-on is unloaded
when Firefox exits. Read Installing on Firefox below before relying on it.
- Firefox:
1. Open `about:debugging#/runtime/this-firefox`.
2. Click "Load Temporary Add-on".
3. Pick `dist/firefox/manifest.json`.
A temporary add-on is unloaded when Firefox exits. Read Installing on Firefox
below before relying on it.
The user guide, [`docs/README.md`](docs/README.md), explains how to use the
wallet. The release procedure is in [`docs/RELEASE.md`](docs/RELEASE.md).
@@ -375,12 +379,18 @@ Three rules follow from the worker being stopped:
worker stopping. Such a connection ends when the browser closes, when the
active address changes, or when the site is removed in Settings.
Two things live only in the worker's memory and are lost when it stops: pending
approvals and the record of broadcast nonces. An approval, and the site's
request waiting on it, exist nowhere else. So while any approval is pending, the
worker calls `runtime.getPlatformInfo()` every 20 seconds. Chrome counts that
call as activity and keeps the worker running. The timer stops itself once
nothing is pending.
These things live only in the worker's memory and are lost when it stops:
- pending approvals;
- the record of broadcast nonces;
- the timer that keeps the worker running while an approval is pending;
- the record of the one open transaction approval;
- the job that sets up the alarms at every start, while it runs.
An approval, and the site's request waiting on it, exist nowhere else. So while
any approval is pending, the worker calls `runtime.getPlatformInfo()` every 20
seconds. Chrome counts that call as activity and keeps the worker running. The
timer stops itself once nothing is pending.
Alarms the code no longer handles are listed in `OBSOLETE_ALARMS` and cleared on
every start. The worker fetches nothing when it starts.
@@ -461,7 +471,9 @@ When "Back" returns to a screen that this page load has not drawn yet,
Home is shown instead. "Back" onto a screen already drawn only unhides it, so
unsaved input in Settings survives. Home is drawn again on every "Back". When a
balance refresh returns while AddressDetail or AddressToken is showing, that
screen draws its balances again.
screen draws its balances again. When a refresh returns while Send is showing,
Send draws its From address and current balance again. Its inputs and dropdown
stay as the user left them.
A network switch, or a change to "Use Blockscout", cuts the stack at the first
screen that cannot be drawn from saved state. The cut also drops AddressToken
@@ -558,7 +570,8 @@ three lines:
A contract creation has no counterparty. Its last line reads "This transaction
creates a new contract. It has no recipient." While "Use Blockscout" is off, the
list shows only the line "No transaction history is shown, because "Use
Blockscout" is off in Settings."
Blockscout" is off in Settings." After a network switch, the list shows
"Loading..." in place of the previous network's rows or failure message.
Transitions:
@@ -568,8 +581,9 @@ Transitions:
- "+": derives the next address in place.
- `[info]`: AddressDetail.
- `[x]`: DeleteAddress.
- "Send": Send. Refused with a flash message when the active address has a zero
ETH balance or belongs to a wallet carrying the `walletDefects.js` notice.
- "Send": Send, with its dropdown on ETH. Refused with a flash message when the
active address has a zero ETH balance or belongs to a wallet carrying the
`walletDefects.js` notice.
- "Receive": Receive.
- Tap a transaction row: TransactionDetail.
- "Add additional wallet...": AddWallet.
@@ -786,6 +800,8 @@ Shown after a broadcast. The screen holds:
- the heading "Transaction Broadcast", with no "Back" button;
- the amount and symbol;
- each `Moved from the sender` amount of a swap;
- each `Permit2 allowance` amount of a swap, with its `Allowance token` line;
- the recipient;
- the transaction hash, with an explorer link;
- the counter "Waiting for confirmation... Ns".
@@ -809,7 +825,9 @@ For a swap whose amount could not be decoded, the amount reads
Shown when the receipt arrives. The screen holds:
- the heading "Transaction Confirmed";
- the amount and the recipient, or the decoded action when there is one;
- the decoded action, when there is one;
- the amount, unless a line of the decoded action shows it;
- the recipient, unless a line of the decoded action shows it;
- the block number, with an explorer link;
- the transaction hash, with an explorer link;
- a "Done" button.
@@ -823,7 +841,10 @@ Shown when signing or a broadcast fails, or when a wait ends without a receipt.
The screen holds:
- the heading "Transaction Failed";
- the amount and the recipient;
- the amount;
- each `Moved from the sender` amount of a swap;
- each `Permit2 allowance` amount of a swap, with its `Allowance token` line;
- the recipient;
- the error message, in a dashed box;
- the transaction hash, when one exists;
- a "Done" button that behaves as on SuccessTx.
@@ -895,7 +916,8 @@ not change. A flash message refuses:
- a token already tracked;
- an address on the scam list;
- a contract that fails the lookup.
- a contract that fails the lookup;
- a token that fakes a known symbol.
A network switch before the lookup answers adds nothing, and a flash message
says so.
@@ -1019,6 +1041,8 @@ removing the address only stops the wallet tracking it. It explains the way
back: delete the whole wallet in Settings and import its backup again. The scan
after an import finds only addresses with on-chain activity. When the address
holds anything, the screen lists the holdings and the USD total under a warning.
An ETH balance that no refresh has read counts as holding something. The warning
then reads "This address may hold a balance."
"Remove Address" removes the address and its site permissions. It returns to
Home with the flash message "Address removed." The wallet's `nextIndex` is not
@@ -1197,14 +1221,39 @@ The token's symbol comes from the same places. The bundled list answers for a
token on Ethereum Mainnet only. When none of them answers, the amount reads
`5000000000 base units (decimals unknown)`, and nothing is formatted with
guessed decimals. A holding whose decimals are unknown shows "quantity unknown"
in the balance lists. A token just added shows "quantity unknown" in place of a
zero, until a refresh has read its balance at every address. Only a whole number
from 0 to 80 counts as a token's decimals. A token's `decimals` is stored only
as a source reported it, never guessed. A `null` balance means the decimals are
unknown, never that the balance is zero. While "Use Blockscout" is off, the
bundled list and the tracked tokens may lack a token's symbol or decimals. The
approval and status screens then add a line under that token saying that
Blockscout's report is not used.
in the balance lists.
A tracked token with no stored balance at an address can show "quantity unknown"
there in place of a zero. It does so after each of these:
- the token is added;
- an address or a wallet is added;
- the network is switched.
This lasts until a refresh in the popup has read the token's balance at every
address.
An address's ETH balance is `null` until a refresh has read that address. This
holds for a new address, and for every address after a network switch. While it
is `null`:
- the balance lists show "quantity unknown";
- Home's ETH balance reads `unknown (ETH)` on mainnet and `unknown (SepoliaETH)`
on Sepolia;
- with ETH selected, Send reads `Current balance: unknown (ETH)` on mainnet and
`Current balance: unknown (SepoliaETH)` on Sepolia;
- with ETH selected, "Max" fills in nothing and shows the flash message "This
token's balance is unknown.";
- "Send" is not refused for a zero balance;
- the address has no total;
- DeleteAddress shows its warning.
Only a whole number from 0 to 80 counts as a token's decimals. A token's
`decimals` is stored only as a source reported it, never guessed. A token's
`null` balance means the decimals are unknown, never that the balance is zero.
While "Use Blockscout" is off, the bundled list and the tracked tokens may lack
a token's symbol or decimals. The approval and status screens then add a line
under that token saying that Blockscout's report is not used.
The wallet's own ERC-20 send is encoded with the decimals the screen showed. At
signing time the contract's `decimals()` is read only to be compared with those.
@@ -1239,6 +1288,7 @@ unpriced holding was left out. `formatAddressTotal()` renders that result the
same way on every screen:
- on Sepolia, or before the first price fetch: no total;
- while the address's ETH balance is unknown: no total;
- everything priced: `Total: $5,500.00`;
- part priced: `Total: $3,000.00 plus unpriced tokens`;
- nothing priced but something held: `Total: unpriced tokens only`.
@@ -1365,15 +1415,19 @@ when both tokens are known. The lines are:
the sender that the `Amount` line does not include. The same three lines show
what a `PERMIT2_TRANSFER_FROM` moves and where. A `WRAP_ETH` can pay its WETH
to a recipient other than the router. When its amount gets these lines, its
`Moved to` line names that recipient, or reads `The sender`. A `WRAP_ETH`
after the step that sets `Token In` takes the amount it states from the
sender. When it pays anyone but the router, that amount gets these lines. When
it pays the router, that amount joins the `Amount` line if `Token In` is ETH,
and gets these lines if not. A later `WRAP_ETH` of `CONTRACT_BALANCE` states
no amount, so it gets no lines. ETH that the sender pays a V4 step or a
`WRAP_ETH` of a stated amount can get these three lines when the router may
hold ETH from an earlier step. When the decoder knows the amount, its
`Moved from the sender` line reads `Up to` and the amount.
`Moved to` line names that recipient, or reads `The sender`. For a `WRAP_ETH`
into the router, `Moved to` reads `The router`. A `WRAP_ETH` after the step
that sets `Token In` takes the amount it states from the sender. When it pays
anyone but the router, that amount gets these lines. When it pays the router,
that amount joins the `Amount` line if `Token In` is ETH, and gets these lines
if not. A later `WRAP_ETH` of `CONTRACT_BALANCE` to anyone but the router gets
these lines. It gets none after an `UNWRAP_WETH` into the router, or while the
router holds ETH from a V4 step. When it gets them, its
`Moved from the sender` line reads `All the ETH the transaction carries`. ETH
that the sender pays a V4 step or a `WRAP_ETH` of a stated amount can get
these three lines when the router may hold ETH from an earlier step. When the
decoder knows the amount, its `Moved from the sender` line reads `Up to` and
the amount.
- `Permit2 allowance`, `Allowance token` and `Allowance spender`: each allowance
that a `PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` grants, unless the permit's
amount already stands as the `Amount` figure.
@@ -1693,7 +1747,9 @@ The `Makefile` targets are thin shims over `script/`:
- `script/bootstrap`: install the dependencies. Node and yarn come from the
system when present. Otherwise node comes from a pinned nvm install and yarn
from corepack. Fails when node cannot find a package named in `package.json`.
from corepack. When corepack and nvm are both missing, yarn comes from
`npm install -g`. Fails when node cannot find a package named in
`package.json`.
- `script/setup`: `script/bootstrap`, then the git pre-commit hook.
- `script/test`: build the `test` stage of the `Dockerfile` with `--no-cache`.
The stage runs the jest suite, stopped at 90 seconds and rerun verbose on
+33 -21
View File
@@ -60,7 +60,8 @@ settings.
- "Check recipients on Etherscan". Etherscan is a block explorer website. With
the setting on, the wallet fetches the recipient's Etherscan page each time
you review a send. The wallet warns when Etherscan labels the address as
phishing, scam, exploiter or wallet drainer. Only the recipient's address is
phishing, scam, exploiter or wallet drainer. A wallet drainer is a contract
that empties a wallet once given permission. Only the recipient's address is
sent. The Etherscan server cannot be changed.
Each time a site's request opens a window, the wallet checks the site against a
@@ -211,10 +212,18 @@ allowed or denied for it.
Getting the address back is not easy. "+" computes only addresses that this
wallet has never listed. The removed address was listed once, so "+" skips it.
The way back is to delete the whole wallet and import its backup again. After
the import, the scan lists the address again if it holds ETH or has sent a
transaction. Otherwise "+" may reach it again, because the new wallet has never
listed it.
The way back is to delete the whole wallet and import its backup again. The
import lists the wallet's first address, then scans for used addresses. An
address is used when it holds ETH or has sent a transaction. A read that fails
during the scan counts the address as unused. The scan stops after five unused
addresses in a row. When the scan finds two or more used addresses, the wallet
lists those instead. The wallet does not list the removed address when:
- the scan finds two or more used addresses, and the address is not among them;
- the scan finds fewer than two, and the address is not the first.
When the wallet does not list the address, "+" may reach it again, because the
new wallet has never listed it.
### Deleting a wallet
@@ -245,9 +254,11 @@ else is treated as spam and hidden. Turning "Use Blockscout" off removes every
untracked token from the lists at once.
The balance list and the send screen hide a token that carries a well-known
symbol, `ETH` included, but comes from another contract. A token that fakes a
known symbol cannot be tracked ("Not added: this token fakes a known symbol."),
and is never listed at zero. See Scam protection.
symbol but comes from another contract. `ETH` and `SepoliaETH` are well-known
symbols on every network. The symbols of the built-in list are well-known on
Ethereum Mainnet only. A token that fakes a known symbol cannot be tracked ("Not
added: this token fakes a known symbol."), and is never listed at zero. See Scam
protection.
### Tracking a token
@@ -263,8 +274,9 @@ From Settings, under Tracked Tokens, click "+ Add token". That screen offers the
top 10 tokens as buttons, the top 100 in a dropdown, and a contract address
field. The two lists are offered on Ethereum Mainnet only.
A line for the new token reads `quantity unknown` until a refresh has read its
balance.
While "Show tracked tokens with zero balance" is on, a line for the new token
reads `quantity unknown` until a refresh has read its balance. With that setting
off, the token has no line until a refresh has read its balance.
A token is tracked on the network that it was added on. Settings lists the
tracked tokens of the active network, each with an `[x]` button that stops
@@ -274,7 +286,8 @@ The wallet refuses to track:
- a token that is already tracked;
- an address on its scam list;
- a contract that does not answer as an ERC-20 token.
- a contract that does not answer as an ERC-20 token;
- a token that fakes a known symbol.
### The token screen
@@ -602,11 +615,11 @@ Blockscout reports no holder count for a token, the history and the send screen
still show it. While "Use Blockscout" is off, no holder count is known.
Fraud contracts. When the wallet detects a fake token transfer, it adds the
token's contract to a list that it keeps. While "Hide transactions from detected
fraud contracts" is on, the history hides transfers from the contracts on that
list. The send screen never offers those tokens. Turning "Hide fake tokens
impersonating a known symbol" off also stops new contracts being added to the
list.
token's contract to a list. A network switch empties the list. While "Hide
transactions from detected fraud contracts" is on, the history hides transfers
from the contracts on that list. The send screen never offers those tokens.
Turning "Hide fake tokens impersonating a known symbol" off also stops new
contracts being added to the list.
Dust transactions. Scammers send tiny amounts of ETH from an address that
resembles one you have dealt with, so that the similar address lands in your
@@ -616,11 +629,10 @@ threshold starts at 100,000 gwei, which is 0.0001 ETH. A threshold of 0 hides
nothing.
Known scam addresses. The wallet ships a list of addresses known for fraud,
including phishing addresses and wallet drainers. A wallet drainer is a contract
that empties a wallet once given permission. Sending to a listed address raises
a warning on the confirmation screen, and a listed address cannot be tracked as
a token. The list holds only addresses involved in fraud; it is not a sanctions
list.
including phishing addresses and wallet drainers. Sending to a listed address
raises a warning on the confirmation screen, and a listed address cannot be
tracked as a token. The list holds only addresses involved in fraud; it is not a
sanctions list.
Addresses and hashes are shown in full, because address poisoning relies on
shortened ones. The only shortened address is the label of a token with no known
+1 -1
View File
@@ -1,7 +1,7 @@
// Turn a verified dist/ into the two distributable archives.
//
// Invoked by script/package, which runs `make build` first so that dist/ has
// already been checked against the build's own receipt (see the Build Receipts
// already been checked against the build's own receipt (see the Builds
// section of README.md). This program does not build anything and does not
// write into dist/: it reads the emitted tree and writes release/.
//
+6 -6
View File
@@ -60,12 +60,12 @@ main() {
echo "Running the Firefox e2e suite..."
# --shm-size=1g: Firefox needs more than the default 64MB /dev/shm.
# --network none: the suite stubs nothing, so this is what keeps the
# run offline and deterministic. The extension swallows its own
# fetch failures, so the popup flows work unchanged; see the
# network note in README.md. Weaker than the Chrome suite's
# fixture interception, and honestly so — it proves no request
# escaped, but it cannot report which ones were attempted.
# --network none: the container has no network, and the suite reaches
# only the test page and stub node on loopback (README.md, Browser
# tests). The extension swallows its own fetch failures, so the popup
# flows work unchanged. Weaker than the Chrome suite's fixture
# interception, and honestly so — it proves no request escaped, but it
# cannot report which ones were attempted.
# HOME=/tmp: the image's root home is not a reliable place for the
# browser profile.
#
+6 -6
View File
@@ -18,11 +18,11 @@
// updateState(fn) read fresh, apply fn to that fresh record, write it
// back — all holding the lock every write of the stored
// record takes, so no other write lands in between, by
// the background or by any page (README, "Stored state
// and its version"). The read is one storage round trip
// ahead of the write rather than a page lifetime ahead of
// it (which is what made the popup's saveState() need a
// per-field merge against a baseline at all).
// the background or by any page (README, "Stored data").
// The read is one storage round trip ahead of the write
// rather than a page lifetime ahead of it (which is what
// made the popup's saveState() need a per-field merge
// against a baseline at all).
//
// A handler that must both read and write therefore does its network work
// against a snapshot it owns, and applies the RESULT inside updateState().
@@ -72,7 +72,7 @@ async function updateStateOnce(mutate) {
// src/shared/browserApi.js) is held from the read to the write: two
// background turns, or a turn and a page's save, never interleave
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits
// while it is held (README, "Stored state and its version").
// while it is held (README, "Stored data").
//
// `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the inner turn waits for the lock the outer one holds,
+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>
+19 -8
View File
@@ -98,15 +98,19 @@ let refreshTimer = null;
// 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. trackToken() in
// src/popup/views/helpers.js sets that field. 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.
// 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.
// on AddressDetail or AddressToken when that screen is up. When Send is up, its
// From address and current balance are drawn again, and its inputs and dropdown
// stay as the user left them. No other screen is drawn again, DeleteAddress and
// its balance lines included.
async function doRefreshAndRender() {
if (refreshInFlight) {
refreshAgain = true;
@@ -149,13 +153,20 @@ async function doRefreshAndRender() {
if (!stored.useBlockscout) {
dropUntrackedTokens(state.wallets, stored.trackedTokens);
}
clearBalanceNotFetched(stored, readTokens);
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();
if (state.currentView === "send") send.updateSendBalance();
} 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)) {
+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 =
+7 -5
View File
@@ -146,11 +146,12 @@ async function loadTransactions(address) {
}
// If the network changed during the lookups above, or the screen now
// shows another address, nothing is saved or drawn. See
// src/shared/storedSettings.js.
// 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 (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
state.networkId !== networkId ||
$("address-line").dataset.full !== address
) {
return;
@@ -187,8 +188,8 @@ async function loadTransactions(address) {
// If the network changed, or the screen now shows another address,
// the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
state.networkId !== networkId ||
$("address-line").dataset.full !== address
) {
return;
@@ -272,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;
}
+16 -7
View File
@@ -161,7 +161,8 @@ function renderBalance() {
const tokenId = state.selectedToken;
let amount, price;
if (tokenId === "ETH") {
amount = parseFloat(addr.balance || "0");
// null when no refresh has read the balance.
amount = unknownableAmount(addr.balance);
price = getPrice("ETH");
} else {
const id = tokenId.toLowerCase();
@@ -172,8 +173,8 @@ function renderBalance() {
(t) => t.address.toLowerCase() === id,
);
// null when the scale is unknown, or when no refresh has read the
// balance of a token just added: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000.
// 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);
@@ -241,11 +242,13 @@ async function loadTransactions(address, tokenId) {
}
// If the network changed during the lookups above, or the screen now
// shows another address or token, nothing is saved or drawn. See
// 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 (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
state.networkId !== networkId ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
@@ -283,8 +286,8 @@ async function loadTransactions(address, tokenId) {
// If the network changed, or the screen now shows another address or
// token, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
state.networkId !== networkId ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
@@ -357,6 +360,11 @@ function init(_ctx) {
});
$("btn-address-token-back").addEventListener("click", () => {
// AddressDetail's show() clears the token, but Back onto a drawn
// screen only unhides it. "Send" below may have left the dropdown on
// the token as well.
state.selectedToken = null;
$("send-token").value = "ETH";
goBack();
});
@@ -370,7 +378,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;
+10 -10
View File
@@ -319,12 +319,12 @@ function showTxApproval(details) {
);
}
// A swap the decoder read no amount for, such as one whose V4 swap
// action could not be read or a V4 exact-out swap. The wait and error
// screens say the amount could not be decoded, with no currency after
// it, rather than show the transaction's ETH value as the amount. A
// swap paid in native ETH keeps that value: it is the ETH the swap
// pays (for an exact-out swap, the most it pays), not a figure that
// had to be read from the calldata.
// action could not be read or a V4 exact-out swap. The wait, success
// and error screens say the amount could not be decoded, with no
// currency after it, rather than show the transaction's ETH value as
// the amount. A swap paid in native ETH keeps that value: it is the
// ETH the swap pays (for an exact-out swap, the most it pays), not a
// figure that had to be read from the calldata.
const tokenIn = decoded.details.find((d) => d.label === "Token In");
if (
tokenIn &&
@@ -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>`;
}
+14 -6
View File
@@ -83,19 +83,27 @@ function recoveryPathText(wallet) {
// rounded number would report "0.0000 ETH" for an address holding real money.
// The lines below it carry the amounts, in the same format as Home and
// AddressDetail, followed by the USD total when there is one to give — no
// total line at all on testnet or before the first price fetch, and no figure
// when every holding here is one with no price, since "$0.00" directly under
// "This address holds a balance." is a contradiction. A token holding below
// 0.000001 does not count, as the lines below leave it out.
// total line at all on testnet, before the first price fetch or while no
// refresh has read the ETH balance, and no figure when every holding here is
// one with no price, since "$0.00" directly under "This address holds a
// balance." is a contradiction. A token holding below 0.000001 does not count,
// as the lines below leave it out.
//
// While no refresh has read the ETH balance the address may be empty, so the
// sentence says "may hold".
function balanceWarningHtml(addr) {
if (!addressHoldsFunds(addr)) return "&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
);
+28 -16
View File
@@ -272,7 +272,7 @@ function clearFlash() {
}
// The flash line reserves exactly one line, and a message that wrapped would
// push the screen below it down (README, No Layout Shift). So #flash-msg never
// push the screen below it down (README, Appearance). So #flash-msg never
// wraps: text too long for the line is cut with an ellipsis, and the whole
// message is also put in the line's title. Write messages to fit, at most 50
// characters, so none is cut.
@@ -283,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);
@@ -394,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 =
@@ -430,10 +431,17 @@ 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",
);
@@ -486,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 —
@@ -517,9 +528,9 @@ const ADDRESS_MIN_DISPLAY_LEN = 32;
// Truncate the middle of a string, replacing removed characters with "…".
// Safety: refuses to truncate more than 10 characters, which is the maximum
// that still prevents address spoofing attacks (see Display Consistency in
// README). Callers that need to display less should use a different UI
// approach rather than silently making addresses insecure.
// that still prevents address spoofing attacks (see Addresses in README).
// Callers that need to display less should use a different UI approach rather
// than silently making addresses insecure.
function truncateMiddle(str, maxLen) {
if (str.length <= maxLen) return str;
const removed = str.length - maxLen + 1; // +1 for the ellipsis char
@@ -622,9 +633,9 @@ function formatAddressHtml(address, ensName, maxLen, title) {
return renderAddressHtml(address, { title, ensName, maxLen });
}
// A transaction's time as every screen shows it (README, Display
// Consistency): the ISO datetime, in UTC when the UTC Timestamps setting is
// on, and the relative age. Views import these two; they keep no copies.
// A transaction's time as every screen shows it (README, Amounts): the ISO
// datetime, in UTC when the UTC Timestamps setting is on, and the relative age.
// Views import these two; they keep no copies.
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
@@ -850,6 +861,7 @@ module.exports = {
balanceLine,
balanceLinesForAddress,
trackToken,
markTrackedTokensNotFetched,
addressHoldsFunds,
keyMaterialName,
unknownableAmount,
+48 -11
View File
@@ -3,6 +3,8 @@ const {
showView,
showFlash,
balanceLinesForAddress,
unknownableAmount,
markTrackedTokensNotFetched,
isoDate,
timeAgo,
addressDotHtml,
@@ -75,10 +77,16 @@ function renderTotalValue() {
if (subEl) subEl.innerHTML = "&nbsp;";
return;
}
const ethBal = parseFloat(addr.balance || "0");
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
el.textContent = ethStr + ethUsd;
const ethBal = unknownableAmount(addr.balance);
if (ethBal === null) {
el.textContent = "unknown (" + nativeCurrency() + ")";
} else {
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
const ethUsd = ethPrice
? " (" + formatUsd(ethBal * ethPrice) + ")"
: "";
el.textContent = ethStr + ethUsd;
}
if (subEl) {
subEl.innerHTML =
@@ -99,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;
@@ -185,6 +196,19 @@ 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,
@@ -205,10 +229,12 @@ async function loadHomeTxs(ctx) {
const results = await Promise.all(fetches);
// If the network changed during the lookups, nothing is saved or
// drawn. See src/shared/storedSettings.js.
// 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 (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
!(await storedNetworkIs(networkId)) ||
state.networkId !== networkId
) {
return;
}
@@ -249,6 +275,7 @@ async function loadHomeTxs(ctx) {
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
@@ -257,11 +284,12 @@ async function loadHomeTxs(ctx) {
log.errorf("loadHomeTxs failed:", e.message);
// If the network changed, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
!(await storedNetworkIs(networkId)) ||
state.networkId !== networkId
) {
return;
}
listNetworkId = networkId;
const list = $("home-tx-list");
if (list) {
list.innerHTML =
@@ -390,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();
@@ -476,15 +505,20 @@ function init(ctx) {
return;
}
const addr = currentAddress();
if (!addr.balance || parseFloat(addr.balance) === 0) {
// A null balance is one no refresh has read, so it is not refused.
if (parseFloat(addr.balance) === 0) {
showFlash("Cannot send \u2014 zero balance.");
return;
}
// Send uses a selected token ahead of its dropdown, and AddressToken
// can leave one selected on the way here.
state.selectedToken = null;
$("send-to").value = "";
$("send-amount").value = "";
$("send-token-dropdown").classList.remove("hidden");
$("send-token-static").classList.add("hidden");
renderSendTokenSelect(addr);
$("send-token").value = "ETH";
updateSendBalance();
resetSendValidation();
pushCurrentView();
@@ -496,6 +530,9 @@ function init(ctx) {
showFlash("No active address selected.");
return;
}
// Receive warns about a selected token, and AddressToken can leave one
// selected on the way here.
state.selectedToken = null;
ctx.showReceive();
});
}
+22 -10
View File
@@ -230,11 +230,14 @@ 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") +
" " +
nativeCurrency();
addr.balance == null
? "Current balance: unknown (" + nativeCurrency() + ")"
: "Current balance: " +
truncateAmountNeverZero(addr.balance) +
" " +
nativeCurrency();
} else {
const symbol = resolveSymbol(
token,
@@ -242,10 +245,11 @@ function updateSendBalance() {
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
@@ -288,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();
@@ -305,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);
@@ -320,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 ||
+1 -1
View File
@@ -265,7 +265,7 @@ async function renderSiteLists() {
// A network switch, made by the user here or saved by the background once the
// user approved a site's request (src/popup/index.js), leaves every screen on
// the page showing the previous network. Two functions leave the page as
// README's Screen Map describes:
// README's Navigation section describes:
//
// - cutViewStack(), before the switch is saved, so that save keeps the cut for
// a popup closed straight after
+5
View File
@@ -45,6 +45,11 @@ 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,
+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>`;
}
+95 -42
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);
} else {
html += toAddressHtml(d.address, chainId);
}
html += decodedAddressHtml(d, chainId);
} else {
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
html += `<div class="font-bold break-words">${escapeHtml(d.value)}</div>`;
}
html += `</div>`;
}
@@ -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");
$("success-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : "");
$("success-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
}
// 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");
}
+1 -1
View File
@@ -1,4 +1,4 @@
// The 4-decimal amount rule from README.md's Display Consistency section, and
// The 4-decimal amount rule from README.md's Amounts section, and
// the one exception to it, in one place. Three call sites had grown their own
// copy of the truncation — the history and balance lists
// (`src/shared/transactions.js`), the approval screen's ERC-20 amount line
+5 -6
View File
@@ -240,10 +240,9 @@ function onStorageChanged(listener) {
/**
* Run `fn` while holding the browser's lock called `name`
* (`navigator.locks`), and settle as `fn` settles. Every page of the extension
* and its background share these locks, except a page in a Firefox private
* window (README, "Stored state and its version"), so while one of them holds
* `name` another asking for it waits, in the order they asked. The lock is let
* go when `fn` settles, whether it resolved or rejected.
* and its background share these locks, so while one of them holds `name`
* another asking for it waits, in the order they asked. The lock is let go when
* `fn` settles, whether it resolved or rejected.
*
* Every write of the stored record holds the lock named after its key,
* "autistmask", from its read to its write: saveState() in
@@ -266,8 +265,8 @@ function withLock(name, fn) {
// `storage.session`, the browser's session storage: what is kept there lasts
// until the browser closes. It survives the background worker being stopped
// and started again, which module-level variables do not (README, Background
// scheduling). The background keeps the connections allowed without
// and started again, which module-level variables do not (README, The
// background worker). The background keeps the connections allowed without
// "Remember" there; see src/background/index.js. Firefox has it from version
// 115, the minimum manifest/firefox.json declares.
//
+2 -2
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;
@@ -100,7 +100,7 @@ async function onChainSwitch(newNetworkId) {
stored.fraudContracts = [];
for (const wallet of stored.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.balance = null;
addr.tokenBalances = [];
}
}
+9 -3
View File
@@ -48,9 +48,14 @@ function applyChainSwitchFields(s, newNetworkId) {
// It is a copy because a save writes a changed s.networkTrackedTokens whole,
// over any list another page stored. An add or removal on the active network
// must change only s.trackedTokens.
//
// Each token of the copy gets `balanceNotFetched`, because the balances are
// cleared below and no refresh has read this network's yet. Each reads
// `quantity unknown` until one has, not 0.0000.
if (!s.networkTrackedTokens) s.networkTrackedTokens = {};
s.networkTrackedTokens[s.networkId] = s.trackedTokens;
s.trackedTokens = structuredClone(s.networkTrackedTokens[net.id] || []);
for (const token of s.trackedTokens) token.balanceNotFetched = true;
s.networkId = net.id;
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
@@ -63,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 = [];
}
}
@@ -103,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
+61 -26
View File
@@ -404,6 +404,11 @@ const OPEN_DELTA = Symbol("v4-open-delta");
// that nothing is swapped.
const ALREADY_PAID = Symbol("v2-already-paid");
// A later WRAP_ETH of CONTRACT_BALANCE wraps all the ETH the router holds, and
// so all the ETH the transaction carries. The calldata states no quantity for
// it. The approval screen's Value line gives that ETH.
const TRANSACTION_ETH = Symbol("transaction-eth");
// The router's whole-balance amount, universal-router
// `ActionConstants.CONTRACT_BALANCE`: a V2 exact-in given it as `amountIn`
// pays the pair all the router holds of the input token.
@@ -416,6 +421,7 @@ const CONTRACT_BALANCE = 2n ** 255n;
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
const ALREADY_PAID_AMOUNT =
"Whatever an earlier step sent to the pair (V2 already paid)";
const TRANSACTION_ETH_AMOUNT = "All the ETH the transaction carries";
const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
@@ -1267,9 +1273,9 @@ 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).
// The recipient of a WRAP_ETH that set the input side, as written;
// otherwise null. That WRAP_ETH pays its WETH there (see the Moved to
// lines).
let wrapEthRecipient = null;
// The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop).
@@ -1289,6 +1295,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let minOutput = null;
let outputEstablished = false;
let hasUnwrapWeth = false;
// Set once an UNWRAP_WETH pays ETH to the router, which a later
// WRAP_ETH of CONTRACT_BALANCE then wraps.
let unwrappedIntoRouter = false;
// Set when an UNWRAP_WETH pays out, as ETH, the output side's WETH,
// which the router holds; cleared whenever the output side is set
// again, or a later step may pay that ETH out as WETH (see after the
@@ -1368,9 +1377,9 @@ 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. 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.
// "Up to" and the amount. A take with a `recipient` is the ETH of a
// later WRAP_ETH. If that recipient is not the router, the take never
// joins the Amount line either: its Moved to line names that recipient.
const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender, or at most that when `atMost` is set. A
@@ -1906,9 +1915,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount,
);
inputPaidByUser = true;
if (!isRouter(w.recipient)) {
wrapEthRecipient = w.recipient;
}
wrapEthRecipient = w.recipient;
} else if (
w.amount > 0n &&
w.amount !== CONTRACT_BALANCE
@@ -1916,17 +1923,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// 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.
// then pays at most that amount.
userPaid.push({
token: "0x0000000000000000000000000000000000000000",
amount: w.amount,
atMost: heldByRouter.has(
"0x0000000000000000000000000000000000000000",
),
recipient: isRouter(w.recipient)
? null
: w.recipient,
recipient: w.recipient,
});
} else if (
w.amount === CONTRACT_BALANCE &&
!isRouter(w.recipient) &&
!unwrappedIntoRouter &&
!heldByRouter.has(
"0x0000000000000000000000000000000000000000",
)
) {
// It pays the recipient all the ETH the transaction
// carries. It gets no lines after an UNWRAP_WETH
// into the router, or while the router holds ETH
// from a V4 step: Uniswap's routes pay back what
// their swap did not spend this way, and carry no
// ETH.
userPaid.push({
token: "0x0000000000000000000000000000000000000000",
amount: TRANSACTION_ETH,
recipient: w.recipient,
});
}
addPayee(w.recipient);
@@ -2169,6 +2192,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
heldByRouter.has(w),
);
WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w));
if (isRouter(u.recipient)) {
unwrappedIntoRouter = true;
}
if (isRouter(u.recipient) && heldWeth) {
heldByRouter.set(
"0x0000000000000000000000000000000000000000",
@@ -2266,8 +2292,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What the steps take that the Amount line does not give, as
// { 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).
// know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH has
// a recipient (see wrapEthRecipient and userPaid).
const spentFromSender = [];
let total = null;
// The takes from the sender that `total` adds up, each a quantity, in
@@ -2315,7 +2341,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
typeof total === "bigint" &&
typeof t.amount === "bigint" &&
!t.atMost &&
!present(t.recipient)
(!present(t.recipient) || isRouter(t.recipient))
) {
total += t.amount;
inTotal.push(t);
@@ -2413,13 +2439,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
// The text of an amount the swap steps spend, for the Amount line and
// the lines for other tokens below it. Three amounts need no scale to
// describe and are named rather than formatted: V4's open delta and
// V2's already-paid zero, neither of which is a quantity at all (see
// OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two
// tests come first — the sentinels are not bigints and cannot be
// compared with one. `atMost` marks an amount that is only the most a
// take may be (see userPaid).
// the lines for other tokens below it. Four amounts need no scale to
// describe and are named rather than formatted: V4's open delta, V2's
// already-paid zero and the ETH the transaction carries, none of which
// is a quantity at all (see OPEN_DELTA, ALREADY_PAID and
// TRANSACTION_ETH), and an unbounded permit. Those three tests come
// first — the sentinels are not bigints and cannot be compared with
// one. `atMost` marks an amount that is only the most a take may be
// (see userPaid).
const spentText = (amount, info, atMost) => {
if (amount === OPEN_DELTA) {
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
@@ -2430,6 +2457,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
display: ALREADY_PAID_AMOUNT,
};
}
if (amount === TRANSACTION_ETH) {
return {
raw: TRANSACTION_ETH_AMOUNT,
display: TRANSACTION_ETH_AMOUNT,
};
}
if (amount >= MAX_UINT160) {
return { raw: "Unlimited", display: "Unlimited" };
}
@@ -2489,8 +2522,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// 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. 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.
// that spends it. A take with a recipient goes to that recipient:
// the router, the sender as `MSG_SENDER`, or an address.
for (const s of spentFromSender) {
details.push({
label: "Moved from the sender",
@@ -2514,6 +2547,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
label: "Moved to",
value: "The swap step that spends it",
});
} else if (isRouter(s.recipient)) {
details.push({ label: "Moved to", value: "The router" });
} else if (s.recipient.toLowerCase() === MSG_SENDER) {
details.push({ label: "Moved to", value: "The sender" });
} else {
+46 -1
View File
@@ -3,7 +3,9 @@
// (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). Driven through the real
// (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 {
@@ -197,6 +199,8 @@ describe.each([
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 () => {
@@ -213,3 +217,44 @@ describe.each([
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]);
});
+666 -5
View File
@@ -11,14 +11,53 @@
// switch keeps each network's records apart. A refresh takes the field off the
// stored record's tokens and keeps another page's changes to the stored list.
//
// Every tracked token carries that field again once an address or a wallet is
// added, and after a network switch, so it reads the same until a refresh has
// read it (https://git.eeqj.de/sneak/AutistMask/issues/720).
//
// An address's ETH balance is null until a refresh has read that address, so
// its ETH line reads the same, and Send states it as unknown
// (https://git.eeqj.de/sneak/AutistMask/issues/736).
//
// A refresh that returns while Send is up draws Send's balance line again, and
// leaves what the user entered as it is
// (https://git.eeqj.de/sneak/AutistMask/issues/748).
//
// Driven through the real popup over a stored profile on Ethereum Mainnet,
// with "Show tracked tokens with zero balance" on, as it is by default.
const { HDNodeWallet, Mnemonic } = require("ethers");
const {
ADDRESS,
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const { hdWalletFromMnemonic } = require("../src/shared/wallet");
const PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const PASSWORD = "correct horse battery staple";
// What AddWallet's tabs take. The first and last scan for more addresses.
const FROM_PHRASE = {
tab: "tab-mnemonic",
field: "wallet-mnemonic",
secret: PHRASE,
};
const FROM_PRIVATE_KEY = {
tab: "tab-privkey",
field: "import-private-key",
secret: "0x" + "11".repeat(32),
};
const FROM_XPRV = {
tab: "tab-xprv",
field: "import-xprv-key",
secret: HDNodeWallet.fromSeed(Mnemonic.fromPhrase(PHRASE).computeSeed())
.extendedKey,
};
// Tracked by nobody until a test adds it.
const NEW_TOKEN = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
@@ -66,11 +105,39 @@ function bootOnAddress(extra) {
);
}
// The popup over one wallet that "+" can derive an address from. Its one
// address holds a million NEW_TOKEN, and NEW_TOKEN is tracked.
function bootOnDerivingWallet() {
const { xpub, firstAddress } = hdWalletFromMnemonic(PHRASE);
return bootPopup(
unversionedValidProfile({
wallets: [
{
type: "hd",
name: "Wallet 1",
xpub,
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{
address: firstAddress,
balance: "1.5",
tokenBalances: [MILLION],
},
],
},
],
activeAddress: firstAddress,
trackedTokens: [NEW_TRACKED],
}),
);
}
// Holds the next balance refresh open. The returned function lets it finish
// with the address's ETH balance at 2 and `tokenBalances` as its token
// entries, having read the balance of every token tracked when it started.
// Given null, it finishes having read no token's balance, as when every
// balanceOf fails, and leaves the token entries as they were.
// with every address's ETH balance at 2 and `tokenBalances` as the first
// address's token entries, having read the balance of every token tracked when
// it started. Given null, it finishes having read no token's balance, as when
// every balanceOf fails, and leaves the token entries as they were.
function holdNextRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let release;
@@ -80,7 +147,9 @@ function holdNextRefresh(page) {
const tokenBalances = await new Promise((resolve) => {
release = resolve;
});
wallets[0].addresses[0].balance = "2.0";
for (const wallet of wallets) {
for (const addr of wallet.addresses) addr.balance = "2.0";
}
if (tokenBalances === null) return [];
wallets[0].addresses[0].tokenBalances = tokenBalances;
return read;
@@ -175,6 +244,63 @@ function newTokenRow(page, id = "address-balances") {
);
}
// Every address's row for NEW_TOKEN on Home, as markup, in the order Home lists
// the addresses.
function newTokenRowsOnHome(page) {
return page
.node("wallet-list")
.innerHTML.split("<div ")
.filter((row) =>
row.includes('data-token="' + NEW_TOKEN.toLowerCase() + '"'),
);
}
// Every ETH row in the balance list in element `id`, as markup, in the order
// the list holds them.
function ethRows(page, id) {
return page
.node(id)
.innerHTML.split("<div ")
.filter((row) => row.includes('data-token="ETH"'));
}
// A stored profile whose one address no refresh has read.
function profileNotRead(extra) {
const profile = unversionedValidProfile(extra);
profile.wallets[0].addresses[0].balance = null;
return profile;
}
// The "+" button Home draws for the first wallet. As in firstRemoveButton(),
// the list hands Home a stand-in for that button each time it is drawn, and
// `press()` presses the last one drawn. Call it before Home next draws its list.
function plusButton(page) {
const button = { press: null };
page.node("wallet-list").querySelectorAll = (selector) =>
selector !== ".btn-add-address"
? []
: [
{
dataset: { wallet: "0" },
addEventListener: (name, fn) => {
button.press = () =>
fn({ stopPropagation: () => {} });
},
},
];
return button;
}
// "Add wallet" on Home, filled in from `source`, and "Import".
async function addWallet(page, { tab, field, secret }) {
await page.click("btn-add-wallet-bottom");
await page.click(tab);
page.node(field).value = secret;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
// AddressDetail for the one address opened from Home, as its `[info]` button
// does.
async function openAddress(page) {
@@ -310,6 +436,62 @@ test("Send for the new token reads its balance as unknown before any refresh has
await finish([MILLION]);
});
test("Send for the new token reads the fetched balance once the refresh returns, without being reopened", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
await openToken(page, NEW_TOKEN.toLowerCase());
await page.click("btn-address-token-send");
expect(page.text("send-balance")).toBe("Current balance: unknown (NEW)");
await finish([MILLION]);
expect(page.visibleViews()).toEqual(["send"]);
expect(page.text("send-balance")).toBe("Current balance: 1000000.0000 NEW");
});
test("a refresh that returns while Send is up draws an unread ETH balance again and leaves the inputs alone", async () => {
const page = await bootPopup(profileNotRead());
// The dropdown's first option, which a browser selects by itself.
page.node("send-token").value = "ETH";
await page.click("btn-main-send");
expect(page.text("send-balance")).toBe("Current balance: unknown (ETH)");
const recipient = "0x" + "dd".repeat(20);
page.node("send-to").value = recipient;
page.node("send-amount").value = "0.5";
// Stands for the options on the screen, which a refresh leaves as they are.
page.node("send-token").innerHTML = "options on the screen";
// Stands for the From address from before the refresh.
page.node("send-from").innerHTML = "From address before the refresh";
await refreshOnce(page, []);
expect(page.visibleViews()).toEqual(["send"]);
expect(page.text("send-balance")).toBe("Current balance: 2.0000 ETH");
expect(page.node("send-from").innerHTML).toContain(ADDRESS);
expect(page.value("send-to")).toBe(recipient);
expect(page.value("send-amount")).toBe("0.5");
expect(page.value("send-token")).toBe("ETH");
expect(page.node("send-token").innerHTML).toBe("options on the screen");
});
test("a refresh that returns while Send is up keeps the token picked in the dropdown and draws its new balance", async () => {
const page = await bootOnDerivingWallet();
const dropdown = page.node("send-token");
dropdown.value = "ETH";
await page.click("btn-main-send");
dropdown.value = NEW_TOKEN;
for (const fn of dropdown.listeners.change) await fn();
expect(page.text("send-balance")).toBe("Current balance: 1000000.0000 NEW");
page.node("send-amount").value = "0.5";
await refreshOnce(page, [{ ...MILLION, balance: "3000000.0" }]);
expect(page.text("send-balance")).toBe("Current balance: 3000000.0000 NEW");
expect(page.value("send-token")).toBe(NEW_TOKEN);
expect(page.value("send-amount")).toBe("0.5");
});
test("a token added by contract address on SettingsAddToken reads quantity unknown until the refresh returns", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.visibleViews()).toEqual(["main"]);
@@ -456,3 +638,482 @@ test("a refresh does not bring back a token that another page removed after this
NEW_TRACKED,
]);
});
test("an address just added reads quantity unknown for a tracked token until a refresh has read it", async () => {
const page = await bootOnDerivingWallet();
const plus = plusButton(page);
await refreshOnce(page, [MILLION]);
const finish = holdNextRefresh(page);
await plus.press();
await page.settle();
const [held, added] = newTokenRowsOnHome(page);
expect(held).toContain("1000000.0000");
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
expect(
page.storage.read("autistmask").trackedTokens[0].balanceNotFetched,
).toBe(true);
// It reads the new address's balance, which is zero.
await finish([MILLION]);
expect(newTokenRowsOnHome(page)[1]).toContain("0.0000");
});
test('an address "+" adds during a refresh reads quantity unknown when that refresh returns', async () => {
const page = await bootOnDerivingWallet();
const plus = plusButton(page);
await refreshOnce(page, [MILLION]);
const finishRunning = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await plus.press();
await page.settle();
const finishNext = holdNextRefresh(page);
await finishRunning([MILLION]);
await ticking;
const added = newTokenRowsOnHome(page)[1];
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
// It reads the new address's balance, which is zero.
await finishNext([MILLION]);
expect(newTokenRowsOnHome(page)[1]).toContain("0.0000");
});
test.each([FROM_PHRASE, FROM_PRIVATE_KEY, FROM_XPRV])(
"a wallet just added from $tab reads quantity unknown for a tracked token until a refresh has read it",
async (source) => {
const page = await bootPopup(
unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }),
);
const finish = holdNextRefresh(page);
await addWallet(page, source);
const added = newTokenRowsOnHome(page)[1];
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
// It reads the new address's balance, which is zero.
await finish([]);
expect(newTokenRowsOnHome(page)[1]).toContain("0.0000");
},
);
test("a wallet from a private key added during a refresh reads quantity unknown when that refresh returns", async () => {
const page = await bootPopup(
unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }),
);
const finishRunning = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await addWallet(page, FROM_PRIVATE_KEY);
const finishNext = holdNextRefresh(page);
await finishRunning([]);
await ticking;
const added = newTokenRowsOnHome(page)[1];
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
// It reads the new address's balance, which is zero.
await finishNext([]);
expect(newTokenRowsOnHome(page)[1]).toContain("0.0000");
});
test.each([FROM_PHRASE, FROM_XPRV])(
"addresses the scan finds for a wallet from $tab read quantity unknown, even after a refresh returned during the scan",
async (source) => {
const page = await bootPopup(
unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }),
);
const { scanForAddresses } = require("../src/shared/balances");
let finish;
scanForAddresses.mockImplementationOnce(async () => {
await refreshOnce(page, []);
finish = holdNextRefresh(page);
return {
addresses: [
{ address: "0x" + "dd".repeat(20), index: 0 },
{ address: "0x" + "ee".repeat(20), index: 1 },
],
nextIndex: 2,
};
});
await addWallet(page, source);
const [, first, second] = newTokenRowsOnHome(page);
for (const found of [first, second]) {
expect(found).toContain("quantity unknown");
expect(found).not.toContain("0.0000");
}
await finish([]);
expect(newTokenRowsOnHome(page)[2]).toContain("0.0000");
},
);
test.each([FROM_PHRASE, FROM_XPRV])(
"addresses the scan finds for a wallet from $tab read quantity unknown when a refresh running as the scan ends returns",
async (source) => {
const page = await bootPopup(
unversionedValidProfile({ trackedTokens: [NEW_TRACKED] }),
);
const { scanForAddresses } = require("../src/shared/balances");
let finishRunning;
let ticking;
scanForAddresses.mockImplementationOnce(async () => {
finishRunning = holdNextRefresh(page);
ticking = page.tick();
await page.settle();
return {
addresses: [
{ address: "0x" + "dd".repeat(20), index: 0 },
{ address: "0x" + "ee".repeat(20), index: 1 },
],
nextIndex: 2,
};
});
await addWallet(page, source);
const finishNext = holdNextRefresh(page);
await finishRunning([]);
await ticking;
const [, first, second] = newTokenRowsOnHome(page);
for (const found of [first, second]) {
expect(found).toContain("quantity unknown");
expect(found).not.toContain("0.0000");
}
// It reads the new addresses' balances, which are zero.
await finishNext([]);
expect(newTokenRowsOnHome(page)[2]).toContain("0.0000");
},
);
test("after a network switch in Settings, a tracked token reads quantity unknown until a refresh has read it", async () => {
const page = await bootOnAddress({
trackedTokens: [OTHER_TOKEN],
networkTrackedTokens: { sepolia: [NEW_TRACKED] },
});
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
// It reads the balance, which is zero.
await refreshOnce(page, []);
expect(newTokenRow(page)).toContain("0.0000");
});
test("after a switch saved by the background, a tracked token reads quantity unknown until a refresh has read it", async () => {
const page = await bootOnAddress({
networkTrackedTokens: { sepolia: [NEW_TRACKED] },
});
const {
applyChainSwitchFields,
} = require("../src/shared/chainSwitchFields");
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
page.storage.write("autistmask", record);
// The popup takes the switch, and this refresh reads no token's balance.
await page.tick();
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
});
test("a refresh deletes no balanceNotFetched while the stored record holds an address it did not read", async () => {
const page = await bootOnAddress({
trackedTokens: [{ ...NEW_TRACKED, balanceNotFetched: true }],
});
// Another popup page adds an address. This page is not told of the write.
const record = page.storage.read("autistmask");
record.wallets[0].addresses.push({
address: "0x" + "dd".repeat(20),
balance: null,
tokenBalances: [],
});
page.storage.write("autistmask", record);
await refreshOnce(page, []);
expect(
page.storage.read("autistmask").trackedTokens[0].balanceNotFetched,
).toBe(true);
expect(newTokenRow(page)).toContain("quantity unknown");
});
test("an address just added reads quantity unknown for ETH until a refresh has read it, then the fetched balance", async () => {
const page = await bootOnDerivingWallet();
const plus = plusButton(page);
await refreshOnce(page, []);
const finish = holdNextRefresh(page);
await plus.press();
await page.settle();
const [held, added] = ethRows(page, "wallet-list");
expect(held).toContain("2.0000");
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
expect(
page.storage.read("autistmask").wallets[0].addresses[1].balance,
).toBeNull();
await finish([]);
expect(ethRows(page, "wallet-list")[1]).toContain("2.0000");
});
test('an address "+" adds during a refresh reads quantity unknown for ETH when that refresh returns', async () => {
const page = await bootOnDerivingWallet();
const plus = plusButton(page);
await refreshOnce(page, []);
const finishRunning = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await plus.press();
await page.settle();
const finishNext = holdNextRefresh(page);
await finishRunning([]);
await ticking;
const added = ethRows(page, "wallet-list")[1];
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
await finishNext([]);
expect(ethRows(page, "wallet-list")[1]).toContain("2.0000");
});
test.each([FROM_PHRASE, FROM_PRIVATE_KEY, FROM_XPRV])(
"a wallet just added from $tab reads quantity unknown for ETH until a refresh has read it",
async (source) => {
const page = await bootPopup(unversionedValidProfile());
const finish = holdNextRefresh(page);
await addWallet(page, source);
const added = ethRows(page, "wallet-list")[1];
expect(added).toContain("quantity unknown");
expect(added).not.toContain("0.0000");
await finish([]);
expect(ethRows(page, "wallet-list")[1]).toContain("2.0000");
},
);
test.each([FROM_PHRASE, FROM_XPRV])(
"addresses the scan finds for a wallet from $tab read quantity unknown for ETH until a refresh has read them",
async (source) => {
const page = await bootPopup(unversionedValidProfile());
const { scanForAddresses } = require("../src/shared/balances");
let finish;
scanForAddresses.mockImplementationOnce(async () => {
await refreshOnce(page, []);
finish = holdNextRefresh(page);
return {
addresses: [
{ address: "0x" + "dd".repeat(20), index: 0 },
{ address: "0x" + "ee".repeat(20), index: 1 },
],
nextIndex: 2,
};
});
await addWallet(page, source);
const [, first, second] = ethRows(page, "wallet-list");
for (const found of [first, second]) {
expect(found).toContain("quantity unknown");
expect(found).not.toContain("0.0000");
}
await finish([]);
expect(ethRows(page, "wallet-list")[2]).toContain("2.0000");
},
);
test("after a network switch in Settings, the ETH line reads quantity unknown until a refresh has read it, then the fetched balance", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
const [row] = ethRows(page, "address-balances");
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
expect(
page.storage.read("autistmask").wallets[0].addresses[0].balance,
).toBeNull();
await refreshOnce(page, []);
expect(ethRows(page, "address-balances")[0]).toContain("2.0000");
});
test("after a switch saved by the background, the ETH line reads quantity unknown until a refresh has read it, then the fetched balance", async () => {
const page = await bootOnAddress();
const {
applyChainSwitchFields,
} = require("../src/shared/chainSwitchFields");
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
page.storage.write("autistmask", record);
// The popup takes the switch, and this refresh reads no ETH balance.
await page.tick();
const [row] = ethRows(page, "address-balances");
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
await refreshOnce(page, []);
expect(ethRows(page, "address-balances")[0]).toContain("2.0000");
});
test("a switch taken from the stored record leaves an address the record does not have with no ETH balance", () => {
const {
copyChainSwitchFields,
} = require("../src/shared/chainSwitchFields");
const taker = {
wallets: [
{
addresses: [
{ address: "0x1", balance: "1.5", tokenBalances: [] },
],
},
],
};
copyChainSwitchFields(taker, { wallets: [] });
expect(taker.wallets[0].addresses[0].balance).toBeNull();
});
test("closed and opened again before any refresh has read it, the ETH line still reads quantity unknown", async () => {
const closed = await bootOnAddress();
await closed.click("btn-settings");
await chooseNetwork(closed, "sepolia");
await closed.click("btn-settings-back");
cleanupPopup();
const page = await bootPopup(undefined, { storage: closed.storage });
expect(page.visibleViews()).toEqual(["address"]);
const [row] = ethRows(page, "address-balances");
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
});
test("Home's ETH balance reads unknown until a refresh has read it, then the fetched balance", async () => {
const page = await bootPopup(profileNotRead());
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("unknown (ETH)");
await refreshOnce(page, []);
expect(page.text("total-value")).toBe("2.0000 ETH");
});
test("AddressToken for ETH reads quantity unknown until a refresh has read it, then the fetched balance", async () => {
const page = await bootPopup(
profileNotRead({
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: "ETH",
}),
);
expect(page.visibleViews()).toEqual(["address-token"]);
const before = page.node("address-token-balance").innerHTML;
expect(before).toContain("quantity unknown");
expect(before).not.toContain("0.0000");
await refreshOnce(page, []);
expect(page.node("address-token-balance").innerHTML).toContain("2.0000");
});
test.each([
["main", {}, "btn-main-send"],
[
"address",
{ currentView: "address", selectedWallet: 0, selectedAddress: 0 },
"btn-send",
],
[
"address-token",
{
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: "ETH",
},
"btn-address-token-send",
],
])(
"Send from %s reads an unread ETH balance as unknown, Max says so, and only a balance read as zero is refused",
async (view, extra, sendButton) => {
const page = await bootPopup(profileNotRead(extra));
expect(page.visibleViews()).toEqual([view]);
// The dropdown's first option, which a browser selects by itself.
page.node("send-token").value = "ETH";
await page.click(sendButton);
expect(page.visibleViews()).toEqual(["send"]);
expect(page.text("send-balance")).toBe(
"Current balance: unknown (ETH)",
);
await page.click("btn-send-max");
expect(page.text("flash-msg")).toBe("This token's balance is unknown.");
await page.click("btn-send-back");
const { refreshBalances } = require("../src/shared/balances");
refreshBalances.mockImplementationOnce(async (wallets) => {
wallets[0].addresses[0].balance = "0.0";
return [];
});
await page.tick();
await page.click(sendButton);
expect(page.visibleViews()).toEqual([view]);
expect(page.text("flash-msg")).toBe("Cannot send — zero balance.");
},
);
+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.
+165
View File
@@ -0,0 +1,165 @@
// "Back" from AddressToken leaves no token selected. AddressDetail's show()
// would clear it, but Back onto a drawn screen only unhides that screen, and
// Send and Receive both read the field
// (https://git.eeqj.de/sneak/AutistMask/issues/751). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
const TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressDetail, at an address holding 1.5 ETH and
// 2.5 TST.
function bootOnAddress() {
const profile = unversionedValidProfile({
currentView: "address",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [TOKEN],
});
profile.wallets[0].addresses[0].tokenBalances = [
{ ...TOKEN, balance: "2.5" },
];
return bootPopup(profile);
}
// AddressToken opened from AddressDetail, as tapping the token's row does.
async function openToken(page) {
const { state } = require("../src/shared/state");
state.selectedToken = TOKEN_ADDRESS;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
expect(page.visibleViews()).toEqual(["address-token"]);
}
async function backFromToken(page) {
await page.click("btn-address-token-back");
expect(page.visibleViews()).toEqual(["address"]);
}
// ETH chosen in Send's dropdown, as the user does.
async function chooseEth(page) {
const dropdown = page.node("send-token");
dropdown.value = "ETH";
for (const fn of dropdown.listeners.change) await fn();
}
// Review for 1 of the token Send has, to RECIPIENT.
async function review(page) {
page.node("send-to").value = RECIPIENT;
page.node("send-amount").value = "1";
await page.click("btn-send-review");
expect(page.visibleViews()).toEqual(["confirm-tx"]);
}
describe("after Back from AddressToken", () => {
test("no token is selected, on the page or in the stored record", async () => {
const page = await bootOnAddress();
await openToken(page);
await backFromToken(page);
const { state } = require("../src/shared/state");
expect(state.selectedToken).toBeNull();
expect(page.storage.read("autistmask").selectedToken).toBeNull();
});
test("Send with ETH chosen shows the ETH balance", async () => {
const page = await bootOnAddress();
await openToken(page);
await backFromToken(page);
await page.click("btn-send");
await chooseEth(page);
expect(page.text("send-balance")).toBe("Current balance: 1.5000 ETH");
});
test("Send with ETH chosen reviews ETH", async () => {
const page = await bootOnAddress();
await openToken(page);
await backFromToken(page);
await page.click("btn-send");
await chooseEth(page);
await review(page);
expect(page.text("confirm-type")).toBe("Native ETH transfer");
});
test("Receive shows no token warning", async () => {
const page = await bootOnAddress();
await openToken(page);
await backFromToken(page);
await page.click("btn-receive");
expect(page.visibleViews()).toEqual(["receive"]);
expect(page.text("receive-erc20-warning")).toBe("");
});
// AddressToken's Send sets the dropdown to the token, and nothing else
// rebuilds it on the way back to AddressDetail.
test("Send opens on ETH after AddressToken's Send has been used", async () => {
const page = await bootOnAddress();
await openToken(page);
await page.click("btn-address-token-send");
await page.click("btn-send-back");
await backFromToken(page);
await page.click("btn-send");
expect(page.value("send-token")).toBe("ETH");
expect(page.text("send-balance")).toBe("Current balance: 1.5000 ETH");
});
});
describe("Send opened from AddressToken sends that token", () => {
async function expectSendsToken(page) {
await page.click("btn-address-token-send");
expect(page.text("send-balance")).toBe("Current balance: 2.5000 TST");
await review(page);
expect(page.text("confirm-type")).toBe("ERC-20 token transfer (TST)");
}
test("the first time", async () => {
const page = await bootOnAddress();
await openToken(page);
await expectSendsToken(page);
});
test("after Back from Send", async () => {
const page = await bootOnAddress();
await openToken(page);
await page.click("btn-address-token-send");
await page.click("btn-send-back");
await expectSendsToken(page);
});
test("when the token is opened again after Back from AddressToken", async () => {
const page = await bootOnAddress();
await openToken(page);
await backFromToken(page);
await openToken(page);
await expectSendsToken(page);
});
});
+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", () => {
+6 -4
View File
@@ -151,10 +151,10 @@ test("names no token, and states the amount in base units", async () => {
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-to").innerHTML).not.toContain("USDC");
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>',
);
});
@@ -162,8 +162,10 @@ test("names no token, and states the amount in base units", async () => {
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">USDC</div>');
expect(decodedLine("Token")).toContain(
'<div class="font-bold break-words">USDC</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 USDC</div>',
'<div class="font-bold break-words">1234.5000 USDC</div>',
);
});
+5 -5
View File
@@ -1,11 +1,11 @@
// The floor of the approval screen's amount line.
//
// Amounts are truncated to four decimal places for scannability (README.md,
// Display Consistency). With the token's true scale resolved, that truncation
// can still take a real amount below the floor and print it as `0.0000`: one
// base unit of an 18-decimal token, or a few hundred of an 8-decimal one. On
// the one screen whose job is to state what is being authorized, a nonzero
// transfer or allowance then reads as nothing.
// Amounts). With the token's true scale resolved, that truncation can still
// take a real amount below the floor and print it as `0.0000`: one base unit of
// an 18-decimal token, or a few hundred of an 8-decimal one. On the one screen
// whose job is to state what is being authorized, a nonzero transfer or
// allowance then reads as nothing.
//
// The invariant asserted here is narrow: a nonzero amount never renders as
// zero. The four-decimal rule itself is unchanged, and the string the
+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();
+1 -1
View File
@@ -445,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();
+6 -6
View File
@@ -70,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);
}
@@ -86,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();
@@ -138,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);
});
@@ -166,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");
+1 -1
View File
@@ -328,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",
+7 -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: [] }],
},
];
}
@@ -242,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,
},
]);
});
+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.
+4 -4
View File
@@ -74,7 +74,7 @@ describe("parsing the dust threshold", () => {
});
describe("the rejection message", () => {
// README, Language & Labeling: error messages are full sentences.
// README, Wording: error messages are full sentences.
test("is a full sentence naming the constraint", () => {
expect(DUST_THRESHOLD_MESSAGE).toMatch(/^[A-Z].*\.$/);
expect(DUST_THRESHOLD_MESSAGE).toContain("whole number of gwei");
@@ -95,9 +95,9 @@ describe("the flash line the message is shown in", () => {
// NOT and CANNOT assert that the message fits inside it: jest runs on
// the node environment here, with no layout engine, so every rendered
// height is zero. An earlier version of this block claimed to pin the
// No Layout Shift policy with this regex, and it passed at any message
// length, including one that wrapped to two lines and pushed the
// settings view down 12px.
// reserved space rule in README, Appearance, with this regex, and it
// passed at any message length, including one that wrapped to two lines
// and pushed the settings view down 12px.
//
// The line cuts a message too long for it with an ellipsis (see
// showFlash() in src/popup/views/helpers.js). The assertions that
+7 -7
View File
@@ -1280,7 +1280,7 @@ test("the correct password reveals the full phrase, and nothing logs it (#161)",
);
assert(!promptShown, "the password prompt is still shown after unlock");
// Full Identifiers Policy: shown whole, and copyable.
// README, ShowRecoveryPhrase: shown whole, and copyable.
const title = await env.page.getAttribute(
"#show-phrase-value",
"title",
@@ -2255,7 +2255,7 @@ async function waitForFilledFlashLine(page) {
}
}
// README, No Layout Shift: the rejection message goes into #flash-msg,
// README, Appearance: the rejection message goes into #flash-msg,
// whose min-h-[1.25rem] reserves exactly ONE line at text-xs, and which
// cuts a message too long for that line with an ellipsis rather than wrap
// it. This shows the real message and measures that nothing moves; the
@@ -2603,7 +2603,7 @@ function toHexWei(wei) {
}
// A fee in wei as the confirmation screen writes it: truncated to four decimal
// places (README.md, Display Consistency). Deliberately a second
// places (README.md, Amounts). Deliberately a second
// implementation rather than an import of src/shared/amountDisplay.js:
// asserting against an independent rendering is stronger than asserting a
// function equals itself. The fixture's fees are above 0.0001 ETH, so the
@@ -3298,7 +3298,7 @@ async function goToTokenConfirm(env) {
amount: TOKEN_AMOUNT,
});
await waitForEstimate(env.page);
// TOKEN_AMOUNT to four decimal places (README.md, Display Consistency).
// TOKEN_AMOUNT to four decimal places (README.md, Amounts).
const shown = await shownAmount(env.page);
assert(
shown === "0.2500 " + STUB_TOKEN.symbol,
@@ -3453,7 +3453,7 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
// ------------------------- a send Sign & Send refuses moves nothing (#583)
//
// README, No Layout Shift. Sign & Send refuses a send on the confirmation
// README, Appearance. Sign & Send refuses a send on the confirmation
// screen when the active network changed since review and when its gas limit
// or fee is over its bound, and each refusal's sentence sits in a box that held
// its space from the first paint. This drives the total fee refusal, the longer
@@ -5770,8 +5770,8 @@ async function main() {
// There is no fourth: once the context is closed nothing can record,
// because the route handler and the console listeners died with it.
// Traffic that a test defers past the trailing drain is therefore
// never observed at all — a real limit of this design, stated in the
// README, and not one any post-teardown hook could close.
// never observed at all — a real limit of this design, and not one
// any post-teardown hook could close.
//
// Two green-but-vacuous runs on this harness were the same shape: a
// record falling outside somebody's window and being dropped. First
+135 -1
View File
@@ -1,6 +1,8 @@
// 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).
// 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
@@ -154,6 +156,21 @@ function holdLock() {
};
}
// 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,
@@ -409,6 +426,123 @@ describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
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) => {
+185
View File
@@ -0,0 +1,185 @@
// Home's "Send" and "Receive" open with no token selected. Three routes reach
// Home with a token still selected, Send reads that token ahead of its dropdown,
// and Receive warns about it
// (https://git.eeqj.de/sneak/AutistMask/issues/753). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
const OTHER_WALLET_ADDRESS = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const PRIVATE_KEY = "0x" + "11".repeat(32);
const PASSWORD = "correct horse battery staple";
const TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
afterEach(() => {
cleanupPopup();
});
// AddressToken opened from AddressDetail, as tapping the token's row does.
async function openToken(page) {
const { state } = require("../src/shared/state");
state.selectedToken = TOKEN_ADDRESS;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
expect(page.visibleViews()).toEqual(["address-token"]);
}
// The popup open on AddressToken, at an address holding 1.5 ETH and 2.5 TST.
// A second wallet is there for the route that deletes one.
async function bootOnToken() {
const profile = unversionedValidProfile({
currentView: "address",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [TOKEN],
});
profile.wallets[0].addresses[0].tokenBalances = [
{ ...TOKEN, balance: "2.5" },
];
profile.wallets.push({
type: "hd",
name: "Wallet 2",
xpub: "xpub-wallet-2",
encryptedSecret: "encrypted-secret-2",
nextIndex: 1,
addresses: [
{
address: OTHER_WALLET_ADDRESS,
balance: "0",
tokenBalances: [],
},
],
});
const page = await bootPopup(profile);
await openToken(page);
return page;
}
async function reopenAfterTokenSend() {
const closed = await bootOnToken();
await closed.click("btn-address-token-send");
cleanupPopup();
return bootPopup(undefined, { storage: closed.storage });
}
async function addWalletFromTokenSettings() {
const page = await bootOnToken();
await page.click("btn-settings");
await page.click("btn-main-add-wallet");
await page.click("tab-privkey");
page.node("import-private-key").value = PRIVATE_KEY;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
return page;
}
// Deletes through the lost-password screen. It asks for the wallet's name, and
// the stored wallets here have no real password.
async function deleteWalletFromTokenSettings() {
const page = await bootOnToken();
await page.click("btn-settings");
// What the second wallet's [x] button in Settings does.
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/deleteWallet").show(1);
await page.click("btn-delete-wallet-lost-password");
page.node("delete-wallet-lost-name-input").value = "Wallet 2";
await page.click("btn-delete-wallet-lost-confirm");
await page.click("btn-settings-back");
return page;
}
const ROUTES = [
["reopening the popup after AddressToken's Send", reopenAfterTokenSend],
[
"the Settings gear on AddressToken, then adding a wallet",
addWalletFromTokenSettings,
],
[
"the Settings gear on AddressToken, then deleting another wallet, then Back",
deleteWalletFromTokenSettings,
],
];
// A route that stopped reaching Home with the token selected would prove nothing
// in the tests below, so it fails here.
async function arriveOnHome(route) {
const page = await route();
const { state } = require("../src/shared/state");
expect(page.visibleViews()).toEqual(["main"]);
expect(state.selectedToken).toBe(TOKEN_ADDRESS);
return page;
}
// ETH chosen in Send's dropdown, as the user does.
async function chooseEth(page) {
const dropdown = page.node("send-token");
dropdown.value = "ETH";
for (const fn of dropdown.listeners.change) await fn();
}
// Review for 1 of the token Send has, to RECIPIENT.
async function review(page) {
page.node("send-to").value = RECIPIENT;
page.node("send-amount").value = "1";
await page.click("btn-send-review");
expect(page.visibleViews()).toEqual(["confirm-tx"]);
}
describe.each(ROUTES)("after %s", (_name, route) => {
test("choosing ETH on Home's Send reviews ETH", async () => {
const page = await arriveOnHome(route);
await page.click("btn-main-send");
await chooseEth(page);
await review(page);
expect(page.text("confirm-type")).toBe("Native ETH transfer");
expect(page.storage.read("autistmask").selectedToken).toBeNull();
});
test("Home's Send starts on ETH", async () => {
const page = await arriveOnHome(route);
await page.click("btn-main-send");
expect(page.text("send-balance")).toBe("Current balance: 1.5000 ETH");
expect(page.value("send-token")).toBe("ETH");
});
test("Home's Receive shows no token warning", async () => {
const page = await arriveOnHome(route);
await page.click("btn-main-receive");
expect(page.visibleViews()).toEqual(["receive"]);
expect(page.text("receive-erc20-warning")).toBe("");
expect(page.storage.read("autistmask").selectedToken).toBeNull();
});
});
test("AddressToken's Send still sends the token after Home's Send has opened", async () => {
const page = await arriveOnHome(deleteWalletFromTokenSettings);
await page.click("btn-main-send");
await page.click("btn-send-back");
await openToken(page);
await page.click("btn-address-token-send");
expect(page.text("send-balance")).toBe("Current balance: 2.5000 TST");
await review(page);
expect(page.text("confirm-type")).toBe("ERC-20 token transfer (TST)");
});
+2 -2
View File
@@ -165,9 +165,9 @@ describe("declared icons", () => {
});
});
// README, Installing on Firefox: the extension does not run in private windows.
// Firefox gives a page in a private window locks of its own, so that page's
// save could land between the background's read and its write (README,
// "Stored state and its version").
// save could land between the background's read and its write.
describe("private windows", () => {
test("a page in a firefox private window cannot read or write extension storage", () => {
expect(readManifest("firefox").incognito).toBe("not_allowed");
+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)
+21
View File
@@ -432,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.
+1 -1
View File
@@ -5,7 +5,7 @@
// browser's own does for the one call the extension makes,
// `navigator.locks.request(name, callback)`. Every module a test file loads
// shares it, as the extension's background and its pages share the browser's
// (README, "Stored state and its version").
// (README, "Stored data").
//
// Run before each test file by `setupFiles` in package.json.
+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");
});
});
+3 -3
View File
@@ -1,7 +1,7 @@
// A transaction's time is written by isoDate() and timeAgo() in
// src/popup/views/helpers.js on every screen that shows one (README, Display
// Consistency; https://git.eeqj.de/sneak/AutistMask/issues/168). AddressDetail
// and AddressToken used to define their own copies, so a fix to the shared pair
// src/popup/views/helpers.js on every screen that shows one (README, Amounts;
// https://git.eeqj.de/sneak/AutistMask/issues/168). AddressDetail and
// AddressToken used to define their own copies, so a fix to the shared pair
// would not have reached them.
//
// The pair is replaced before the views are loaded, because a view takes it
+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>',
);
});
+25 -7
View File
@@ -180,7 +180,10 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
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");
@@ -276,7 +279,9 @@ test("a token tracked on Sepolia when another page switched there is not tracked
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored.trackedTokens).toEqual([
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
@@ -441,7 +446,10 @@ test("a token another page added on Ethereum Mainnet is tracked when this page s
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN, newToken]);
expect(stored.trackedTokens).toEqual([
{ ...MAINNET_TOKEN, balanceNotFetched: true },
{ ...newToken, balanceNotFetched: true },
]);
});
// This page tracks DAI on Ethereum Mainnet and switches to Sepolia. Another
@@ -489,7 +497,10 @@ test("a token another page added on Ethereum Mainnet stays when this page adds a
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, added]);
expect(stored.trackedTokens).toEqual([
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
added,
]);
});
// This page tracks DAI on Ethereum Mainnet and switches to Sepolia, where TST is
@@ -513,7 +524,10 @@ test("a token another page removed on Ethereum Mainnet stays removed when this p
const stored = boot.storage.read("autistmask");
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
expect(stored.networkTrackedTokens.sepolia).toEqual([SEPOLIA_TOKEN]);
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN, added]);
expect(stored.trackedTokens).toEqual([
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
added,
]);
});
// Another popup page records a fraud contract on Ethereum Mainnet after this
@@ -1031,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([]);
});
@@ -1044,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]);
});
});
+16 -13
View File
@@ -1,15 +1,15 @@
// Tests for the address-poisoning defense in src/shared/transactions.js.
//
// README.md:730-814 describes four filters as a core security property of
// the wallet: known token symbol verification, the low-holder threshold, the
// README, Scam protection, describes four filters as a core security property
// of the wallet: known token symbol verification, the low-holder threshold, the
// fraud contract blocklist, and the dust threshold. A regression in any of
// them does not crash — it silently stops filtering, and poisoned look-alike
// addresses reappear in the user's transaction history. These tests pin the
// behaviour down in both directions: the documented attacks must be filtered,
// behaviour down in both directions: the two attacks below must be filtered,
// and legitimate transactions must survive untouched.
//
// Fixtures are static local objects modelled on the two real attacks cited in
// the README. Nothing here touches the network: global.fetch is replaced
// Fixtures are static local objects modelled on two real attacks.
// Nothing here touches the network: global.fetch is replaced
// with a throwing stub and the only fetch path in the module under test
// (debugFetch, from src/shared/log) is mocked at the module boundary.
@@ -46,11 +46,12 @@ const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state");
// ---------------------------------------------------------------------------
// Addresses and hashes from the two attacks documented in README.md:730-814.
// Addresses and hashes from two real attacks. README, Scam protection, has the
// filters that stop them.
// ---------------------------------------------------------------------------
// The fake "Ethereum" token with symbol "ETH" and zero holders
// (README.md:735-750).
// The fake "Ethereum" token with symbol "ETH" and zero holders. See "Known
// symbol spoofing" and "Low-holder tokens" in README, Scam protection.
const FAKE_ETH_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
// The fraudulent Transfer event it emitted.
const FAKE_ETH_TRANSFER_HASH =
@@ -62,7 +63,8 @@ const LEGIT_RECIPIENT = "0xc3c693ae04bad5f13c45885c1e85a9557798f37e";
// The scam address the fake token transfer pointed at ("0xC3C0" vs "0xC3c6").
const TOKEN_SCAM_LOOKALIKE = "0xc3c0aea127c575b9ffd03bf11c6a878e8979c37f";
// The second wave: a real 1 gwei native transfer (README.md:800-808).
// The second wave: a real 1 gwei native transfer. See "Dust transactions" in
// README, Scam protection.
const DUST_TX_HASH =
"0x2708ebddfb9b5fa3f7a89d3ea398ef9fd8771b83ed861ecb7c21cd55d18edc74";
const DUST_SENDER_LOOKALIKE = "0xc3c6b3b4402bd78a9582ab6b00e747769344f37e";
@@ -79,7 +81,8 @@ const NOVEL_SPAM_SYMBOL = "SPAMTKN";
// An ordinary counterparty for legitimate-transaction fixtures.
const ORDINARY_PEER = "0x5aa0f9f1e0a1d0e0e5c1e7ce3b7dbbe9c19f0a11";
// The documented default settings (README.md:810-814, state.js:24-27).
// The documented default settings (README, Settings; DEFAULT_STATE in
// src/shared/persistedState.js).
const DEFAULT_FILTERS = {
hideSpoofedSymbols: true,
hideLowHolderTokens: true,
@@ -220,7 +223,7 @@ describe("token list assumptions the fixtures rely on", () => {
});
});
describe("the real attacks documented in README.md:730-814", () => {
describe("the real attacks", () => {
test('the fake "Ethereum"/"ETH" token transfer is filtered by default', () => {
const attack = fakeEthTokenTransfer();
const result = filterTransactions([attack], filters());
@@ -399,8 +402,8 @@ describe("known-symbol spoof verification", () => {
});
// Turning the other three filters off must not turn this one off: each
// filter is independent, and this is the one the README calls out as the
// defense against the fake "ETH" attack.
// filter is independent, and this is the one that stops the fake "ETH"
// attack.
test("the check still runs when the other three filters are off", () => {
const result = filterTransactions(
[fakeEthTokenTransfer()],
+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
+356 -21
View File
@@ -2474,10 +2474,11 @@ describe("uniswap decoder", () => {
// 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.
// Every row reads at least the ETH that can leave the wallet. The row
// marked "reads as on next" reads the same on next. The row with a
// SETTLE_ALL of at most 3 ETH read that ETH as not known 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";
@@ -2625,7 +2626,7 @@ describe("uniswap decoder", () => {
],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
...wrapLines("The router"),
...ethLines("Up to 1.0000 ETH"),
],
},
@@ -2674,7 +2675,7 @@ describe("uniswap decoder", () => {
],
},
{
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",
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",
commands: [0x0b, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
@@ -2697,10 +2698,7 @@ describe("uniswap decoder", () => {
],
),
],
lines: [
...wrapLines("The swap step that spends it"),
...ethLines(UNDECODED),
],
lines: [...wrapLines("The router"), ...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",
@@ -2761,8 +2759,8 @@ describe("uniswap decoder", () => {
// 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.
// wrap CONTRACT_BALANCE into the router after their first step, as the
// last row does.
const CONTRACT_BALANCE = 2n ** 255n;
const WRAP_TO_OTHER = encodeWrapEth(OTHER_ADDR, ONE_ETH);
test.each([
@@ -2787,7 +2785,7 @@ describe("uniswap decoder", () => {
inputs: [V3_600, WRAP_INTO_ROUTER],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
...wrapLines("The router"),
],
},
{
@@ -2796,7 +2794,7 @@ describe("uniswap decoder", () => {
inputs: [V3_600, encodeWrapEth(ROUTER_ADDR, ONE_ETH)],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The swap step that spends it"),
...wrapLines("The router"),
],
},
{
@@ -2839,7 +2837,9 @@ describe("uniswap decoder", () => {
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", "The router"],
],
},
{
@@ -2877,12 +2877,6 @@ describe("uniswap decoder", () => {
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],
@@ -2923,6 +2917,347 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/747 — a WRAP_ETH into the
// router pays its WETH to the router, whichever step then takes it. On
// next that Moved to line read The swap step that spends it in every
// row.
const SWAP_STEP = "The swap step that spends it";
// Each pays 1 WETH from the router, for USDC, to the user.
const V3_WETH_FROM_ROUTER = coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
ONE_ETH,
1n,
encodeV3Path([WETH_ADDR, USDC_ADDR]),
false,
],
);
const V2_WETH_FROM_ROUTER = coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[USER_ADDR, ONE_ETH, 1n, [WETH_ADDR, USDC_ADDR], false],
);
const V4_WETH_FROM_ROUTER = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(WETH_ADDR, USDC_ADDR, ONE_ETH, 1n),
settle(WETH_ADDR, 0n, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
// SWEEP has the layout of TRANSFER, and its amount is a minimum.
const SWEEP_WETH = payOut(WETH_ADDR, 0n);
test.each([
{
kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, then a SWEEP of that WETH to another address",
commands: [0x00, 0x0b, 0x04],
inputs: [V3_600, WRAP_INTO_ROUTER, SWEEP_WETH],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The router"),
],
},
{
kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, a SWEEP of that WETH to another address, then a V3 exact-in paid from the router's WETH",
commands: [0x00, 0x0b, 0x04, 0x00],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
SWEEP_WETH,
V3_WETH_FROM_ROUTER,
],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The router"),
],
},
{
kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, then a V3 exact-in of WETH that the user pays",
commands: [0x00, 0x0b, 0x00],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
encodeV3SwapExactIn(USER_ADDR, ONE_ETH, 1n, [
WETH_ADDR,
USDC_ADDR,
]),
],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The router"),
["Moved from the sender", "1.0000 WETH"],
["Token moved", "WETH (" + WETH_ADDR + ")"],
["Moved to", SWAP_STEP],
],
},
{
kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, a V3 exact-in paid from the router's WETH, then another WRAP_ETH of 1 ETH into the router",
commands: [0x00, 0x0b, 0x00, 0x0b],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
V3_WETH_FROM_ROUTER,
WRAP_INTO_ROUTER,
],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The router"),
...wrapLines("The router"),
],
},
{
kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH into the router, then a V3 exact-in paid from the router's WETH",
commands: [0x00, 0x0b, 0x00],
inputs: [V3_600, WRAP_INTO_ROUTER, V3_WETH_FROM_ROUTER],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The router"),
],
},
{
kind: "the same with a V2 exact-in paid from the router's WETH",
commands: [0x00, 0x0b, 0x08],
inputs: [V3_600, WRAP_INTO_ROUTER, V2_WETH_FROM_ROUTER],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The router"),
],
},
{
kind: "the same with a V3 exact-out paid from the router's WETH",
commands: [0x00, 0x0b, 0x01],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
encodeV3SwapExactOut(
USER_ADDR,
390000000n,
ONE_ETH,
[USDC_ADDR, WETH_ADDR],
false,
),
],
lines: [
["Amount", "Up to 600.0000 USDT"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", "The router"],
],
},
{
kind: "the same with a V4 swap that the router's WETH settles",
commands: [0x00, 0x0b, 0x10],
inputs: [V3_600, WRAP_INTO_ROUTER, V4_WETH_FROM_ROUTER],
lines: [
["Amount", "600.0000 USDT"],
...wrapLines("The router"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router that sets the input side, a SWEEP of that WETH to another address, then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x04, 0x21],
inputs: [WRAP_INTO_ROUTER, SWEEP_WETH, SUB_PLAN],
lines: [
...wrapLines("The router"),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router that sets the input side, a V3 exact-in paid from the router's WETH, then an EXECUTE_SUB_PLAN",
commands: [0x0b, 0x00, 0x21],
inputs: [WRAP_INTO_ROUTER, V3_WETH_FROM_ROUTER, SUB_PLAN],
lines: [
...wrapLines("The router"),
["Moved from the sender", UNDECODED],
],
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a WRAP_ETH of 1 ETH into the router, then a V3 exact-in of 600 USDT",
commands: [0x0a, 0x0b, 0x00],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
WRAP_INTO_ROUTER,
V3_600,
],
lines: [
["Amount", "5000.0000 USDT"],
...wrapLines("The router"),
],
},
{
kind: "the same with a V3 exact-in paid from the router's WETH after the WRAP_ETH",
commands: [0x0a, 0x0b, 0x00, 0x00],
inputs: [
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
WRAP_INTO_ROUTER,
V3_WETH_FROM_ROUTER,
V3_600,
],
lines: [
["Amount", "5000.0000 USDT"],
...wrapLines("The router"),
],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/747 — a later WRAP_ETH
// of CONTRACT_BALANCE wraps all the ETH the router holds. Unless an
// earlier step paid ETH to the router, that is the ETH the transaction
// carries. On next no line gave it. Into the router it gets no lines.
// It gets none after an UNWRAP_WETH into the router, or while the
// router holds ETH from a V4 step: Uniswap's own routes pay back what
// their swap did not spend this way, and carry no ETH. In the rows
// marked "reads as on next" that WRAP_ETH gets no lines.
const WRAP_ALL_TO_OTHER = encodeWrapEth(OTHER_ADDR, CONTRACT_BALANCE);
const WRAP_ALL_TO_SENDER = encodeWrapEth(MSG_SENDER, CONTRACT_BALANCE);
// The lines of such a WRAP_ETH whose Moved to line reads `to`.
const allEthLines = (to) => [
["Moved from the sender", "All the ETH the transaction carries"],
["Token moved", "ETH"],
["Moved to", to],
];
// The first steps of a route that spends WETH on a V4 pool of ETH:
// a PERMIT2_TRANSFER_FROM of 1 WETH into the router, an UNWRAP_WETH
// into the router, then a V4 exact-out paid from the router's ETH.
const UNWRAP_ROUTE = [
coder.encode(
["address", "address", "uint160"],
[WETH_ADDR, ADDRESS_THIS, ONE_ETH],
),
UNWRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
390000000n,
ONE_ETH,
),
settle(ZERO_ADDR, 0n, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
];
// The lines of that route on next: this decoder cannot tell what the
// V4 step's SETTLE of ETH pays.
const UNWRAP_ROUTE_LINES = [
["Moved from the sender", UNDECODED],
["Token moved", "ETH"],
["Moved to", SWAP_STEP],
];
test.each([
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to another address",
commands: [0x00, 0x0b],
inputs: [V3_600, WRAP_ALL_TO_OTHER],
lines: [
["Amount", "600.0000 USDT"],
...allEthLines(OTHER_ADDR),
],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to the sender",
commands: [0x00, 0x0b],
inputs: [V3_600, WRAP_ALL_TO_SENDER],
lines: [
["Amount", "600.0000 USDT"],
...allEthLines("The sender"),
],
},
{
kind: "a V3 exact-out of at most 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to another address",
commands: [0x01, 0x0b],
inputs: [
encodeV3SwapExactOut(USER_ADDR, 390000000n, USDT_600, [
USDC_ADDR,
USDT_ADDR,
]),
WRAP_ALL_TO_OTHER,
],
lines: [
["Amount", "Up to 600.0000 USDT"],
...allEthLines(OTHER_ADDR),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a WRAP_ETH of CONTRACT_BALANCE to another address",
commands: [0x0b, 0x0b],
inputs: [WRAP_INTO_ROUTER, WRAP_ALL_TO_OTHER],
lines: [["Amount", "1.0000 ETH"], ...allEthLines(OTHER_ADDR)],
},
{
kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE into the router, reads as on next",
commands: [0x00, 0x0b],
inputs: [V3_600, encodeWrapEth(ADDRESS_THIS, CONTRACT_BALANCE)],
lines: [["Amount", "600.0000 USDT"]],
},
{
kind: "a V3 exact-in of 600 USDT, an UNWRAP_WETH to the sender, then a WRAP_ETH of CONTRACT_BALANCE to another address",
commands: [0x00, 0x0c, 0x0b],
inputs: [
V3_600,
encodeWrapEth(MSG_SENDER, 0n),
WRAP_ALL_TO_OTHER,
],
lines: [
["Amount", "600.0000 USDT"],
...allEthLines(OTHER_ADDR),
],
},
{
kind: "a V4 swap of 600 USDT that takes ETH into the router, then a WRAP_ETH of CONTRACT_BALANCE to the sender, reads as on next",
commands: [0x10, 0x0b],
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),
],
),
WRAP_ALL_TO_SENDER,
],
lines: [["Amount", "600.0000 USDT"]],
},
{
kind: "the unwrap route, then a WRAP_ETH of CONTRACT_BALANCE to the sender, reads as on next",
commands: [0x02, 0x0c, 0x10, 0x0b],
inputs: [...UNWRAP_ROUTE, WRAP_ALL_TO_SENDER],
lines: UNWRAP_ROUTE_LINES,
},
{
kind: "the unwrap route, then a WRAP_ETH of CONTRACT_BALANCE to another address, reads as on next",
commands: [0x02, 0x0c, 0x10, 0x0b],
inputs: [...UNWRAP_ROUTE, WRAP_ALL_TO_OTHER],
lines: UNWRAP_ROUTE_LINES,
},
])("$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) =>