Compare commits
1
Commits
next
..
894d972caa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
894d972caa |
@@ -19,10 +19,13 @@ 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. 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.
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
@@ -33,7 +36,8 @@ 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.
|
||||
# programs, so a hung browser frees the shared runner. README.md
|
||||
# "In CI" has the measured times.
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
# actions/checkout v4.2.2, 2026-02-22
|
||||
@@ -43,7 +47,8 @@ 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.
|
||||
# programs, so a hung browser frees the shared runner. README.md
|
||||
# "In CI" has the measured times.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# actions/checkout v4.2.2, 2026-02-22
|
||||
|
||||
@@ -89,13 +89,9 @@ Load it:
|
||||
2. Turn on "Developer mode".
|
||||
3. Click "Load unpacked".
|
||||
4. Pick `dist/chrome/`.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
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).
|
||||
@@ -379,18 +375,12 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Alarms the code no longer handles are listed in `OBSOLETE_ALARMS` and cleared on
|
||||
every start. The worker fetches nothing when it starts.
|
||||
@@ -407,16 +397,10 @@ is missing, a write fails.
|
||||
|
||||
The popup's `saveState()` (`src/shared/state.js`) reads the stored record,
|
||||
merges this page's changes into it and writes it back. It writes only the fields
|
||||
this page changed since its last save. It merges the fields below entry by
|
||||
entry, so two pages changing different things do not overwrite each other:
|
||||
|
||||
- wallets;
|
||||
- site lists;
|
||||
- endpoints;
|
||||
- tracked tokens.
|
||||
|
||||
The background's `updateState()` reads the record, applies one change and writes
|
||||
it back.
|
||||
this page changed since its last save. Wallets, site lists and endpoints are
|
||||
merged entry by entry, so two pages changing different things do not overwrite
|
||||
each other. The background's `updateState()` reads the record, applies one
|
||||
change and writes it back.
|
||||
|
||||
Every write stamps `schemaVersion` with `STATE_SCHEMA_VERSION` from
|
||||
`src/shared/stateSchema.js`, currently `1`. The version is raised only when the
|
||||
@@ -471,9 +455,7 @@ 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. 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.
|
||||
screen draws its balances again.
|
||||
|
||||
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
|
||||
@@ -570,8 +552,7 @@ 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." After a network switch, the list shows
|
||||
"Loading..." in place of the previous network's rows or failure message.
|
||||
Blockscout" is off in Settings."
|
||||
|
||||
Transitions:
|
||||
|
||||
@@ -581,9 +562,8 @@ Transitions:
|
||||
- "+": derives the next address in place.
|
||||
- `[info]`: AddressDetail.
|
||||
- `[x]`: DeleteAddress.
|
||||
- "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.
|
||||
- "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.
|
||||
- "Receive": Receive.
|
||||
- Tap a transaction row: TransactionDetail.
|
||||
- "Add additional wallet...": AddWallet.
|
||||
@@ -800,8 +780,6 @@ 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".
|
||||
@@ -825,9 +803,7 @@ 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 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 amount and the recipient, or the decoded action when there is one;
|
||||
- the block number, with an explorer link;
|
||||
- the transaction hash, with an explorer link;
|
||||
- a "Done" button.
|
||||
@@ -841,10 +817,7 @@ Shown when signing or a broadcast fails, or when a wait ends without a receipt.
|
||||
The screen holds:
|
||||
|
||||
- the heading "Transaction Failed";
|
||||
- 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 amount and the recipient;
|
||||
- the error message, in a dashed box;
|
||||
- the transaction hash, when one exists;
|
||||
- a "Done" button that behaves as on SuccessTx.
|
||||
@@ -916,8 +889,7 @@ not change. A flash message refuses:
|
||||
|
||||
- a token already tracked;
|
||||
- an address on the scam list;
|
||||
- a contract that fails the lookup;
|
||||
- a token that fakes a known symbol.
|
||||
- a contract that fails the lookup.
|
||||
|
||||
A network switch before the lookup answers adds nothing, and a flash message
|
||||
says so.
|
||||
@@ -1041,8 +1013,6 @@ 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
|
||||
@@ -1221,39 +1191,14 @@ 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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -1288,7 +1233,6 @@ 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`.
|
||||
@@ -1415,19 +1359,15 @@ 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`. 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.
|
||||
`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 `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.
|
||||
- `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.
|
||||
@@ -1747,9 +1687,7 @@ 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. When corepack and nvm are both missing, yarn comes from
|
||||
`npm install -g`. Fails when node cannot find a package named in
|
||||
`package.json`.
|
||||
from corepack. 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
|
||||
|
||||
+21
-33
@@ -60,8 +60,7 @@ 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. A wallet drainer is a contract
|
||||
that empties a wallet once given permission. Only the recipient's address is
|
||||
phishing, scam, exploiter or wallet drainer. 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
|
||||
@@ -212,18 +211,10 @@ 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. 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.
|
||||
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.
|
||||
|
||||
### Deleting a wallet
|
||||
|
||||
@@ -254,11 +245,9 @@ 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 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.
|
||||
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.
|
||||
|
||||
### Tracking a token
|
||||
|
||||
@@ -274,9 +263,8 @@ 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.
|
||||
|
||||
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 line for the new token reads `quantity unknown` 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
|
||||
@@ -286,8 +274,7 @@ 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 token that fakes a known symbol.
|
||||
- a contract that does not answer as an ERC-20 token.
|
||||
|
||||
### The token screen
|
||||
|
||||
@@ -615,11 +602,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. 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.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -629,10 +616,11 @@ 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. 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. 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.
|
||||
|
||||
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,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 Builds
|
||||
// already been checked against the build's own receipt (see the Build Receipts
|
||||
// section of README.md). This program does not build anything and does not
|
||||
// write into dist/: it reads the emitted tree and writes release/.
|
||||
//
|
||||
|
||||
@@ -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 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.
|
||||
# --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.
|
||||
# HOME=/tmp: the image's root home is not a reliable place for the
|
||||
# browser profile.
|
||||
#
|
||||
|
||||
@@ -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 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).
|
||||
// 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).
|
||||
//
|
||||
// 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 data").
|
||||
// while it is held (README, "Stored state and its version").
|
||||
//
|
||||
// `mutate` must also not call updateState() itself, directly or through
|
||||
// anything it awaits: the inner turn waits for the lock the outer one holds,
|
||||
|
||||
+6
-18
@@ -810,10 +810,7 @@
|
||||
<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 break-words"
|
||||
></div>
|
||||
<div id="wait-tx-summary" class="font-bold"></div>
|
||||
<div id="wait-tx-token-note"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
@@ -833,10 +830,7 @@
|
||||
<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 break-words"
|
||||
></div>
|
||||
<div id="success-tx-summary" class="font-bold"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="text-xs text-muted mb-1">To</div>
|
||||
@@ -863,10 +857,7 @@
|
||||
<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 break-words"
|
||||
></div>
|
||||
<div id="error-tx-summary" class="font-bold"></div>
|
||||
<div id="error-tx-token-note"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
@@ -1646,7 +1637,7 @@
|
||||
<div class="text-xs text-muted mb-1">Action</div>
|
||||
<div
|
||||
id="tx-detail-calldata-action"
|
||||
class="text-xs font-bold mb-2 break-words"
|
||||
class="text-xs font-bold mb-2"
|
||||
></div>
|
||||
<div
|
||||
id="tx-detail-calldata-details"
|
||||
@@ -1721,7 +1712,7 @@
|
||||
<div class="text-xs text-muted mb-1">Action</div>
|
||||
<div
|
||||
id="approve-tx-action"
|
||||
class="text-xs font-bold mb-2 break-words"
|
||||
class="text-xs font-bold mb-2"
|
||||
></div>
|
||||
<div id="approve-tx-decoded-details" class="text-xs"></div>
|
||||
</div>
|
||||
@@ -1736,10 +1727,7 @@
|
||||
</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 break-words"
|
||||
></div>
|
||||
<div id="approve-tx-value" class="text-xs font-bold"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="text-xs text-muted mb-1">Network fee (max)</div>
|
||||
|
||||
+8
-19
@@ -98,19 +98,15 @@ 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. 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.
|
||||
// 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.
|
||||
//
|
||||
// Once the save is done, the wallet list is drawn again. So are the balances
|
||||
// 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.
|
||||
// on AddressDetail or AddressToken when that screen is up. No other screen is
|
||||
// drawn again, DeleteAddress and its balance lines included.
|
||||
async function doRefreshAndRender() {
|
||||
if (refreshInFlight) {
|
||||
refreshAgain = true;
|
||||
@@ -153,20 +149,13 @@ async function doRefreshAndRender() {
|
||||
if (!stored.useBlockscout) {
|
||||
dropUntrackedTokens(state.wallets, stored.trackedTokens);
|
||||
}
|
||||
const readEveryAddress = [
|
||||
...state.wallets,
|
||||
...stored.wallets,
|
||||
].every((wallet) =>
|
||||
wallet.addresses.every((addr) => refreshed.has(addr.address)),
|
||||
);
|
||||
if (readEveryAddress) clearBalanceNotFetched(stored, readTokens);
|
||||
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
|
||||
|
||||
@@ -116,6 +116,21 @@ 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
|
||||
@@ -194,6 +209,7 @@ 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)) {
|
||||
|
||||
@@ -17,11 +17,6 @@ const { log } = require("../../shared/log");
|
||||
// AddToken was last opened changes neither the screen nor the status line.
|
||||
let addTokenScreenOpenings = 0;
|
||||
|
||||
function findTracked(address) {
|
||||
const lower = address.toLowerCase();
|
||||
return state.trackedTokens.find((t) => t.address.toLowerCase() === lower);
|
||||
}
|
||||
|
||||
function show() {
|
||||
addTokenScreenOpenings++;
|
||||
$("add-token-address").value = "";
|
||||
@@ -55,7 +50,9 @@ function init(ctx) {
|
||||
showFlash("Enter a valid contract address starting with 0x.");
|
||||
return;
|
||||
}
|
||||
const already = findTracked(contractAddr);
|
||||
const already = state.trackedTokens.find(
|
||||
(t) => t.address.toLowerCase() === contractAddr.toLowerCase(),
|
||||
);
|
||||
if (already) {
|
||||
showFlash(already.symbol + " is already being tracked.");
|
||||
return;
|
||||
@@ -88,18 +85,6 @@ function init(ctx) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Another click on Add may have tracked this token while this
|
||||
// lookup waited.
|
||||
const trackedMeanwhile = findTracked(contractAddr);
|
||||
if (trackedMeanwhile) {
|
||||
showFlash(
|
||||
trackedMeanwhile.symbol + " is already being tracked.",
|
||||
);
|
||||
if (addTokenScreenOpenings !== opening) return;
|
||||
infoEl.textContent = "";
|
||||
infoEl.style.visibility = "hidden";
|
||||
return;
|
||||
}
|
||||
// The balance list never shows a token that the spoof rule
|
||||
// refuses, so such a token is not tracked.
|
||||
if (isSpoofedSymbol(info.symbol, contractAddr, chainId)) {
|
||||
|
||||
@@ -7,7 +7,6 @@ const {
|
||||
goBack,
|
||||
clearViewStack,
|
||||
onViewLeave,
|
||||
markTrackedTokensNotFetched,
|
||||
} = require("./helpers");
|
||||
const {
|
||||
generateMnemonic,
|
||||
@@ -190,13 +189,12 @@ async function importMnemonic(ctx) {
|
||||
{
|
||||
address: firstAddress,
|
||||
index: 0,
|
||||
balance: null,
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
state.wallets.push(wallet);
|
||||
markTrackedTokensNotFetched();
|
||||
state.hasWallet = true;
|
||||
if (state.activeAddress === null) {
|
||||
state.activeAddress = firstAddress;
|
||||
@@ -218,11 +216,10 @@ async function importMnemonic(ctx) {
|
||||
wallet.addresses = scan.addresses.map((a) => ({
|
||||
address: a.address,
|
||||
index: a.index,
|
||||
balance: null,
|
||||
balance: "0.0000",
|
||||
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.
|
||||
@@ -267,9 +264,8 @@ async function importPrivateKey(ctx) {
|
||||
type: "key",
|
||||
name: "Wallet " + walletNum,
|
||||
encryptedSecret: encrypted,
|
||||
addresses: [{ address: addr, balance: null, tokenBalances: [] }],
|
||||
addresses: [{ address: addr, balance: "0.0000", tokenBalances: [] }],
|
||||
});
|
||||
markTrackedTokensNotFetched();
|
||||
state.hasWallet = true;
|
||||
if (state.activeAddress === null) {
|
||||
state.activeAddress = addr;
|
||||
@@ -328,13 +324,12 @@ async function importXprvKey(ctx) {
|
||||
{
|
||||
address: firstAddress,
|
||||
index: 0,
|
||||
balance: null,
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
state.wallets.push(wallet);
|
||||
markTrackedTokensNotFetched();
|
||||
state.hasWallet = true;
|
||||
if (state.activeAddress === null) {
|
||||
state.activeAddress = firstAddress;
|
||||
@@ -356,11 +351,10 @@ async function importXprvKey(ctx) {
|
||||
wallet.addresses = scan.addresses.map((a) => ({
|
||||
address: a.address,
|
||||
index: a.index,
|
||||
balance: null,
|
||||
balance: "0.0000",
|
||||
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 =
|
||||
|
||||
@@ -146,12 +146,11 @@ async function loadTransactions(address) {
|
||||
}
|
||||
|
||||
// If the network changed during the lookups above, or the screen now
|
||||
// shows another address, nothing is saved or drawn. The page's network
|
||||
// is compared after the read of storage, because a switch made on this
|
||||
// page can land during that read. See src/shared/storedSettings.js.
|
||||
// shows another address, nothing is saved or drawn. See
|
||||
// src/shared/storedSettings.js.
|
||||
if (
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
state.networkId !== networkId ||
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
$("address-line").dataset.full !== address
|
||||
) {
|
||||
return;
|
||||
@@ -188,8 +187,8 @@ async function loadTransactions(address) {
|
||||
// If the network changed, or the screen now shows another address,
|
||||
// the failure is not shown.
|
||||
if (
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
state.networkId !== networkId ||
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
$("address-line").dataset.full !== address
|
||||
) {
|
||||
return;
|
||||
@@ -273,8 +272,7 @@ function init(_ctx) {
|
||||
state.wallets[state.selectedWallet].addresses[
|
||||
state.selectedAddress
|
||||
];
|
||||
// A null balance is one no refresh has read, so it is not refused.
|
||||
if (parseFloat(addr.balance) === 0) {
|
||||
if (!addr.balance || parseFloat(addr.balance) === 0) {
|
||||
showFlash("Cannot send \u2014 zero balance.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,8 +161,7 @@ function renderBalance() {
|
||||
const tokenId = state.selectedToken;
|
||||
let amount, price;
|
||||
if (tokenId === "ETH") {
|
||||
// null when no refresh has read the balance.
|
||||
amount = unknownableAmount(addr.balance);
|
||||
amount = parseFloat(addr.balance || "0");
|
||||
price = getPrice("ETH");
|
||||
} else {
|
||||
const id = tokenId.toLowerCase();
|
||||
@@ -173,8 +172,8 @@ function renderBalance() {
|
||||
(t) => t.address.toLowerCase() === id,
|
||||
);
|
||||
// null when the scale is unknown, or when no refresh has read the
|
||||
// token's balance: no quantity to show, and none to price.
|
||||
// balanceLine() states that rather than printing 0.0000.
|
||||
// balance of a token just added: 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);
|
||||
@@ -242,13 +241,11 @@ 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. The page's
|
||||
// network is compared after the read of storage, because a switch made
|
||||
// on this page can land during that read. See
|
||||
// shows another address or token, nothing is saved or drawn. See
|
||||
// src/shared/storedSettings.js.
|
||||
if (
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
state.networkId !== networkId ||
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
$("address-token-line").dataset.full !== address ||
|
||||
state.selectedToken !== tokenId
|
||||
) {
|
||||
@@ -286,8 +283,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 (
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
state.networkId !== networkId ||
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
$("address-token-line").dataset.full !== address ||
|
||||
state.selectedToken !== tokenId
|
||||
) {
|
||||
@@ -360,11 +357,6 @@ 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();
|
||||
});
|
||||
|
||||
@@ -378,8 +370,7 @@ function init(_ctx) {
|
||||
state.wallets[state.selectedWallet].addresses[
|
||||
state.selectedAddress
|
||||
];
|
||||
// A null balance is one no refresh has read, so it is not refused.
|
||||
if (parseFloat(addr.balance) === 0) {
|
||||
if (!addr.balance || parseFloat(addr.balance) === 0) {
|
||||
if (state.selectedToken === "ETH") {
|
||||
showFlash("Cannot send \u2014 zero balance.");
|
||||
return;
|
||||
|
||||
+10
-10
@@ -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, 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.
|
||||
// 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.
|
||||
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 break-words">${escapeHtml(symbol)}</div>`;
|
||||
toHtml += `<div class="font-bold mb-1">${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 break-words">${escapeHtml(decoded.description)}</div>`;
|
||||
detailsHtml += `<div class="mb-2">${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 break-words">${escapeHtml(tokenLabel(d.address, approvedTx.chainId) || "Unknown token")}</div>`;
|
||||
detailsHtml += `<div class="font-bold">${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 break-words">${escapeHtml(d.value)}</div>`;
|
||||
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
||||
}
|
||||
detailsHtml += `</div>`;
|
||||
}
|
||||
|
||||
@@ -83,27 +83,19 @@ 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, 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".
|
||||
// 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.
|
||||
function balanceWarningHtml(addr) {
|
||||
if (!addressHoldsFunds(addr)) return " ";
|
||||
const line = formatAddressTotal(getAddressValue(addr));
|
||||
const total = line
|
||||
? `<div class="text-xs text-muted mt-1">${escapeHtml(line)}</div>`
|
||||
: "";
|
||||
const sentence =
|
||||
addr.balance == null
|
||||
? "This address may hold a balance."
|
||||
: "This address holds a balance.";
|
||||
return (
|
||||
`<p class="mb-1">${sentence} Removing it does not move or spend ` +
|
||||
`anything; the balance stays at the address.</p>` +
|
||||
`<p class="mb-1">This address holds a balance. Removing it does not ` +
|
||||
`move or spend anything; the balance stays at the address.</p>` +
|
||||
balanceLinesForAddress(addr, state.trackedTokens, false) +
|
||||
total
|
||||
);
|
||||
|
||||
+16
-28
@@ -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, Appearance). So #flash-msg never
|
||||
// push the screen below it down (README, No Layout Shift). So #flash-msg never
|
||||
// wraps: text too long for the line is cut with an ellipsis, and the whole
|
||||
// message is also put in the line's title. Write messages to fit, at most 50
|
||||
// characters, so none is cut.
|
||||
@@ -283,10 +283,9 @@ function showFlash(msg, duration = 2000) {
|
||||
flashTimer = setTimeout(clearFlash, duration);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
function unknownableAmount(balance) {
|
||||
if (balance == null) return null;
|
||||
const n = parseFloat(balance);
|
||||
@@ -395,10 +394,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), 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.
|
||||
// (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.
|
||||
function balanceLine(symbol, amount, price, tokenId) {
|
||||
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
||||
const usd =
|
||||
@@ -431,17 +430,10 @@ 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(),
|
||||
unknownableAmount(addr.balance),
|
||||
parseFloat(addr.balance || "0"),
|
||||
getPrice("ETH"),
|
||||
"ETH",
|
||||
);
|
||||
@@ -494,10 +486,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
// about holdings must ask this, not the rendered figure.
|
||||
function addressHoldsFunds(addr) {
|
||||
if (!addr) return false;
|
||||
// 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;
|
||||
if (parseFloat(addr.balance || "0") > 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 —
|
||||
@@ -528,9 +517,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 Addresses 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 Display Consistency 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
|
||||
@@ -633,9 +622,9 @@ function formatAddressHtml(address, ensName, maxLen, title) {
|
||||
return renderAddressHtml(address, { title, ensName, maxLen });
|
||||
}
|
||||
|
||||
// A transaction's time as every screen shows it (README, Amounts): the ISO
|
||||
// datetime, in UTC when the UTC Timestamps setting is on, and the relative age.
|
||||
// Views import these two; they keep no copies.
|
||||
// A transaction's time as every screen shows it (README, Display
|
||||
// Consistency): the ISO datetime, in UTC when the UTC Timestamps setting is
|
||||
// on, and the relative age. Views import these two; they keep no copies.
|
||||
function isoDate(timestamp) {
|
||||
const d = new Date(timestamp * 1000);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
@@ -861,7 +850,6 @@ module.exports = {
|
||||
balanceLine,
|
||||
balanceLinesForAddress,
|
||||
trackToken,
|
||||
markTrackedTokensNotFetched,
|
||||
addressHoldsFunds,
|
||||
keyMaterialName,
|
||||
unknownableAmount,
|
||||
|
||||
+11
-48
@@ -3,8 +3,6 @@ const {
|
||||
showView,
|
||||
showFlash,
|
||||
balanceLinesForAddress,
|
||||
unknownableAmount,
|
||||
markTrackedTokensNotFetched,
|
||||
isoDate,
|
||||
timeAgo,
|
||||
addressDotHtml,
|
||||
@@ -77,16 +75,10 @@ function renderTotalValue() {
|
||||
if (subEl) subEl.innerHTML = " ";
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
const ethBal = parseFloat(addr.balance || "0");
|
||||
const ethStr = ethBal.toFixed(4) + " " + nativeCurrency();
|
||||
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
|
||||
el.textContent = ethStr + ethUsd;
|
||||
|
||||
if (subEl) {
|
||||
subEl.innerHTML =
|
||||
@@ -107,9 +99,6 @@ 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;
|
||||
@@ -196,19 +185,6 @@ 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,
|
||||
@@ -229,12 +205,10 @@ async function loadHomeTxs(ctx) {
|
||||
const results = await Promise.all(fetches);
|
||||
|
||||
// If the network changed during the lookups, nothing is saved or
|
||||
// drawn. The page's network is compared after the read of storage,
|
||||
// because a switch made on this page can land during that read. See
|
||||
// src/shared/storedSettings.js.
|
||||
// drawn. See src/shared/storedSettings.js.
|
||||
if (
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
state.networkId !== networkId
|
||||
state.networkId !== networkId ||
|
||||
!(await storedNetworkIs(networkId))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -275,7 +249,6 @@ 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
|
||||
@@ -284,12 +257,11 @@ async function loadHomeTxs(ctx) {
|
||||
log.errorf("loadHomeTxs failed:", e.message);
|
||||
// If the network changed, the failure is not shown.
|
||||
if (
|
||||
!(await storedNetworkIs(networkId)) ||
|
||||
state.networkId !== networkId
|
||||
state.networkId !== networkId ||
|
||||
!(await storedNetworkIs(networkId))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
listNetworkId = networkId;
|
||||
const list = $("home-tx-list");
|
||||
if (list) {
|
||||
list.innerHTML =
|
||||
@@ -418,11 +390,10 @@ function render(ctx) {
|
||||
wallet.addresses.push({
|
||||
address: newAddr,
|
||||
index: wallet.nextIndex,
|
||||
balance: null,
|
||||
balance: "0.0000",
|
||||
tokenBalances: [],
|
||||
});
|
||||
wallet.nextIndex++;
|
||||
markTrackedTokensNotFetched();
|
||||
await saveState();
|
||||
render(ctx);
|
||||
ctx.doRefreshAndRender();
|
||||
@@ -505,20 +476,15 @@ function init(ctx) {
|
||||
return;
|
||||
}
|
||||
const addr = currentAddress();
|
||||
// A null balance is one no refresh has read, so it is not refused.
|
||||
if (parseFloat(addr.balance) === 0) {
|
||||
if (!addr.balance || 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();
|
||||
@@ -530,9 +496,6 @@ 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();
|
||||
});
|
||||
}
|
||||
|
||||
+10
-22
@@ -230,14 +230,11 @@ 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 =
|
||||
addr.balance == null
|
||||
? "Current balance: unknown (" + nativeCurrency() + ")"
|
||||
: "Current balance: " +
|
||||
truncateAmountNeverZero(addr.balance) +
|
||||
" " +
|
||||
nativeCurrency();
|
||||
"Current balance: " +
|
||||
truncateAmountNeverZero(addr.balance || "0") +
|
||||
" " +
|
||||
nativeCurrency();
|
||||
} else {
|
||||
const symbol = resolveSymbol(
|
||||
token,
|
||||
@@ -245,11 +242,10 @@ function updateSendBalance() {
|
||||
state.trackedTokens,
|
||||
currentNetwork().chainId,
|
||||
);
|
||||
// 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.
|
||||
// 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.
|
||||
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
||||
$("send-balance").textContent =
|
||||
bal == null
|
||||
@@ -292,12 +288,6 @@ 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();
|
||||
@@ -315,7 +305,7 @@ async function fillMaxAmount() {
|
||||
provider.estimateGas({
|
||||
from: addr.address,
|
||||
to,
|
||||
value: parseEther(addr.balance),
|
||||
value: parseEther(addr.balance || "0"),
|
||||
}),
|
||||
]);
|
||||
feeWei = feeReserveWei(gasLimit, feeData);
|
||||
@@ -330,10 +320,8 @@ 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. A network switch in that time
|
||||
// leaves no balance to fill in from.
|
||||
// or the recipient: what they did wins.
|
||||
if (
|
||||
addr.balance == null ||
|
||||
state.currentView !== "send" ||
|
||||
sendScreenOpenings !== opening ||
|
||||
currentAddress()?.address !== addr.address ||
|
||||
|
||||
@@ -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 Navigation section describes:
|
||||
// README's Screen Map describes:
|
||||
//
|
||||
// - cutViewStack(), before the switch is saved, so that save keeps the cut for
|
||||
// a popup closed straight after
|
||||
|
||||
@@ -45,11 +45,6 @@ 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,
|
||||
@@ -168,14 +163,6 @@ function init(_ctx) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Another click on Add, or a "Top tokens:" button, may have
|
||||
// tracked this token while this lookup waited.
|
||||
if (isTracked(addr)) {
|
||||
showFlash("Already tracked.");
|
||||
infoEl.textContent = "";
|
||||
infoEl.style.visibility = "hidden";
|
||||
return;
|
||||
}
|
||||
// The balance list never shows a token that the spoof rule
|
||||
// refuses, so such a token is not tracked. The rule never refuses
|
||||
// a bundled token, so the "Top tokens:" buttons and the top 100
|
||||
|
||||
@@ -126,16 +126,13 @@ function render() {
|
||||
const exactStr = tx.exactValue
|
||||
? tx.exactValue + " " + detailSym
|
||||
: tx.directionLabel + " " + detailSym;
|
||||
$("tx-detail-value").innerHTML = copyableHtml(
|
||||
exactStr,
|
||||
"font-bold break-words",
|
||||
);
|
||||
$("tx-detail-value").innerHTML = copyableHtml(exactStr, "font-bold");
|
||||
|
||||
// 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, "break-words");
|
||||
nativeEl.innerHTML = copyableHtml(nativeStr, "");
|
||||
nativeEl.parentElement.classList.remove("hidden");
|
||||
} else {
|
||||
nativeEl.innerHTML = "";
|
||||
@@ -371,7 +368,7 @@ async function loadFullTxDetails(txHash, chainId) {
|
||||
actionEl.textContent = decoded.name;
|
||||
let detailsHtml = "";
|
||||
if (decoded.description) {
|
||||
detailsHtml += `<div class="mb-2 break-words">${escapeHtml(decoded.description)}</div>`;
|
||||
detailsHtml += `<div class="mb-2">${escapeHtml(decoded.description)}</div>`;
|
||||
}
|
||||
for (const d of decoded.details || []) {
|
||||
detailsHtml += `<div class="mb-2">`;
|
||||
@@ -380,13 +377,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 break-words">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
|
||||
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
|
||||
}
|
||||
detailsHtml += renderAddressHtml(d.address, { chainId });
|
||||
} else if (d.address) {
|
||||
detailsHtml += renderAddressHtml(d.address, { chainId });
|
||||
} else {
|
||||
detailsHtml += `<div class="font-bold break-words">${escapeHtml(d.value)}</div>`;
|
||||
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
||||
}
|
||||
detailsHtml += `</div>`;
|
||||
}
|
||||
|
||||
+42
-95
@@ -73,58 +73,6 @@ 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);
|
||||
@@ -161,9 +109,10 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
}
|
||||
$("wait-tx-summary").textContent =
|
||||
txInfo.amount + (symbol ? " " + symbol : "");
|
||||
$("wait-tx-token-note").innerHTML =
|
||||
blockscoutOffTokenNoteHtml(txInfo.token, txInfo.chainId) +
|
||||
movedOrAllowedHtml(txInfo.decoded, txInfo.chainId);
|
||||
$("wait-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
|
||||
txInfo.token,
|
||||
txInfo.chainId,
|
||||
);
|
||||
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
|
||||
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
|
||||
attachCopyHandlers("view-wait-tx");
|
||||
@@ -274,7 +223,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. txInfo.decoded is not checked either: see isReadableLine().
|
||||
// ETH.
|
||||
function restoreWait() {
|
||||
const d = state.viewData;
|
||||
if (!d || !d.pendingWait) return false;
|
||||
@@ -321,31 +270,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) {
|
||||
const lines = readableLines(decoded);
|
||||
if (lines.length === 0) return "";
|
||||
if (!decoded || !decoded.details) return "";
|
||||
let html = `<div class="border border-border border-dashed p-2 mb-3">`;
|
||||
if (decoded.name && typeof decoded.name === "string") {
|
||||
if (decoded.name) {
|
||||
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Action</div>`;
|
||||
html += `<div class="font-bold break-words">${escapeHtml(decoded.name)}</div></div>`;
|
||||
html += `<div class="font-bold">${escapeHtml(decoded.name)}</div></div>`;
|
||||
}
|
||||
if (decoded.description && typeof decoded.description === "string") {
|
||||
if (decoded.description) {
|
||||
html += `<div class="mb-2"><div class="text-xs text-muted mb-1">Description</div>`;
|
||||
html += `<div class="break-words">${escapeHtml(decoded.description)}</div></div>`;
|
||||
html += `<div>${escapeHtml(decoded.description)}</div></div>`;
|
||||
}
|
||||
for (const d of lines) {
|
||||
for (const d of decoded.details) {
|
||||
html += `<div class="mb-2">`;
|
||||
html += `<div class="text-xs text-muted mb-1">${escapeHtml(d.label)}</div>`;
|
||||
if (d.address) {
|
||||
html += decodedAddressHtml(d, chainId);
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
html += `<div class="font-bold break-words">${escapeHtml(d.value)}</div>`;
|
||||
html += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
@@ -357,32 +306,30 @@ function renderSuccess() {
|
||||
const d = state.viewData;
|
||||
if (!d || !d.hash) return;
|
||||
|
||||
const decodedHtml = decodedDetailsHtml(d.decoded, d.chainId);
|
||||
const hasDecoded = d.decoded && d.decoded.details;
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
}
|
||||
|
||||
$("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 && decodedHtml) {
|
||||
decodedEl.innerHTML = decodedHtml;
|
||||
if (decodedEl && hasDecoded) {
|
||||
decodedEl.innerHTML = decodedDetailsHtml(d.decoded, d.chainId);
|
||||
decodedEl.classList.remove("hidden");
|
||||
} else if (decodedEl) {
|
||||
decodedEl.classList.add("hidden");
|
||||
@@ -409,7 +356,6 @@ function showError(txInfo, txHash, message) {
|
||||
to: txInfo.to,
|
||||
hash: txHash || null,
|
||||
message: message,
|
||||
decoded: txInfo.decoded || null,
|
||||
chainId: txInfo.chainId,
|
||||
};
|
||||
renderError();
|
||||
@@ -420,20 +366,21 @@ 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) +
|
||||
movedOrAllowedHtml(d.decoded, d.chainId);
|
||||
$("error-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
|
||||
d.token,
|
||||
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,4 +1,4 @@
|
||||
// The 4-decimal amount rule from README.md's Amounts section, and
|
||||
// The 4-decimal amount rule from README.md's Display Consistency 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
|
||||
|
||||
@@ -240,9 +240,10 @@ 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, 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, 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.
|
||||
*
|
||||
* Every write of the stored record holds the lock named after its key,
|
||||
* "autistmask", from its read to its write: saveState() in
|
||||
@@ -265,8 +266,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, The
|
||||
// background worker). The background keeps the connections allowed without
|
||||
// and started again, which module-level variables do not (README, Background
|
||||
// scheduling). 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.
|
||||
//
|
||||
|
||||
@@ -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 null, clearing that balance is no
|
||||
// When this page last saved a balance of "0", 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 = null;
|
||||
addr.balance = "0";
|
||||
addr.tokenBalances = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,20 +42,11 @@ function applyChainSwitchFields(s, newNetworkId) {
|
||||
// Tracked tokens are kept per network the same way, because a contract
|
||||
// address names a token on one network only. s.trackedTokens stays the
|
||||
// active network's list, so nothing that reads it changes: the outgoing
|
||||
// list is put away under its network, and a copy of the incoming network's
|
||||
// list, or an empty list, takes its place.
|
||||
//
|
||||
// It is a copy because a save writes a changed s.networkTrackedTokens whole,
|
||||
// over any list another page stored. An add or removal on the active network
|
||||
// must change only s.trackedTokens.
|
||||
//
|
||||
// Each token of the copy gets `balanceNotFetched`, because the balances are
|
||||
// cleared below and no refresh has read this network's yet. Each reads
|
||||
// `quantity unknown` until one has, not 0.0000.
|
||||
// list is put away under its network, and the incoming network's list, or
|
||||
// an empty one, takes its place.
|
||||
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.trackedTokens = s.networkTrackedTokens[net.id] || [];
|
||||
|
||||
s.networkId = net.id;
|
||||
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
|
||||
@@ -68,11 +59,10 @@ 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. A balance of null is one no
|
||||
// refresh has read: it reads as unknown until one has, not as 0.0000.
|
||||
// refresh on the new chain is in flight.
|
||||
for (const wallet of s.wallets || []) {
|
||||
for (const addr of wallet.addresses || []) {
|
||||
addr.balance = null;
|
||||
addr.balance = "0";
|
||||
addr.tokenBalances = [];
|
||||
}
|
||||
}
|
||||
@@ -109,7 +99,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 : null;
|
||||
addr.balance = got ? got.balance : "0";
|
||||
addr.tokenBalances = got ? got.tokenBalances : [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,8 +146,7 @@ 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
|
||||
// no total can be stated — testnet, before the first fetch, or an
|
||||
// ETH balance no refresh has read.
|
||||
// nothing is knowable at all — testnet, or before the first fetch.
|
||||
// partial: the address also holds a token with no price, so usd is a floor
|
||||
// and not the total.
|
||||
//
|
||||
@@ -156,8 +155,7 @@ function getAddressValue(addr) {
|
||||
const { currentNetwork } = require("./state");
|
||||
if (currentNetwork().isTestnet) return { usd: null, partial: false };
|
||||
if (!prices.ETH) return { usd: null, partial: false };
|
||||
if (addr.balance == null) return { usd: null, partial: false };
|
||||
let usd = parseFloat(addr.balance) * prices.ETH;
|
||||
let usd = parseFloat(addr.balance || "0") * 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
|
||||
|
||||
+22
-66
@@ -404,36 +404,6 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
return mergeMapByKey(base, ours, theirs, mergeEndpointEntry);
|
||||
}
|
||||
|
||||
// Merges one network's tracked tokens by contract address:
|
||||
//
|
||||
// - A token another page added or removed since this page's last save stays
|
||||
// added or removed.
|
||||
// - A token that this page and `theirs` both hold keeps this page's record.
|
||||
// - The result holds one record per contract address.
|
||||
//
|
||||
// mergeListByIdentity() keeps records that share an identity, on purpose, for
|
||||
// wallets. A contract address names one token, so this keeps one record for it.
|
||||
// Without that, a token that `ours` and `theirs` each hold twice comes back four
|
||||
// times, and each later merge multiplies it again.
|
||||
//
|
||||
// `theirs` takes this page's records first. mergeListByIdentity() would
|
||||
// otherwise log an error for two different records of one token.
|
||||
function mergeTrackedTokens(base, ours, theirs) {
|
||||
const contract = (token) => token.address.toLowerCase();
|
||||
const oursByContract = new Map(
|
||||
ours.map((token) => [contract(token), token]),
|
||||
);
|
||||
const merged = mergeListByIdentity(
|
||||
base,
|
||||
ours,
|
||||
theirs.map((token) => oursByContract.get(contract(token)) || token),
|
||||
contract,
|
||||
(baseToken, ourToken) => ourToken,
|
||||
);
|
||||
const byContract = new Map(merged.map((token) => [contract(token), token]));
|
||||
return [...byContract.values()];
|
||||
}
|
||||
|
||||
// Returns a copy of `stored.networkTrackedTokens` for when this page has not yet
|
||||
// taken the switch in `stored`. `page` is this page's state. A token that this
|
||||
// page tracked or removed since its last save is tracked or removed in the list
|
||||
@@ -441,10 +411,12 @@ function mergeTrackedTokens(base, ours, theirs) {
|
||||
function networkTrackedTokensWithChange(stored, page) {
|
||||
return {
|
||||
...stored.networkTrackedTokens,
|
||||
[page.networkId]: mergeTrackedTokens(
|
||||
[page.networkId]: mergeListByIdentity(
|
||||
baseline.trackedTokens,
|
||||
page.trackedTokens,
|
||||
stored.networkTrackedTokens[page.networkId] || [],
|
||||
stored.networkTrackedTokens[page.networkId],
|
||||
(token) => token.address.toLowerCase(),
|
||||
(base, ours) => ours,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -489,27 +461,23 @@ function networkTrackedTokensWithChange(stored, page) {
|
||||
// comment at mergeEndpointEntry() for why the collision this closes is
|
||||
// milder than the other two.
|
||||
//
|
||||
// `trackedTokens` is merged by contract address (mergeTrackedTokens()) when this
|
||||
// page has changed it, so a token another page added or removed since this
|
||||
// page's last save stays added or removed. A token both lists hold keeps this
|
||||
// page's record. Two saves differ:
|
||||
//
|
||||
// - When the stored record is on another network than this page's, a save keeps
|
||||
// the stored `trackedTokens`. It merges this page's change into the list in
|
||||
// `networkTrackedTokens` for this page's network, by contract address.
|
||||
// - A save that switches networks writes this page's list whole. A switch in
|
||||
// Settings first merges that list by contract address
|
||||
// (takeStoredTrackedTokens()).
|
||||
//
|
||||
// Every other persisted field stays a whole-field diff:
|
||||
// `fraudContracts`/`viewStack` are arrays of scalars with no per-element
|
||||
// identity to merge by; `networkTrackedTokens` is diffed whole like `networkId`,
|
||||
// which a network switch moves together with it; `tokenHolderCache` is a map
|
||||
// shaped like the ones above, but nothing in src/ ever writes an entry into it —
|
||||
// it is only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there
|
||||
// is no in-place mutation for a whole-field diff to collide with; `viewData` is
|
||||
// this page's own UI scratch space, not data another page has any reason to
|
||||
// share membership of.
|
||||
// identity to merge by; a save diffs `trackedTokens` whole, and a switch in
|
||||
// Settings first merges that list by contract address
|
||||
// (takeStoredTrackedTokens()); `networkTrackedTokens` is diffed whole like
|
||||
// `trackedTokens` and `networkId`, the two fields a network switch moves
|
||||
// together with it; `tokenHolderCache` is a map shaped like the ones above,
|
||||
// but nothing in src/ ever writes an entry into it — it is only ever reset
|
||||
// wholesale to `{}` (applyChainSwitchFields()) — so there is no in-place
|
||||
// mutation for a whole-field diff to collide with; `viewData` is this page's
|
||||
// own UI scratch space, not data another page has any reason to share
|
||||
// membership of.
|
||||
//
|
||||
// One exception: when the stored record is on another network than this
|
||||
// page's, a save keeps the stored `trackedTokens`. It merges this page's change
|
||||
// into the list in `networkTrackedTokens` for this page's network, by contract
|
||||
// address.
|
||||
//
|
||||
// This does not make two pages that both change the SAME leaf concurrently
|
||||
// safe: last write wins on that one leaf, same as before. What it removes
|
||||
@@ -596,20 +564,6 @@ async function saveStateOnce(beforeWrite) {
|
||||
fresh,
|
||||
current,
|
||||
);
|
||||
} else if (
|
||||
baseline !== null &&
|
||||
baseline.networkId === current.networkId &&
|
||||
!deepEqual(current.trackedTokens, baseline.trackedTokens)
|
||||
) {
|
||||
// This page's network is the one it last saved on, and the stored
|
||||
// record is on it too, so all three lists are of one network. This
|
||||
// page's change goes into the stored list, and another page's add or
|
||||
// removal stays (https://git.eeqj.de/sneak/AutistMask/issues/717).
|
||||
merged.trackedTokens = mergeTrackedTokens(
|
||||
baseline.trackedTokens,
|
||||
current.trackedTokens,
|
||||
fresh.trackedTokens,
|
||||
);
|
||||
}
|
||||
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
|
||||
// Stamped on every write, never merged or diffed: the record that goes to
|
||||
@@ -726,10 +680,12 @@ function takeStoredNetwork(stored) {
|
||||
// `baseline` takes the stored list, so the save stores the list in `state`
|
||||
// whenever the two differ.
|
||||
function takeStoredTrackedTokens(stored) {
|
||||
rawState.trackedTokens = mergeTrackedTokens(
|
||||
rawState.trackedTokens = mergeListByIdentity(
|
||||
baseline.trackedTokens,
|
||||
rawState.trackedTokens,
|
||||
stored.trackedTokens,
|
||||
(token) => token.address.toLowerCase(),
|
||||
(base, ours) => ours,
|
||||
);
|
||||
baseline.trackedTokens = structuredClone(stored.trackedTokens);
|
||||
}
|
||||
|
||||
+26
-61
@@ -404,11 +404,6 @@ 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.
|
||||
@@ -421,7 +416,6 @@ 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".
|
||||
@@ -1273,9 +1267,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;
|
||||
// otherwise null. That WRAP_ETH pays its WETH there (see the Moved to
|
||||
// lines).
|
||||
// The recipient of a WRAP_ETH that set the input side, as written,
|
||||
// when it is not the router; otherwise null. That WRAP_ETH pays its
|
||||
// WETH there, not to a swap step (see the Moved to lines).
|
||||
let wrapEthRecipient = null;
|
||||
// The last PERMIT2_PERMIT this decoder read before a step set the
|
||||
// input side, or null (see permitStands after the loop).
|
||||
@@ -1295,9 +1289,6 @@ 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
|
||||
@@ -1377,9 +1368,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` 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.
|
||||
// "Up to" and the amount. A take with a `recipient`, the ETH of a
|
||||
// later WRAP_ETH that pays anyone but the router, never joins the
|
||||
// Amount line either: its Moved to line names that recipient.
|
||||
const userPaid = [];
|
||||
// A swap step the user pays for (`payerIsUser` true) takes `amount`
|
||||
// of `token` from the sender, or at most that when `atMost` is set. A
|
||||
@@ -1915,7 +1906,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
w.amount,
|
||||
);
|
||||
inputPaidByUser = true;
|
||||
wrapEthRecipient = w.recipient;
|
||||
if (!isRouter(w.recipient)) {
|
||||
wrapEthRecipient = w.recipient;
|
||||
}
|
||||
} else if (
|
||||
w.amount > 0n &&
|
||||
w.amount !== CONTRACT_BALANCE
|
||||
@@ -1923,33 +1916,17 @@ 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.
|
||||
// then pays at most that amount. CONTRACT_BALANCE
|
||||
// states no amount to take.
|
||||
userPaid.push({
|
||||
token: "0x0000000000000000000000000000000000000000",
|
||||
amount: w.amount,
|
||||
atMost: heldByRouter.has(
|
||||
"0x0000000000000000000000000000000000000000",
|
||||
),
|
||||
recipient: 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,
|
||||
recipient: isRouter(w.recipient)
|
||||
? null
|
||||
: w.recipient,
|
||||
});
|
||||
}
|
||||
addPayee(w.recipient);
|
||||
@@ -2192,9 +2169,6 @@ 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",
|
||||
@@ -2292,8 +2266,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 has
|
||||
// a recipient (see wrapEthRecipient and userPaid).
|
||||
// know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH that
|
||||
// pays elsewhere has a recipient (see wrapEthRecipient and userPaid).
|
||||
const spentFromSender = [];
|
||||
let total = null;
|
||||
// The takes from the sender that `total` adds up, each a quantity, in
|
||||
@@ -2341,7 +2315,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
||||
typeof total === "bigint" &&
|
||||
typeof t.amount === "bigint" &&
|
||||
!t.atMost &&
|
||||
(!present(t.recipient) || isRouter(t.recipient))
|
||||
!present(t.recipient)
|
||||
) {
|
||||
total += t.amount;
|
||||
inTotal.push(t);
|
||||
@@ -2439,14 +2413,13 @@ 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. 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).
|
||||
// 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).
|
||||
const spentText = (amount, info, atMost) => {
|
||||
if (amount === OPEN_DELTA) {
|
||||
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
||||
@@ -2457,12 +2430,6 @@ 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" };
|
||||
}
|
||||
@@ -2522,8 +2489,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:
|
||||
// the router, the sender as `MSG_SENDER`, or an address.
|
||||
// that spends it. A take with a recipient goes to that recipient;
|
||||
// `MSG_SENDER` means the sender.
|
||||
for (const s of spentFromSender) {
|
||||
details.push({
|
||||
label: "Moved from the sender",
|
||||
@@ -2547,8 +2514,6 @@ 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 {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
// A contract lookup on AddToken can answer after the user has left AddToken.
|
||||
// The token is still added, and the screen the user is on does not change
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/603). An answer while AddToken
|
||||
// still shows goes to AddressDetail. On either add-token screen, an answer for a
|
||||
// token tracked while the lookup waited tracks nothing
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/727). A second click on a "Top
|
||||
// tokens:" button tracks nothing
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/729). Driven through the real
|
||||
// popup over a stored profile.
|
||||
// still shows goes to AddressDetail. Driven through the real popup over a
|
||||
// stored profile.
|
||||
|
||||
const {
|
||||
bootPopup,
|
||||
@@ -47,9 +43,9 @@ async function openAddToken() {
|
||||
return boot;
|
||||
}
|
||||
|
||||
// Presses `button` with a contract lookup that answers only when the function
|
||||
// returned here is called. That function waits for the add to finish.
|
||||
function addWithPendingLookup(boot, button = "btn-add-token-confirm") {
|
||||
// Presses Add with a contract lookup that answers only when the function
|
||||
// returned here is called. That function waits for Add to finish.
|
||||
function addWithPendingLookup(boot) {
|
||||
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||
let answer;
|
||||
lookupTokenInfo.mockImplementationOnce(
|
||||
@@ -58,7 +54,7 @@ function addWithPendingLookup(boot, button = "btn-add-token-confirm") {
|
||||
answer = resolve;
|
||||
}),
|
||||
);
|
||||
const adding = boot.click(button);
|
||||
const adding = boot.click("btn-add-token-confirm");
|
||||
return async () => {
|
||||
answer({
|
||||
symbol: TOKEN.symbol,
|
||||
@@ -140,121 +136,3 @@ test("an answer while AddToken still shows goes to AddressDetail with the token
|
||||
expect(boot.node("address-balances").innerHTML).toContain("TST");
|
||||
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||
});
|
||||
|
||||
test("an answer for a token tracked after AddToken was opened again leaves the status line as it is", async () => {
|
||||
const boot = await openAddToken();
|
||||
const answerFirstLookup = addWithPendingLookup(boot);
|
||||
await boot.click("btn-add-token-back");
|
||||
await boot.click("btn-add-token");
|
||||
boot.node("add-token-address").value = OTHER_TOKEN_ADDRESS;
|
||||
// The second lookup is never answered.
|
||||
addWithPendingLookup(boot);
|
||||
require("../src/popup/views/helpers").trackToken(TOKEN);
|
||||
|
||||
await answerFirstLookup();
|
||||
expect(boot.text("flash-msg")).toBe("TST is already being tracked.");
|
||||
expect(boot.text("add-token-info")).toBe("Looking up token...");
|
||||
});
|
||||
|
||||
// Each screen is opened from AddressDetail by the buttons named, in order.
|
||||
describe.each([
|
||||
[
|
||||
"AddToken",
|
||||
["btn-add-token"],
|
||||
"add-token-address",
|
||||
"btn-add-token-confirm",
|
||||
"add-token-info",
|
||||
"TST is already being tracked.",
|
||||
],
|
||||
[
|
||||
"SettingsAddToken",
|
||||
["btn-settings", "btn-settings-add-token"],
|
||||
"settings-addtoken-address",
|
||||
"btn-settings-addtoken-manual",
|
||||
"settings-addtoken-info",
|
||||
"Already tracked.",
|
||||
],
|
||||
])("%s", (_, opening, field, button, statusLine, alreadyTrackedFlash) => {
|
||||
// The screen open with TOKEN's contract address typed in.
|
||||
async function openScreen() {
|
||||
const boot = await bootPopup(
|
||||
unversionedValidProfile({
|
||||
currentView: "address",
|
||||
selectedWallet: 0,
|
||||
selectedAddress: 0,
|
||||
}),
|
||||
);
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
for (const id of opening) await boot.click(id);
|
||||
boot.node(field).value = TOKEN.address;
|
||||
return boot;
|
||||
}
|
||||
|
||||
test("a second click on Add while the lookup waits tracks the token once", async () => {
|
||||
const boot = await openScreen();
|
||||
const answerFirstClick = addWithPendingLookup(boot, button);
|
||||
const answerSecondClick = addWithPendingLookup(boot, button);
|
||||
|
||||
await answerFirstClick();
|
||||
await answerSecondClick();
|
||||
expect(boot.text("flash-msg")).toBe(alreadyTrackedFlash);
|
||||
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||
const { state } = require("../src/shared/state");
|
||||
expect(state.trackedTokens).toEqual([TOKEN]);
|
||||
});
|
||||
|
||||
test("an answer for a token tracked while the lookup waited clears the status line", async () => {
|
||||
const boot = await openScreen();
|
||||
const answerLookup = addWithPendingLookup(boot, button);
|
||||
expect(boot.text(statusLine)).toBe("Looking up token...");
|
||||
require("../src/popup/views/helpers").trackToken(TOKEN);
|
||||
|
||||
await answerLookup();
|
||||
expect(boot.text("flash-msg")).toBe(alreadyTrackedFlash);
|
||||
expect(boot.text(statusLine)).toBe("");
|
||||
expect(boot.node(statusLine).style.visibility).toBe("hidden");
|
||||
const { state } = require("../src/shared/state");
|
||||
expect(state.trackedTokens).toEqual([TOKEN]);
|
||||
});
|
||||
});
|
||||
|
||||
test('a double click on a "Top tokens:" button tracks the token once', async () => {
|
||||
const boot = await bootPopup(
|
||||
unversionedValidProfile({
|
||||
currentView: "address",
|
||||
selectedWallet: 0,
|
||||
selectedAddress: 0,
|
||||
}),
|
||||
);
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
// The page stub does not turn the markup the screen draws into elements.
|
||||
// So the list hands the screen a stand-in for TOKEN's button.
|
||||
let press;
|
||||
boot.node("settings-addtoken-top10").querySelectorAll = () => [
|
||||
{
|
||||
dataset: {
|
||||
address: TOKEN.address,
|
||||
symbol: TOKEN.symbol,
|
||||
decimals: String(TOKEN.decimals),
|
||||
name: TOKEN.name,
|
||||
},
|
||||
addEventListener: (name, fn) => {
|
||||
press = fn;
|
||||
},
|
||||
},
|
||||
];
|
||||
await boot.click("btn-settings");
|
||||
await boot.click("btn-settings-add-token");
|
||||
|
||||
const firstClick = press();
|
||||
const secondClick = press();
|
||||
const { state } = require("../src/shared/state");
|
||||
expect(state.trackedTokens).toEqual([TOKEN]);
|
||||
// Read before the first click's save ends, when "Added TST" replaces it.
|
||||
expect(boot.text("flash-msg")).toBe("Already tracked.");
|
||||
|
||||
await Promise.all([firstClick, secondClick]);
|
||||
await boot.settle();
|
||||
expect(state.trackedTokens).toEqual([TOKEN]);
|
||||
expect(boot.storage.read("autistmask").trackedTokens).toEqual([TOKEN]);
|
||||
});
|
||||
|
||||
@@ -11,53 +11,14 @@
|
||||
// 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";
|
||||
@@ -105,39 +66,11 @@ 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 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.
|
||||
// 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.
|
||||
function holdNextRefresh(page) {
|
||||
const { refreshBalances } = require("../src/shared/balances");
|
||||
let release;
|
||||
@@ -147,9 +80,7 @@ function holdNextRefresh(page) {
|
||||
const tokenBalances = await new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
for (const wallet of wallets) {
|
||||
for (const addr of wallet.addresses) addr.balance = "2.0";
|
||||
}
|
||||
wallets[0].addresses[0].balance = "2.0";
|
||||
if (tokenBalances === null) return [];
|
||||
wallets[0].addresses[0].tokenBalances = tokenBalances;
|
||||
return read;
|
||||
@@ -244,63 +175,6 @@ 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) {
|
||||
@@ -436,62 +310,6 @@ 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"]);
|
||||
@@ -638,482 +456,3 @@ 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.");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -44,7 +44,6 @@ jest.doMock("../src/popup/views/helpers", () => ({
|
||||
goBack: () => {},
|
||||
clearViewStack: () => {},
|
||||
onViewLeave: () => {},
|
||||
markTrackedTokensNotFetched: () => {},
|
||||
}));
|
||||
|
||||
// state.js reads chrome.storage.local at load, and saveState() writes it.
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
// "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);
|
||||
});
|
||||
});
|
||||
@@ -125,14 +125,6 @@ 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", () => {
|
||||
|
||||
@@ -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 break-words">Unknown token</div>',
|
||||
'<div class="font-bold">Unknown token</div>',
|
||||
);
|
||||
expect(decodedLine("Amount")).toContain(
|
||||
'<div class="font-bold break-words">1234500000 base units (decimals unknown)</div>',
|
||||
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -162,10 +162,8 @@ 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 break-words">USDC</div>',
|
||||
);
|
||||
expect(decodedLine("Token")).toContain('<div class="font-bold">USDC</div>');
|
||||
expect(decodedLine("Amount")).toContain(
|
||||
'<div class="font-bold break-words">1234.5000 USDC</div>',
|
||||
'<div class="font-bold">1234.5000 USDC</div>',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// The floor of the approval screen's amount line.
|
||||
//
|
||||
// Amounts are truncated to four decimal places for scannability (README.md,
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// The invariant asserted here is narrow: a nonzero amount never renders as
|
||||
// zero. The four-decimal rule itself is unchanged, and the string the
|
||||
|
||||
@@ -24,8 +24,6 @@ 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");
|
||||
@@ -195,13 +193,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 break-words">TDOL</div>',
|
||||
'<div class="font-bold mb-1">TDOL</div>',
|
||||
);
|
||||
expect(decodedLine("Token")).toContain(
|
||||
'<div class="font-bold break-words">TDOL</div>',
|
||||
'<div class="font-bold">TDOL</div>',
|
||||
);
|
||||
expect(decodedLine("Amount")).toContain(
|
||||
'<div class="font-bold break-words">1234.5000 TDOL</div>',
|
||||
'<div class="font-bold">1234.5000 TDOL</div>',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -222,51 +220,13 @@ 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 break-words">Unknown token</div>',
|
||||
'<div class="font-bold">Unknown token</div>',
|
||||
);
|
||||
expect(decodedLine("Amount")).toContain(
|
||||
'<div class="font-bold break-words">1234500000 base units (decimals unknown)</div>',
|
||||
'<div class="font-bold">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();
|
||||
|
||||
@@ -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).toBeNull();
|
||||
expect(after.wallets[0].addresses[0].balance).toBe("0");
|
||||
expect(after.lastBalanceRefresh).toBe(0);
|
||||
|
||||
await bg.fireBalanceAlarm();
|
||||
|
||||
@@ -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).toBeNull();
|
||||
expect(stored.wallets[0].addresses[0].balance).toBe("0");
|
||||
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).toBeNull();
|
||||
expect(state.wallets[0].addresses[0].balance).toBe("0");
|
||||
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("unknown (SepoliaETH)");
|
||||
expect(page.text("total-value")).toBe("0.0000 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).toBeNull();
|
||||
expect(state.wallets[0].addresses[0].balance).toBe("0");
|
||||
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 null and no
|
||||
// in Settings waits for the lock. This page last saved a balance of "0" 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 = null;
|
||||
profile.wallets[0].addresses[0].balance = "0";
|
||||
const page = await bootPopup(profile);
|
||||
expect(page.pageErrors).toEqual([]);
|
||||
const background = require("../src/background/state");
|
||||
|
||||
@@ -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).toBeNull();
|
||||
expect(after.wallets[0].addresses[0].balance).toBe("0");
|
||||
expect(bg.chainChangedEvents()).toEqual([
|
||||
{
|
||||
type: "AUTISTMASK_EVENT",
|
||||
|
||||
@@ -39,7 +39,7 @@ function walletFixture() {
|
||||
{
|
||||
name: "Wallet 1",
|
||||
type: "hd",
|
||||
addresses: [{ address: ADDRESS, balance: null, tokenBalances: [] }],
|
||||
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -242,12 +242,7 @@ 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,
|
||||
balanceNotFetched: true,
|
||||
},
|
||||
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ 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",
|
||||
@@ -107,23 +106,14 @@ 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 does not", () => {
|
||||
test("a missing address or missing fields do not", () => {
|
||||
expect(addressHoldsFunds(undefined)).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);
|
||||
expect(addressHoldsFunds({ address: "0x1" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,20 +140,6 @@ 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.
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("parsing the dust threshold", () => {
|
||||
});
|
||||
|
||||
describe("the rejection message", () => {
|
||||
// README, Wording: error messages are full sentences.
|
||||
// README, Language & Labeling: 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
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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
@@ -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");
|
||||
|
||||
// README, ShowRecoveryPhrase: shown whole, and copyable.
|
||||
// Full Identifiers Policy: shown whole, and copyable.
|
||||
const title = await env.page.getAttribute(
|
||||
"#show-phrase-value",
|
||||
"title",
|
||||
@@ -2255,7 +2255,7 @@ async function waitForFilledFlashLine(page) {
|
||||
}
|
||||
}
|
||||
|
||||
// README, Appearance: the rejection message goes into #flash-msg,
|
||||
// README, No Layout Shift: 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, Amounts). Deliberately a second
|
||||
// places (README.md, Display Consistency). 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, Amounts).
|
||||
// TOKEN_AMOUNT to four decimal places (README.md, Display Consistency).
|
||||
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, Appearance. Sign & Send refuses a send on the confirmation
|
||||
// README, No Layout Shift. 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, and not one
|
||||
// any post-teardown hook could close.
|
||||
// never observed at all — a real limit of this design, stated in the
|
||||
// README, 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
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Home, AddressDetail and AddressToken drop a transaction history answer that
|
||||
// lands after a network switch. A dropped answer draws no rows and saves no
|
||||
// fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657). Home's list
|
||||
// shows nothing of the previous network once it is drawn after a switch
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/718).
|
||||
// fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657).
|
||||
//
|
||||
// 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
|
||||
@@ -156,21 +154,6 @@ 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,
|
||||
@@ -426,123 +409,6 @@ 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) => {
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
// 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)");
|
||||
});
|
||||
@@ -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.
|
||||
// save could land between the background's read and its write (README,
|
||||
// "Stored state and its version").
|
||||
describe("private windows", () => {
|
||||
test("a page in a firefox private window cannot read or write extension storage", () => {
|
||||
expect(readManifest("firefox").incognito).toBe("not_allowed");
|
||||
|
||||
@@ -416,6 +416,20 @@ 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" },
|
||||
@@ -1068,57 +1082,3 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,63 +134,9 @@ test.each([
|
||||
await backgroundSwitch(page, "sepolia");
|
||||
|
||||
expect(page.visibleViews()).toEqual(["main"]);
|
||||
expect(page.text("total-value")).toBe("unknown (SepoliaETH)");
|
||||
expect(page.text("total-value")).toBe("0.0000 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();
|
||||
@@ -299,9 +245,7 @@ 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, balanceNotFetched: true },
|
||||
]);
|
||||
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
|
||||
expect(page.text("flash-msg")).toBe("Switched to Ethereum Mainnet.");
|
||||
const told = globalThis.chrome.runtime.sendMessage.mock.calls
|
||||
.map(([message]) => message)
|
||||
|
||||
@@ -432,27 +432,6 @@ 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.
|
||||
|
||||
@@ -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 data").
|
||||
// (README, "Stored state and its version").
|
||||
//
|
||||
// Run before each test file by `setupFiles` in package.json.
|
||||
|
||||
|
||||
+11
-184
@@ -231,15 +231,6 @@ 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;
|
||||
@@ -296,27 +287,20 @@ describe.each([
|
||||
expect(node("error-tx-summary").textContent).toBe(expected);
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
// 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 () => {
|
||||
await approveSwap(v4SwapData(actions), { txHash: TX_HASH });
|
||||
await receiptArrives();
|
||||
const summary = node("success-tx-summary");
|
||||
expect(summary.parentElement.classList.contains("hidden")).toBe(
|
||||
!expected.startsWith("Unknown"),
|
||||
);
|
||||
expect(summary.textContent).toBe(expected);
|
||||
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);
|
||||
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
|
||||
@@ -355,161 +339,4 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, 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
|
||||
// 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
|
||||
// would not have reached them.
|
||||
//
|
||||
// The pair is replaced before the views are loaded, because a view takes it
|
||||
|
||||
@@ -21,8 +21,6 @@
|
||||
// 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", () => ({
|
||||
@@ -53,7 +51,6 @@ 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";
|
||||
@@ -200,63 +197,15 @@ 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 break-words">TDOL</div>');
|
||||
expect(token).toContain('<div class="font-bold">TDOL</div>');
|
||||
expect(token).toContain(TOKEN);
|
||||
expect(token).not.toContain(RECIPIENT);
|
||||
expect(decodedLine("Recipient")).toContain(RECIPIENT);
|
||||
expect(decodedLine("Amount")).toContain(
|
||||
'<div class="font-bold break-words">1234.5000 TDOL</div>',
|
||||
'<div class="font-bold">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.
|
||||
@@ -329,11 +278,9 @@ test("with mainnet active, a Sepolia token transfer's decoded details name the t
|
||||
};
|
||||
await showSepoliaTransferWithMainnetActive();
|
||||
|
||||
expect(decodedLine("Token")).toContain(
|
||||
'<div class="font-bold break-words">TDOL</div>',
|
||||
);
|
||||
expect(decodedLine("Token")).toContain('<div class="font-bold">TDOL</div>');
|
||||
expect(decodedLine("Amount")).toContain(
|
||||
'<div class="font-bold break-words">1234.5000 TDOL</div>',
|
||||
'<div class="font-bold">1234.5000 TDOL</div>',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -348,6 +295,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 break-words">1234500000 base units (decimals unknown)</div>',
|
||||
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -117,17 +117,6 @@ async function chooseNetwork(boot, networkId) {
|
||||
await boot.settle();
|
||||
}
|
||||
|
||||
// Adds NEW_TOKEN_ADDRESS by contract address on SettingsAddToken, as the user
|
||||
// does. Returns the record the add tracks.
|
||||
async function addNewToken(boot) {
|
||||
const { lookupTokenInfo } = require("../src/shared/balances");
|
||||
const info = { symbol: "NEW", decimals: 18, name: "New Token" };
|
||||
lookupTokenInfo.mockResolvedValueOnce(info);
|
||||
boot.node("settings-addtoken-address").value = NEW_TOKEN_ADDRESS;
|
||||
await boot.click("btn-settings-addtoken-manual");
|
||||
return { address: NEW_TOKEN_ADDRESS, ...info, balanceNotFetched: true };
|
||||
}
|
||||
|
||||
// The Settings gear, a switch to Sepolia, and Back.
|
||||
async function gearSwitchBack(boot) {
|
||||
await boot.click("btn-settings");
|
||||
@@ -180,10 +169,7 @@ 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");
|
||||
// No refresh has read a balance on this network since the switch.
|
||||
expect(stored().trackedTokens).toEqual([
|
||||
{ ...MAINNET_TOKEN, balanceNotFetched: true },
|
||||
]);
|
||||
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
|
||||
const onMainnet = boot.node("settings-tracked-tokens").innerHTML;
|
||||
expect(onMainnet).toContain("DAI");
|
||||
expect(onMainnet).not.toContain("TST");
|
||||
@@ -279,9 +265,7 @@ 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, balanceNotFetched: true },
|
||||
]);
|
||||
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
|
||||
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -446,10 +430,7 @@ 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, balanceNotFetched: true },
|
||||
{ ...newToken, balanceNotFetched: true },
|
||||
]);
|
||||
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN, newToken]);
|
||||
});
|
||||
|
||||
// This page tracks DAI on Ethereum Mainnet and switches to Sepolia. Another
|
||||
@@ -474,62 +455,6 @@ test("a token another page removed on Ethereum Mainnet stays removed when this p
|
||||
expect(stored.trackedTokens).toEqual([]);
|
||||
});
|
||||
|
||||
// This page switches to Sepolia, where TST is tracked. Another popup page has
|
||||
// not taken that switch, and its save tracks DAI on Ethereum Mainnet. This page
|
||||
// then adds a token on Sepolia. Its save changes only `trackedTokens`, so DAI
|
||||
// stays on Ethereum Mainnet's list.
|
||||
test("a token another page added on Ethereum Mainnet stays when this page adds a token on Sepolia", async () => {
|
||||
const boot = await bootOnAddToken("mainnet", {
|
||||
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
|
||||
});
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
await gearSwitchBack(boot);
|
||||
boot.storage.write("autistmask", {
|
||||
...boot.storage.read("autistmask"),
|
||||
networkTrackedTokens: {
|
||||
mainnet: [MAINNET_TOKEN],
|
||||
sepolia: [SEPOLIA_TOKEN],
|
||||
},
|
||||
});
|
||||
|
||||
const added = await addNewToken(boot);
|
||||
|
||||
const stored = boot.storage.read("autistmask");
|
||||
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
||||
expect(stored.networkTrackedTokens.sepolia).toEqual([SEPOLIA_TOKEN]);
|
||||
expect(stored.trackedTokens).toEqual([
|
||||
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
|
||||
added,
|
||||
]);
|
||||
});
|
||||
|
||||
// This page tracks DAI on Ethereum Mainnet and switches to Sepolia, where TST is
|
||||
// tracked. Another popup page has not taken that switch, and its save removes
|
||||
// DAI from Ethereum Mainnet's list. This page then adds a token on Sepolia. Its
|
||||
// save changes only `trackedTokens`, so DAI does not come back.
|
||||
test("a token another page removed on Ethereum Mainnet stays removed when this page adds a token on Sepolia", async () => {
|
||||
const boot = await bootOnAddToken("mainnet", {
|
||||
trackedTokens: [MAINNET_TOKEN],
|
||||
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
|
||||
});
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
await gearSwitchBack(boot);
|
||||
boot.storage.write("autistmask", {
|
||||
...boot.storage.read("autistmask"),
|
||||
networkTrackedTokens: { mainnet: [], sepolia: [SEPOLIA_TOKEN] },
|
||||
});
|
||||
|
||||
const added = await addNewToken(boot);
|
||||
|
||||
const stored = boot.storage.read("autistmask");
|
||||
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
||||
expect(stored.networkTrackedTokens.sepolia).toEqual([SEPOLIA_TOKEN]);
|
||||
expect(stored.trackedTokens).toEqual([
|
||||
{ ...SEPOLIA_TOKEN, balanceNotFetched: true },
|
||||
added,
|
||||
]);
|
||||
});
|
||||
|
||||
// Another popup page records a fraud contract on Ethereum Mainnet after this
|
||||
// page's last save. The switch to Sepolia drops that fraud contract, as the
|
||||
// switch drops this page's own.
|
||||
@@ -760,113 +685,6 @@ test("a token removed while this page waits to take another page's switch is gon
|
||||
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
|
||||
});
|
||||
|
||||
// Another popup page tracks DAI after this page's last save. This page then
|
||||
// adds a token. Its save keeps DAI.
|
||||
test("a token another page added stays when this page adds a token", async () => {
|
||||
const boot = await bootOnAddToken("mainnet");
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
boot.storage.write("autistmask", {
|
||||
...boot.storage.read("autistmask"),
|
||||
trackedTokens: [MAINNET_TOKEN],
|
||||
});
|
||||
|
||||
const added = await addNewToken(boot);
|
||||
|
||||
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
|
||||
MAINNET_TOKEN,
|
||||
added,
|
||||
]);
|
||||
});
|
||||
|
||||
// Another popup page tracks a new token after this page's last save. This page
|
||||
// then removes DAI, as the `[x]` on a tracked token in Settings does. Its save
|
||||
// keeps the new token.
|
||||
test("a token another page added stays when this page removes a token", async () => {
|
||||
const boot = await bootPopup(
|
||||
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
|
||||
);
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
const otherPageToken = {
|
||||
address: NEW_TOKEN_ADDRESS,
|
||||
symbol: "NEW",
|
||||
decimals: 18,
|
||||
name: "New Token",
|
||||
};
|
||||
boot.storage.write("autistmask", {
|
||||
...boot.storage.read("autistmask"),
|
||||
trackedTokens: [MAINNET_TOKEN, otherPageToken],
|
||||
});
|
||||
|
||||
const { state, saveState } = require("../src/shared/state");
|
||||
state.trackedTokens.splice(0, 1);
|
||||
await saveState();
|
||||
|
||||
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
|
||||
otherPageToken,
|
||||
]);
|
||||
});
|
||||
|
||||
// Another popup page stops tracking DAI after this page's last save. This page
|
||||
// still holds DAI and adds a token. Its save does not store DAI again.
|
||||
test("a token another page removed stays removed when this page adds a token", async () => {
|
||||
const boot = await bootOnAddToken("mainnet", {
|
||||
trackedTokens: [MAINNET_TOKEN],
|
||||
});
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
boot.storage.write("autistmask", {
|
||||
...boot.storage.read("autistmask"),
|
||||
trackedTokens: [],
|
||||
});
|
||||
|
||||
const added = await addNewToken(boot);
|
||||
|
||||
expect(boot.storage.read("autistmask").trackedTokens).toEqual([added]);
|
||||
});
|
||||
|
||||
// Another popup page tracks DAI and the new token after this page's last save,
|
||||
// and its refresh has read the new token's balance. This page adds the new token
|
||||
// too, so the two pages hold different records for it. The save stores one
|
||||
// record for the new token, this page's, and keeps DAI.
|
||||
test("a token both pages added is stored once, with this page's record", async () => {
|
||||
const boot = await bootOnAddToken("mainnet");
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
boot.storage.write("autistmask", {
|
||||
...boot.storage.read("autistmask"),
|
||||
trackedTokens: [
|
||||
MAINNET_TOKEN,
|
||||
{
|
||||
address: NEW_TOKEN_ADDRESS,
|
||||
symbol: "NEW",
|
||||
decimals: 18,
|
||||
name: "New Token",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const added = await addNewToken(boot);
|
||||
|
||||
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
|
||||
MAINNET_TOKEN,
|
||||
added,
|
||||
]);
|
||||
});
|
||||
|
||||
// This page and the stored list each hold DAI twice. This page then adds a
|
||||
// token. Its save stores DAI once.
|
||||
test("a token held twice is stored once when this page adds a token", async () => {
|
||||
const boot = await bootOnAddToken("mainnet", {
|
||||
trackedTokens: [MAINNET_TOKEN, MAINNET_TOKEN],
|
||||
});
|
||||
expect(boot.pageErrors).toEqual([]);
|
||||
|
||||
const added = await addNewToken(boot);
|
||||
|
||||
expect(boot.storage.read("autistmask").trackedTokens).toEqual([
|
||||
MAINNET_TOKEN,
|
||||
added,
|
||||
]);
|
||||
});
|
||||
|
||||
describe("Back after a switch from the Settings gear", () => {
|
||||
test("SettingsAddToken no longer offers the common tokens", async () => {
|
||||
const boot = await bootOnAddToken("mainnet");
|
||||
@@ -1045,9 +863,7 @@ 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, balanceNotFetched: true },
|
||||
]);
|
||||
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
|
||||
expect(stored().networkTrackedTokens.sepolia).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1060,9 +876,7 @@ 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, balanceNotFetched: true },
|
||||
]);
|
||||
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
|
||||
expect(stored().networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
|
||||
});
|
||||
});
|
||||
|
||||
+13
-16
@@ -1,15 +1,15 @@
|
||||
// Tests for the address-poisoning defense in src/shared/transactions.js.
|
||||
//
|
||||
// README, Scam protection, describes four filters as a core security property
|
||||
// of the wallet: known token symbol verification, the low-holder threshold, the
|
||||
// README.md:730-814 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 two attacks below must be filtered,
|
||||
// behaviour down in both directions: the documented attacks must be filtered,
|
||||
// and legitimate transactions must survive untouched.
|
||||
//
|
||||
// Fixtures are static local objects modelled on two real attacks.
|
||||
// Nothing here touches the network: global.fetch is replaced
|
||||
// Fixtures are static local objects modelled on the two real attacks cited in
|
||||
// the README. 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,12 +46,11 @@ const { debugFetch } = require("../src/shared/log");
|
||||
const { state } = require("../src/shared/state");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Addresses and hashes from two real attacks. README, Scam protection, has the
|
||||
// filters that stop them.
|
||||
// Addresses and hashes from the two attacks documented in README.md:730-814.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The fake "Ethereum" token with symbol "ETH" and zero holders. See "Known
|
||||
// symbol spoofing" and "Low-holder tokens" in README, Scam protection.
|
||||
// The fake "Ethereum" token with symbol "ETH" and zero holders
|
||||
// (README.md:735-750).
|
||||
const FAKE_ETH_CONTRACT = "0xd05339f9ea5ab9d9f03b9d57f671d2abd1f55c82";
|
||||
// The fraudulent Transfer event it emitted.
|
||||
const FAKE_ETH_TRANSFER_HASH =
|
||||
@@ -63,8 +62,7 @@ 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. See "Dust transactions" in
|
||||
// README, Scam protection.
|
||||
// The second wave: a real 1 gwei native transfer (README.md:800-808).
|
||||
const DUST_TX_HASH =
|
||||
"0x2708ebddfb9b5fa3f7a89d3ea398ef9fd8771b83ed861ecb7c21cd55d18edc74";
|
||||
const DUST_SENDER_LOOKALIKE = "0xc3c6b3b4402bd78a9582ab6b00e747769344f37e";
|
||||
@@ -81,8 +79,7 @@ const NOVEL_SPAM_SYMBOL = "SPAMTKN";
|
||||
// An ordinary counterparty for legitimate-transaction fixtures.
|
||||
const ORDINARY_PEER = "0x5aa0f9f1e0a1d0e0e5c1e7ce3b7dbbe9c19f0a11";
|
||||
|
||||
// The documented default settings (README, Settings; DEFAULT_STATE in
|
||||
// src/shared/persistedState.js).
|
||||
// The documented default settings (README.md:810-814, state.js:24-27).
|
||||
const DEFAULT_FILTERS = {
|
||||
hideSpoofedSymbols: true,
|
||||
hideLowHolderTokens: true,
|
||||
@@ -223,7 +220,7 @@ describe("token list assumptions the fixtures rely on", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the real attacks", () => {
|
||||
describe("the real attacks documented in README.md:730-814", () => {
|
||||
test('the fake "Ethereum"/"ETH" token transfer is filtered by default', () => {
|
||||
const attack = fakeEthTokenTransfer();
|
||||
const result = filterTransactions([attack], filters());
|
||||
@@ -402,8 +399,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 that stops the fake "ETH"
|
||||
// attack.
|
||||
// filter is independent, and this is the one the README calls out as the
|
||||
// defense against the fake "ETH" attack.
|
||||
test("the check still runs when the other three filters are off", () => {
|
||||
const result = filterTransactions(
|
||||
[fakeEthTokenTransfer()],
|
||||
|
||||
+4
-416
@@ -101,9 +101,6 @@ 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
|
||||
@@ -118,7 +115,6 @@ 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";
|
||||
@@ -618,8 +614,8 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
|
||||
const decoded = {
|
||||
name: "Transfer",
|
||||
details: [
|
||||
{ label: "Token", value: TOKEN, address: TOKEN, isToken: true },
|
||||
{ label: "Recipient", value: RECIPIENT, address: RECIPIENT },
|
||||
{ label: "Token", address: TOKEN, isToken: true },
|
||||
{ label: "Recipient", address: RECIPIENT },
|
||||
],
|
||||
};
|
||||
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
|
||||
@@ -637,7 +633,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 break-words">TDOL</div>',
|
||||
'<div class="font-bold">TDOL</div>',
|
||||
);
|
||||
}
|
||||
state.networkTrackedTokens = {
|
||||
@@ -645,9 +641,7 @@ describe("SuccessTx and ErrorTx for a Sepolia transaction with mainnet active",
|
||||
};
|
||||
const decoded = {
|
||||
name: "Transfer",
|
||||
details: [
|
||||
{ label: "Token", value: TOKEN, address: TOKEN, isToken: true },
|
||||
],
|
||||
details: [{ label: "Token", address: TOKEN, isToken: true }],
|
||||
};
|
||||
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
|
||||
txStatus.showWait({ ...sepoliaTx, decoded }, TX_HASH);
|
||||
@@ -678,412 +672,6 @@ 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("<b>400.0000</b> 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("<b>400.0000</b> 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
|
||||
|
||||
+21
-357
@@ -2474,11 +2474,10 @@ 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 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.
|
||||
// Every row reads at least the ETH that can leave the wallet. The two
|
||||
// rows marked "reads as on next" read the same on next. On next every
|
||||
// other row added that ETH to the Amount line, or read it as an exact
|
||||
// amount.
|
||||
//
|
||||
// ADDRESS_THIS is the router on every release.
|
||||
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
|
||||
@@ -2626,7 +2625,7 @@ describe("uniswap decoder", () => {
|
||||
],
|
||||
lines: [
|
||||
["Amount", "600.0000 USDT"],
|
||||
...wrapLines("The router"),
|
||||
...wrapLines("The swap step that spends it"),
|
||||
...ethLines("Up to 1.0000 ETH"),
|
||||
],
|
||||
},
|
||||
@@ -2675,7 +2674,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",
|
||||
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 exact-out paid in ETH with a SETTLE_ALL of at most 3 ETH, reads as on next",
|
||||
commands: [0x0b, 0x0c, 0x10],
|
||||
inputs: [
|
||||
WRAP_INTO_ROUTER,
|
||||
@@ -2698,7 +2697,10 @@ describe("uniswap decoder", () => {
|
||||
],
|
||||
),
|
||||
],
|
||||
lines: [...wrapLines("The router"), ...ethLines(UNDECODED)],
|
||||
lines: [
|
||||
...wrapLines("The swap step that spends it"),
|
||||
...ethLines(UNDECODED),
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "a WRAP_ETH of 1 ETH into the router, a V3 exact-out of at most 1 WETH paid from the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
|
||||
@@ -2758,9 +2760,8 @@ describe("uniswap decoder", () => {
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/721 — a WRAP_ETH that
|
||||
// does not set the input side takes the amount it states from the
|
||||
// sender. On next no line gave that ETH. The rows marked "reads as on
|
||||
// next" state no amount to take from the sender. Uniswap's own routes
|
||||
// wrap CONTRACT_BALANCE into the router after their first step, as the
|
||||
// last row does.
|
||||
// next" take nothing from the sender. Uniswap's own routes wrap
|
||||
// CONTRACT_BALANCE after their first step, as the last two rows do.
|
||||
const CONTRACT_BALANCE = 2n ** 255n;
|
||||
const WRAP_TO_OTHER = encodeWrapEth(OTHER_ADDR, ONE_ETH);
|
||||
test.each([
|
||||
@@ -2785,7 +2786,7 @@ describe("uniswap decoder", () => {
|
||||
inputs: [V3_600, WRAP_INTO_ROUTER],
|
||||
lines: [
|
||||
["Amount", "600.0000 USDT"],
|
||||
...wrapLines("The router"),
|
||||
...wrapLines("The swap step that spends it"),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2794,7 +2795,7 @@ describe("uniswap decoder", () => {
|
||||
inputs: [V3_600, encodeWrapEth(ROUTER_ADDR, ONE_ETH)],
|
||||
lines: [
|
||||
["Amount", "600.0000 USDT"],
|
||||
...wrapLines("The router"),
|
||||
...wrapLines("The swap step that spends it"),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2837,9 +2838,7 @@ describe("uniswap decoder", () => {
|
||||
],
|
||||
lines: [
|
||||
["Amount", "1.0000 ETH"],
|
||||
["Moved from the sender", "Up to 1.0000 ETH"],
|
||||
["Token moved", "ETH"],
|
||||
["Moved to", "The router"],
|
||||
...ethLines("Up to 1.0000 ETH"),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -2877,6 +2876,12 @@ 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],
|
||||
@@ -2917,347 +2922,6 @@ 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) =>
|
||||
|
||||
Reference in New Issue
Block a user