Author SHA1 Message Date
sneak d7f0e241ff fix: Blockscout and Etherscan lookups are off until the user turns them on (closes #668)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Out of the box the wallet sent the user's addresses to Blockscout for history
and token lookups, and each recipient to Etherscan for its label. Two settings,
"Use Blockscout" and "Check recipients on Etherscan", now gate those requests
where they are made. Both are off in a new profile and offered unticked on
Welcome and in Settings. While one is off, the screens that relied on it say so
in a plain line naming the setting; after either changes in Settings, Back
draws the screen it lands on again. ENS lookups ask only the RPC endpoint, so a
name resolved off-chain does not resolve. README and the user guide say what is
contacted by default and what each setting adds.

Model: opus-5-5
2026-10-21 16:11:02 +00:00
clawbot c316d9ee78 fix: tracked tokens' balances are read through the RPC endpoint (closes #669)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
refreshBalances() now reads the balance of every token tracked on the
active network, at every address, with balanceOf through the same
provider as the ETH balance. The entry takes its holder count and
decimals from Blockscout's entry for the token in the same refresh, or
keeps them from before while Blockscout fails. A failed balanceOf keeps
the token's previous entry, or takes Blockscout's entry when there is
none, and is logged. Blockscout's answer still adds the tokens the user
has not tracked, as before.

The browser test for a hostile symbol now untracks the stub token
first, because a tracked token is listed under the symbol it was added
with.

Model: opus-5-5
2026-10-21 14:16:26 +02:00
clawbot b9bb1569fd fix: a V4 step that spends some of its output currency shows no Min. received (closes #574)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A V4 step's Min. received was the sum of the minimums of the swap
actions that buy its output currency, even when another swap action in
the same step spends some of that currency. The user then receives
less than that figure. decodeV4Swap() now gives no minimum when a swap
action in the step also spends the output currency, as it already does
for other minimums it cannot state. A multi-hop action counts only the
currency it spends and the one it ends with, so a path through an
intermediate currency is not read as spending it.

Model: opus-5-5
2026-10-21 07:34:26 +02:00
clawbot 4be625dc06 fix: an allowance to another spender leaves no Amount figure for its token and no Min. received (closes #658)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Code that a step runs, such as a V4 pool's hook, can call a spender other
than the router, which can then take up to its allowance from the user's
wallet before the transaction ends. The Amount figure and Min. received took
no account of it.

In decode(), an allowance of Token In to a spender other than the router
now leaves no Amount line, as an unread permit already did, so the swap
steps' own takes get lines of their own. Any allowance to another spender,
or a permit or batch the decoder cannot read, makes the minimum read
Min. paid out. Permits to the router read as before.

Model: opus-5-5
2026-10-21 06:24:51 +02:00
clawbot e2965469ba fix: with no Amount line, the user's takes the total adds up get lines of their own (closes #659)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
When a step this decoder cannot read, an unreadable permit, or a take
of Token In with an unknown amount held back the Amount line, the
amount the step that sets the input side states, and each later take
of Token In added to it, appeared on no line, while other takes kept
their own Moved from the sender lines. Those takes now get the same
lines, first. The first step's amount gets them only when the user
pays that step (a V2 or V3 step with payerIsUser true, a V4 step the
user pays some of its input token, or a WRAP_ETH): one paid from the
router takes nothing from the wallet. Routes with an Amount line read
as before.

Model: opus-5-5
2026-10-21 05:15:21 +02:00
clawbot 2691702f80 fix: an open popup follows a network switch the background saves (closes #650)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
When the background saves a network switch after the user approves a
site's request, or another popup page saves one, an open popup takes the
new network from the stored record inside a save, holding the lock, and
checks the record once at start-up for a switch saved before it
listened. The network it takes is not counted as the popup's own change,
even when that save fails to write. It then leaves the page as a switch
in Settings does. A switch in Settings is now made inside its own save
too, so one picked while a follow waits for the lock is made after it.
The popup never switches the network itself.

Model: opus-5-5
2026-10-21 04:26:51 +02:00
clawbot 5f2b606754 fix: a PERMIT2_TRANSFER_FROM before a permit keeps its amount from standing as the Amount figure (closes #655)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A PERMIT2_TRANSFER_FROM that runs before a PERMIT2_PERMIT of the same
token moves it under whatever allowance the router already had, so it
can move more than the permit's amount. The permit's amount still stood
as the Amount figure, which could then read lower than what leaves the
wallet.

decode() now counts such a transfer, into the router or anywhere else,
among the steps before the permit that took its token, so the permit no
longer stands: the Amount line gives the total the steps take, as with
no permit, and the permit gets its own lines. A transfer after the
permit, or of another token before it, still lets it stand; tests cover
both. README Transaction Decoding states the condition.

Model: opus-5-5
2026-10-21 03:16:51 +02:00
clawbot f53b503b15 fix: a PERMIT2_PERMIT_BATCH, and a permit that cannot be read, state what they allow (closes #652)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A PERMIT2_PERMIT_BATCH was never read, and a PERMIT2_PERMIT whose input
could not be read got no lines, so the approval screen showed nothing of
the allowances either grants.

Each allowance of a batch now gets the three lines a permit whose amount
does not stand gets: Permit2 allowance, Allowance token and Allowance
spender. A permit or batch that cannot be read gets one Permit2 allowance
line reading Unknown (could not be decoded) and leaves no Amount line.
Any batch in the transaction keeps a PERMIT2_PERMIT's amount from
standing. A batch pays nobody, so it no longer adds a Paid to line.
README Transaction Decoding and the TxApproval element list match.

Model: opus-5-5
2026-10-21 01:41:35 +02:00
clawbot 45aead6340 fix: only a permit whose amount is the Amount figure names Token In (closes #370)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A PERMIT2_PERMIT named Token In whether or not its amount stood as the
Amount figure, so the approval screen could show a permit's token, or an
allowance, where the swap's own input belonged.

Now only a permit whose amount stands names Token In. Every other
permit, before the swap steps or after them, leaves Token In and Amount
as they read with no permit and gets three lines of its own: Permit2
allowance, Allowance token and Allowance spender. A V3 swap step's
tokens are checksummed, as every other decoded address is, so a token's
address reads the same on every line. README Transaction Decoding, its
unknown token scale passage and the TxApproval element list match.

Model: opus-5-5
2026-10-21 00:56:41 +02:00
60 changed files with 3470 additions and 433 deletions
+287 -129
View File
@@ -14,14 +14,18 @@ None of the common alternatives work on Firefox.
Hence, a minimally viable ERC20 browser wallet/signer that works cross-platform.
Everything you need, nothing you don't. We import as few libraries as possible,
don't implement any crypto, and don't send user-specific data anywhere but a
(user-configurable) Ethereum RPC endpoint (which defaults to a public node). The
extension contacts three user-configurable services: the configured RPC node for
blockchain interactions, a public CoinDesk API (no API key) for realtime price
information, and a Blockscout block-explorer API for transaction history and
token balances. It also performs best-effort Etherscan address label lookups
during transaction confirmation. A community-maintained phishing domain
blocklist is built into the extension at build time and checked locally; nothing
is fetched for it at runtime.
(user-configurable) Ethereum RPC endpoint (which defaults to a public node)
unless the user turns on one of the two lookups below. Out of the box the
extension contacts the configured RPC node for blockchain interactions and a
public CoinDesk API (no API key) for realtime price information, which is sent
token symbols and nothing else. Two more services are sent none of the user's
data until the user turns them on, on the Welcome screen or in Settings: a
Blockscout block-explorer API for transaction history, the tokens the user has
not tracked and transaction details ("Use Blockscout"), and best-effort
Etherscan address label lookups during transaction confirmation ("Check
recipients on Etherscan"). A community-maintained phishing domain blocklist is
built into the extension at build time and checked locally; nothing is fetched
for it at runtime.
In the extension is a hardcoded list of the top ERC20 contract addresses. You
can add any ERC20 contract by contract address if you wish, but the hardcoded
@@ -422,6 +426,24 @@ It also covers the red banner on the Settings screen: switching the network
there to Sepolia must put up the `[TESTNET]` banner, and switching back to
mainnet must take it down, both while Settings is still on screen.
It also covers what a new install sends to Blockscout and Etherscan
([#668](https://git.eeqj.de/sneak/AutistMask/issues/668)). The route handler
records every request the extension makes, from its pages and its background
worker, from when the harness installs it, before the first popup page opens; a
request the background worker makes while it starts up, before then, is not
recorded. Welcome must offer "Use Blockscout" and "Check recipients on
Etherscan" unticked. With neither changed, Home, AddressDetail, AddressToken and
ConfirmTx must show the lines saying what is off; the popup is then closed until
the background has run a balance refresh of its own, and not one recorded
request may have gone to Blockscout or Etherscan. "Use Blockscout" is then
ticked from AddressDetail's gear: on "Back", AddressDetail must ask Blockscout
for its history and no longer say it is off, and Blockscout must then be asked
for the token balances. "Check recipients on Etherscan" is ticked from
ConfirmTx's gear: on "Back", ConfirmTx must ask Etherscan about the recipient
and show the label the stub page gives it. Both stay on for the rest of the run.
TransactionDetail with "Use Blockscout" off is not driven here, since no history
row leads to it then; the unit tests cover it.
It also covers the confirmation screen, for both a native ETH send and an ERC-20
send: Send disabled while the fee estimate is in flight, enabled once it lands,
the fee block quoting the expected cost and the reserve separately, the distinct
@@ -461,13 +483,15 @@ address, the transaction assertions run against the raw signed transaction
captured at `eth_sendRawTransaction` rather than against anything the extension
reported, rejecting each prompt is required to return a rejection to the page
rather than hang or resolve, a network switch request is required to leave the
stored network unchanged and send no `chainChanged` until it is approved, a
network switch in Settings is required to send the page `chainChanged` with the
new chain id, a prompt raised while another approval window has focus is
required to open a window of its own, and the password is required to be absent
from every message the approval window sends to the background — with the
message that would carry it required to be present, so that check cannot pass by
observing nothing. That last one is the standing floor under
stored network unchanged and send no `chainChanged` until it is approved, the
popup open all along is required to show the `[TESTNET]` banner once a switch to
Sepolia is approved and to drop it once the switch back is, a network switch in
Settings is required to send the page `chainChanged` with the new chain id, a
prompt raised while another approval window has focus is required to open a
window of its own, and the password is required to be absent from every message
the approval window sends to the background — with the message that would carry
it required to be present, so that check cannot pass by observing nothing. That
last one is the standing floor under
[#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
The limits of that coverage and of the rest of the Chrome suite, none of them
@@ -603,9 +627,10 @@ suite), wallet creation through the UI, the Add Token screen, and the dApp round
trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain`
rejected and then approved, `eth_sendTransaction`, and a closed approval window
rejecting with EIP-1193 4001 — driven through the real content script,
background page and approval windows. The `personal_sign` prompt is also
required to fit its window without scrolling sideways, with the extension's
pages at 170% zoom.
background page and approval windows. The popup open all along is required to
show the `[TESTNET]` banner once the switch to Sepolia is approved and to drop
it once the switch back is. The `personal_sign` prompt is also required to fit
its window without scrolling sideways, with the extension's pages at 170% zoom.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and
@@ -842,7 +867,8 @@ src/
views/ — one JS module per screen (home, send, approval, etc.)
shared/ — modules used by both popup and background
alarms.js — recurring background jobs (extension alarms API)
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
balances.js — ETH and tracked token balances via RPC, others via
Blockscout while "Use Blockscout" is on
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
ens.js — ENS forward/reverse resolution (popup only)
holders.js — holder-count parsing and the low-holder rule
@@ -1170,8 +1196,8 @@ fetched, without that cross-address check, and shows the row's figure.
**Decoded amount lines on the transaction approval screen:** the `Amount` line
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless
only the user is paid, see Transaction Decoding), do not always read as a
number. They can read:
only the user is paid and no allowance goes to a spender other than the router,
see Transaction Decoding), do not always read as a number. They can read:
- A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known:
truncated to four decimals, with the floor and the zero cases described above.
@@ -1240,7 +1266,11 @@ only when no earlier step set it. It finds each side by three rules:
is their sum; in any other case the step gives no `Amount`. The decoder reads
no amount from an exact-out action, and an open delta is not a stated amount.
Its `Min. received` is found the same way, from the `amountOutMin` of the
actions that buy the output currency.
actions that buy the output currency, except that the step gives none when a
swap action in it also spends the output currency, because the step then does
not pay out all that those actions buy. A step that buys at least 0.5 WETH
with USDT and swaps 0.3 WETH of it for USDC, with WETH as its output side,
gives no `Min. received`.
3. If any swap action in the step could not be read, only `SETTLE`,
`SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and the step gives no
`Amount` or `Min. received`. A side they did not name is unknown, and the
@@ -1372,25 +1402,37 @@ The core hierarchy is **Wallets → Addresses**:
- The user can have multiple wallets, each with multiple addresses (HD) or a
single address (key).
Which tokens an address shows is decided by `fetchTokenBalances()` in
`src/shared/balances.js`, from the Blockscout `token-balances` response, so
tokens do appear without the user adding them. An ERC-20 is shown when its
balance is nonzero and it is in the bundled known-token list, is tracked by the
user, or has 1,000 or more holders; a token claiming a symbol from the bundled
list from any other contract address is always dropped, and so is any token
claiming a symbol that belongs to the native asset and therefore has no
legitimate contract at all (`"ETH"`, and every network's `nativeCurrency`, such
as `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
Which tokens an address shows is decided on every refresh by `refreshBalances()`
in `src/shared/balances.js`. The balance of each token tracked on the active
network is read with `balanceOf` through the RPC endpoint, for every address.
Its holder count and `decimals` are those of Blockscout's entry for it in the
same refresh, kept from before while Blockscout fails. A read that fails keeps
that token's previous entry, as a failed ETH balance keeps its figure, or takes
Blockscout's entry from the same refresh when there is none. Every other token
comes from the Blockscout `token-balances` response (`fetchTokenBalances()`), so
tokens do appear without the user adding them, and they keep their previous
figures while Blockscout fails. The refresh asks Blockscout only while the user
has "Use Blockscout" on. While it is off, as it is until the user turns it on, a
tracked token's entry has no holder count and no `decimals` of Blockscout's, so
its balance is stated at the scale the bundled list or the tracked token
records, and no other token is listed, those from earlier refreshes included. A
tracked token is shown when its balance is nonzero, and any other ERC-20 when
its balance is nonzero and it is in the bundled known-token list or has 1,000 or
more holders. A token claiming a symbol from the bundled list from any other
contract address is always dropped, tracked or not, and so is any token claiming
a symbol that belongs to the native asset and therefore has no legitimate
contract at all (`"ETH"`, and every network's `nativeCurrency`, such as
`"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
tokens with fewer than 1,000 holders" setting governs the transaction history
and the send-screen token selector, not this list. A token's holder count is
unknown when the explorer reports none, or reports anything other than a whole
number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()`
in `src/shared/holders.js`). This list does not take an unknown count as 1,000
or more, so such a token is shown only when it is on the bundled list or
tracked. `fetchTokenBalances()` stores every nonzero holding of a token it
admits, however small, but a holding below 0.000001 is left out of the balance
lists, the send-screen token selector, the address total and the remove-address
warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
tracked. Every nonzero holding of a token shown is stored exactly, however
small, but a holding below 0.000001 is left out of the balance lists, the
send-screen token selector, the address total and the remove-address warning
(`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked
tokens with a zero balance are listed as well while "Show tracked tokens with
zero balance" is on.
@@ -1562,22 +1604,34 @@ renders as it goes, and `viewRouter.js` records every screen that reaches
more — rendering it a second time would re-fetch and overwrite what it holds,
such as an edit typed into Settings and not yet saved. Home is the one screen
"Back" always re-renders, so the wallet list reflects anything that changed
while the user was away from it.
while the user was away from it. Changing "Use Blockscout" or "Check recipients
on Etherscan" in Settings makes every screen but Settings count as not yet
rendered, so "Back" renders the screen it lands on under the new setting; the
stack is left as it is.
A network switch in Settings leaves every screen beneath Settings showing the
previous network, so after it "Back" behaves as in a reopened popup: every
screen on the stack counts as not yet rendered, and the stack is cut at the
first screen that cannot be rendered from saved state, such as Send or AddToken,
or at AddressToken for any token but the native one, because that token was
picked on the previous network. The cut stack is saved with the switch, so a
popup closed straight after it and reopened onto Settings lands in the same
place. "Back" then renders the screen it lands on for the new network, and
anything typed there and not saved, which belonged to the previous network, is
dropped. ConfirmTx, WaitTx, SuccessTx, ErrorTx and TransactionDetail are
rendered instead for the network of the transaction they show: the network it
was reviewed on for ConfirmTx, and the network it was sent on for the others.
Without a wallet the stack is not cut, because it starts at Welcome, which shows
nothing from a network.
A network switch leaves every screen on the page showing the previous network.
The user makes it in Settings, or approves a site's request and the background
saves it. Any other popup open meanwhile takes the switch from the stored
record, even one saved while it was opening, and never switches the network
itself. A switch the user picks in its Settings while it takes one is made after
it, not overwritten by it. An approval window does not take the switch. After
the switch, whichever way the page got it, "Back" behaves as in a reopened
popup: every screen on the stack counts as not yet rendered, and the stack is
cut at the first screen that cannot be rendered from saved state, such as Send
or AddToken, or at AddressToken for any token but the native one, because that
token was picked on the previous network and is no longer selected. The cut
stack is saved with the popup's new network, so a popup closed straight after
and reopened lands in the same place. The screen on top is drawn again for the
new network: Settings in place, and any other screen as a reopened popup draws
it, so Home takes the place of one that cannot be rendered from saved state or
of AddressToken for a token no longer selected. "Back" then renders the screen
it lands on for the new network, and anything typed there and not saved, which
belonged to the previous network, is dropped. ConfirmTx, WaitTx, SuccessTx,
ErrorTx and TransactionDetail are rendered instead for the network of the
transaction they show: the network it was reviewed on for ConfirmTx, and the
network it was sent on for the others. Without a wallet the stack is not cut and
only Settings is drawn again, because the stack starts at Welcome, and Welcome
and AddWallet show nothing from a network.
Every screen that holds secret material in the page registers a cleanup with
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
@@ -1596,8 +1650,15 @@ view would leave a wallet one click from deletion.
screen in that case.
- **Elements**:
- "Welcome! To get started, add a wallet." text
- A well saying that the wallet sends the user's data only to the Ethereum
RPC endpoint unless one of the two settings in it is turned on, here or
later in Settings: the "Use Blockscout" and "Check recipients on
Etherscan" checkboxes, the same two as in Settings, each with its sentence
on what it sends and to whom. Both are unticked until the user ticks them
- "Add wallet" button
- **Transitions**:
- Ticking or unticking either checkbox → stores the setting, as Settings
does (no screen change)
- "Add wallet" → **AddWallet**
#### Home (`main`)
@@ -1618,6 +1679,8 @@ view would leave a wallet one click from deletion.
holding more than one address); the ENS name, if resolved, is below it;
then the full address on a row of its own, followed by the address USD
total and a balance line for ETH and for each token shown for that address
- While "Use Blockscout" is off, a line under the wallet list: "Only tracked
tokens are listed, because "Use Blockscout" is off in Settings."
- "Recent Transactions": up to 25 transactions merged across every address
of every wallet, deduplicated by hash and filtered. Each row is three
lines: age and direction, then the counterparty's colour dot (with our own
@@ -1625,7 +1688,11 @@ view would leave a wallet one click from deletion.
counterparty's full address on a row of its own. A contract creation has
no counterparty: its second line is the amount alone and its third line
says "This transaction creates a new contract. It has no recipient." The
transaction lists on AddressDetail and AddressToken draw the same rows
transaction lists on AddressDetail and AddressToken draw the same rows.
The history comes from Blockscout. While "Use Blockscout" is off,
Blockscout is not asked, and this list and those two show the one line "No
transaction history is shown, because "Use Blockscout" is off in
Settings."
- "Add additional wallet..." link at bottom
- **Transitions**:
- Tap address row → sets the active address and broadcasts
@@ -1693,10 +1760,13 @@ view would leave a wallet one click from deletion.
- Full address (color dot, etherscan link, tap to copy)
- USD total for address (see [Partial USD totals](#partial-usd-totals))
- Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal
places, USD inline). Each balance row is clickable → **AddressToken**
places, USD inline). Each balance row is clickable → **AddressToken**.
While "Use Blockscout" is off, the line Home has under its wallet list
follows it
- Send / Receive / + Token buttons and a "···" menu button
- "···" dropdown containing a single "Export Private Key" entry
- Transaction list (with ENS resolution for counterparties)
- Transaction list (with ENS resolution for counterparties), or the line
Home's list shows while "Use Blockscout" is off
- **Transitions**:
- Tap balance row → **AddressToken** (for that token)
- "Send" → **Send** (refuses with a flash message on a zero balance)
@@ -1765,8 +1835,10 @@ view would leave a wallet one click from deletion.
etherscan link) plus name, symbol, decimals, holder count and project
website where known. The "Holders:" row is left out, not shown as 0, when
the token's balance-list entry has no holder count: the explorer did not
report a readable one, or the token is not in the balance list
- Token-filtered transaction list (only this token's transfers)
report a readable one, "Use Blockscout" is off, or the token is not in the
balance list
- Token-filtered transaction list (only this token's transfers), or the line
Home's list shows while "Use Blockscout" is off
- **Transitions**:
- "Send" → **Send** (token locked: the dropdown is replaced by a static
symbol and contract address)
@@ -1852,7 +1924,12 @@ view would leave a wallet one click from deletion.
recipient that has never sent a transaction and holds no ETH, recipient is
a contract, burn address, and an Etherscan phishing/scam label. An address
that has received ETH but never sent a transaction still holds that ETH,
so it gets no warning
so it gets no warning. The Etherscan label check is made only while "Check
recipients on Etherscan" is on. While it is off, the Etherscan box is left
out and a line in its place says "This address was not checked on
Etherscan, because "Check recipients on Etherscan" is off in Settings."
Which of the two is there is known when the screen is drawn, so neither
moves anything later
- Errors (an invalid amount), plus five reserved error boxes, so the balance
and the fee arriving later move nothing — the amount exceeds the balance
(for a token whose scale is unknown it says the token's balance is
@@ -1898,8 +1975,8 @@ view would leave a wallet one click from deletion.
are that network's, so a testnet transaction shows none, its From, To and
token contract links open that network's explorer, the balance read, the
fee estimate and the contract and never-sent recipient checks ask that
network's RPC endpoint, and the Etherscan label check asks that network's
explorer
network's RPC endpoint, and the Etherscan label check, when it is made,
asks that network's explorer
- "Sign & Send" (correct password) when the gas limit, a fee per gas or the
total fee the RPC endpoint fills in at signing is over its bound in
`assertWithinCeilings()` (`src/shared/approvalVerify.js`) → nothing is
@@ -2018,7 +2095,12 @@ view would leave a wallet one click from deletion.
AddressToken. The screen describes the transaction on the network it was sent
on, whichever network is active: its block, network details, decoded details
and raw data are read from that network's Blockscout, the one Settings has for
it, and its etherscan links open that network's explorer.
it, and its etherscan links open that network's explorer. They are read only
while "Use Blockscout" is on. The screen can still be shown with it off, when
the user opens Settings from it, turns the setting off and presses Back; then
Blockscout is not asked, and a line in their place says "No block, decoded
details, network details or raw data are shown, because "Use Blockscout" is
off in Settings."
- **Elements** (grouped into logical blocks using light well containers; field
labels are self-explanatory so groups have no headings):
- "Transaction" heading, "Back" button
@@ -2101,8 +2183,18 @@ view would leave a wallet one click from deletion.
already active changes nothing and sends nothing
- Ethereum RPC: endpoint URL input + "Save" button (validated against
`eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against
`/stats` before being saved)
- Blockscout API: a "Use Blockscout" checkbox, unticked until the user ticks
it, with a sentence saying it sends the user's addresses, and the hash of
each transaction opened, to the Blockscout API below, for transaction
history, tokens not tracked, and transaction details; then the endpoint
URL input + "Save" button (validated against `/stats` before being saved,
a request the user presses for that carries no user data, made whether or
not the checkbox is ticked)
- Etherscan: a "Check recipients on Etherscan" checkbox, unticked until the
user ticks it, with a sentence saying it sends the address about to be
sent to, to Etherscan, to look for a phishing or scam label on it. Both
checkboxes are one setting each for every network, and Welcome offers the
same two
- Token Spam Protection:
- "Hide fake tokens impersonating a known symbol" checkbox
- "Hide tokens with fewer than 1,000 holders" checkbox
@@ -2126,6 +2218,9 @@ view would leave a wallet one click from deletion.
- Debug: hidden until revealed, then an "Enable debug mode" checkbox that
turns on the red banner and verbose logging
- **Transitions**:
- Ticking or unticking "Use Blockscout" or "Check recipients on Etherscan" →
stores the setting (no screen change). The next balance refresh and every
screen drawn after that follow it
- "+ Add wallet" → **AddWallet**
- "+ Add token" → **SettingsAddToken**
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
@@ -2428,10 +2523,11 @@ view would leave a wallet one click from deletion.
- Decoded action (if calldata is recognized): action name, token details,
amounts, what each swap step spends that the `Amount` line does not give,
what each `PERMIT2_TRANSFER_FROM` moves and where to, what each
`PERMIT2_PERMIT` whose amount is not the `Amount` figure lets its spender
take, steps, deadline, and unless a swap pays only the From address, a
`Paid to` line for each other address it pays, or saying that a token
stays in the router or who it pays is unknown (see Transaction Decoding)
`PERMIT2_PERMIT` whose amount is not the `Amount` figure, and each
`PERMIT2_PERMIT_BATCH`, lets its spender take, steps, deadline, and unless
a swap pays only the From address, a `Paid to` line for each other address
it pays, or saying that a token stays in the router or who it pays is
unknown (see Transaction Decoding)
- From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx
@@ -2641,16 +2737,27 @@ view would leave a wallet one click from deletion.
### External Services
AutistMask is not a fully self-contained offline tool. It necessarily
communicates with three external services to function as a wallet:
AutistMask is not a fully self-contained offline tool. By default it sends the
user's data to one service only, the Ethereum RPC endpoint, and asks one more,
the CoinDesk price API, for prices with nothing but token symbols. Two more
services are sent none of the user's data until the user turns on the setting
for each, on the Welcome screen or in Settings: Blockscout ("Use Blockscout")
and Etherscan ("Check recipients on Etherscan"). Both settings are off in a new
profile, and each is one setting for every network.
Contacted by default:
- **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query
balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`),
balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`), among
them the balance of every tracked token at every address (`balanceOf`),
estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
broadcast transactions (`eth_sendRawTransaction`), and check transaction
receipts. The default endpoint is a public RPC (configurable by the user to
any endpoint they prefer, including a local node). By default the extension
talks to `https://ethereum-rpc.publicnode.com`.
receipts. ENS lookups, of a name typed into Send and of the names shown for
the user's addresses, are made through it alone, so a name whose resolver says
it is resolved off-chain does not resolve. The default endpoint is a public
RPC (configurable by the user to any endpoint they prefer, including a local
node). By default the extension talks to
`https://ethereum-rpc.publicnode.com`.
- **Data sent**: Ethereum addresses, transaction data, contract call
parameters. The RPC endpoint can see all on-chain queries and submitted
transactions.
@@ -2662,13 +2769,36 @@ communicates with three external services to function as a wallet:
- **Data sent**: Token symbol strings only (e.g. "ETH", "USDC"). No
addresses or user-specific data.
- **Blockscout block-explorer API**: Used to fetch transaction history (normal
transactions and ERC-20 token transfers), ERC-20 token balances, and token
holder counts (for spam filtering). The default endpoint is
`https://eth.blockscout.com/api/v2` (configurable by the user in Settings).
- **Data sent**: Ethereum addresses. Blockscout receives the user's
addresses to query their transaction history and token balances. No
private keys, passwords, or signing operations are sent.
Sent the user's data only while the user has turned the setting on:
- **Blockscout block-explorer API** ("Use Blockscout"): Used to fetch
transaction history (normal transactions and ERC-20 token transfers) for Home,
AddressDetail and AddressToken, the balances of the ERC-20 tokens the user has
not tracked, token holder counts (for spam filtering), and a transaction's
block, network details and calldata for TransactionDetail. The default
endpoint is `https://eth.blockscout.com/api/v2` (configurable by the user in
Settings). While the setting is off, those screens say in a plain line what is
not shown and that this setting turns it on, and only tracked tokens are
listed, their balances read through the RPC endpoint. The endpoint check
Settings makes when the user presses "Save" beside the URL asks only for
`/stats` and carries no user data; it is made whether or not the setting is
on.
- **Data sent**: Ethereum addresses, and the hash of each transaction the
user opens. Blockscout receives the user's addresses to query their
transaction history and token balances. No private keys, passwords, or
signing operations are sent.
- **Etherscan address labels** ("Check recipients on Etherscan"): When
confirming a transaction, the extension performs a best-effort lookup of the
recipient address on Etherscan to check for phishing/scam labels. This is a
direct page fetch with no API key; the user's browser makes the request. While
the setting is off, ConfirmTx says that no Etherscan check was made.
- **Data sent**: the recipient address. The user's own addresses are not
sent.
The etherscan links on the screens open a page in the browser when the user
clicks one. They are not requests the wallet makes, and nothing is fetched from
them until then.
What the extension does NOT do:
@@ -2677,16 +2807,9 @@ What the extension does NOT do:
- No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer
In addition to the three user-configurable services above (RPC endpoint,
CoinDesk price API, and Blockscout API), AutistMask also contacts:
- **Etherscan address labels**: When confirming a transaction, the extension
performs a best-effort lookup of the recipient address on Etherscan to check
for phishing/scam labels. This is a direct page fetch with no API key; the
user's browser makes the request.
Users who want maximum privacy can point the RPC and Blockscout URLs at their
own self-hosted instances (price fetching can be disabled in a future version).
Users who want maximum privacy can point the RPC URL at their own node, and,
when they turn Blockscout on, the Blockscout URL at their own instance (price
fetching can be disabled in a future version).
### Dependencies
@@ -2868,8 +2991,10 @@ logged.
- Add multiple addresses within an HD wallet
- Manage multiple wallets simultaneously
- View ETH balance per address
- View ERC-20 token balances (tokens on the bundled known-token list, tokens
with 1,000 or more holders, and tokens the user adds by contract address)
- View ERC-20 token balances (tokens the user adds by contract address, and,
while "Use Blockscout" is on, tokens on the bundled known-token list and
tokens with 1,000 or more holders)
- View transaction history and transaction details, while "Use Blockscout" is on
- Send ETH to an address
- Send ERC-20 tokens to an address
- Receive ETH/tokens (display address, copy to clipboard, QR code)
@@ -3184,8 +3309,9 @@ Currently supported:
`Min. paid out`, because the swap's minimum includes the fee. The minimum
reads `Min. received` only when some step pays the transaction's sender (the
From address on TxApproval, the sender the explorer reports on
TransactionDetail) and no `Paid to` line is shown. `Paid to` lines after
`Token Out` name each other address paid; one reads
TransactionDetail), no `Paid to` line is shown, and no permit or batch grants
an allowance to a spender other than the router (see below). `Paid to` lines
after `Token Out` name each other address paid; one reads
`The bridge, which pays the deposit's recipient on another network` for a
bridge deposit; one reads, for example,
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
@@ -3224,38 +3350,55 @@ Currently supported:
`Unlimited` included, stands as the `Amount` figure in place of the total,
which it does only in the plain shape of Uniswap's own routes: it comes before
the step that sets the input side, it is the only `PERMIT2_PERMIT` in the
transaction, its spender is the router, the router may not let it fail, no
step before it takes its token or a token the decoder does not know, and the
decoder reads every step after it in full. Permit2 then lets the router take
no more of the token than that amount, so the permit covers every take of it.
A step the decoder does not read in full may raise what Permit2 lets the
router take, as a `PERMIT2_PERMIT_BATCH` can: a command the decoder does not
read (listed at the end of this section), a step whose input it cannot read,
in whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`,
before the step that sets the input side or after it, leaves `Token In` and
the `Amount` line as they read with no permit: a permit of 5,000 DAI to
another address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC`
with `Amount 600.0000 USDT`, and so do that exact-in and then a permit of
5,000 DAI to the router. When a permit's amount stands and the step that sets
the input side spends a token other than the permit's, or one the decoder does
not know, that step's amount gets lines of its own, unless it states no amount
and the user pays it none of its input token. Each take the total does not
count gives three lines after the `Amount` line (after `Token In` when there
is none): one of a token other than `Token In`; one of `Token In` after an
amount that is not a quantity (a V4 open delta, `ALREADY_PAID`, which keep
their wording on the `Amount` line, or no stated amount, as of a V4 exact-out
step); and one whose amount the decoder does not know. When the step that sets
the input side states no amount, the user pays it some of its input token, and
there is no `Amount` line, that step gets them too whenever another take's
lines give a figure, or a `PERMIT2_TRANSFER_FROM` of its token gives one, on
the lines below or, into the router, on its `Paid to` line, so that figure
cannot read as all that leaves. When it states no amount and the user pays it
none of its input token, as when the router pays it, that step takes none of
that token from the user's wallet and never gets them, with a permit or
without. They come in step order, the step that sets the input side first when
it has them: `Moved from the sender`, its amount, worded as the `Amount` line
words one, or `Unknown (could not be decoded)` when the decoder does not know
it; `Token moved`, giving the token's address as the `Token In` line does; and
transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router,
the router may not let it fail, no step before it takes its token, a
`PERMIT2_TRANSFER_FROM` of it included, or a token the decoder does not know,
and the decoder reads every step after it in full. Permit2 then lets the
router take no more of the token than that amount, so the permit covers every
take of it. A step before the permit takes under whatever allowance the router
already had, so it may take more than the permit's amount, which would then
read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of
2,000 USDT into the router, then a permit of 600 USDT to the router and a V3
exact-in of 2,000 USDT paid from the router, read `Amount 2000.0000 USDT`. A
later permit or batch may raise what Permit2 lets the router take, and so may
a step the decoder does not read in full: a command the decoder does not read
(listed at the end of this section), a step whose input it cannot read, in
whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, before
the step that sets the input side or after it, leaves `Token In` and the
`Amount` line as they read with no permit, unless it lets a spender other than
the router take `Token In` (see below): a permit of 5,000 DAI to another
address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with
`Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI
to the router. When a permit's amount stands and the step that sets the input
side spends a token other than the permit's, or one the decoder does not know,
that step's amount gets lines of its own, unless it states no amount and the
user pays it none of its input token. Each take the total does not count gives
three lines after the `Amount` line (after `Token In` when there is none): one
of a token other than `Token In`; one of `Token In` after an amount that is
not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their wording on
the `Amount` line, or no stated amount, as of a V4 exact-out step); and one
whose amount the decoder does not know. When there is no `Amount` line, each
take the total counts gets them too, so that every amount the decoder reads is
shown: the amount the step that sets the input side states, when it is a
quantity and the user pays that step (a V2 or V3 step whose `payerIsUser` is
true, a V4 step the user pays some of its input token, or a `WRAP_ETH`, whose
ETH comes from the transaction's value), and each later take of `Token In`
added to it. A step that sets the input side and is paid from the router takes
nothing from the user's wallet and gets none. A `PERMIT2_PERMIT` the decoder
cannot read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give
lines for both amounts. When the step that sets the input side states no
amount, the user pays it some of its input token, and there is no `Amount`
line, that step gets them too whenever another take's lines give a figure, or
a `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into
the router, on its `Paid to` line, so that figure cannot read as all that
leaves. When it states no amount and the user pays it none of its input token,
as when the router pays it, that step takes none of that token from the user's
wallet and never gets them, with a permit or without. They come in step order,
except that the step that sets the input side comes first when it has them,
followed, when there is no `Amount` line, by the other takes the total counts:
`Moved from the sender`, its amount, worded as the `Amount` line words one, or
`Unknown (could not be decoded)` when the decoder does not know it;
`Token moved`, giving the token's address as the `Token In` line does; and
`Moved to`, which reads `The swap step that spends it`. A token the decoder
does not know gives the `Moved from the sender` line alone. A step may also
take from the user's wallet what the decoder did not read: a V4 step whose
@@ -3284,11 +3427,26 @@ Currently supported:
giving the token's address as the `Token In` line does; and
`Allowance spender`, the address it lets take the token. Only a permit whose
amount stands gives none: the `Amount` line gives its amount, `Token In` its
token, and its spender is the router. These lines change neither the `Paid to`
lines nor the minimum's label. A step goes unread when its recipient does not
decode as an address, when it is a V3 swap step whose path is shorter than one
token address or has bytes after its last hop, or when it is a command the
decoder does not read: every command other than `PERMIT2_PERMIT`,
token, and its spender is the router. Each allowance a `PERMIT2_PERMIT_BATCH`
grants, one for each token it names, all for its one spender, gives the same
three lines, in step order with those of each `PERMIT2_PERMIT`. A
`PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` whose input the decoder cannot
read, such as one whose token or spender word has a nonzero top byte, may let
any spender take any amount of any token: it gives one `Permit2 allowance`
line alone, reading `Unknown (could not be decoded)`. These lines change no
`Paid to` line: a permit or batch pays nobody. But code that a step runs, such
as a V4 pool's hook or a token's own code, can call a spender other than the
router, which can then take up to its allowance from the user's wallet before
the transaction ends. So an allowance to any spender but the router, or a
permit or batch the decoder cannot read, makes the minimum read
`Min. paid out`, and when its token is `Token In`, or the decoder does not
know its token or `Token In`, it leaves no `Amount` line, whose figure might
be too low: a permit of 5,000 USDT to another address and then a V3 exact-in
of 600 USDT read with no `Amount` line, and the 600 USDT gets lines of its
own. A step goes unread when its recipient does not decode as an address, when
it is a V3 swap step whose path is shorter than one token address or has bytes
after its last hop, or when it is a command the decoder does not read: every
command other than `PERMIT2_PERMIT`, `PERMIT2_PERMIT_BATCH`,
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`,
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`,
+47 -27
View File
@@ -57,11 +57,12 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
- **No NFT galleries or portfolio views.** This is a wallet, not a dashboard.
- **No third-party token list APIs.** Token balances come from the same block
explorer you configure for transaction history, and the extension ships its
own hardcoded list of top ERC-20 contract addresses for symbol-spoofing
detection. Any token you want tracked across all your addresses, you add
yourself by contract address.
- **No third-party token list APIs.** The balances of the tokens you track are
read from your RPC endpoint; while you have "Use Blockscout" on, the others
come from the same block explorer you configure for transaction history. The
extension ships its own hardcoded list of top ERC-20 contract addresses for
symbol-spoofing detection. Any token you want tracked across all your
addresses, you add yourself by contract address.
- **No backend servers operated by the developer.** Nothing is sent to any
server run by AutistMask. Every network destination is listed below.
@@ -69,8 +70,11 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
## How It Works
AutistMask is a browser extension that runs entirely in your browser. It does
not have a backend server. It communicates with five external destinations:
three you configure yourself, and two fixed ones used for scam detection.
not have a backend server. Out of the box it sends your data only to the
Ethereum RPC endpoint, and asks the CoinDesk price API for prices with nothing
but token symbols. Blockscout and Etherscan are sent none of your data until you
turn on "Use Blockscout" or "Check recipients on Etherscan", on the Welcome
screen or later in Settings. Both are off in a new install.
### External Services
@@ -96,16 +100,23 @@ addresses are necessarily visible to the RPC provider when querying balances.
**Blockscout API** (default: `eth.blockscout.com/api/v2`;
`eth-sepolia.blockscout.com/api/v2` on Sepolia)
Used to fetch token balances and transaction history. Blockscout is an
open-source blockchain explorer. AutistMask queries it for your ERC-20 token
balances (including the holder counts used for spam filtering) and your recent
transactions and token transfers. You can change this in Settings to a
self-hosted Blockscout instance.
Off until you turn on "Use Blockscout". Used to fetch transaction history, the
balances of the tokens you have not tracked, and the details of a transaction
you open. Blockscout is an open-source blockchain explorer. AutistMask queries
it for your ERC-20 token balances (including the holder counts used for spam
filtering), your recent transactions and token transfers, and the block, fees
and calldata of a transaction you open. You can change this in Settings to a
self-hosted Blockscout instance. While it is off, the screens that would show
history say so, and only the tokens you track are listed.
When it is contacted: on every balance refresh, and whenever a screen showing
transaction history is opened.
When it is contacted: while "Use Blockscout" is on, on every balance refresh,
whenever a screen showing transaction history is opened, and when you open a
transaction. The one exception, whether or not the setting is on, is pressing
"Save" beside the Blockscout URL in Settings, which checks that URL by asking it
for `/stats` and sends none of your data.
What gets sent: your Ethereum addresses (to look up balances and transactions).
What gets sent: your Ethereum addresses (to look up balances and transactions)
and the hash of each transaction you open.
**CoinDesk CADLI price API** (`data-api.coindesk.com`)
@@ -122,13 +133,16 @@ address.
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
When you review a send, AutistMask fetches the recipient's public Etherscan
address page and looks for a "Fake_Phishing"/"Phish/Hack" label or a scam
warning, and shows a red warning if it finds one. This is a plain page fetch
with no API key, made by your browser. It is best-effort: if it fails, it is
silently ignored. This endpoint is not user-configurable.
Off until you turn on "Check recipients on Etherscan". When you review a send,
AutistMask fetches the recipient's public Etherscan address page and looks for a
"Fake_Phishing"/"Phish/Hack" label or a scam warning, and shows a red warning if
it finds one. This is a plain page fetch with no API key, made by your browser.
It is best-effort: if it fails, it is silently ignored. This endpoint is not
user-configurable. While it is off, the confirmation screen says that no
Etherscan check was made.
When it is contacted: each time you reach the send confirmation screen.
When it is contacted: only while "Check recipients on Etherscan" is on, each
time you reach the send confirmation screen.
What gets sent: the recipient address you are about to send to, and your IP
address. Your own addresses are not sent.
@@ -185,6 +199,9 @@ password.
1. Click the AutistMask icon in your browser toolbar.
2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet".
On first use the same screen offers "Use Blockscout" and "Check recipients on
Etherscan", both off; tick either if you want what it does (see External
Services). You can change both later in Settings.
3. On the "From Phrase" tab, click the die button to generate a random 12-word
recovery phrase.
4. **Write down the recovery phrase and store it safely.** Anyone with these
@@ -220,10 +237,11 @@ always produce the same sequence of addresses.
### Adding ERC-20 Tokens
Tokens you hold show up automatically only if they are in the extension's
bundled list of well-known tokens or have at least 1,000 holders; everything
else is treated as spam and hidden. To track a token explicitly (which also
shows it at zero balance), add it by contract address:
Tokens you hold show up automatically only while "Use Blockscout" is on, and
then only if they are in the extension's bundled list of well-known tokens or
have at least 1,000 holders; everything else is treated as spam and hidden. To
track a token explicitly (which also shows it at zero balance, and shows it
whether or not "Use Blockscout" is on), add it by contract address:
1. Go to an address detail view (click `[info]` on any address).
2. Click "+ Token".
@@ -389,8 +407,10 @@ Click the gear icon on the home screen to access settings:
that network's defaults if you have not used it before.
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
You can use your own node for maximum privacy.
- **Blockscout API**: Change the Blockscout instance used for token balances and
transaction history. You can use a self-hosted instance.
- **Blockscout API**: "Use Blockscout", off until you tick it, and the
Blockscout instance used for transaction history, the tokens you have not
tracked and transaction details. You can use a self-hosted instance.
- **Etherscan**: "Check recipients on Etherscan", off until you tick it.
- **Token Spam Protection**: Toggle individual scam filters and set the dust
transaction threshold.
- **Allowed Sites / Denied Sites**: View and manage web3 site permissions.
+1
View File
@@ -1397,6 +1397,7 @@ async function backgroundRefresh() {
wallets,
s.rpcUrl,
s.blockscoutUrl,
s.useBlockscout,
s.trackedTokens,
s.networkId,
);
+89 -1
View File
@@ -43,6 +43,38 @@
<!-- ============ WELCOME / FIRST USE ============ -->
<div id="view-welcome" class="view hidden">
<p class="mb-3">Welcome! To get started, add a wallet.</p>
<div class="bg-well p-3 mx-1 mb-3">
<p class="text-xs mb-2">
This wallet sends your data only to the Ethereum RPC
endpoint, unless you turn on either of these, here or
later in Settings.
</p>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input type="checkbox" id="welcome-use-blockscout" />
Use Blockscout
</label>
<p class="text-xs text-muted mb-2">
Sends your addresses, and the hash of each transaction
you open, to the Blockscout API, for transaction
history, tokens you have not tracked, and transaction
details.
</p>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input
type="checkbox"
id="welcome-check-recipients-on-etherscan"
/>
Check recipients on Etherscan
</label>
<p class="text-xs text-muted">
Sends the address you are about to send to, to
Etherscan, to look for a phishing or scam label on it.
</p>
</div>
<button
id="btn-welcome-add"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
@@ -272,6 +304,10 @@
<!-- wallet list -->
<div id="wallet-list"></div>
<p id="home-tokens-note" class="text-xs text-muted py-1 hidden">
Only tracked tokens are listed, because "Use Blockscout" is
off in Settings.
</p>
<!-- recent transactions across all addresses -->
<div>
@@ -336,6 +372,13 @@
<div id="address-balances" class="min-h-[1.25rem]">
&nbsp;
</div>
<p
id="address-tokens-note"
class="text-xs text-muted hidden"
>
Only tracked tokens are listed, because "Use Blockscout"
is off in Settings.
</p>
</div>
<!-- actions -->
@@ -660,6 +703,13 @@
phishing/scam. Do not send funds to this address.
</div>
</div>
<p
id="confirm-etherscan-note"
class="mb-2 text-xs text-muted hidden"
>
This address was not checked on Etherscan, because "Check
recipients on Etherscan" is off in Settings.
</p>
<div
id="confirm-errors"
class="mb-2 border border-border border-dashed p-2 invisible min-h-[1.25rem]"
@@ -1002,8 +1052,19 @@
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Blockscout API</h3>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input type="checkbox" id="settings-use-blockscout" />
Use Blockscout
</label>
<p class="text-xs text-muted mb-2">
Sends your addresses, and the hash of each transaction
you open, to the Blockscout API below, for transaction
history, tokens you have not tracked, and transaction
details.
</p>
<p class="text-xs text-muted mb-1">
Used to fetch token balances and transaction history.
Change this if you run your own Blockscout instance.
</p>
<input
@@ -1019,6 +1080,23 @@
</button>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Etherscan</h3>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input
type="checkbox"
id="settings-check-recipients-on-etherscan"
/>
Check recipients on Etherscan
</label>
<p class="text-xs text-muted">
Sends the address you are about to send to, to
Etherscan, to look for a phishing or scam label on it.
</p>
</div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Token Spam Protection</h3>
<p class="text-xs text-muted mb-2">
@@ -1504,6 +1582,16 @@
</div>
</div>
<!-- Shown in place of the block, decoded details, network
details and raw data, which only Blockscout gives. -->
<p
id="tx-detail-blockscout-note"
class="text-xs text-muted mx-1 mb-3 hidden"
>
No block, decoded details, network details or raw data are
shown, because "Use Blockscout" is off in Settings.
</p>
<!-- ── Decoded details ── -->
<div id="tx-detail-calldata-section" class="hidden">
<div class="bg-well p-3 mx-1 mb-3">
+46 -7
View File
@@ -5,12 +5,14 @@ const {
state,
saveState,
onSaveFailure,
takeStoredNetwork,
loadState,
} = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema");
const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances");
const { clearPopupCaches } = require("../shared/chainSwitch");
const {
$,
showView,
@@ -22,6 +24,7 @@ const {
} = require("./views/helpers");
const { applyTheme } = require("./theme");
const {
onStorageChanged,
tabsGetCurrent,
tabsGetZoom,
windowsGetCurrent,
@@ -72,12 +75,13 @@ let refreshTimer = null;
// lock, its results are copied onto the address records in `state` only if the
// network it ran on is still the active one, on this page and in the stored
// record; the background's switch, after the user approves a site's request,
// changes only the stored record. A switch while the refresh ran cleared every
// address's balances (applyChainSwitchFields() in
// src/shared/chainSwitchFields.js), and the previous network's must not come
// back over them (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped
// refresh is not recorded as done. Results are copied by address, so an address
// added or removed meanwhile is left as it is.
// reaches this page only after the stored record (followNetworkSwitch() below).
// A switch while the refresh ran cleared every address's balances
// (applyChainSwitchFields() in src/shared/chainSwitchFields.js), and the
// previous network's must not come back over them
// (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped refresh is not
// recorded as done. Results are copied by address, so an address added or
// removed meanwhile is left as it is.
async function doRefreshAndRender() {
if (refreshInFlight) return;
refreshInFlight = true;
@@ -90,6 +94,7 @@ async function doRefreshAndRender() {
wallets,
state.rpcUrl,
state.blockscoutUrl,
state.useBlockscout,
state.trackedTokens,
networkId,
pageClosed.signal,
@@ -147,6 +152,7 @@ const ctx = {
renderWalletList,
doRefreshAndRender,
startRefresh,
restoreView,
pageClosed: pageClosed.signal,
showAddWalletView: () => {
pushCurrentView();
@@ -214,6 +220,28 @@ function fallbackView() {
showView("main");
}
// When the background saves a network switch, once the user approves a site's
// request, or another popup page saves one, this page follows the stored
// record. It takes the network the record names as a save reads it, holding
// the lock, so no later save of this page writes the previous network back, and
// then leaves the page as a switch in Settings leaves it. This page's own
// switch in Settings is made under the lock as well (onChainSwitch() in
// src/shared/chainSwitch.js), so a record naming a network other than this
// page's always holds a switch saved elsewhere, and a switch the user picks
// here while this waits for the lock is made after it.
async function followNetworkSwitch() {
let switched = false;
// A failed save is reported by saveState() itself: see onSaveFailure().
await saveState((stored) => {
if (stored.networkId === state.networkId) return false;
settings.cutViewStack();
takeStoredNetwork(stored);
clearPopupCaches();
switched = true;
}).catch(() => {});
if (switched) settings.showNewNetwork();
}
// The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width, and a screen taller than
// the window takes a vertical scrollbar out of it too. So once the screen is
@@ -358,8 +386,19 @@ async function init() {
settingsAddToken.init(ctx);
deleteAddress.init(ctx);
// Only a record naming a network other than this page's is followed: the
// one this page's own switch in Settings saves names the network it
// already has, and a record naming none is no switch.
onStorageChanged((changes) => {
const written = changes.autistmask && changes.autistmask.newValue;
if (!written || !written.networkId) return;
if (written.networkId !== state.networkId) followNetworkSwitch();
});
// A switch saved since loadState() above, before there was a listener.
followNetworkSwitch();
if (!state.hasWallet) {
showView("welcome");
welcome.show();
} else {
renderWalletList();
restoreView();
+6 -4
View File
@@ -39,10 +39,12 @@ function markViewRendered(view) {
}
// Begin a fresh page-load scope. The popup gets one by being loaded, and
// asks for one when the user switches the network, because every screen
// then on the page shows the previous network (views/settings.js); the
// unit tests, which simulate several page loads against one module
// instance, ask for one too.
// asks for one after a network switch, because every screen then on the page
// shows the previous network (showNewNetwork() in views/settings.js), and
// after "Use Blockscout" or "Check recipients on Etherscan" changes in
// Settings, because every screen then on the page was drawn under the old
// setting; the unit tests, which simulate several page loads against one
// module instance, ask for one too.
function resetRenderedViews() {
renderedViews.clear();
}
+8
View File
@@ -4,6 +4,7 @@ const {
showFlash,
balanceLinesForAddress,
txCounterpartyHtml,
HISTORY_OFF_TEXT,
addressTitle,
escapeHtml,
displaySymbol,
@@ -83,6 +84,8 @@ function show() {
ctx.showAddressToken();
});
});
// Tokens the user has not tracked are Blockscout's to report.
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
@@ -99,6 +102,7 @@ async function loadTransactions(address) {
const rawTxs = await fetchRecentTransactions(
address,
state.blockscoutUrl,
state.useBlockscout,
currentNetwork().chainId,
);
const result = filterTransactions(rawTxs, {
@@ -155,6 +159,10 @@ async function loadTransactions(address) {
function renderTransactions(txs) {
const list = $("tx-list");
if (!state.useBlockscout) {
list.innerHTML = `<div class="text-muted text-xs py-1">${escapeHtml(HISTORY_OFF_TEXT)}</div>`;
return;
}
if (txs.length === 0) {
list.innerHTML =
'<div class="text-muted text-xs py-1">No transactions found.</div>';
+6
View File
@@ -7,6 +7,7 @@ const {
showFlash,
flashCopyFeedback,
txCounterpartyHtml,
HISTORY_OFF_TEXT,
addressTitle,
escapeHtml,
displaySymbol,
@@ -173,6 +174,7 @@ async function loadTransactions(address, tokenId) {
const rawTxs = await fetchRecentTransactions(
address,
state.blockscoutUrl,
state.useBlockscout,
currentNetwork().chainId,
);
const result = filterTransactions(rawTxs, {
@@ -239,6 +241,10 @@ async function loadTransactions(address, tokenId) {
function renderTransactions(txs) {
const list = $("address-token-tx-list");
if (!state.useBlockscout) {
list.innerHTML = `<div class="text-muted text-xs py-1">${escapeHtml(HISTORY_OFF_TEXT)}</div>`;
return;
}
if (txs.length === 0) {
list.innerHTML =
'<div class="text-muted text-xs py-1">No transactions found.</div>';
+6
View File
@@ -334,6 +334,11 @@ function show(txInfo) {
$("confirm-contract-warning").style.visibility = "hidden";
$("confirm-burn-warning").style.visibility = "hidden";
$("confirm-etherscan-warning").style.visibility = "hidden";
// While "Check recipients on Etherscan" is off there is no Etherscan
// warning to reserve space for, and a line in its place says so.
const etherscanOn = !!state.checkRecipientsOnEtherscan;
$("confirm-etherscan-warning").classList.toggle("hidden", !etherscanOn);
$("confirm-etherscan-note").classList.toggle("hidden", etherscanOn);
// Show burn warning via reserved element (in addition to inline warning)
if (isBurnAddress(txInfo.to)) {
@@ -612,6 +617,7 @@ async function checkRecipientHistory(txInfo) {
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from,
chainId: txInfo.chainId,
checkRecipientsOnEtherscan: state.checkRecipientsOnEtherscan,
});
// The user may have left this transaction while the checks were in
// flight; its warnings must not reach another recipient's screen.
+2 -1
View File
@@ -16,6 +16,7 @@ const {
broadcastActiveChanged,
} = require("../../shared/walletDelete");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
const welcome = require("./welcome");
let deleteWalletIndex = null;
let lostPasswordIndex = null;
@@ -135,7 +136,7 @@ async function finishDelete(walletIdx) {
// Save before broadcasting: the background reads the active
// address back out of storage to build accountsChanged.
if (activeAddressChanged) broadcastActiveChanged();
showView("welcome");
welcome.show();
return;
}
+6
View File
@@ -534,6 +534,11 @@ function addressTitle(address, wallets) {
const CONTRACT_CREATION_TEXT =
"This transaction creates a new contract. It has no recipient.";
// What the transaction lists on Home, AddressDetail and AddressToken say while
// the user has "Use Blockscout" off: the history comes from Blockscout alone.
const HISTORY_OFF_TEXT =
'No transaction history is shown, because "Use Blockscout" is off in Settings.';
// The last two lines of a transaction history row: the counterparty's colour
// dot and name beside the amount, then its full address. A contract creation
// the user sent has no counterparty (its `to` is ""), so its row has the
@@ -799,6 +804,7 @@ module.exports = {
displaySymbol,
addressTitle,
CONTRACT_CREATION_TEXT,
HISTORY_OFF_TEXT,
txCounterpartyHtml,
formatAddressHtml,
renderAddressHtml,
+8
View File
@@ -7,6 +7,7 @@ const {
timeAgo,
addressDotHtml,
txCounterpartyHtml,
HISTORY_OFF_TEXT,
addressTitle,
escapeHtml,
displaySymbol,
@@ -100,6 +101,10 @@ let homeTxs = [];
function renderHomeTxList(ctx) {
const list = $("home-tx-list");
if (!list) return;
if (!state.useBlockscout) {
list.innerHTML = `<div class="text-muted text-xs py-1">${escapeHtml(HISTORY_OFF_TEXT)}</div>`;
return;
}
if (homeTxs.length === 0) {
list.innerHTML =
'<div class="text-muted text-xs py-1">No transactions found.</div>';
@@ -192,6 +197,7 @@ async function loadHomeTxs(ctx) {
fetchRecentTransactions(
addr,
state.blockscoutUrl,
state.useBlockscout,
currentNetwork().chainId,
),
);
@@ -300,6 +306,8 @@ function walletListHtml() {
function render(ctx) {
const container = $("wallet-list");
// Tokens the user has not tracked are Blockscout's to report.
$("home-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
if (state.wallets.length === 0) {
container.innerHTML =
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
+92 -28
View File
@@ -38,6 +38,8 @@ const {
const { notify, sendMessage } = require("../../shared/browserApi");
let ctx;
let versionClickCount = 0;
let versionClickTimer = null;
@@ -199,6 +201,10 @@ function show() {
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
$("settings-network").value = state.networkId;
// Set on every show, not only at init: Welcome can change these two.
$("settings-use-blockscout").checked = state.useBlockscout;
$("settings-check-recipients-on-etherscan").checked =
state.checkRecipientsOnEtherscan;
renderTrackedTokens();
renderSiteLists();
renderWalletListSettings();
@@ -243,7 +249,51 @@ async function renderSiteLists() {
);
}
function init(ctx) {
// A network switch, made by the user here or saved by the background once the
// user approved a site's request (src/popup/index.js), leaves every screen on
// the page showing the previous network. These two leave the page as README's
// Screen Map describes, the first before the switch is saved, so that save
// keeps the cut for a popup closed straight after, the second once `state`
// holds the new network.
//
// The stack is cut at the first screen that cannot be rendered from saved
// state, such as Send or AddToken, and at AddressToken unless it shows the
// native token: any other token there was picked on the previous network, so
// it is no longer selected either. Without a wallet the stack starts at
// Welcome, which that cut would drop, and Welcome shows nothing from a network.
function cutViewStack() {
if (!state.hasWallet) return;
if (state.selectedToken !== "ETH") {
const tokenView = state.viewStack.indexOf("address-token");
if (tokenView !== -1) {
state.viewStack = state.viewStack.slice(0, tokenView);
}
state.selectedToken = null;
}
state.viewStack = restorableStack(state.viewStack, state.currentView);
}
// Every screen counts as not yet rendered, so "Back" renders the one it lands
// on for the new network. Settings on top is brought up to date in place;
// any other screen is drawn again as a reopened popup draws it, which is Home
// for one that cannot be rendered from saved state. Without a wallet any other
// screen is Welcome or AddWallet, which show nothing from a network.
function showNewNetwork() {
resetRenderedViews();
if (state.currentView === "settings") {
markViewRendered("settings");
$("settings-network").value = state.networkId;
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
} else if (state.hasWallet) {
ctx.restoreView();
}
updateDebugBanner(state.currentView);
}
function init(_ctx) {
ctx = _ctx;
deleteWallet.init(ctx);
showPhrase.init();
@@ -317,39 +367,47 @@ function init(ctx) {
showFlash("Saved.");
});
// The screens under Settings were drawn under the old setting, so each
// change makes every screen but Settings count as not yet drawn, as a
// network switch does, and Back draws the one it lands on again. Welcome
// offers the same two settings and is only unhidden on Back, so its boxes
// are set here too.
$("settings-use-blockscout").addEventListener("change", async () => {
state.useBlockscout = $("settings-use-blockscout").checked;
$("welcome-use-blockscout").checked = state.useBlockscout;
resetRenderedViews();
markViewRendered("settings");
await saveState();
});
$("settings-check-recipients-on-etherscan").addEventListener(
"change",
async () => {
state.checkRecipientsOnEtherscan = $(
"settings-check-recipients-on-etherscan",
).checked;
$("welcome-check-recipients-on-etherscan").checked =
state.checkRecipientsOnEtherscan;
resetRenderedViews();
markViewRendered("settings");
await saveState();
},
);
const networkSelect = $("settings-network");
networkSelect.addEventListener("change", async () => {
// Whether `state` already has the network picked is asked only once
// the switch holds the lock: while an earlier pick waits for it,
// `state` still has the network before that pick, and a pick back to
// that network must still be made.
const newId = networkSelect.value;
if (newId === state.networkId) return;
// The screens beneath Settings show the previous network, so Back
// finds them as it does in a reopened popup: each is rendered again
// when Back lands on it, and the stack is cut at the first one that
// cannot be rendered from saved state, such as Send or AddToken. It
// is cut at AddressToken too, unless that shows the native token: any
// other token there was picked on the previous network. Without a
// wallet the stack starts at Welcome, which that cut would drop, and
// Welcome shows nothing from a network. The cut comes before the
// switch so that the switch's save keeps it for a popup closed
// straight after.
if (state.hasWallet) {
const tokenView = state.viewStack.indexOf("address-token");
if (tokenView !== -1 && state.selectedToken !== "ETH") {
state.viewStack = state.viewStack.slice(0, tokenView);
}
state.viewStack = restorableStack(state.viewStack, "settings");
}
cutViewStack();
const net = await onChainSwitch(newId);
if (!net) return;
// Open pages are told by the background, as after a site's approved
// switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
resetRenderedViews();
// Settings itself, and the testnet banner above it, are brought up
// to date for the new network here.
markViewRendered("settings");
$("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens();
updateDebugBanner(state.currentView);
showNewNetwork();
showFlash("Switched to " + net.name + ".");
});
@@ -484,4 +542,10 @@ function init(ctx) {
});
}
module.exports = { init, show, renderSiteLists };
module.exports = {
init,
show,
renderSiteLists,
cutViewStack,
showNewNetwork,
};
+9
View File
@@ -191,6 +191,11 @@ function render() {
if (el) el.classList.add("hidden");
}
// Those details are Blockscout's; this line says why there are none.
$("tx-detail-blockscout-note").classList.toggle(
"hidden",
!!state.useBlockscout,
);
loadFullTxDetails(tx.hash, tx.chainId);
const isoStr = isoDate(tx.timestamp);
@@ -303,7 +308,11 @@ function populateOnChainDetails(txData, chainId) {
}
}
// The block, network details, calldata and its decoding, from Blockscout. While
// the user has "Use Blockscout" off, Blockscout is not asked and none of them
// is shown.
async function loadFullTxDetails(txHash, chainId) {
if (!state.useBlockscout) return;
const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
+29 -2
View File
@@ -1,7 +1,34 @@
const { $ } = require("./helpers");
// The first screen, shown while there is no wallet. It offers the two lookups
// at services other than the RPC endpoint, both off until the user ticks them,
// which Settings offers as well.
const { $, showView } = require("./helpers");
const { state, saveState } = require("../../shared/state");
function show() {
$("welcome-use-blockscout").checked = state.useBlockscout;
$("welcome-check-recipients-on-etherscan").checked =
state.checkRecipientsOnEtherscan;
showView("welcome");
}
function init(ctx) {
$("btn-welcome-add").addEventListener("click", ctx.showAddWalletView);
$("welcome-use-blockscout").addEventListener("change", async () => {
state.useBlockscout = $("welcome-use-blockscout").checked;
await saveState();
});
$("welcome-check-recipients-on-etherscan").addEventListener(
"change",
async () => {
state.checkRecipientsOnEtherscan = $(
"welcome-check-recipients-on-etherscan",
).checked;
await saveState();
},
);
}
module.exports = { init };
module.exports = { init, show };
+3
View File
@@ -60,6 +60,8 @@ function getLocalWarnings(address, options = {}) {
* @param {string} [options.fromAddress] - Sender address (for self-send check).
* @param {string} [options.chainId] - Chain id of the network whose explorer
* the Etherscan label check asks; the active network's when omitted.
* @param {boolean} [options.checkRecipientsOnEtherscan] - The user's setting
* of that name. The Etherscan label check is made only while it is on.
* @returns {Promise<Array<{type: string, message: string, severity: string}>>}
*/
async function getFullWarnings(address, provider, options = {}) {
@@ -114,6 +116,7 @@ async function getFullWarnings(address, provider, options = {}) {
try {
const etherscanWarning = await checkEtherscanLabel(
address,
options.checkRecipientsOnEtherscan,
options.chainId,
);
if (etherscanWarning) {
+153 -21
View File
@@ -1,5 +1,7 @@
// Balance fetching: ETH balances via RPC, ERC-20 token balances via
// Blockscout, ENS reverse lookup via RPC.
// Balance fetching: ETH balances and the balances of the tokens the user
// tracks via RPC, the other ERC-20 token balances and the holder counts via
// Blockscout while the user has "Use Blockscout" on, ENS reverse lookup via
// RPC.
const {
JsonRpcProvider,
@@ -36,9 +38,18 @@ const { resolveTokenDecimals } = require("./approvalAmount");
// ethers knows chains this wallet does not, so an id that is not one of ours
// is a caller bug and must not resolve to a working provider for some other
// chain.
//
// `disableCcipRead` keeps every ENS lookup on the RPC endpoint. Left on, as
// ethers has it, a resolver that says a name is resolved off-chain makes ethers
// fetch the web address the resolver gives, straight from the browser, with the
// name being looked up; for the reverse lookup of the user's addresses that is
// the user's own name (https://git.eeqj.de/sneak/AutistMask/issues/668). Such a
// name does not resolve.
function getProvider(rpcUrl, networkId) {
const net = Network.from(requireNetworkId(networkId).id);
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
const provider = new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
provider.disableCcipRead = true;
return provider;
}
function requireNetworkId(networkId) {
@@ -80,8 +91,15 @@ function rawUnits(value) {
// Fetch token balances for a single address from Blockscout.
// Returns [{ address, name, symbol, decimals, balance, holders }].
//
// `useBlockscout` is the user's "Use Blockscout" setting. While it is off,
// Blockscout is not asked and the answer is an empty list: no token the user
// has not tracked is listed.
//
// Filters out spam: only shows tokens that are in the known token list,
// explicitly tracked by the user, or have >= 1000 holders.
// explicitly tracked by the user, or have >= 1000 holders. A tracked token's
// balance is read through the RPC endpoint instead, and its row here gives that
// entry its holder count and `decimals` (fetchTrackedTokenBalances()).
//
// `decimals` and `balance` are each null when the answer is unknown, the same
// way `holders` already is. Absence is never filled in here: this is the
@@ -93,9 +111,11 @@ function rawUnits(value) {
async function fetchTokenBalances(
address,
blockscoutUrl,
useBlockscout,
trackedTokens,
signal,
) {
if (!useBlockscout) return [];
try {
const resp = await debugFetch(
blockscoutUrl + "/addresses/" + address + "/token-balances",
@@ -205,18 +225,109 @@ async function fetchTokenBalances(
}
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// The entries at `address` of the tokens the user tracks, shaped as
// fetchTokenBalances() returns them, for the tokens the address holds.
//
// The balance is read with balanceOf through the RPC endpoint. The name and
// symbol are the tracked token's. `decimals` and `holders` are those of
// Blockscout's entry for the token in `explorerRequest`, this refresh's
// fetchTokenBalances() for the address; while Blockscout fails they are kept
// from the token's entry in `previous`, the address's entries before this
// refresh. The balance is stated at the scale the screens read the token at
// (resolveTokenDecimals()): the bundled list's, then the one the tracked token
// records, then Blockscout's, as in fetchTokenBalances().
//
// A token whose balanceOf fails keeps its entry in `previous`, as a failed ETH
// balance keeps its figure, or takes Blockscout's entry from this refresh when
// it has none. The failure is logged. A balance of zero gets no entry, as a
// zero Blockscout reports gets none: the balance lists take a tracked token's
// zero from the tracked list. A token whose symbol the spoof rule refuses gets
// no entry either, as in fetchTokenBalances().
//
// `signal` is as for fetchTokenBalances().
async function fetchTrackedTokenBalances(
provider,
address,
trackedTokens,
previous,
explorerRequest,
signal,
) {
const listed = trackedTokens.filter(
(token) => !isSpoofedSymbol(token.symbol, token.address),
);
// Each token's balanceOf answer, or null where it failed.
const balances = await Promise.all(
listed.map(async (token) => {
try {
const contract = new Contract(
token.address,
ERC20_ABI,
provider,
);
return await contract.balanceOf(address);
} catch (e) {
if (!signal?.aborted) {
log.errorf(
"token balance failed",
address,
token.address,
e.shortMessage || e.message,
);
}
return null;
}
}),
);
const explorerRows = await explorerRequest;
const reported = explorerRows ?? previous;
const rows = [];
listed.forEach((token, i) => {
const lower = token.address.toLowerCase();
const entryIn = (list) =>
list.find((row) => String(row.address).toLowerCase() === lower);
const raw = balances[i];
if (raw === null) {
const kept = entryIn(previous) || entryIn(explorerRows || []);
if (kept) rows.push(kept);
return;
}
if (raw === 0n) return;
const entry = entryIn(reported);
const decimals = entry ? entry.decimals : null;
const known = resolveTokenDecimals(lower, { trackedTokens });
const scale = known !== null ? known : decimals;
rows.push({
address: token.address,
name: token.name || "",
symbol: token.symbol || "???",
decimals: decimals,
// null when nothing knows the scale: a holding whose amount
// cannot be stated, never a zero.
balance: scale === null ? null : formatTokenBalance(raw, scale),
holders: entry ? entry.holders : null,
});
});
return rows;
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// `trackedTokens` are the tokens tracked on the active network.
// `useBlockscout` and `signal` are as for fetchTokenBalances().
async function refreshBalances(
wallets,
rpcUrl,
blockscoutUrl,
useBlockscout,
trackedTokens,
networkId,
signal,
) {
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
const provider = getProvider(rpcUrl, networkId);
const tracked = trackedTokens || [];
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
const updates = [];
for (const wallet of wallets) {
@@ -266,23 +377,44 @@ async function refreshBalances(
}),
);
// ERC-20 token balances via Blockscout
// ERC-20 token balances: the tracked tokens' through the RPC
// endpoint, and from Blockscout the tokens the user has not
// tracked. When Blockscout fails, the untracked rows from before
// this refresh stay; while "Use Blockscout" is off there are none.
// The Blockscout request is started first and handed to
// fetchTrackedTokenBalances() too, which waits for it after
// sending its balanceOf reads, so the two run side by side.
const previous = addr.tokenBalances || [];
const explorerRequest = fetchTokenBalances(
addr.address,
blockscoutUrl,
useBlockscout,
tracked,
signal,
);
updates.push(
fetchTokenBalances(
addr.address,
blockscoutUrl,
trackedTokens,
signal,
).then((balances) => {
if (balances !== null) {
addr.tokenBalances = balances;
log.debugf(
"Token balances",
addr.address,
balances.length,
"tokens",
);
}
Promise.all([
fetchTrackedTokenBalances(
provider,
addr.address,
tracked,
previous,
explorerRequest,
signal,
),
explorerRequest,
]).then(([trackedRows, explorerRows]) => {
const untrackedRows = (explorerRows ?? previous).filter(
(row) =>
!trackedSet.has(String(row.address).toLowerCase()),
);
addr.tokenBalances = trackedRows.concat(untrackedRows);
log.debugf(
"Token balances",
addr.address,
addr.tokenBalances.length,
"tokens",
);
}),
);
}
+14
View File
@@ -224,6 +224,19 @@ function storageRemove(keys) {
return Promise.resolve(storage.remove(keys));
}
/**
* Call `listener` after every write to `storage.local`, whether this page,
* another page or the background made it, with what the write changed, as the
* browser reports it: `{ [key]: { oldValue, newValue } }`.
*
* @param {function(Object): void} listener
*/
function onStorageChanged(listener) {
namespaceMember("storage").onChanged.addListener((changes, areaName) => {
if (areaName === "local") listener(changes);
});
}
/**
* Run `fn` while holding the browser's lock called `name`
* (`navigator.locks`), and settle as `fn` settles. Every page of the extension
@@ -385,6 +398,7 @@ module.exports = {
getPlatformInfo,
hasBrowserNamespace,
notify,
onStorageChanged,
runtimeApi,
sendMessage,
sessionStorageGet,
+37 -17
View File
@@ -1,8 +1,18 @@
// Consolidated chain-switch handler for the popup.
// The popup's network switch.
//
// Every state change required when the active network changes is
// performed here so that callers (settings UI, future chain additions) all go
// through a single code path.
// A network switch reaches the popup's `state` in one of two ways:
//
// - onChainSwitch() below makes the switch the user picks in Settings, with
// applyChainSwitchFields() (chainSwitchFields.js).
// - followNetworkSwitch() in src/popup/index.js takes a switch the background
// or another popup page saved, from the stored record, with
// takeStoredNetwork() (state.js), which copies it with
// copyChainSwitchFields() (chainSwitchFields.js).
//
// Both change `state` inside a save, holding the lock, and both reset the
// popup's own caches through clearPopupCaches(), so a reset added there
// reaches both. A field applyChainSwitchFields() sets has to be copied by
// copyChainSwitchFields() too.
//
// Adding a new chain (e.g. ETC) requires only a new entry in
// networks.js — no per-caller wiring is needed.
@@ -20,22 +30,32 @@
const { applyChainSwitchFields } = require("./chainSwitchFields");
const { clearPrices } = require("./prices");
// The popup's own caches a network switch resets. Prices are chain-specific
// (testnet tokens are worthless, ETC has different pricing, etc.). In-memory
// and per bundle, so this is the popup's own cache — the only context that
// ever fills it.
function clearPopupCaches() {
clearPrices();
}
// Switch the active chain and reset all chain-specific cached state.
// Returns the network configuration object for the new chain.
// Returns the network configuration object for the new chain, or null when
// `state` already has that network once the save holds the lock, and nothing
// is switched.
//
// `state` is switched inside the save that stores the switch, holding the
// lock, so it is never switched while a follow of a switch saved elsewhere
// waits for the lock (followNetworkSwitch() in src/popup/index.js): that
// follow runs first, and this switch is made after it.
async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state");
const net = applyChainSwitchFields(state, newNetworkId);
// --- price cache ---
// Prices are chain-specific (testnet tokens are worthless,
// ETC has different pricing, etc.). In-memory and per bundle, so this is
// the popup's own cache — the only context that ever fills it.
clearPrices();
await saveState();
let net = null;
await saveState(() => {
if (state.networkId === newNetworkId) return false;
net = applyChainSwitchFields(state, newNetworkId);
clearPopupCaches();
});
return net;
}
module.exports = { onChainSwitch };
module.exports = { onChainSwitch, clearPopupCaches };
+31 -2
View File
@@ -7,7 +7,7 @@
// the singleton and saves; the background applies it to the detached record of
// its own read-modify-write (src/background/state.js).
//
// Everything here is synchronous and touches nothing but the object it is
// Everything here is synchronous and touches nothing but the objects it is
// given: no storage, no caches, no imports beyond the network table. That is
// what makes it usable on a record that has been read fresh from storage
// microseconds earlier and is about to be written back.
@@ -76,4 +76,33 @@ function applyChainSwitchFields(s, newNetworkId) {
return net;
}
module.exports = { applyChainSwitchFields };
// Give `s` every field applyChainSwitchFields() sets, as `switched` holds it:
// the popup takes a switch the background saved from the stored record this
// way, rather than performing it again (takeStoredNetwork() in
// src/shared/state.js). Balances are taken by address, and an address
// `switched` does not have keeps none.
function copyChainSwitchFields(s, switched) {
s.networkEndpoints = switched.networkEndpoints;
s.networkTrackedTokens = switched.networkTrackedTokens;
s.trackedTokens = switched.trackedTokens;
s.networkId = switched.networkId;
s.rpcUrl = switched.rpcUrl;
s.blockscoutUrl = switched.blockscoutUrl;
s.lastBalanceRefresh = switched.lastBalanceRefresh;
s.tokenHolderCache = switched.tokenHolderCache;
s.fraudContracts = switched.fraudContracts;
const stored = new Map();
for (const wallet of switched.wallets) {
for (const addr of wallet.addresses) stored.set(addr.address, addr);
}
for (const wallet of s.wallets) {
for (const addr of wallet.addresses) {
const got = stored.get(addr.address);
addr.balance = got ? got.balance : "0";
addr.tokenBalances = got ? got.tokenBalances : [];
}
}
}
module.exports = { applyChainSwitchFields, copyChainSwitchFields };
+10 -1
View File
@@ -78,11 +78,20 @@ function parseEtherscanPage(html) {
* when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on
* Sepolia.
*
* While the user's "Check recipients on Etherscan" setting is off, Etherscan
* is not asked and the answer is null.
*
* @param {string} address - Ethereum address to check.
* @param {boolean} checkRecipientsOnEtherscan - The user's setting.
* @param {string} [chainId] - Chain id of the network to look it up on.
* @returns {Promise<{type: string, message: string, severity: string}|null>}
*/
async function checkEtherscanLabel(address, chainId) {
async function checkEtherscanLabel(
address,
checkRecipientsOnEtherscan,
chainId,
) {
if (!checkRecipientsOnEtherscan) return null;
try {
// Lazy require to avoid pulling in chrome.storage at module scope
// (which breaks unit tests that only exercise parseEtherscanPage).
+12
View File
@@ -48,6 +48,12 @@ const DEFAULT_STATE = {
hideDustTransactions: true,
dustThresholdGwei: 100000,
utcTimestamps: false,
// "Use Blockscout" and "Check recipients on Etherscan" in Settings, one
// setting each for every network. Both off until the user turns them on:
// the requests they allow send the user's addresses to a service other
// than the RPC endpoint.
useBlockscout: false,
checkRecipientsOnEtherscan: false,
fraudContracts: [],
tokenHolderCache: {},
theme: "system",
@@ -406,6 +412,12 @@ function normalizePersisted(saved) {
: 100000;
out.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
out.useBlockscout =
saved.useBlockscout !== undefined ? saved.useBlockscout : false;
out.checkRecipientsOnEtherscan =
saved.checkRecipientsOnEtherscan !== undefined
? saved.checkRecipientsOnEtherscan
: false;
// A list of contract addresses, floored the same way: send.js builds its
// fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())`
// and filterTransactions() maps the same list through normalizeAddress(),
+20 -3
View File
@@ -23,6 +23,7 @@
// guarantee.
const { networkById } = require("./networks");
const { copyChainSwitchFields } = require("./chainSwitchFields");
const {
DEFAULT_STATE,
PERSISTED_FIELDS,
@@ -125,8 +126,10 @@ function currentNetwork() {
}
// The persisted fields as this page held them when its last loadState()
// finished, or when its last successful saveState() began. saveState() diffs
// the live state against this to find only the fields THIS page changed since.
// finished, or when its last successful saveState() began, with any network
// switch saved elsewhere that this page has taken since (takeStoredNetwork()).
// saveState() diffs the live state against this to find only the fields THIS
// page changed since.
//
// Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -466,7 +469,9 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// exists to remove, just delayed and confined to one page instead of two
// (caught by tests/txStatus.test.js). A page's live picture of a field it
// does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current.
// before this fix; only the persisted record is guaranteed current. The
// network is the exception: the popup takes a switch another context saved
// through `beforeWrite` (followNetworkSwitch() in src/popup/index.js).
async function saveStateOnce(beforeWrite) {
const result = await storageGet("autistmask");
// The record in storage right now is about to be merged into and written
@@ -597,6 +602,17 @@ function saveState(beforeWrite) {
return turn;
}
// Give this page the network switch held by `stored`, a record a save of this
// page has just read (followNetworkSwitch() in src/popup/index.js calls this
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does
// with a whole record. The switch is then not this page's own change, even when
// that save fails to write: a later switch back to the previous network still
// differs from `baseline`, and is stored.
function takeStoredNetwork(stored) {
copyChainSwitchFields(rawState, stored);
copyChainSwitchFields(baseline, structuredClone(stored));
}
// Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first
@@ -642,6 +658,7 @@ module.exports = {
state,
saveState,
onSaveFailure,
takeStoredNetwork,
loadState,
currentAddress,
currentNetwork,
+6
View File
@@ -235,12 +235,18 @@ function mergeTransactions(txs, tokenTransfers) {
// `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry
// carries it, and a native entry is labelled with that network's
// `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`).
//
// `useBlockscout` is the user's "Use Blockscout" setting. While it is off,
// Blockscout is not asked and there is no history: the answer is an empty
// list.
async function fetchRecentTransactions(
address,
blockscoutUrl,
useBlockscout,
chainId,
count = 25,
) {
if (!useBlockscout) return [];
log.debugf("fetchRecentTransactions", address);
const addrLower = normalizeAddress(address);
+169 -60
View File
@@ -336,7 +336,8 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// currencies, and those commands may name one, so saying the calldata did not
// name it would be false. The Paid to line uses it too, when a step may pay an
// address this decoder did not read, and so do the lines for what a step
// takes from the sender, when this decoder does not know it.
// takes from the sender, when this decoder does not know it, and for what a
// permit it could not read allows.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// The Paid to line for each token paid to the router, the swap's output or
@@ -528,6 +529,32 @@ function decodePermit2(input) {
}
}
// Decode PERMIT2_PERMIT_BATCH (command 0x03) input bytes into the allowances
// it grants, one for each token it names, all for its one spender, in the
// shape decodePermit2() returns. Each release's
// `contracts/base/Dispatcher.sol`, at the commit linked for it above, hands
// Permit2 the batch in this shape.
// ABI: (((address token, uint160 amount, uint48 expiration, uint48 nonce)[],
// address spender, uint256 sigDeadline), bytes signature)
function decodePermit2Batch(input) {
try {
const d = coder.decode(
[
"tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)",
"bytes",
],
input,
);
return d[0][0].map((details) => ({
token: details[0],
amount: details[1],
spender: d[0][1],
}));
} catch {
return null;
}
}
// Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes. The router passes the
// check when `owner` holds at least `minBalance` of `token`, and reads `owner`
// as written, without map(), on every release that has the command:
@@ -597,12 +624,14 @@ function decodeV2SwapExactOut(input) {
// token(20) ...), or null when it is not one token followed by whole hops of
// a fee and a token (40 hex digits, then 46 per hop). The router reads only
// whole hops and ignores any bytes after the last one, so the last 20 bytes
// of such a path are not the token it reads there.
// of such a path are not the token it reads there. Both are checksummed, as
// the ABI decoder gives every other address, so a token reads the same on
// every line.
function decodeV3Path(pathHex) {
const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex;
if (hex.length < 40 || (hex.length - 40) % 46 !== 0) return null;
const first = "0x" + hex.slice(0, 40);
const last = "0x" + hex.slice(-40);
const first = getAddress("0x" + hex.slice(0, 40));
const last = getAddress("0x" + hex.slice(-40));
return { first, last };
}
@@ -924,7 +953,9 @@ function totalAmount(amounts) {
// 2. Amount is the amountIn of the swap action that spends Token In. If
// several exact-in actions spend it, each with a stated amount, it is
// their sum; in any other case there is none. Min. received is the same
// for amountOutMin and the actions that buy Token Out.
// for amountOutMin and the actions that buy Token Out, except that there
// is none when a swap action in the step also spends Token Out: the step
// then does not pay out all that those actions buy.
// 3. If any swap action in the step cannot be read, only SETTLE, SETTLE_ALL,
// TAKE and TAKE_ALL name the sides, and there is no Amount or Min.
// received.
@@ -1158,11 +1189,13 @@ function decodeV4Swap(input) {
const boughtOnly = currenciesNotIn(bought, spent);
if (present(takeToken) || boughtOnly.length === 1) {
tokenOut = present(takeToken) ? takeToken : boughtOnly[0];
amountOutMin = totalAmount(
swaps
.filter((s) => s.tokenOut === tokenOut)
.map((s) => s.amountOutMin),
);
if (!spent.includes(tokenOut)) {
amountOutMin = totalAmount(
swaps
.filter((s) => s.tokenOut === tokenOut)
.map((s) => s.amountOutMin),
);
}
}
return {
@@ -1205,17 +1238,21 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let inputToken = null;
let inputAmount = null;
let inputEstablished = false;
// Whether the user pays the V4 step that set the input side any of
// its input currency (see decodeV4Swap()). Only a V4 step can set the
// input side with no amount, the one case in which this is read.
// Whether the user pays the step that set the input side: a V2 or V3
// swap step with `payerIsUser` true, a V4 step the user pays some of
// its input currency (see decodeV4Swap()), or a WRAP_ETH, whose ETH
// comes from the transaction's value. A step paid from the router
// takes nothing from the sender.
let inputPaidByUser = false;
// The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop).
let permit = null;
// Every PERMIT2_PERMIT this decoder read, before a step set the input
// side or after, in step order, as { token, amount, spender, step }.
// Each whose amount is not the Amount figure gets lines of its own
// (see after the PERMIT2_TRANSFER_FROM lines).
// Every allowance a PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH grants,
// before a step set the input side or after, in step order, as
// { token, amount, spender, step }. A permit or batch whose input
// this decoder cannot read is one with a null token, amount and
// spender. Each but a permit whose amount is the Amount figure gets
// lines of its own (see after the PERMIT2_TRANSFER_FROM lines).
const permits = [];
// The index of each step this decoder did not read in full: a
// command it does not read, a step whose input it cannot read, in
@@ -1320,6 +1357,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const spentUnread = () => {
userPaid.push({ token: null, amount: null });
};
// A PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH at `step` whose input this
// decoder cannot read may let any spender take any amount of any
// token: one line says so, there is no Amount line, whose figure may
// then be too low, and the minimum reads Min. paid out (see
// toAnotherSpender).
const permitUnread = (step) => {
permits.push({ token: null, amount: null, spender: null, step });
stepsUnread.push(step);
};
// Who the steps pay, in whichever step and of whatever token.
// MSG_SENDER is the sender. The router itself, as ADDRESS_THIS or by
@@ -1475,7 +1521,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
permits.push({ ...p, step: i });
keepsRouterWeth = true;
} else {
stepsUnread.push(i);
permitUnread(i);
}
// Only a permit whose amount stands as the Amount figure
// names Token In (see permitStands). Every other permit,
@@ -1485,14 +1531,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// side never stands, so only one before it is kept here.
if (p && !inputEstablished) {
// Whether a step before it took its token, or may
// have: one in `userPaid`, when its token is the
// permit's or not known. No step has set the input
// side yet.
// have: one in `userPaid`, `movedIntoRouter` or
// `movedFromSender`, when its token is the permit's
// or not known. No step has set the input side yet.
const mayBeItsToken = (token) =>
!present(token) || sameAddress(token, p.token);
const tookBefore = userPaid.some((t) =>
mayBeItsToken(t.token),
);
const tookBefore = [
...userPaid,
...movedIntoRouter,
...movedFromSender,
].some((t) => mayBeItsToken(t.token));
permit = {
...p,
step: i,
@@ -1500,6 +1548,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
beforeEveryTake: !tookBefore,
};
}
} else if (command === "PERMIT2_PERMIT_BATCH") {
// Each allowance it grants gets the lines of a permit
// whose amount does not stand. It pays nobody.
const batch = decodePermit2Batch(inputs[i]);
if (batch) {
permits.push(...batch.map((p) => ({ ...p, step: i })));
} else {
permitUnread(i);
}
} else if (command === "PERMIT2_TRANSFER_FROM") {
const p = decodePermit2TransferFrom(inputs[i]);
if (!p) {
@@ -1572,6 +1629,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
@@ -1595,6 +1653,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
@@ -1626,6 +1685,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountInMax);
inputPaidByUser = s.payerIsUser;
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax);
}
@@ -1721,10 +1781,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "WRAP_ETH") {
const w = decodeWrapEth(inputs[i]);
if (w) {
setInputOnce(
"0x0000000000000000000000000000000000000000",
w.amount,
);
if (!inputEstablished) {
setInputOnce(
"0x0000000000000000000000000000000000000000",
w.amount,
);
inputPaidByUser = true;
}
addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH.
if (w.amount === CONTRACT_BALANCE) {
@@ -1908,8 +1971,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
// Any other step may pay an address this decoder does not
// read: PERMIT2_PERMIT_BATCH and the NFT commands among
// them, and a type the release names no command for.
// read: the NFT commands among them, and a type the
// release names no command for.
payeeUnread = true;
stepsUnread.push(i);
}
@@ -1959,17 +2022,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// Amount figure in place of the total, which it does only in the
// plain shape of Uniswap's own routes: it comes before the step that
// set the input side, it is the only PERMIT2_PERMIT in the
// transaction, its spender is the router, the router may not let it
// fail, no step before it took its token or one not known, and this
// decoder read every step after it in full (see stepsUnread). Permit2
// then lets the router take no more of the token than that amount, so
// the permit covers every take of it. A later step this decoder did
// not read may raise what Permit2 lets the router take, as a
// PERMIT2_PERMIT_BATCH can. Every other PERMIT2_PERMIT leaves Token In
// and the Amount line as they read with no permit, and gets lines of
// its own below. A step that may take from the sender what this decoder
// did not read leaves no figure, permit or not (see spentUnread()),
// and an EXECUTE_SUB_PLAN step leaves both sides unknown (below).
// transaction and there is no PERMIT2_PERMIT_BATCH, its spender is
// the router, the router may not let it fail, no step before it took
// its token or one not known (a PERMIT2_TRANSFER_FROM of its token
// included), and this decoder read every step after it in full (see
// stepsUnread). Permit2 then lets the router take no more of the token
// than that amount, so the permit covers every take of it. A step
// before it takes under whatever allowance the router already had, so
// it may take more. A later permit or batch, or a later step this
// decoder did not read, may raise what Permit2 lets the router take.
// Every other PERMIT2_PERMIT leaves Token In and the Amount line as
// they read with no permit, and gets lines of its own below. A step
// that may take from the sender what this decoder did not read leaves
// no figure, permit or not (see spentUnread()), and so do a permit
// or batch whose input it cannot read (see permitUnread()) and an
// allowance of Token In to another spender (see toAnotherSpender). An
// EXECUTE_SUB_PLAN step leaves both sides unknown (below).
const stepCommands = Array.from(
commandsBytes,
(b) => commands[b & commandTypeMask],
@@ -1977,6 +2045,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const permitStands =
permit !== null &&
stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 &&
!stepCommands.includes("PERMIT2_PERMIT_BATCH") &&
sameAddress(permit.spender, toAddress) &&
!permit.allowedToFail &&
permit.beforeEveryTake &&
@@ -1986,6 +2055,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// (see spentUnread()), or a token it does not know.
const spentFromSender = [];
let total = null;
// The takes from the sender that `total` adds up, each a quantity, as
// { token, amount }. When there is no Amount line, each gets lines of
// its own (see below). The amount of a step that set the input side
// and is paid from the router counts in `total` but is not among them.
const inTotal = [];
// Whether the step that set the input side counts as a take from the
// sender, which may get lines of its own. The amount it stated counts
// whoever pays it. Stating none, it counts only when the user pays it
@@ -1998,6 +2072,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let firstTakeUnstated = null;
if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount;
if (typeof inputAmount === "bigint" && inputPaidByUser) {
inTotal.push({ token: inputToken, amount: inputAmount });
}
if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null };
}
@@ -2016,6 +2093,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
typeof t.amount === "bigint"
) {
total += t.amount;
inTotal.push(t);
continue;
}
spentFromSender.push(t);
@@ -2039,10 +2117,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
// The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens
// or pay the user, before or after any other step, and a permit among
// them would name Token In. What the other steps state is
// then not the whole swap, so both sides are set unknown, with no
// figure, wherever the step stands.
// or pay the user, before or after any other step. What the other
// steps state is then not the whole swap, so both sides are set
// unknown, with no figure, wherever the step stands.
if (hasSubPlan) {
inputToken = null;
inputAmount = null;
@@ -2145,12 +2222,30 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
return amountText(amount, info);
};
// Each allowance to a spender other than the router. Code that a step
// runs, such as a V4 pool's hook or a token's own code, can call that
// spender, which can then take up to the allowance from the sender
// before the transaction ends. A permit or batch this decoder could
// not read may be to any spender, of any token.
const toAnotherSpender = permits.filter(
(p) => !sameAddress(p.spender, toAddress),
);
// A null token in `spentFromSender`, a step this decoder did not read
// or a token it does not know, leaves no Amount figure, which may
// then be too low.
// or a token it does not know, and an allowance to another spender
// that may be of Token In, leave no Amount figure, which may then be
// too low. With no Amount line, each take the total adds up gets
// lines of its own, first, so that every amount this decoder read is
// shown.
if (
present(inputAmount) &&
spentFromSender.every((s) => present(s.token))
spentFromSender.every((s) => present(s.token)) &&
toAnotherSpender.every(
(p) =>
present(p.token) &&
present(inputToken) &&
!sameAddress(p.token, inputToken),
)
) {
const amount = spentText(inputAmount, inInfo);
details.push({
@@ -2158,6 +2253,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
value: amount.display,
rawValue: amount.raw,
});
} else {
spentFromSender.unshift(...inTotal);
}
// What steps take from the sender beyond the Amount line, in the shape
@@ -2200,16 +2297,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
);
}
// What each PERMIT2_PERMIT lets its spender take: its amount, its
// token and the spender, an address on a line of its own. Permit2's
// maximum is unbounded. Only a permit whose amount stands as the
// Amount figure (see permitStands) gets none: that line already gives
// its amount and Token In its token, and its spender is the router.
// Every other permit, one after the step that set the input side
// included, gets these lines, so nothing it lets anyone take goes
// unstated.
// What each allowance of a PERMIT2_PERMIT or PERMIT2_PERMIT_BATCH lets
// its spender take: its amount, its token and the spender, an address
// on a line of its own. Permit2's maximum is unbounded. Only a permit
// whose amount stands as the Amount figure (see permitStands) gets
// none: that line already gives its amount and Token In its token,
// and its spender is the router. Every other allowance, one after the
// step that set the input side included, gets these lines, so nothing
// it lets anyone take goes unstated. A permit or batch this decoder
// could not read gets one line saying so.
for (const p of permits) {
if (permitStands && p.step === permit.step) continue;
if (!present(p.token)) {
details.push({
label: "Permit2 allowance",
value: UNDECODED_CURRENCY,
});
continue;
}
details.push({
label: "Permit2 allowance",
value:
@@ -2261,9 +2366,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// step pays the bridge, when one moves the sender's tokens into the
// router, when a token stays in the router, and when a payout could
// not be read. The output is received by the sender only when some
// step pays the sender and there is no such line. When no step names
// anyone paid, one line says so. `paidTo` holds these lines and the
// token line after each line about a token in the router.
// step pays the sender, there is no such line, and no allowance goes
// to another spender (see toAnotherSpender). When no step names anyone
// paid, one line says so. `paidTo` holds these lines and the token
// line after each line about a token in the router.
const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const paidTo = paidElsewhere.map((address) => ({
@@ -2310,7 +2416,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (payeeUnread) {
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
}
const received = paidToSender && paidTo.length === 0;
const received =
paidToSender &&
paidTo.length === 0 &&
toAnotherSpender.length === 0;
if (!paidToSender && paidTo.length === 0) {
paidTo.push({ label: "Paid to", value: UNNAMED_CURRENCY });
}
+8 -1
View File
@@ -410,7 +410,14 @@ describe("a balance refresh under a network switch", () => {
const ranOn = [];
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets, rpcUrl, explorer, tokens, id) => {
refreshBalances: async (
wallets,
rpcUrl,
explorer,
useBlockscout,
tokens,
id,
) => {
ranOn.push(id);
reachedNetwork.resolve();
await roundTrip.promise;
+11 -4
View File
@@ -18,6 +18,11 @@ const {
const RPC_URL = "https://rpc.example.invalid";
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
const ADDRESS = "0x1111111111111111111111111111111111111111";
const TRACKED = {
address: "0x7777777777777777777777777777777777777777",
symbol: "TRK",
decimals: 6,
};
const realFetch = globalThis.fetch;
let logged;
@@ -30,9 +35,9 @@ beforeEach(() => {
const failedToFetch = async () => {
throw new TypeError("Failed to fetch");
};
// The RPC calls (ETH balance, ENS name, and the scan's balance and
// transaction count) and the explorer request (token balances) all fail
// the same way.
// The RPC calls (ETH balance, ENS name, the tracked token's balance, and
// the scan's balance and transaction count) and the explorer request (the
// other token balances) all fail the same way.
FetchRequest.registerGetUrl(failedToFetch);
globalThis.fetch = jest.fn(failedToFetch);
});
@@ -49,7 +54,8 @@ function refresh(signal) {
wallets,
RPC_URL,
EXPLORER_URL,
[],
true,
[TRACKED],
"mainnet",
signal,
);
@@ -60,6 +66,7 @@ test("a failure while the popup is open is reported", async () => {
for (const label of [
"ETH balance failed",
"ENS reverse failed",
"token balance failed",
"fetchTokenBalances failed: Failed to fetch",
]) {
expect(logged.some((line) => line.includes(label))).toBe(true);
+6 -5
View File
@@ -91,15 +91,15 @@ test("a refresh that returns after a switch in Settings writes nothing, and the
await page.tick();
expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
expect(refreshBalances.mock.lastCall[5]).toBe("sepolia");
expect(page.storage.read("autistmask").lastBalanceRefresh).toBeGreaterThan(
0,
);
});
// The stored record still holds Ethereum Mainnet while the refresh's save reads
// it, so only this page's own network shows the switch.
test("a refresh whose save is reading the stored record when the user switches in Settings writes nothing", async () => {
// The switch is made only once the refresh's save, holding the lock, has
// written Ethereum Mainnet's balances, and the switch then clears them.
test("a refresh whose save is reading the stored record when the user switches in Settings leaves none of its balances stored", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { state } = require("../src/shared/state");
@@ -145,7 +145,8 @@ test("a refresh that returns after the background switched the network writes no
const refresh = await holdRefresh(page);
// The background writes the switch to Sepolia, as its updateState() does
// once the user approves a site's request. This page is not told.
// once the user approves a site's request. This page is not told of it
// yet.
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
page.storage.write("autistmask", record);
+470
View File
@@ -0,0 +1,470 @@
// Blockscout and Etherscan are sent none of the user's data until the user
// turns them on (https://git.eeqj.de/sneak/AutistMask/issues/668). A new
// profile has "Use Blockscout" and "Check recipients on Etherscan" both off.
// Each request to either service is skipped where it is made while its setting
// is off, and made while it is on; the popup and the background hand the
// stored setting to the code that makes the request. The one exception is the
// endpoint check Settings makes when the user presses "Save" beside the
// Blockscout URL. It asks only for `/stats` and carries no user data; it is
// made whether or not the setting is on.
//
// The functions that make the requests are driven directly, against a fetch()
// that records every URL it is asked for. The screens are driven through the
// real popup over a stored profile (tests/support/popupBoot.js), whose fetch()
// stub records the same way.
"use strict";
const { FetchRequest } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const { makeStorageStub } = require("./support/storageStub");
const {
DEFAULT_STATE,
normalizePersisted,
} = require("../src/shared/persistedState");
const {
fetchTokenBalances,
refreshBalances,
} = require("../src/shared/balances");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const { getFullWarnings } = require("../src/shared/addressWarnings");
const { HISTORY_OFF_TEXT, escapeHtml } = require("../src/popup/views/helpers");
const RPC_URL = "https://rpc.example.invalid";
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
const RECIPIENT = "0x2222222222222222222222222222222222222222";
const TX_HASH = "0x" + "ab".repeat(32);
const ZERO_WORD = "0x" + "0".repeat(64);
// USDC, from the bundled list, as Blockscout reports a holding of it.
const USDC_ROW = {
value: "5000000",
token: {
type: "ERC-20",
address_hash: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
symbol: "USDC",
name: "USD Coin",
decimals: "6",
holders_count: "50000",
},
};
const realFetch = globalThis.fetch;
afterEach(() => {
cleanupPopup();
jest.restoreAllMocks();
});
describe("a new profile", () => {
test("has both settings off", () => {
expect(DEFAULT_STATE.useBlockscout).toBe(false);
expect(DEFAULT_STATE.checkRecipientsOnEtherscan).toBe(false);
const loaded = normalizePersisted(undefined);
expect(loaded.useBlockscout).toBe(false);
expect(loaded.checkRecipientsOnEtherscan).toBe(false);
});
test("keeps either setting the user turned on", () => {
const loaded = normalizePersisted({
useBlockscout: true,
checkRecipientsOnEtherscan: true,
});
expect(loaded.useBlockscout).toBe(true);
expect(loaded.checkRecipientsOnEtherscan).toBe(true);
});
});
describe("where each request is made", () => {
// Every URL fetch() was asked for, in order.
let asked;
beforeEach(() => {
asked = [];
globalThis.fetch = jest.fn(async (url) => {
asked.push(String(url));
return {
ok: true,
status: 200,
json: async () =>
String(url).endsWith("/token-balances")
? [USDC_ROW]
: { items: [] },
text: async () => "<html><body></body></html>",
};
});
});
afterEach(() => {
globalThis.fetch = realFetch;
});
test("Blockscout's token balances are asked for only while Use Blockscout is on", async () => {
expect(
await fetchTokenBalances(ADDRESS, BLOCKSCOUT, false, []),
).toEqual([]);
expect(asked).toEqual([]);
const rows = await fetchTokenBalances(ADDRESS, BLOCKSCOUT, true, []);
expect(rows.map((row) => row.symbol)).toEqual(["USDC"]);
expect(asked).toEqual([
BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-balances",
]);
});
test("the transaction history is asked for only while Use Blockscout is on", async () => {
expect(
await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, false, "0x1"),
).toEqual([]);
expect(asked).toEqual([]);
await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, true, "0x1");
expect(asked.sort()).toEqual([
BLOCKSCOUT +
"/addresses/" +
ADDRESS +
"/token-transfers?type=ERC-20",
BLOCKSCOUT + "/addresses/" + ADDRESS + "/transactions",
]);
});
test("the Etherscan label check is made only while Check recipients on Etherscan is on", async () => {
const provider = {
getCode: async () => "0x",
getTransactionCount: async () => 1,
getBalance: async () => 0n,
};
await getFullWarnings(RECIPIENT, provider, { chainId: "0x1" });
await getFullWarnings(RECIPIENT, provider, {
chainId: "0x1",
checkRecipientsOnEtherscan: false,
});
expect(asked).toEqual([]);
await getFullWarnings(RECIPIENT, provider, {
chainId: "0x1",
checkRecipientsOnEtherscan: true,
});
expect(asked).toEqual(["https://etherscan.io/address/" + RECIPIENT]);
});
describe("a balance refresh", () => {
// The RPC endpoint answers every balance with zero, and every other
// call, the ENS reverse lookup's, with no resolver.
beforeEach(() => {
FetchRequest.registerGetUrl(async (req) => {
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
const reply = (r) => ({
jsonrpc: "2.0",
id: r.id,
result: r.method === "eth_getBalance" ? "0x0" : ZERO_WORD,
});
const answer = Array.isArray(body)
? body.map(reply)
: reply(body);
return {
statusCode: 200,
statusMessage: "OK",
headers: { "content-type": "application/json" },
body: Buffer.from(JSON.stringify(answer)),
};
});
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
});
// An address that was refreshed while Use Blockscout was on, and holds
// the USDC Blockscout reported then.
function addressWithUsdc() {
return {
address: ADDRESS,
tokenBalances: [
{
address: USDC_ROW.token.address_hash,
name: "USD Coin",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
},
],
};
}
async function refresh(addr, useBlockscout) {
await refreshBalances(
[{ addresses: [addr] }],
RPC_URL,
BLOCKSCOUT,
useBlockscout,
[],
"mainnet",
);
}
test("asks Blockscout nothing while Use Blockscout is off, and then lists no token the user has not tracked", async () => {
const addr = addressWithUsdc();
await refresh(addr, false);
expect(asked).toEqual([]);
expect(addr.tokenBalances).toEqual([]);
});
test("asks Blockscout for the token balances while Use Blockscout is on", async () => {
const addr = addressWithUsdc();
await refresh(addr, true);
expect(asked).toEqual([
BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-balances",
]);
expect(addr.tokenBalances.map((row) => row.symbol)).toEqual([
"USDC",
]);
});
});
});
describe("the popup", () => {
// Every URL the booted page handed to fetch().
function fetched() {
return globalThis.fetch.mock.calls.map((call) => String(call[0]));
}
function blockscoutOrEtherscan(url) {
return url.includes("blockscout") || url.includes("etherscan");
}
beforeEach(() => {
// The screens below log their failed RPC reads; the stub node in
// popupBoot.js answers none.
jest.spyOn(console, "error").mockImplementation(() => {});
});
test("hands its balance refresh and Home's history the stored Use Blockscout setting", async () => {
await bootPopup(unversionedValidProfile());
const {
refreshBalances: popupRefresh,
} = require("../src/shared/balances");
const {
fetchRecentTransactions: popupHistory,
} = require("../src/shared/transactions");
expect(popupRefresh.mock.calls[0][3]).toBe(false);
expect(popupHistory.mock.calls[0][2]).toBe(false);
cleanupPopup();
await bootPopup(unversionedValidProfile({ useBlockscout: true }));
const {
refreshBalances: onRefresh,
} = require("../src/shared/balances");
const {
fetchRecentTransactions: onHistory,
} = require("../src/shared/transactions");
expect(onRefresh.mock.calls[0][3]).toBe(true);
expect(onHistory.mock.calls[0][2]).toBe(true);
});
test("Home says why there is no history and only tracked tokens while Use Blockscout is off", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.node("home-tx-list").innerHTML).toContain(
escapeHtml(HISTORY_OFF_TEXT),
);
expect(page.hidden("home-tokens-note")).toBe(false);
cleanupPopup();
const on = await bootPopup(
unversionedValidProfile({ useBlockscout: true }),
);
expect(on.node("home-tx-list").innerHTML).toContain(
"No transactions found.",
);
expect(on.hidden("home-tokens-note")).toBe(true);
});
// The transaction screen, as a reopened popup draws it.
function onTransactionScreen(extra) {
return unversionedValidProfile({
currentView: "transaction",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
viewData: {
tx: {
hash: TX_HASH,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
direction: "sent",
directionLabel: "Sent",
contractAddress: null,
chainId: "0x1",
},
},
...extra,
});
}
test("TransactionDetail asks Blockscout for the transaction only while Use Blockscout is on", async () => {
const page = await bootPopup(onTransactionScreen());
expect(page.visibleViews()).toEqual(["transaction"]);
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([]);
expect(page.hidden("tx-detail-blockscout-note")).toBe(false);
cleanupPopup();
const on = await bootPopup(
onTransactionScreen({ useBlockscout: true }),
);
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([
BLOCKSCOUT + "/transactions/" + TX_HASH,
]);
expect(on.hidden("tx-detail-blockscout-note")).toBe(true);
});
// The confirmation screen for a send of 0.1 ETH to RECIPIENT, as a
// reopened popup draws it.
function onConfirmScreen(extra) {
return unversionedValidProfile({
currentView: "confirm-tx",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
viewData: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: RECIPIENT,
amount: "0.1",
chainId: "0x1",
},
},
...extra,
});
}
test("ConfirmTx asks Etherscan about the recipient only while Check recipients on Etherscan is on", async () => {
const page = await bootPopup(onConfirmScreen());
expect(page.visibleViews()).toEqual(["confirm-tx"]);
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([]);
expect(page.hidden("confirm-etherscan-note")).toBe(false);
expect(page.hidden("confirm-etherscan-warning")).toBe(true);
cleanupPopup();
const on = await bootPopup(
onConfirmScreen({ checkRecipientsOnEtherscan: true }),
);
expect(fetched().filter(blockscoutOrEtherscan)).toEqual([
"https://etherscan.io/address/" + RECIPIENT,
]);
expect(on.hidden("confirm-etherscan-note")).toBe(true);
expect(on.hidden("confirm-etherscan-warning")).toBe(false);
});
// Ticks a checkbox on the page, as the user does.
async function tick(page, id) {
const box = page.node(id);
box.checked = true;
for (const fn of box.listeners.change) await fn();
await page.settle();
}
test("Welcome offers both settings unticked, and ticking one there stores it", async () => {
const page = await bootPopup(undefined);
expect(page.visibleViews()).toEqual(["welcome"]);
expect(page.node("welcome-use-blockscout").checked).toBe(false);
expect(page.node("welcome-check-recipients-on-etherscan").checked).toBe(
false,
);
await tick(page, "welcome-use-blockscout");
expect(page.storage.read("autistmask").useBlockscout).toBe(true);
await tick(page, "welcome-check-recipients-on-etherscan");
expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe(
true,
);
});
test("Settings shows both settings unticked, and ticking one there stores it", async () => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
expect(page.node("settings-use-blockscout").checked).toBe(false);
expect(
page.node("settings-check-recipients-on-etherscan").checked,
).toBe(false);
await tick(page, "settings-use-blockscout");
expect(page.storage.read("autistmask").useBlockscout).toBe(true);
await tick(page, "settings-check-recipients-on-etherscan");
expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe(
true,
);
});
});
describe("the background", () => {
afterEach(() => {
delete globalThis.chrome;
jest.dontMock("../src/shared/balances");
});
// Starts the background over `profile`, fires its balance alarm, and
// answers with the Use Blockscout setting its refresh was handed.
async function settingTheRefreshIsHanded(profile) {
jest.resetModules();
let handed;
const called = new Promise((resolve) => {
handed = resolve;
});
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: async (...args) => handed(args[3]),
}));
let onAlarm = null;
globalThis.chrome = {
storage: makeStorageStub({ autistmask: profile }),
alarms: {
create: () => {},
get: async () => undefined,
clear: async () => false,
onAlarm: {
addListener: (fn) => {
onAlarm = fn;
},
},
},
runtime: {
onMessage: { addListener: () => {} },
onConnect: { addListener: () => {} },
onInstalled: { addListener: () => {} },
onStartup: { addListener: () => {} },
getURL: (p) => "chrome-extension://test/" + p,
lastError: null,
},
windows: { onRemoved: { addListener: () => {} } },
tabs: { query: () => {}, sendMessage: () => {} },
action: { setPopup: () => {} },
};
require("../src/background/index");
const { BALANCE_REFRESH_ALARM } = require("../src/shared/alarms");
onAlarm({ name: BALANCE_REFRESH_ALARM });
return called;
}
test("hands its balance refresh the stored Use Blockscout setting", async () => {
const profile = { ...unversionedValidProfile(), lastBalanceRefresh: 0 };
expect(await settingTheRefreshIsHanded(profile)).toBe(false);
expect(
await settingTheRefreshIsHanded({
...profile,
useBlockscout: true,
}),
).toBe(true);
});
});
+4 -1
View File
@@ -446,7 +446,10 @@ describe("switching the network in Settings tells every open tab", () => {
global.chrome.runtime.sendMessage = (msg) => {
bg.send(msg, POPUP);
};
await require("../src/shared/state").loadState();
const { state, loadState } = require("../src/shared/state");
await loadState();
// Set by showView(), which is mocked above.
state.currentView = "settings";
require("../src/popup/views/settings").init({
pageClosed: new AbortController().signal,
});
+1
View File
@@ -247,6 +247,7 @@ async function openSend(token, tokenUnits = null) {
state.wallets,
"https://rpc.example.invalid",
"https://blockscout.example/api/v2",
true,
state.trackedTokens,
"mainnet",
);
+7 -1
View File
@@ -50,8 +50,14 @@ function explorerPage(title) {
// The explorer labels no address.
global.fetch = jest.fn(async () => explorerPage("Etherscan"));
// The Etherscan label check is made, so its box can be shown.
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.chrome = {
storage: makeStorageStub({
autistmask: { checkRecipientsOnEtherscan: true },
}),
runtime: { sendMessage() {} },
};
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
+8 -2
View File
@@ -142,13 +142,19 @@ jest.mock("../src/popup/views/txStatus", () => ({
}));
// The Etherscan label check is its only caller here: the explorer pages it
// asks for are recorded, and nothing is fetched.
// asks for are recorded, and nothing is fetched. The stored profile has the
// check turned on.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
global.chrome = {
storage: makeStorageStub({
autistmask: { checkRecipientsOnEtherscan: true },
}),
runtime: { sendMessage() {} },
};
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
+2
View File
@@ -297,6 +297,8 @@ describe.each([
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
// The history is Blockscout's.
state.useBlockscout = true;
});
test("a contract creation's row says so, with no colour dot and no address line", async () => {
+12
View File
@@ -867,6 +867,14 @@ step(
"the page was not told of the approved switch: " +
JSON.stringify(events),
);
// The popup, open all along, shows the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/650).
await d.switchToWindow(env.popupWindow);
await d.waitFor(
"the open popup to show the testnet banner",
`const banner = document.getElementById("debug-banner");
return banner !== null && banner.textContent === "[TESTNET]";`,
);
await openNetworkPrompt(env, "switch-restore", sepolia, mainnet);
await d.click("#btn-approve-network");
@@ -881,6 +889,10 @@ step(
isDeepStrictEqual(await storedNetwork(env), before),
"switching back did not restore the mainnet network and endpoints",
);
await d.waitFor(
"the open popup to drop the testnet banner",
`return document.getElementById("debug-banner") === null;`,
);
await d.switchToWindow(env.dappWindow);
},
);
+23 -2
View File
@@ -653,6 +653,12 @@ function traceEnabled(raw) {
* confirmed receipt instead of null, so a wait screen resolves.
* @param {boolean} [opts.failReceiptLookup] answer eth_getTransactionReceipt
* with an error, so every receipt lookup fails; read at request time.
* @param {string} [opts.etherscanLabel] the label Etherscan's page gives the
* address asked about, such as "Fake_Phishing1"; none when unset. Read at
* request time.
* @param {object[]} [opts.requests] every request this handler sees, from the
* popup's pages and the background worker alike, appended in order as
* { method, url, fromWorker }.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>}
*/
@@ -691,6 +697,13 @@ async function installNetworkStubs(ctx, opts) {
const origin = fromWorker ? "[sw] " : "[page] ";
console.log("# routed " + origin + req.method() + " " + req.url());
}
if (Array.isArray(opts.requests)) {
opts.requests.push({
method: req.method(),
url: req.url(),
fromWorker,
});
}
// JSON-RPC endpoint (any host): a POST with a JSON-RPC body.
if (req.method() === "POST") {
@@ -762,12 +775,20 @@ async function installNetworkStubs(ctx, opts) {
return route.fulfill({ status: 204, body: "" });
}
// Best-effort Etherscan address labels: served as an empty page.
// Best-effort Etherscan address labels: served as an empty page, or
// as a page labelling the address with opts.etherscanLabel.
if (url.hostname.endsWith("etherscan.io")) {
const title = opts.etherscanLabel
? "<title>" +
opts.etherscanLabel +
" | Address: " +
p.split("/").pop() +
" | Etherscan</title>"
: "";
return route.fulfill({
status: 200,
contentType: "text/html",
body: "<html><body></body></html>",
body: "<html><head>" + title + "</head><body></body></html>",
});
}
+286
View File
@@ -269,6 +269,62 @@ test("the exact confirmation phrase erases the record and reloads into Welcome (
}
});
// ------------------ Blockscout and Etherscan are sent no user data (#668)
//
// A fresh install sends Blockscout and Etherscan none of the user's data until
// the user turns on "Use Blockscout" or "Check recipients on Etherscan". The
// endpoint check Settings makes when the user presses "Save" beside the
// Blockscout URL asks only for `/stats` and carries no user data; it is made
// whether or not the setting is on. The route handler in tests/e2e/network.js
// records every request the extension makes, from its pages and its background
// worker, in env.routeOpts.requests. Recording starts when launch() in
// tests/e2e/harness.js installs that handler, before the first popup page
// opens; a request the background worker makes while it starts up, before
// then, is not recorded. These tests drive Home, AddressDetail, AddressToken
// and ConfirmTx, and the background's own balance refresh, before either
// setting is changed, then turn each on and watch its requests start.
// TransactionDetail, which also asks Blockscout, is left to the unit tests in
// tests/blockscoutEtherscanSettings.test.js.
// What the three transaction lists say while Use Blockscout is off. Written
// out here rather than imported, so the assertion does not compare the popup
// with itself.
const HISTORY_OFF =
'No transaction history is shown, because "Use Blockscout" is off in Settings.';
// The requests made so far to Blockscout or Etherscan.
function explorerRequests(env) {
return env.routeOpts.requests.filter(({ url }) => {
const host = new URL(url).hostname;
return host.endsWith("blockscout.com") || host.endsWith("etherscan.io");
});
}
// Waits until the list `listId` says what Use Blockscout being off leaves out.
async function waitForHistoryOff(page, listId) {
await page.waitForFunction(
([id, want]) => document.getElementById(id).textContent.trim() === want,
[listId, HISTORY_OFF],
{ timeout: 15000 },
);
}
test("Welcome offers Use Blockscout and Check recipients on Etherscan, both unticked (#668)", async (env) => {
await visible(env.page, "#view-welcome");
for (const id of [
"welcome-use-blockscout",
"welcome-check-recipients-on-etherscan",
]) {
const st = await controlState(env.page, id);
assert(st !== null, "Welcome has no #" + id);
assert(
st.tag === "input" && st.type === "checkbox",
"#" + id + " is a <" + st.tag + " type=" + st.type + ">",
);
assert(st.checked === false, "#" + id + " is ticked on a new install");
}
});
// The address scan that follows creating the wallet is held, so the popup
// reloads while it is in flight. As for the reloads mid-refresh further down,
// the assertion for that is the harness's own: a console.error from the page
@@ -296,6 +352,202 @@ test("wallet creation through the UI reaches the main view, and reloading mid-sc
assert(addrCount > 0, "no addresses rendered in the wallet list");
});
test("with neither setting changed, Home, AddressDetail, AddressToken and ConfirmTx say what is off (#668)", async (env) => {
// Funded, so Send leads to the confirmation screen.
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
try {
await visible(env.page, "#view-main");
await waitForHistoryOff(env.page, "home-tx-list");
await visible(env.page, "#home-tokens-note");
await env.page.waitForFunction(
(want) =>
document
.getElementById("wallet-list")
.textContent.includes(want),
quantity(FUNDED_ETH_WEI),
{ timeout: 60000 },
);
await openAddressDetail(env.page);
await waitForHistoryOff(env.page, "tx-list");
await visible(env.page, "#address-tokens-note");
await env.page.locator('#address-balances [data-token="ETH"]').click();
await visible(env.page, "#view-address-token");
await waitForHistoryOff(env.page, "address-token-tx-list");
await env.page.click("#btn-address-token-back");
await visible(env.page, "#view-address");
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await waitForEstimate(env.page);
await visible(env.page, "#confirm-etherscan-note");
assert(
!(await env.page.isVisible("#confirm-etherscan-warning")),
"ConfirmTx keeps the Etherscan warning's place while no Etherscan check is made",
);
await backToAddress(env.page);
} finally {
env.routeOpts.ethBalanceWei = null;
}
});
// The background refreshes balances on its alarm, once a minute, and skips a
// refresh when the stored record says one ran in the last 30 seconds, which
// the open popup's own refresh does every 10. So the popup is closed, and that
// record is written back to say no refresh has run, from the background
// worker, as another extension page saving it would.
test("with neither setting changed, nothing has reached Blockscout or Etherscan, the background's refresh included (#668)", async (env) => {
await waitForPersisted(
env.page,
"currentView",
"address",
"before the popup is closed",
);
await env.page.close();
const [worker] = env.ctx.serviceWorkers();
await worker.evaluate(
() =>
new Promise((resolve) => {
chrome.storage.local.get("autistmask", (r) => {
const record = r.autistmask;
record.lastBalanceRefresh = 0;
chrome.storage.local.set({ autistmask: record }, resolve);
});
}),
);
const before = env.routeOpts.requests.length;
const deadline = Date.now() + 75000;
while (
!env.routeOpts.requests
.slice(before)
.some((r) => r.fromWorker && r.method === "POST")
) {
assert(
Date.now() < deadline,
"the background's balance refresh never asked the RPC endpoint",
);
await sleep(250);
}
// Its RPC reads and any Blockscout request start together.
await sleep(3000);
const seen = explorerRequests(env);
assert(
seen.length === 0,
"a fresh install asked Blockscout or Etherscan: " +
JSON.stringify(seen),
);
// The watch saw the popup's own traffic as well as the worker's.
assert(
env.routeOpts.requests.some((r) => !r.fromWorker),
"no request from the popup was recorded",
);
// It reopens on the address screen the test above left it on.
env.page = await openPopup(env.ctx, env.popupUrl);
await visible(env.page, "#view-address");
});
// Ticked from AddressDetail's gear, so Back lands on the screen that said the
// setting is off.
test("turning on Use Blockscout from AddressDetail's gear asks Blockscout for that screen's history on Back, then for the tokens (#668)", async (env) => {
await openAddressDetail(env.page);
await waitForHistoryOff(env.page, "tx-list");
await openSettings(env.page);
await env.page.check("#settings-use-blockscout");
await waitForPersisted(env.page, "useBlockscout", true, "once ticked");
const historyAsked = env.page.waitForRequest(
(r) =>
r.url().includes("blockscout.com") &&
r.url().endsWith("/transactions"),
);
const tokensAsked = env.page.waitForRequest(
(r) => r.url().endsWith("/token-balances"),
{ timeout: 30000 },
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-address");
await historyAsked;
await env.page.waitForFunction(
() =>
document
.getElementById("tx-list")
.textContent.includes("No transactions found."),
null,
{ timeout: 15000 },
);
assert(
!(await env.page.isVisible("#address-tokens-note")),
"AddressDetail still says only tracked tokens are listed",
);
await goHome(env.page);
await tokensAsked;
await env.page.waitForFunction(
() =>
document
.getElementById("home-tx-list")
.textContent.includes("No transactions found."),
null,
{ timeout: 15000 },
);
assert(
!(await env.page.isVisible("#home-tokens-note")),
"Home still says only tracked tokens are listed",
);
});
// Ticked from ConfirmTx's gear, so Back lands on the screen that said no
// Etherscan check was made. Left on for the rest of the run, as the check was
// always made before #668.
test("turning on Check recipients on Etherscan from ConfirmTx's gear has ConfirmTx ask Etherscan on Back and show its label (#668)", async (env) => {
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.etherscanLabel = "Fake_Phishing668";
try {
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI });
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await visible(env.page, "#confirm-etherscan-note");
await openSettings(env.page);
await env.page.check("#settings-check-recipients-on-etherscan");
await waitForPersisted(
env.page,
"checkRecipientsOnEtherscan",
true,
"once ticked",
);
const before = env.routeOpts.requests.length;
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-confirm-tx");
await visible(env.page, "#confirm-etherscan-warning", 30000);
assert(
!(await env.page.isVisible("#confirm-etherscan-note")),
"ConfirmTx says no Etherscan check was made, with the check on",
);
const asked = env.routeOpts.requests
.slice(before)
.map((r) => r.url.toLowerCase())
.filter((url) => url.includes("etherscan.io"));
assert(
asked.length === 1 &&
asked[0] ===
"https://etherscan.io/address/" + STUB_COUNTERPARTY,
"ConfirmTx asked Etherscan for " + JSON.stringify(asked),
);
await backToAddress(env.page);
} finally {
env.routeOpts.ethBalanceWei = null;
env.routeOpts.etherscanLabel = null;
}
});
test("add token screen opens from address detail (#150)", async (env) => {
await openAddressDetail(env.page);
await env.page.click("#btn-add-token");
@@ -3480,6 +3732,24 @@ function hostileSymbolState(page, tokenAddress) {
}
test("a token whose symbol() returns markup renders as text (#307)", async (env) => {
// The stub token is no longer tracked from here on, so the explorer is
// what lists it, as it lists a token airdropped to the victim. A tracked
// token is listed from the RPC endpoint under the symbol it was added
// with, which the hostile symbol below never reaches
// (https://git.eeqj.de/sneak/AutistMask/issues/669).
await env.page.click("#btn-settings");
await visible(env.page, "#view-settings");
await env.page.click("#settings-tracked-tokens .btn-remove-token");
// The list is drawn again once the removal is saved.
await env.page.waitForFunction(
() =>
document
.getElementById("settings-tracked-tokens")
.innerText.trim() === "None",
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-address");
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.seedTokenBalance = true;
env.routeOpts.tokenSymbolOverride = HOSTILE_SYMBOL;
@@ -4865,6 +5135,12 @@ test("a site's network switch changes nothing until the user approves it (#408)"
"the page was not told of the approved switch: " +
JSON.stringify(events),
);
// The popup, open all along, shows the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/650).
await env.page.waitForFunction(() => {
const banner = document.getElementById("debug-banner");
return banner !== null && banner.textContent === "[TESTNET]";
});
const restored = await openNetworkPrompt(
env,
@@ -4886,6 +5162,9 @@ test("a site's network switch changes nothing until the user approves it (#408)"
isDeepStrictEqual(await storedNetwork(env.page), before),
"switching back did not restore the mainnet network and endpoints",
);
await env.page.waitForFunction(
() => document.getElementById("debug-banner") === null,
);
});
// Switching the network in Settings tells the page too, as an approved site
@@ -5231,10 +5510,17 @@ async function main() {
seedReceipt: false,
// Whether eth_getTransactionReceipt fails instead of answering (#315).
failReceiptLookup: false,
// The label Etherscan's page gives an address, when it gives one
// (#668).
etherscanLabel: null,
// Every raw signed transaction handed to eth_sendRawTransaction, in
// order. The dApp transaction round trip asserts against these bytes
// rather than against anything the extension reported about them.
broadcastTransactions: [],
// Every request the extension makes, in order, from when launch()
// installs the route handler, before the first popup page opens
// (#668).
requests: [],
};
let session;
+111
View File
@@ -0,0 +1,111 @@
// ENS lookups ask nothing but the RPC endpoint. A resolver can answer that a
// name is resolved off-chain, naming a web address to fetch the answer from;
// ethers fetches it unless the provider has `disableCcipRead` set, and that
// request would carry the name to a server other than the RPC endpoint
// (https://git.eeqj.de/sneak/AutistMask/issues/668). With it set, such a name
// does not resolve.
//
// The provider getProvider() builds runs against a fake JSON-RPC node,
// answered through ethers' own request hook, which also records the address of
// every request ethers makes, the off-chain fetch included.
const { FetchRequest, Interface } = require("ethers");
const { getProvider } = require("../src/shared/balances");
const RPC_URL = "https://rpc.example.invalid";
const RESOLVER = "0x5555555555555555555555555555555555555555";
const USER = "0x1111111111111111111111111111111111111111";
const NAME = "offchain.eth";
// The ENS registry's resolver(), and the resolver's functions ethers calls: the
// registry names RESOLVER for every name, the resolver says it resolves names
// through resolve(), gives NAME as the reverse record of any address, and
// answers resolve() with the OffchainLookup error that says the name is
// resolved off-chain.
const ens = new Interface([
"function resolver(bytes32) view returns (address)",
"function supportsInterface(bytes4) view returns (bool)",
"function name(bytes32) view returns (string)",
"function resolve(bytes, bytes) view returns (bytes)",
"error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData)",
]);
let asked;
// One JSON-RPC answer. Anything but a call to one of the functions above is
// refused.
function reply(req) {
const envelope = { jsonrpc: "2.0", id: req.id };
const call =
req.method === "eth_call" ? ens.parseTransaction(req.params[0]) : null;
if (call === null) {
return {
...envelope,
error: { code: -32601, message: "method not found" },
};
}
if (call.name === "resolve") {
const offchain = ens.encodeErrorResult("OffchainLookup", [
RESOLVER,
["https://gateway.example.invalid/{sender}/{data}.json"],
"0x1234",
"0x12345678",
"0x",
]);
return {
...envelope,
error: { code: 3, message: "execution reverted", data: offchain },
};
}
const answers = {
resolver: [RESOLVER],
supportsInterface: [true],
name: [NAME],
};
return {
...envelope,
result: ens.encodeFunctionResult(call.name, answers[call.name]),
};
}
beforeEach(() => {
asked = [];
FetchRequest.registerGetUrl(async (req) => {
asked.push(req.url);
// Any other address is the off-chain fetch, answered as not found.
if (req.url !== RPC_URL) {
return {
statusCode: 404,
statusMessage: "Not Found",
headers: {},
body: null,
};
}
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
const answer = Array.isArray(body) ? body.map(reply) : reply(body);
return {
statusCode: 200,
statusMessage: "OK",
headers: { "content-type": "application/json" },
body: Buffer.from(JSON.stringify(answer)),
};
});
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
});
test("a name typed into Send that is resolved off-chain does not resolve, and only the RPC endpoint is asked", async () => {
const provider = getProvider(RPC_URL, "mainnet");
const [outcome] = await Promise.allSettled([provider.resolveName(NAME)]);
expect(asked.filter((url) => url !== RPC_URL)).toEqual([]);
expect(outcome).toEqual({ status: "fulfilled", value: null });
});
test("the reverse lookup of a user's address whose name is resolved off-chain finds no name, and only the RPC endpoint is asked", async () => {
const provider = getProvider(RPC_URL, "mainnet");
const [outcome] = await Promise.allSettled([provider.lookupAddress(USER)]);
expect(asked.filter((url) => url !== RPC_URL)).toEqual([]);
expect(outcome).toEqual({ status: "fulfilled", value: null });
});
+1
View File
@@ -114,6 +114,7 @@ async function fetchOnto(items, trackedTokens = []) {
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
true,
trackedTokens,
);
state.trackedTokens = trackedTokens;
+9 -5
View File
@@ -150,19 +150,23 @@ describe("the balance-list spam gate", () => {
test("a token with plenty of reported holders is listed", async () => {
respondWith([item()]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []);
expect(balances).toHaveLength(1);
expect(balances[0].holders).toBe(50000);
});
test("a token reporting zero holders is still excluded", async () => {
respondWith([item({ token: { holders_count: "0" } })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
});
test("an unknown holder count does not admit an unvouched token", async () => {
respondWith([item({ token: { holders_count: null } })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
});
// The path that reaches the send selector and the history filter: a token
@@ -171,7 +175,7 @@ describe("the balance-list spam gate", () => {
// zero that would then hide it downstream.
test("a tracked token with an unknown count is listed with holders null", async () => {
respondWith([item({ token: { holders_count: undefined } })]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [
{ address: NOVEL_TOKEN.toUpperCase() },
]);
expect(balances).toHaveLength(1);
@@ -188,7 +192,7 @@ describe("the balance-list spam gate", () => {
},
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []);
expect(balances).toHaveLength(1);
expect(balances[0].holders).toBeNull();
});
+2
View File
@@ -436,6 +436,8 @@ describe.each([
});
test("the transaction detail screen's type line and fee", async () => {
// The fee is Blockscout's.
state.useBlockscout = true;
debugFetch.mockImplementationOnce(async () => ({
ok: true,
status: 200,
+5 -2
View File
@@ -618,6 +618,8 @@ const CONTRACT = [
"hideFraudContracts",
"hideDustTransactions",
"utcTimestamps",
"useBlockscout",
"checkRecipientsOnEtherscan",
"debugMode",
].map((field) => ({
field,
@@ -625,8 +627,9 @@ const CONTRACT = [
// A flag: only ever tested for truthiness, and written back verbatim.
hostile: [42, "notabool", { a: 1 }],
// Both answers to that truthiness test have to be driven, and 0 is a
// value src/ never writes for a flag. For utcTimestamps and debugMode
// the falsy answer is also the DEFAULT_STATE default.
// value src/ never writes for a flag. For utcTimestamps,
// useBlockscout, checkRecipientsOnEtherscan and debugMode the falsy
// answer is also the DEFAULT_STATE default.
falsy: [0],
})),
];
+4 -1
View File
@@ -44,7 +44,8 @@ test("a flash message's timer does not fire after cleanupPopup()", async () => {
// A boot onto a screen that sends a request no mocked module answers: that
// request reaches the boot's fetch() stub, never the internet
// (https://git.eeqj.de/sneak/AutistMask/issues/559).
// (https://git.eeqj.de/sneak/AutistMask/issues/559). Both requests are made
// only while the user has the setting for them on.
function restoringOnto(view, viewData) {
return unversionedValidProfile({
currentView: view,
@@ -52,6 +53,8 @@ function restoringOnto(view, viewData) {
selectedAddress: 0,
viewStack: ["main"],
viewData,
useBlockscout: true,
checkRecipientsOnEtherscan: true,
});
}
+300
View File
@@ -0,0 +1,300 @@
// An open popup follows a network switch the background saves once the user
// approves a site's request: it takes the new network from the stored record
// and draws the screen it is on for it, as a switch in Settings does
// (https://git.eeqj.de/sneak/AutistMask/issues/650). Driven through the real
// popup over a stored profile on Ethereum Mainnet. The background's write is
// the storage stub's set, which the stub reports to the page as the browser
// does.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const { makeStorageStub } = require("./support/storageStub");
const {
applyChainSwitchFields,
copyChainSwitchFields,
} = require("../src/shared/chainSwitchFields");
const { NETWORKS } = require("../src/shared/networks");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
afterEach(() => {
cleanupPopup();
});
const SEPOLIA_TOKEN = {
address: TOKEN_ADDRESS,
symbol: "TST",
decimals: 18,
name: "Test Token",
};
const MAINNET_TOKEN = {
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
symbol: "DAI",
decimals: 18,
name: "Dai Stablecoin",
};
// A profile on Ethereum Mainnet that reopens the popup onto AddressDetail,
// with DAI tracked there and TST tracked on Sepolia.
function profileOnAddress() {
return unversionedValidProfile({
currentView: "address",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
});
}
// The popup reopened onto it.
function bootOnAddress() {
return bootPopup(profileOnAddress());
}
// The background saves a switch to `networkId`, as its updateState() does
// once the user approves a site's request.
async function backgroundSwitch(page, networkId) {
const record = page.storage.read("autistmask");
applyChainSwitchFields(record, networkId);
await page.storage.local.set({ autistmask: record });
await page.settle();
await page.settle();
}
test("the popup open on AddressDetail shows the new network and its balances", async () => {
const page = await bootOnAddress();
expect(page.pageErrors).toEqual([]);
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain("DAI");
expect(page.node("debug-banner")).toBeNull();
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
expect(state.rpcUrl).toBe(NETWORKS.sepolia.defaultRpcUrl);
expect(page.visibleViews()).toEqual(["address"]);
const balances = page.node("address-balances").innerHTML;
expect(balances).toContain("TST");
expect(balances).toContain("SepoliaETH");
expect(balances).not.toContain("DAI");
expect(page.text("debug-banner")).toBe("[TESTNET]");
// Its next refresh runs on Sepolia, and what it fetched is stored.
await page.tick();
const { refreshBalances } = require("../src/shared/balances");
expect(refreshBalances.mock.lastCall[5]).toBe("sepolia");
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.lastBalanceRefresh).toBeGreaterThan(0);
});
test("the popup open on Settings shows the new network, and Back draws the screen beneath for it", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["settings"]);
await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["settings"]);
expect(page.value("settings-network")).toBe("sepolia");
expect(page.value("settings-rpc")).toBe(NETWORKS.sepolia.defaultRpcUrl);
expect(page.node("settings-tracked-tokens").innerHTML).toContain("TST");
expect(page.text("debug-banner")).toBe("[TESTNET]");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).not.toContain("DAI");
});
// Neither can be drawn again for the new network: Send cannot be rendered from
// saved state, and DAI was picked on Ethereum Mainnet, where alone it is
// tracked. A reopened popup goes to Home in their place.
test.each([
["Send", (page) => page.click("btn-send")],
[
"AddressToken for DAI",
async (page) => {
const { state } = require("../src/shared/state");
state.selectedToken = MAINNET_TOKEN.address;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
},
],
])("the popup open on %s goes to Home", async (_view, open) => {
const page = await bootOnAddress();
await open(page);
expect(page.visibleViews()).not.toEqual(["address"]);
await backgroundSwitch(page, "sepolia");
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("total-value")).toBe("0.0000 SepoliaETH");
});
// 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();
await page.click("btn-settings");
const { get, set } = page.storage.local;
const reads = get.mock.calls.length;
const writes = set.mock.calls.length;
const select = page.node("settings-network");
select.value = "sepolia";
for (const fn of select.listeners.change) await fn();
await page.settle();
await page.settle();
expect(get).toHaveBeenCalledTimes(reads + 1);
expect(set).toHaveBeenCalledTimes(writes + 1);
expect(page.text("flash-msg")).toBe("Switched to Sepolia Testnet.");
});
// The background saves the switch once the popup has read the stored record
// and before it listens for changes to it, so the browser tells the page
// nothing of it. Nor is any later write reported here: Chrome reports none
// that leaves the record as it was, such as the popup's own save at start-up.
test("a switch saved while the popup opens, before it listens for one, is followed", async () => {
const storage = makeStorageStub({ autistmask: profileOnAddress() });
storage.onChanged = { addListener: () => {} };
const read = storage.local.get.getMockImplementation();
storage.local.get.mockImplementationOnce(async (key) => {
const loaded = await read(key);
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
return loaded;
});
const page = await bootPopup(undefined, { storage });
await page.settle();
const { state } = require("../src/shared/state");
expect(page.pageErrors).toEqual([]);
expect(state.networkId).toBe("sepolia");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain("SepoliaETH");
expect(page.text("debug-banner")).toBe("[TESTNET]");
});
// While the popup's follow of the background's switch to Sepolia waits for the
// lock, the user picks Sepolia in Settings and then Ethereum Mainnet again. The
// last pick is the network stored, and the last one sites are told of.
test("a switch in Settings made while a follow waits for the lock is not overwritten by it", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
// Another write holds the lock, so every save of the page waits for it.
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
await backgroundSwitch(page, "sepolia");
const select = page.node("settings-network");
const picks = [];
for (const networkId of ["sepolia", "mainnet"]) {
select.value = networkId;
for (const fn of select.listeners.change) picks.push(fn());
}
releaseLock();
await lockHeld;
await Promise.all(picks);
await page.settle();
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("mainnet");
expect(page.storage.read("autistmask").networkId).toBe("mainnet");
expect(page.value("settings-network")).toBe("mainnet");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
.filter((message) => message.type === "AUTISTMASK_NETWORK_CHANGED");
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// The popup follows the background's switch to Sepolia, but its own write in
// that save fails. The user then picks Ethereum Mainnet in Settings, the
// network the popup was on before: that switch is stored, and only then
// flashed and sent to sites.
test("a switch back in Settings after a follow whose write failed is stored", async () => {
const page = await bootOnAddress();
await page.click("btn-settings");
const { set } = page.storage.local;
const write = set.getMockImplementation();
// The background's write lands; the next, the follow's, fails.
set.mockImplementationOnce(write).mockRejectedValueOnce(
new Error("quota exceeded"),
);
await backgroundSwitch(page, "sepolia");
const { state } = require("../src/shared/state");
expect(state.networkId).toBe("sepolia");
const select = page.node("settings-network");
select.value = "mainnet";
for (const fn of select.listeners.change) await fn();
await page.settle();
const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("mainnet");
expect(stored.rpcUrl).toBe(NETWORKS.mainnet.defaultRpcUrl);
expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
expect(page.text("flash-msg")).toBe("Switched to Ethereum Mainnet.");
const told = globalThis.chrome.runtime.sendMessage.mock.calls
.map(([message]) => message)
.filter((message) => message.type === "AUTISTMASK_NETWORK_CHANGED");
expect(told.at(-1).chainId).toBe(NETWORKS.mainnet.chainId);
});
// Such as one a newer build wrote, which this build cannot read. Nothing reads
// or writes the stored record for it: the popup's next save finds it, as
// before.
test("a record that names no network is not followed", async () => {
const page = await bootOnAddress();
const { get, set } = page.storage.local;
const reads = get.mock.calls.length;
await set({
autistmask: {
schemaVersion: STATE_SCHEMA_VERSION + 1,
wallets: [{ type: "hd", name: "Main", addresses: [] }],
},
});
await page.settle();
expect(get).toHaveBeenCalledTimes(reads);
expect(page.visibleViews()).toEqual(["address"]);
});
// A record that takes the switch from the switched record ends up as that
// record, balances the background fetched since included.
test("copyChainSwitchFields() takes every field applyChainSwitchFields() sets", () => {
const before = unversionedValidProfile({
trackedTokens: [MAINNET_TOKEN],
networkTrackedTokens: { sepolia: [SEPOLIA_TOKEN] },
networkEndpoints: {
sepolia: {
rpcUrl: "http://127.0.0.1:8545",
blockscoutUrl: "http://127.0.0.1:4000/api/v2",
},
},
lastBalanceRefresh: 1,
tokenHolderCache: { [MAINNET_TOKEN.address]: 5000 },
fraudContracts: ["0x1111111111111111111111111111111111111111"],
});
const switched = structuredClone(before);
applyChainSwitchFields(switched, "sepolia");
switched.wallets[0].addresses[0].balance = "2.0";
const follower = structuredClone(before);
copyChainSwitchFields(follower, switched);
expect(follower).toEqual(switched);
});
+1
View File
@@ -88,6 +88,7 @@ test("the balance refresh", async () => {
wallets,
RPC_URL,
"https://explorer.example.invalid/api/v2",
true,
[],
"mainnet",
);
+1
View File
@@ -224,6 +224,7 @@ async function refreshWith(balanceWei, tokenItems = []) {
state.wallets,
"https://rpc.example.invalid",
"https://blockscout.example/api/v2",
true,
state.trackedTokens,
"mainnet",
);
+1
View File
@@ -217,6 +217,7 @@ async function refreshWith(balanceWei, tokenItems = []) {
state.wallets,
"https://rpc.example.invalid",
"https://blockscout.example/api/v2",
true,
state.trackedTokens,
"mainnet",
);
+1 -1
View File
@@ -311,7 +311,7 @@ async function bootPopup(stored, options) {
const windowListeners = {};
globalThis.chrome = {
storage: { local: storage.local },
storage: { local: storage.local, onChanged: storage.onChanged },
runtime: {
sendMessage: jest.fn(async () => ({})),
getURL: (p) => "chrome-extension://autistmask/" + p,
+30 -5
View File
@@ -18,7 +18,9 @@
// a storage stub by hand.
// One storage area: get, set and remove over its own store, cloning both ways.
function makeArea(store, tick) {
// `changed` is handed what each set or remove changed, in the shape
// storage.onChanged reports it.
function makeArea(store, tick, changed) {
const get = jest.fn(async (key) => {
tick();
if (key === undefined || key === null) return structuredClone(store);
@@ -34,14 +36,25 @@ function makeArea(store, tick) {
const set = jest.fn(async (items) => {
tick();
const changes = {};
for (const k of Object.keys(items)) {
changes[k] = structuredClone({
oldValue: store[k],
newValue: items[k],
});
store[k] = structuredClone(items[k]);
}
changed(changes);
});
const remove = jest.fn(async (key) => {
tick();
for (const k of Array.isArray(key) ? key : [key]) delete store[k];
const changes = {};
for (const k of Array.isArray(key) ? key : [key]) {
changes[k] = { oldValue: store[k] };
delete store[k];
}
changed(changes);
});
return { get, set, remove };
@@ -54,12 +67,22 @@ function makeArea(store, tick) {
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
const local = makeArea(store, tick);
// storage.onChanged: after a set or remove has finished, every listener is
// called with what it changed and the area's name, as the browser calls
// them for a write by any page or the background.
const listeners = [];
const announce = (areaName) => (changes) => {
Promise.resolve().then(() => {
for (const fn of listeners) fn(structuredClone(changes), areaName);
});
};
const local = makeArea(store, tick, announce("local"));
return {
// Drop this straight in as chrome.storage.
local,
session: makeArea({}, tick),
session: makeArea({}, tick, announce("session")),
onChanged: { addListener: (fn) => listeners.push(fn) },
get: local.get,
set: local.set,
remove: local.remove,
@@ -71,7 +94,9 @@ function makeStorageStub(initial, onOp) {
? structuredClone(store)
: structuredClone(store[key]),
// Seed or replace a record without going through the module under
// test — for standing in as "another page wrote this".
// test — for standing in as "another page wrote this". Nothing is
// announced to storage.onChanged, so it also stands in for the time
// before the browser tells this page of such a write.
write: (key, value) => {
store[key] = structuredClone(value);
},
+23 -10
View File
@@ -582,12 +582,14 @@ describe("surface 3: the balance list", () => {
// 1,000-holder floor and was listed as a holding named ETH.
test("a fake ETH token clearing the holder floor is filtered", async () => {
respondWith([fakeEthItem()]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
});
test("tracking the fake token manually does not admit it either", async () => {
respondWith([fakeEthItem({ holders_count: "0" })]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [
{ address: FAKE_ETH_CONTRACT },
]);
expect(balances).toEqual([]);
@@ -597,12 +599,16 @@ describe("surface 3: the balance list", () => {
// their belief about what they own, and it renders the symbol into HTML.
test("a padded fake ETH token is filtered too", async () => {
respondWith([fakeEthItem({ symbol: " ETH " })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
});
test("a fake ETH token padded with a no-break space is filtered", async () => {
respondWith([fakeEthItem({ symbol: NBSP + "ETH" + NBSP })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
});
// The false-positive direction on the surface that matters most: a real
@@ -617,7 +623,7 @@ describe("surface 3: the balance list", () => {
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe(" USDC ");
});
@@ -631,7 +637,7 @@ describe("surface 3: the balance list", () => {
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC");
});
@@ -642,7 +648,9 @@ describe("surface 3: the balance list", () => {
// before the symbol rule is consulted.
test("a non-ERC-20 row claiming ETH never reaches the symbol rule", async () => {
respondWith([fakeEthItem({ type: "ERC-721" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
});
// The adjacent finding from the same review as issue #260: the type gate
@@ -660,16 +668,20 @@ describe("surface 3: the balance list", () => {
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC");
});
test("case insensitivity does not admit another token type", async () => {
respondWith([fakeEthItem({ type: "erc-721" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
respondWith([fakeEthItem({ type: "ERC-20-EXTRA" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual(
[],
);
});
// The money test: the user holds real ETH and has been airdropped a fake
@@ -682,6 +694,7 @@ describe("surface 3: the balance list", () => {
[{ addresses: [addr] }],
"https://rpc.example.invalid",
BLOCKSCOUT,
true,
[],
"mainnet",
);
+2
View File
@@ -134,6 +134,8 @@ beforeEach(() => {
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
// The history is Blockscout's.
state.useBlockscout = true;
});
describe.each([
+5
View File
@@ -158,6 +158,7 @@ beforeEach(() => {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.blockscoutUrl = EXPLORER;
state.useBlockscout = true;
state.networkEndpoints = {};
state.wallets = [
{
@@ -177,6 +178,7 @@ test("a token transfer's decoded details name the token contract and the amount
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
true,
NETWORKS.mainnet.chainId,
);
expect(row.to).toBe(RECIPIENT);
@@ -209,6 +211,7 @@ test("with mainnet active, a Sepolia transaction is read from Sepolia's Blocksco
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
true,
NETWORKS.sepolia.chainId,
);
@@ -237,6 +240,7 @@ test("with mainnet active and no Blockscout remembered for Sepolia, a Sepolia tr
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
true,
NETWORKS.sepolia.chainId,
);
mockAsked.length = 0;
@@ -258,6 +262,7 @@ async function showSepoliaTransferWithMainnetActive() {
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
true,
NETWORKS.sepolia.chainId,
);
transactionDetail.show(row);
+304
View File
@@ -0,0 +1,304 @@
// The balance of each token the user tracks is read with balanceOf through the
// RPC endpoint, for every address. Blockscout's answer gives a tracked token its
// holder count and decimals, and adds the tokens the user has not tracked
// (https://git.eeqj.de/sneak/AutistMask/issues/669).
//
// The real refreshBalances() runs against a fake JSON-RPC node, answered
// through ethers' own request hook, and a fake Blockscout, answered through
// fetch().
const { AbiCoder, FetchRequest } = require("ethers");
const { refreshBalances } = require("../src/shared/balances");
const RPC_URL = "https://rpc.example.invalid";
const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
const FIRST = "0x1111111111111111111111111111111111111111";
const SECOND = "0x2222222222222222222222222222222222222222";
// A token the user tracks, outside the bundled list, at 6 decimals.
const TOKEN = "0x7777777777777777777777777777777777777777";
const TRACKED = {
address: TOKEN,
symbol: "TRK",
name: "Tracked Token",
decimals: 6,
};
// USDC, from the bundled list, which the user does not track.
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
// Neither on the bundled list nor tracked.
const OTHER = "0x8888888888888888888888888888888888888888";
const ZERO_WORD = "0x" + "0".repeat(64);
const BALANCE_OF = "0x70a08231";
// What balanceOf answers, by token contract and then by holder. A pair that is
// not here makes balanceOf fail.
let holdings;
// What Blockscout answers for every address; null makes the request fail.
let explorerRows;
let logged;
// One JSON-RPC answer. A call that is not balanceOf is the ENS reverse lookup,
// answered with no resolver, so no name.
function reply(req) {
const envelope = { jsonrpc: "2.0", id: req.id };
if (req.method === "eth_getBalance") {
return { ...envelope, result: "0x0" };
}
if (req.method !== "eth_call") {
return {
...envelope,
error: { code: -32601, message: "method not found" },
};
}
const { to, data } = req.params[0];
if (!data.startsWith(BALANCE_OF)) return { ...envelope, result: ZERO_WORD };
const holder = "0x" + data.slice(-40);
const amount = (holdings[to.toLowerCase()] || {})[holder];
if (amount === undefined) {
return {
...envelope,
error: { code: -32000, message: "fixture: balanceOf failed" },
};
}
return {
...envelope,
result: AbiCoder.defaultAbiCoder().encode(["uint256"], [amount]),
};
}
const realFetch = globalThis.fetch;
beforeEach(() => {
holdings = {};
explorerRows = null;
logged = [];
jest.spyOn(console, "error").mockImplementation((...args) => {
logged.push(args.map(String).join(" "));
});
FetchRequest.registerGetUrl(async (req) => {
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
const answer = Array.isArray(body) ? body.map(reply) : reply(body);
return {
statusCode: 200,
statusMessage: "OK",
headers: { "content-type": "application/json" },
body: Buffer.from(JSON.stringify(answer)),
};
});
globalThis.fetch = jest.fn(async () => {
if (explorerRows === null) throw new TypeError("Failed to fetch");
return { ok: true, status: 200, json: async () => explorerRows };
});
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
globalThis.fetch = realFetch;
jest.restoreAllMocks();
});
// One Blockscout token-balances row.
function explorerRow(address, symbol, value, holders = "50000") {
return {
value: String(value),
token: {
type: "ERC-20",
address_hash: address,
symbol: symbol,
name: symbol,
decimals: "6",
holders_count: holders,
},
};
}
// The row refreshBalances() writes for TOKEN from a balanceOf answer, with the
// decimals and holder count Blockscout reported for it, if any.
function trackedRow(balance, decimals = null, holders = null) {
return {
address: TOKEN,
name: "Tracked Token",
symbol: "TRK",
decimals: decimals,
balance: balance,
holders: holders,
};
}
async function refresh(addresses, trackedTokens) {
await refreshBalances(
[{ addresses }],
RPC_URL,
EXPLORER_URL,
true,
trackedTokens,
"mainnet",
new AbortController().signal,
);
return addresses;
}
test("with Blockscout failing, a tracked token's balance is read through the RPC endpoint for every address", async () => {
holdings[TOKEN] = { [FIRST]: 1234500000n, [SECOND]: 1n };
const [first, second] = await refresh(
[
{ address: FIRST, tokenBalances: [] },
{ address: SECOND, tokenBalances: [] },
],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("1234.5")]);
// Exact, never cut: one base unit is not zero.
expect(second.tokenBalances).toEqual([trackedRow("0.000001")]);
});
test("with Blockscout failing, the untracked rows from before stay", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
const usdc = {
address: USDC,
name: "USD Coin",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
};
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [usdc] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0"), usdc]);
});
test("a tracked token Blockscout also reports shows the RPC figure, and an untracked one is admitted as before", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
explorerRows = [
explorerRow(TOKEN, "TRK", 9000000),
explorerRow(USDC, "USDC", 5000000),
// Too few holders, and neither on the bundled list nor tracked.
explorerRow(OTHER, "SPAM", 5000000, "10"),
// A known symbol from a contract it does not belong to.
explorerRow(OTHER, "USDC", 5000000),
];
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([
trackedRow("2.0", 6, 50000),
{
address: USDC,
name: "USDC",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
},
]);
});
test("a tracked token's entry carries the holder count and decimals Blockscout reports for it", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
// Fewer holders than "Hide tokens with fewer than 1,000 holders" lets
// through to Send's token list.
explorerRows = [explorerRow(TOKEN, "TRK", 9000000, "10")];
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 10)]);
});
test("while Blockscout fails, a tracked token keeps the holder count and decimals of its entry from before", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [trackedRow("7.0", 6, 10)] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 10)]);
});
test("a failed balanceOf keeps the token's entry from before, and is logged", async () => {
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
const before = trackedRow("7.0", 6, 50000);
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [before] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([before]);
expect(logged.some((line) => line.includes("token balance failed"))).toBe(
true,
);
});
test("a failed balanceOf with no entry from before takes Blockscout's entry from this refresh", async () => {
// balanceOf fails at FIRST and answers at SECOND.
holdings[TOKEN] = { [SECOND]: 2000000n };
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
const [first, second] = await refresh(
[
{ address: FIRST, tokenBalances: [] },
{ address: SECOND, tokenBalances: [] },
],
[TRACKED],
);
expect(first.tokenBalances).toEqual([
{
address: TOKEN,
name: "TRK",
symbol: "TRK",
decimals: 6,
balance: "9.0",
holders: 50000,
},
]);
// Where balanceOf answers, its figure stands.
expect(second.tokenBalances).toEqual([trackedRow("2.0", 6, 50000)]);
});
test("a balance of zero read through the RPC endpoint replaces the previous figure", async () => {
holdings[TOKEN] = { [FIRST]: 0n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [trackedRow("7.0")] }],
[TRACKED],
);
expect(first.tokenBalances).toEqual([]);
});
test("a tracked token whose scale nothing knows gets no quantity, not a zero", async () => {
holdings[TOKEN] = { [FIRST]: 5n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[{ ...TRACKED, decimals: null }],
);
expect(first.tokenBalances).toEqual([trackedRow(null)]);
});
test("a tracked token whose scale only Blockscout reports is stated at that scale", async () => {
holdings[TOKEN] = { [FIRST]: 2000000n };
explorerRows = [explorerRow(TOKEN, "TRK", 9000000)];
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[{ ...TRACKED, decimals: null }],
);
expect(first.tokenBalances).toEqual([trackedRow("2.0", 6, 50000)]);
});
test("a tracked token wearing a known symbol from another contract is not listed", async () => {
holdings[OTHER] = { [FIRST]: 5000000n };
// Its entry from before goes too, although Blockscout fails.
const before = {
address: OTHER,
name: "Fake",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: 50000,
};
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [before] }],
[{ address: OTHER, symbol: "USDC", name: "Fake", decimals: 6 }],
);
expect(first.tokenBalances).toEqual([]);
});
+2
View File
@@ -161,6 +161,7 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
);
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]);
await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet");
expect(stored().networkId).toBe("mainnet");
expect(stored().trackedTokens).toEqual([MAINNET_TOKEN]);
@@ -286,6 +287,7 @@ describe("Back after a switch from the Settings gear", () => {
currentView: "confirm-tx",
selectedWallet: 0,
selectedAddress: 0,
checkRecipientsOnEtherscan: true,
viewData: {
pendingTx: {
token: "ETH",
+2
View File
@@ -125,6 +125,8 @@ beforeEach(() => {
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
// The history is Blockscout's.
state.useBlockscout = true;
});
afterEach(() => {
+51 -9
View File
@@ -1219,7 +1219,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
test("queries only the two Blockscout endpoints for the address", async () => {
respondWith([], []);
await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
await fetchRecentTransactions(VICTIM, BLOCKSCOUT, true, "0x1");
expect(debugFetch).toHaveBeenCalledTimes(2);
const urls = debugFetch.mock.calls.map((c) => c[0]);
expect(urls).toContain(
@@ -1283,7 +1283,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1);
const merged = txs[0];
// The received leg (the swap output) supplies the display amount.
@@ -1320,7 +1325,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC");
expect(txs[0].value).toBe("1500.5000");
@@ -1346,7 +1356,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
});
respondWith([], [leg("1000000"), leg("2000000")]);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1);
// Keyed by hash plus contract, so the later leg wins.
expect(txs[0].exactValue).toBe("2.0");
@@ -1386,7 +1401,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
});
@@ -1427,7 +1447,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC");
expect(txs[0].exactValue).toBe("1.0");
@@ -1453,7 +1478,13 @@ describe("fetchRecentTransactions merge and dedup", () => {
});
respondWith([item(6), item(8), item(7)], []);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1", 2);
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
2,
);
expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]);
});
@@ -1479,6 +1510,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const sepolia = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0xaa36a7",
);
expect(sepolia[0].symbol).toBe("SepoliaETH");
@@ -1487,6 +1519,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const mainnet = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(mainnet[0].symbol).toBe("ETH");
@@ -1513,7 +1546,12 @@ describe("fetchRecentTransactions merge and dedup", () => {
],
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1);
expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT);
expect(txs[0].holders).toBe(0);
@@ -1555,6 +1593,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs[0].holders).toBeNull();
@@ -1565,6 +1604,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs[0].holders).toBeNull();
@@ -1575,6 +1615,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(filterTransactions(txs, filters()).transactions).toEqual(
@@ -1591,6 +1632,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs[0].holders).toBe(0);
@@ -1608,7 +1650,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
},
}));
await expect(
fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"),
fetchRecentTransactions(VICTIM, BLOCKSCOUT, true, "0x1"),
).resolves.toEqual([]);
});
+633 -59
View File
@@ -59,6 +59,25 @@ function encodePermit2(token, amount, spender) {
);
}
// Helper: encode a PERMIT2_PERMIT_BATCH input (command 0x03), one allowance
// for each [token, amount], all for `spender`
function encodePermit2Batch(allowances, spender) {
return coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)",
"bytes",
],
[
[
allowances.map(([token, amount]) => [token, amount, 0, 0]),
spender,
9999999999,
],
"0x1234",
],
);
}
// Helper: encode a BALANCE_CHECK_ERC20 input (command 0x0e)
function encodeBalanceCheck(owner, token, minBalance) {
return coder.encode(
@@ -696,12 +715,8 @@ describe("uniswap decoder", () => {
USER_ADDR,
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Token Out").address).toBe(
WETH_ADDR.toLowerCase(),
);
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
const amount = detail(result, "Amount");
expect(amount.value).toBe("Up to 2000.0000 USDT");
@@ -735,9 +750,7 @@ describe("uniswap decoder", () => {
expect(result.name).toBe("Swap ETH → USDC");
expect(detail(result, "Amount").value).toBe("Up to 0.5000 ETH");
expect(detail(result, "Moved from the sender")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(
USDC_ADDR.toLowerCase(),
);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Paid to")).toBeUndefined();
expect(detail(result, "Min. received").value).toBe("1500.0000 USDC");
});
@@ -1434,6 +1447,20 @@ describe("uniswap decoder", () => {
USDT_ADDR,
USDC_ADDR,
]);
// The lines of its 600 USDT when there is no Amount line.
// https://git.eeqj.de/sneak/AutistMask/issues/659 — on next at
// 5f2b606 no line gave the 600 USDT then.
const USDT_600_GROUP = [
["Moved from the sender", "600.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
// The same for a step's 400 USDT.
const USDT_400_GROUP = [
["Moved from the sender", "400.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
// V4 SETTLE of `amount` of `currency`, and TAKE of `amount` of
// `currency` to `recipient`.
const settle = (currency, amount, payerIsUser) =>
@@ -1660,6 +1687,7 @@ describe("uniswap decoder", () => {
encodeV4SettleAll(USDT_ADDR, USDT_400),
],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
@@ -1680,6 +1708,7 @@ describe("uniswap decoder", () => {
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
@@ -1811,6 +1840,8 @@ describe("uniswap decoder", () => {
// A step that may take from the sender what the decoder did not read
// leaves no Amount figure, which may be too low, and one line says so.
// The amount of the step that set the input side then gets lines of
// its own.
const UNREAD_SWAP = V4_SWAP_ACTION_SHAPES[0].newer;
test.each([
{
@@ -1858,6 +1889,7 @@ describe("uniswap decoder", () => {
[V3_600, encodeV4Swap(new Uint8Array(actions), params)],
);
expect(linesBetweenTokens(result)).toEqual([
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
]);
});
@@ -1880,6 +1912,9 @@ describe("uniswap decoder", () => {
],
),
],
// Nothing read says the user pays this step, so its 400 USDT
// gets no lines of its own.
first: [],
},
{
kind: "a V3 step of 600 USDT, then a V4 step whose input cannot be read",
@@ -1950,9 +1985,27 @@ describe("uniswap decoder", () => {
),
],
},
])("$kind", ({ commands, inputs }) => {
// Each take the total would add up gets lines of its own.
{
kind: "a V3 step of 600 USDT and a V2 step of 400 USDT the user pays for, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x00, 0x08, 0x0d],
inputs: [
V3_600,
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER_ADDR, OTHER_ADDR, USDT_400, USDT_ADDR]]],
),
],
first: [...USDT_600_GROUP, ...USDT_400_GROUP],
},
])("$kind", ({ commands, inputs, first = USDT_600_GROUP }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
...first,
["Moved from the sender", UNDECODED],
]);
});
@@ -1971,6 +2024,7 @@ describe("uniswap decoder", () => {
commands: [0x00, 0x08, 0x21],
inputs: [V3_600, V2_DAI_400, SUB_PLAN],
lines: [
...USDT_600_GROUP,
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
@@ -1982,6 +2036,7 @@ describe("uniswap decoder", () => {
commands: [0x21, 0x00, 0x08],
inputs: [SUB_PLAN, V3_600, V2_DAI_400],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
@@ -2000,6 +2055,7 @@ describe("uniswap decoder", () => {
SUB_PLAN,
],
lines: [
...USDT_600_GROUP,
["Moved from the sender", UNDECODED],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
@@ -2052,12 +2108,6 @@ describe("uniswap decoder", () => {
const ONE_USDT_LINES = (spender) =>
permitLines("1.0000 USDT", USDT_ADDR, "USDT", spender);
test.each([
{
kind: "to another spender",
commands: [0x0a],
permits: [ONE_USDT_PERMIT(OTHER_ADDR)],
lines: ONE_USDT_LINES(OTHER_ADDR),
},
{
kind: "that the router lets fail",
commands: [0x8a],
@@ -2106,28 +2156,17 @@ describe("uniswap decoder", () => {
ROUTER_ADDR,
);
// A PERMIT2_PERMIT_BATCH, which this decoder does not read, may raise
// what the router may take of the permit's token again.
// A PERMIT2_PERMIT_BATCH may raise what the router may take of the
// permit's token again, and its allowance gets lines of its own.
test("a split after a PERMIT2_PERMIT and a PERMIT2_PERMIT_BATCH shows the total the steps take", () => {
const permitBatch = coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)",
"bytes",
],
[
[
[[USDT_ADDR, 10000000000n, 0, 0]], // 10,000 USDT
ROUTER_ADDR,
9999999999,
],
"0x1234",
],
);
const result = decodeSteps(
[0x0a, 0x03, 0x00, 0x08],
[
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
permitBatch,
encodePermit2Batch(
[[USDT_ADDR, 10000000000n]], // 10,000 USDT
ROUTER_ADDR,
),
V3_600,
encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [
USDT_ADDR,
@@ -2138,6 +2177,12 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"],
...USDT_600_LINES,
...permitLines(
"10000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
]);
});
@@ -2188,6 +2233,197 @@ describe("uniswap decoder", () => {
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/655 — a
// PERMIT2_TRANSFER_FROM of the permit's token before the permit moves
// it under whatever allowance the router already had, so it may move
// more than the permit's amount. The permit's amount then does not
// stand, and the Amount line gives the total the steps take, as with
// no permit. On next at f53b503 the first two rows read the permit's
// 600.0000 USDT as the Amount figure, with no lines for the permit.
// One after the permit, as in Uniswap's own V2 routes, moves no more
// than the permit's amount, and one of another token before it moves
// none of the permit's token, so in the last two rows the permit's
// amount stands.
const USDT_2000 = 2000000000n;
// The V2 pair of WETH and USDC on Ethereum Mainnet.
const WETH_USDC_PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
// A PERMIT2_TRANSFER_FROM of `amount` of `token` to `recipient`.
const permit2TransferFrom = (token, recipient, amount) =>
coder.encode(
["address", "address", "uint160"],
[token, recipient, amount],
);
test.each([
{
kind: "a PERMIT2_TRANSFER_FROM of 2,000 USDT into the router, then a PERMIT2_PERMIT of 600 USDT and a V3 exact-in of 2,000 USDT paid from the router, shows the total the steps take and the permit's own lines",
commands: [0x02, 0x0a, 0x00],
inputs: [
permit2TransferFrom(USDT_ADDR, ROUTER_ADDR, USDT_2000),
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
USDT_2000,
1n,
encodeV3Path([USDT_ADDR, USDC_ADDR]),
false,
],
),
],
lines: [["Amount", "2000.0000 USDT"], ...USDT_600_LINES],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 2,000 USDT to another address, then a PERMIT2_PERMIT of 600 USDT and a V3 exact-in of 600 USDT, shows the total the steps take and the permit's own lines",
commands: [0x02, 0x0a, 0x00],
inputs: [
permit2TransferFrom(USDT_ADDR, OTHER_ADDR, USDT_2000),
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
V3_600,
],
lines: [
["Amount", "600.0000 USDT"],
["Moved from the sender", "2000.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", OTHER_ADDR],
...USDT_600_LINES,
],
},
{
kind: "a PERMIT2_PERMIT of 1 WETH, then a PERMIT2_TRANSFER_FROM of 1 WETH to the V2 pair and a V2 exact-in of ALREADY_PAID, shows the permit's amount",
commands: [0x0a, 0x02, 0x08],
inputs: [
encodePermit2(WETH_ADDR, ONE_WETH, ROUTER_ADDR),
permit2TransferFrom(WETH_ADDR, WETH_USDC_PAIR, ONE_WETH),
encodeV2SwapExactIn(USER_ADDR, 0n, 1990000000n, [
WETH_ADDR,
USDC_ADDR,
]),
],
lines: [
["Amount", "1.0000 WETH"],
["Moved from the sender", "1.0000 WETH"],
["Token moved", "WETH (" + WETH_ADDR + ")"],
["Moved to", "The V2 pair the next swap spends"],
],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 400 DAI to another address, then a PERMIT2_PERMIT of 600 USDT and a V3 exact-in of 600 USDT, shows the permit's amount",
commands: [0x02, 0x0a, 0x00],
inputs: [
permit2TransferFrom(DAI_ADDR, OTHER_ADDR, DAI_400),
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
V3_600,
],
lines: [
["Amount", "600.0000 USDT"],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", OTHER_ADDR],
],
},
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// A step that sets the input side and is paid from the router takes
// nothing from the sender, so with no Amount line its amount gets no
// lines of its own: in each route below the sender's 600 USDT moves
// into the router first, and such lines would say it left twice.
const INTO_ROUTER_600 = permit2TransferFrom(
USDT_ADDR,
ROUTER_ADDR,
USDT_600,
);
const V3_600_FROM_ROUTER = coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
USER_ADDR,
USDT_600,
590000000n,
encodeV3Path([USDT_ADDR, USDC_ADDR]),
false,
],
);
const TRANSFER_FROM_BATCH = coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER_ADDR, OTHER_ADDR, USDT_400, USDT_ADDR]]],
);
test.each([
{
kind: "a PERMIT2_TRANSFER_FROM of 600 USDT into the router, a V3 exact-in of 600 USDT paid from the router, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x02, 0x00, 0x0d],
inputs: [
INTO_ROUTER_600,
V3_600_FROM_ROUTER,
TRANSFER_FROM_BATCH,
],
lines: [["Moved from the sender", UNDECODED]],
},
{
kind: "a PERMIT2_PERMIT that cannot be read, a PERMIT2_TRANSFER_FROM of 600 USDT into the router, then a V3 exact-in of 600 USDT paid from the router",
commands: [0x0a, 0x02, 0x00],
inputs: [
dirtyWord(
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR),
0,
),
INTO_ROUTER_600,
V3_600_FROM_ROUTER,
],
lines: [["Permit2 allowance", UNDECODED]],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 600 USDT into the router, a V4 step swapping 600 USDT whose SETTLE the router pays, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x02, 0x10, 0x0d],
inputs: [
INTO_ROUTER_600,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
USDT_600,
590000000n,
),
settle(USDT_ADDR, USDT_600, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
TRANSFER_FROM_BATCH,
],
lines: [["Moved from the sender", UNDECODED]],
},
{
kind: "a PERMIT2_TRANSFER_FROM of 600 USDT into the router, a V2 exact-in of CONTRACT_BALANCE paid from the router, then an EXECUTE_SUB_PLAN",
commands: [0x02, 0x08, 0x21],
inputs: [
INTO_ROUTER_600,
coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[
USER_ADDR,
2n ** 255n, // CONTRACT_BALANCE
590000000n,
[USDT_ADDR, USDC_ADDR],
false,
],
),
SUB_PLAN,
],
lines: [["Moved from the sender", UNDECODED]],
},
])("$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) =>
@@ -2308,20 +2544,6 @@ describe("uniswap decoder", () => {
],
lines: USDT_600_LINES,
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to another spender, then a V3 exact-in of 600 USDT,",
commands: [0x0a, 0x00],
inputs: [
encodePermit2(USDT_ADDR, USDT_5000, OTHER_ADDR),
V3_600,
],
lines: permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
OTHER_ADDR,
),
},
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to the router that the router may let fail, then a V3 exact-in of 600 USDT,",
commands: [0x8a, 0x00],
@@ -2373,9 +2595,11 @@ describe("uniswap decoder", () => {
({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(result.name).toBe("Swap USDT → USDC");
expect(
detail(result, "Token In").address.toLowerCase(),
).toBe(USDT_ADDR.toLowerCase());
// The swap step names Token In, its address checksummed,
// as on a permit's Allowance token line.
expect(detail(result, "Token In").value).toBe(
"USDT (" + USDT_ADDR + ")",
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...lines,
@@ -2422,6 +2646,269 @@ describe("uniswap decoder", () => {
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/652 — a
// PERMIT2_PERMIT_BATCH, and a PERMIT2_PERMIT this decoder cannot read,
// showed nothing of what they allow. Each allowance a batch grants now
// gets the lines of a permit whose amount does not stand, and a permit
// or batch that cannot be read gets one line saying so and leaves no
// Amount figure.
describe("a PERMIT2_PERMIT_BATCH, or a permit that cannot be read", () => {
const USDT_5000 = 5000000000n;
const DAI_5000 = 5000000000000000000000n;
const MAX_UINT160 = 2n ** 160n - 1n;
// A batch names one spender for all its allowances. One to
// another spender is under "an allowance to a spender other than
// the router" below.
test("a PERMIT2_PERMIT_BATCH of 5,000 USDT and unlimited DAI to the router, then a V3 exact-in of 600 USDT, shows each allowance", () => {
const result = decodeSteps(
[0x03, 0x00],
[
encodePermit2Batch(
[
[USDT_ADDR, USDT_5000],
[DAI_ADDR, MAX_UINT160],
],
ROUTER_ADDR,
),
V3_600,
],
);
expect(result.name).toBe("Swap USDT → USDC");
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
...permitLines("Unlimited", DAI_ADDR, "DAI", ROUTER_ADDR),
]);
// A batch pays nobody, so the swap's minimum is what the
// sender receives.
expect(detail(result, "Paid to")).toBeUndefined();
expect(detail(result, "Min. received").value).toBe(
"590.0000 USDC",
);
});
// A batch keeps a PERMIT2_PERMIT's amount from standing as the
// Amount figure, before the permit as after it.
test("a PERMIT2_PERMIT_BATCH of 5,000 DAI, then a PERMIT2_PERMIT of 5,000 USDT to the router and a V3 exact-in of 600 USDT, shows the swap's 600 USDT and both permits' lines", () => {
const result = decodeSteps(
[0x03, 0x0a, 0x00],
[
encodePermit2Batch([[DAI_ADDR, DAI_5000]], ROUTER_ADDR),
encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR),
V3_600,
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
ROUTER_ADDR,
),
...permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
ROUTER_ADDR,
),
]);
});
// The permit's token word, and the batch's spender word, have a
// nonzero top byte.
test.each([
{
kind: "a PERMIT2_PERMIT",
command: 0x0a,
input: dirtyWord(
encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR),
0,
),
},
{
kind: "a PERMIT2_PERMIT_BATCH",
command: 0x03,
input: dirtyWord(
encodePermit2Batch(
[[USDT_ADDR, USDT_5000]],
ROUTER_ADDR,
),
3,
),
},
])(
"$kind that cannot be read, then a V3 exact-in of 600 USDT, says its allowances could not be decoded and shows no Amount figure and no Min. received",
({ command, input }) => {
const result = decodeSteps(
[command, 0x00],
[input, V3_600],
);
expect(linesBetweenTokens(result)).toEqual([
...USDT_600_GROUP,
["Permit2 allowance", UNDECODED],
]);
// Its spender may be any address, as with an allowance
// to a spender other than the router (below).
// https://git.eeqj.de/sneak/AutistMask/issues/658 — on
// next at e296546 this read Min. received.
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe(
"590.0000 USDC",
);
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/659 — on next at
// 5f2b606 only the 400 DAI was shown.
test("a PERMIT2_PERMIT that cannot be read, then a V3 exact-in of 600 USDT and a V2 exact-in of 400 DAI, both paid by the user, shows both amounts", () => {
const result = decodeSteps(
[0x0a, 0x00, 0x08],
[
dirtyWord(
encodePermit2(USDT_ADDR, USDT_5000, ROUTER_ADDR),
0,
),
V3_600,
encodeV2SwapExactIn(USER_ADDR, DAI_400, 390000000n, [
DAI_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
...USDT_600_GROUP,
...DAI_GROUP,
["Permit2 allowance", UNDECODED],
]);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/658 — code that a step
// runs can call a spender other than the router, which can then take
// up to its allowance from the sender before the transaction ends.
// On next at e296546 the Amount figure and Min. received took no
// account of it. An allowance of Token In to another spender now
// leaves no Amount figure, the swap steps' own takes get lines of
// their own, and any allowance to another spender leaves no Min.
// received.
describe("an allowance to a spender other than the router", () => {
const USDT_5000 = 5000000000n;
const DAI_5000 = 5000000000000000000000n;
const MAX_UINT160 = 2n ** 160n - 1n;
const USDT_5000_LINES = permitLines(
"5000.0000 USDT",
USDT_ADDR,
"USDT",
OTHER_ADDR,
);
test.each([
{
kind: "a PERMIT2_PERMIT of 5,000 USDT to another spender, then a V3 exact-in of 600 USDT,",
commands: [0x0a, 0x00],
inputs: [
encodePermit2(USDT_ADDR, USDT_5000, OTHER_ADDR),
V3_600,
],
lines: [...USDT_600_GROUP, ...USDT_5000_LINES],
minimum: "590.0000 USDC",
},
{
kind: "a V3 exact-in of 600 USDT, then a PERMIT2_PERMIT of 5,000 USDT to another spender,",
commands: [0x00, 0x0a],
inputs: [
V3_600,
encodePermit2(USDT_ADDR, USDT_5000, OTHER_ADDR),
],
lines: [...USDT_600_GROUP, ...USDT_5000_LINES],
minimum: "590.0000 USDC",
},
{
kind: "a PERMIT2_PERMIT_BATCH of 5,000 USDT and unlimited DAI to another spender, then a V3 exact-in of 600 USDT,",
commands: [0x03, 0x00],
inputs: [
encodePermit2Batch(
[
[USDT_ADDR, USDT_5000],
[DAI_ADDR, MAX_UINT160],
],
OTHER_ADDR,
),
V3_600,
],
lines: [
...USDT_600_GROUP,
...USDT_5000_LINES,
...permitLines(
"Unlimited",
DAI_ADDR,
"DAI",
OTHER_ADDR,
),
],
minimum: "590.0000 USDC",
},
{
kind: "a PERMIT2_PERMIT of 1 USDT to another spender, then a V3 exact-in of 600 USDT and a V2 exact-in of 400 USDT, both paid by the user,",
commands: [0x0a, 0x00, 0x08],
inputs: [
ONE_USDT_PERMIT(OTHER_ADDR),
V3_600,
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
lines: [
...USDT_600_GROUP,
...USDT_400_GROUP,
...ONE_USDT_LINES(OTHER_ADDR),
],
// The last swap step's minimum.
minimum: "390.0000 USDC",
},
])(
"$kind shows each take's own lines and the allowances, and no Amount figure or Min. received",
({ commands, inputs, lines, minimum }) => {
const result = decodeSteps(commands, inputs);
expect(result.name).toBe("Swap USDT → USDC");
expect(linesBetweenTokens(result)).toEqual(lines);
expect(detail(result, "Paid to")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe(minimum);
},
);
// An allowance of another token leaves the Amount figure for
// Token In.
test("a PERMIT2_PERMIT of 5,000 DAI to another spender, then a V3 exact-in of 600 USDT, shows the swap's 600 USDT and no Min. received", () => {
const result = decodeSteps(
[0x0a, 0x00],
[encodePermit2(DAI_ADDR, DAI_5000, OTHER_ADDR), V3_600],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
...permitLines(
"5000.0000 DAI",
DAI_ADDR,
"DAI",
OTHER_ADDR,
),
]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe(
"590.0000 USDC",
);
});
});
// A V3_SWAP_EXACT_OUT whose path is shorter than one token cannot be
// read. It may pay any address and take from the sender, so it
// leaves no Amount figure.
@@ -2431,7 +2918,12 @@ describe("uniswap decoder", () => {
[USER_ADDR, 390000000n, USDT_400, "0x", true],
);
const result = decodeSteps([0x00, 0x01], [V3_600, unreadable]);
// With a V3 exact-out step, the 600 USDT reads as a maximum, as
// the Amount line would.
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", "Up to 600.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", UNDECODED],
]);
expect(detail(result, "Paid to").value).toBe(UNDECODED);
@@ -2482,6 +2974,9 @@ describe("uniswap decoder", () => {
],
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", "Up to 0.5000 WETH"],
["Token moved", "WETH (" + WETH_ADDR + ")"],
["Moved to", "The swap step that spends it"],
["Moved from the sender", UNDECODED],
...permitLines("1.0000 WETH", WETH_ADDR, "WETH", ROUTER_ADDR),
]);
@@ -2637,14 +3132,15 @@ describe("uniswap decoder", () => {
);
// The V4 exact-out route above the other way round, where the later
// take's unknown amount leaves no figure at all, and other ways the
// step that sets the input side states no amount.
// take's unknown amount leaves no Amount figure and the 400 USDT gets
// lines of its own, and other ways the step that sets the input side
// states no amount.
test.each([
{
kind: "a V2 exact-out of at most 400 USDT, then a V4 exact-out step of at most 600 USDT",
commands: [0x09, 0x10],
inputs: [V2_EXACT_OUT_400, V4_EXACT_OUT_600],
lines: UNKNOWN_USDT,
lines: [...UP_TO_400_USDT, ...UNKNOWN_USDT],
},
{
kind: "a PERMIT2_PERMIT of 1 USDT to another spender, a V4 exact-out step of at most 600 USDT, then a V2 exact-out of at most 400 USDT",
@@ -3234,6 +3730,88 @@ describe("uniswap decoder", () => {
},
);
// https://git.eeqj.de/sneak/AutistMask/issues/574 — a V4 step that buys
// at least 0.5 WETH with 1,000 USDT, spends some of it on USDC, and takes
// the USDC and then the WETH, so its output side is WETH. Only what is
// left of the 0.5 WETH is sure to reach the user, and the screen read
// Min. received 0.5000 WETH.
test.each([
{
spend: "0.3 WETH for at least 594 USDC",
swap: {
id: V4_SWAP_EXACT_IN_SINGLE_ID,
param: encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
300000000000000000n,
594000000n,
),
},
},
{
spend: "0.3 WETH through DAI for at least 594 USDC",
swap: {
id: V4_SWAP_EXACT_IN,
param: encodeV4ExactIn(
WETH_ADDR,
[DAI_ADDR, USDC_ADDR],
300000000000000000n,
594000000n,
),
},
},
{
spend: "at most 0.3 WETH for exactly 594 USDC",
swap: {
id: V4_SWAP_EXACT_OUT_SINGLE_ID,
param: encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
594000000n,
300000000000000000n,
),
},
},
])(
"a V4 step that buys WETH and spends $spend, with WETH as its output side, shows no Min. received",
({ swap }) => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
USDT_FOR_WETH_IN.id,
swap.id,
V4_SETTLE_ALL_ID,
V4_TAKE_ID,
V4_TAKE_ID,
]),
[
USDT_FOR_WETH_IN.param,
swap.param,
encodeV4SettleAll(USDT_ADDR, 1000000000n),
encodeV4Take(USDC_ADDR),
encodeV4Take(WETH_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// A V4 step that spends WETH on USDC and on DAI. Its swap actions buy two
// currencies that none of them spends, and CLOSE_CURRENCY, which the
// decoder does not read, names no side, so its output side is unknown.
@@ -3284,9 +3862,7 @@ describe("uniswap decoder", () => {
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
@@ -3344,9 +3920,7 @@ describe("uniswap decoder", () => {
"Unknown (not named in the calldata)",
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Token Out").address).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Token Out").address).toBe(USDT_ADDR);
expect(detail(result, "Min. received").value).toBe("990.0000 USDT");
});
+42 -19
View File
@@ -1332,6 +1332,10 @@ describe("any token left in the router is not received", () => {
),
),
left: tokenLine(WETH, "WETH"),
// The second swap action spends 1 USDC of what the first buys,
// so the step states no minimum
// (https://git.eeqj.de/sneak/AutistMask/issues/574).
noMin: true,
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 SETTLE of OPEN_DELTA from the router before the swap, which the user settles",
@@ -1432,15 +1436,24 @@ describe("any token left in the router is not received", () => {
),
left: { label: "Token moved into the router", value: "ETH" },
},
])("$kind", ({ data, left, spent, unread }) => {
])("$kind", ({ data, left, spent, unread, noMin }) => {
const result = decode(data);
expect(labels(result)).toEqual([
"Protocol",
"Token In",
// A later step whose SETTLE or SETTLE_ALL the decoder cannot read
// may take anything from the sender: one line says so, in place
// of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625).
unread ? "Moved from the sender" : "Amount",
// of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625),
// after the lines the WRAP_ETH's 1 ETH then gets
// (https://git.eeqj.de/sneak/AutistMask/issues/659).
...(unread
? [
"Moved from the sender",
"Token moved",
"Moved to",
"Moved from the sender",
]
: ["Amount"]),
// A later swap step the user pays for spends `spent` of a token
// other than Token In.
...(spent
@@ -1449,7 +1462,7 @@ describe("any token left in the router is not received", () => {
"Token Out",
"Paid to",
"Token moved into the router",
"Min. paid out",
...(noMin ? [] : ["Min. paid out"]),
"Steps",
"Deadline",
]);
@@ -1457,13 +1470,17 @@ describe("any token left in the router is not received", () => {
expect(detail(result, "Moved from the sender").value).toBe(spent);
}
if (unread) {
expect(detail(result, "Moved from the sender").value).toBe(
UNDECODED,
);
expect(
result.details
.filter((d) => d.label === "Moved from the sender")
.map((d) => d.value),
).toEqual(["1.0000 ETH", UNDECODED]);
}
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Token moved into the router")).toEqual(left);
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
expect(detail(result, "Min. paid out")?.value).toBe(
noMin ? undefined : "1990.0000 USDC",
);
});
// Uniswap's own routes: a swap paid in ETH wraps into the router what its
@@ -2791,26 +2808,37 @@ describe("the allow-revert flag", () => {
});
// Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail.
// The permit's spender is the router at ROUTER, which on every other
// router is an allowance to another spender, so the minimum reads
// Min. paid out there.
test.each([
{ router: "UniversalRouterV1", address: ROUTER_V1, mark: "" },
{
router: "UniversalRouterV1",
address: ROUTER_V1,
mark: "",
minimum: "Min. paid out",
},
{
router: "UniversalRouterV1_2_V2Support",
address: ROUTER_V1_2,
mark: "",
minimum: "Min. paid out",
},
{
router: "UniversalRouterV2",
address: ROUTER,
mark: " (allowed to fail)",
minimum: "Min. received",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2,
mark: " (allowed to fail)",
minimum: "Min. paid out",
},
])(
"on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap's lines read as without the flag",
({ address, mark }) => {
({ address, mark, minimum }) => {
const steps = [
permit2Permit(USDT, USDT_2000),
swapToWeth(MSG_SENDER),
@@ -2828,7 +2856,7 @@ describe("the allow-revert flag", () => {
// amount stands as the Amount figure only there, and only without
// the flag. With the flag it stands on no router and gets lines
// of its own, and the swap step names Token In: the same token,
// its address in lowercase.
// its address written the same way.
const permitLines = [
"Permit2 allowance",
"Allowance token",
@@ -2836,19 +2864,13 @@ describe("the allow-revert flag", () => {
];
const swapLines = (r) =>
r.details.filter(
(d) =>
!["Steps", "Token In", ...permitLines].includes(
d.label,
),
(d) => !["Steps", ...permitLines].includes(d.label),
);
expect(swapLines(withFlag)).toEqual(swapLines(plain));
expect(detail(withFlag, "Token In").address.toLowerCase()).toBe(
detail(plain, "Token In").address.toLowerCase(),
);
expect(
labels(withFlag).filter((l) => permitLines.includes(l)),
).toEqual(permitLines);
expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH");
expect(detail(withFlag, minimum).value).toBe("1.0000 WETH");
},
);
@@ -3434,6 +3456,7 @@ async function showDetail(data) {
globalThis.window = { location: { search: "" } };
state.networkId = "mainnet";
state.blockscoutUrl = "https://explorer.example/api/v2";
state.useBlockscout = true;
state.viewData = {};
state.viewStack = [];
state.currentView = null;
+2
View File
@@ -208,6 +208,7 @@ async function showDetail(row, activeNetworkId) {
globalThis.window = { location: { search: "" } };
state.networkId = activeNetworkId;
state.blockscoutUrl = "https://explorer.example/api/v2";
state.useBlockscout = true;
state.viewData = {};
state.viewStack = [];
state.currentView = null;
@@ -287,6 +288,7 @@ async function historyRow(method, sentTo, chainId) {
const rows = await fetchRecentTransactions(
RECIPIENT,
"https://explorer.example/api/v2",
true,
chainId,
);
return rows[0];
+3 -3
View File
@@ -172,7 +172,7 @@ async function fetchOnto(items) {
statusText: "OK",
json: async () => items,
}));
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []);
state.wallets = [
{
name: "Wallet 1",
@@ -197,14 +197,14 @@ async function fetchOntoBoth(itemsA, itemsB) {
statusText: "OK",
json: async () => itemsA,
}));
const a = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const a = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []);
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => itemsB,
}));
const b = await fetchTokenBalances(SECOND_HOLDER, BLOCKSCOUT, []);
const b = await fetchTokenBalances(SECOND_HOLDER, BLOCKSCOUT, true, []);
state.wallets = [
{
name: "Wallet 1",