Author SHA1 Message Date
sneak 6cc06b821a fix: the address screen shows a token just added at its fetched balance, not 0.0000 (closes #516)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
A balance refresh that returns now draws the balances on AddressDetail or
AddressToken again when that screen is up, not only the wallet list. Their
transaction lists are left alone.

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

Model: opus-5-5
2026-10-26 23:18:45 +00:00
clawbot b58ecec1f9 fix: AutistMask does not work in Firefox private windows (closes #646)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Firefox gives an extension page in a private window locks of its own, so a popup there could save between the background's read and its write and undo a network switch the background saved. `manifest/firefox.json` now sets `"incognito": "not_allowed"`, so such a page cannot read or write extension storage, and `tests/manifest.test.js` pins the key. README and the user guide say AutistMask does not work in Firefox private windows. Chrome needs no change: in its default incognito mode an extension page shares the background's lock.

A Firefox user on "Never remember history" can no longer run AutistMask at all.

Model: opus-5-5
2026-10-26 23:51:22 +01:00
clawbot 387f40689e feat: an "Allow weak passwords" setting lets a new wallet have a short password (closes #674)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Settings has a new "Allow weak passwords" checkbox, off in a new profile. While it is off, AddWallet refuses a password shorter than 12 characters, as before. While it is on, AddWallet accepts any password that is not empty. Key derivation is unchanged: one Argon2id derivation at libsodium's interactive setting. README's Key Decisions now states those parameters and why one light derivation is enough: the password's length is what protects the stored secret.

The screens say "password", as README's wording rules require, so the setting is "Allow weak passwords".

Model: opus-5-5
2026-10-26 22:35:02 +01:00
clawbot bf8aa9f5dd 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 and each recipient's address to Etherscan. Two settings, "Use Blockscout" and "Check recipients on Etherscan", now gate every request that sends the user's data to those services. Both start off and are offered unticked on Welcome and in Settings. Each request reads its setting from storage before it asks. An answer that lands after the setting went off is dropped. While a setting is off, the screens that rely on it say so. ENS lookups ask only the RPC endpoint, so a name resolved off-chain does not resolve.

RULES.md's External Communication item is now out of date. Its new wording will be proposed on #211 once this lands.

Model: opus-5-5 (implementation, summary); fable-5-1 (prose)
2026-10-26 21:38:29 +01:00
73 changed files with 2746 additions and 776 deletions
+316 -178
View File
@@ -12,20 +12,29 @@ intercepts tx status links to their own site instead of going to Etherscan, etc.
None of the common alternatives work on Firefox. None of the common alternatives work on Firefox.
Hence, a minimally viable ERC20 browser wallet/signer that works cross-platform. 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, 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 and don't implement any crypto. By default the wallet sends user-specific data
(user-configurable) Ethereum RPC endpoint (which defaults to a public node) only to the Ethereum RPC endpoint. The user can change that endpoint in
unless the user turns on one of the two lookups below. Out of the box the Settings. The endpoint defaults to a public node.
extension contacts the configured RPC node for blockchain interactions and a
public CoinDesk API (no API key) for realtime price information, which is sent Out of the box the extension contacts two services:
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 - the configured RPC node, for blockchain interactions
Blockscout block-explorer API for transaction history, the tokens the user has - a public CoinDesk API (no API key), for realtime prices. The API receives
not tracked and transaction details ("Use Blockscout"), and best-effort token symbols and nothing else.
Etherscan address label lookups during transaction confirmation ("Check
recipients on Etherscan"). A community-maintained phishing domain blocklist is Two more services are sent none of the user's data until the user ticks a
built into the extension at build time and checked locally; nothing is fetched checkbox for each. Welcome and Settings both offer the two checkboxes:
for it at runtime.
- "Use Blockscout": a Blockscout block-explorer API, used for:
- transaction history
- the tokens the user has not tracked
- transaction details
- "Check recipients on Etherscan": a best-effort Etherscan label lookup of the
recipient 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.
In the extension is a hardcoded list of the top ERC20 contract addresses. You 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 can add any ERC20 contract by contract address if you wish, but the hardcoded
@@ -427,22 +436,33 @@ 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. mainnet must take it down, both while Settings is still on screen.
It also covers what a new install sends to Blockscout and Etherscan It also covers what a new install sends to Blockscout and Etherscan
([#668](https://git.eeqj.de/sneak/AutistMask/issues/668)). The route handler ([#668](https://git.eeqj.de/sneak/AutistMask/issues/668)). The harness installs
records every request the extension makes, from its pages and its background a route handler before the first popup page opens. From then on, the handler
worker, from when the harness installs it, before the first popup page opens; a records every request the extension makes, from its pages and from its
request the background worker makes while it starts up, before then, is not background worker. The background worker starts earlier, but it makes no request
recorded. Welcome must offer "Use Blockscout" and "Check recipients on while it starts up. The tests take these steps in order:
Etherscan" unticked. With neither changed, Home, AddressDetail, AddressToken and
ConfirmTx must show the lines saying what is off; the popup is then closed until - Welcome must offer "Use Blockscout" and "Check recipients on Etherscan"
the background has run a balance refresh of its own, and not one recorded unticked.
request may have gone to Blockscout or Etherscan. "Use Blockscout" is then - With neither ticked, Home, AddressDetail, AddressToken and ConfirmTx must each
ticked from AddressDetail's gear: on "Back", AddressDetail must ask Blockscout show the line saying what is off.
for its history and no longer say it is off, and Blockscout must then be asked - The popup is then closed until the background has run a balance refresh of its
for the token balances. "Check recipients on Etherscan" is ticked from own. No recorded request may have gone to Blockscout or Etherscan.
ConfirmTx's gear: on "Back", ConfirmTx must ask Etherscan about the recipient - "Use Blockscout" is ticked from AddressDetail's gear. On "Back", AddressDetail
and show the label the stub page gives it. Both stay on for the rest of the run. must ask Blockscout for its history and no longer say the setting is off.
TransactionDetail with "Use Blockscout" off is not driven here, since no history Blockscout must then be asked for the token balances.
row leads to it then; the unit tests cover it. - The stub Blockscout then reports a token the user has not tracked. Home lists
it. "Use Blockscout" is unticked from AddressDetail's gear. On "Back", the
token must be gone from AddressDetail, from Send's token dropdown and from
Home. Those three screens must each show the line saying the setting is off.
The setting is then ticked again.
- "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 the recipient.
Both settings stay on for the rest of the run. TransactionDetail with "Use
Blockscout" off is not driven here: while the setting is off, there is no
history row to open it from. The unit tests cover that case.
It also covers the confirmation screen, for both a native ETH send and an ERC-20 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, send: Send disabled while the fee estimate is in flight, enabled once it lands,
@@ -746,13 +766,11 @@ Three limits are worth knowing, all real differences from the Chrome suite:
in `tests/e2e/dapp.js`, which serves the dApp page and a JSON-RPC node and in `tests/e2e/dapp.js`, which serves the dApp page and a JSON-RPC node and
which the extension's `rpcUrl` is pointed at for the dApp steps; a JSON-RPC which the extension's `rpcUrl` is pointed at for the dApp steps; a JSON-RPC
method that fixture does not model fails the run rather than answering `null`. method that fixture does not model fails the run rather than answering `null`.
Everything else, Blockscout and the price feed among it, has no fixture and The suite leaves "Use Blockscout" off, so Blockscout is not asked. A
simply fails, and the extension swallows its own fetch failures, so only the `ReferenceError` in the success path of `renderTransactions`, or of price
_failure_ branches of that code are ever executed. A `ReferenceError` in the rendering, passes this suite green. The offline run is also weaker than the
success path of `renderTransactions`, or of price rendering, passes this suite Chrome suite's interception: it proves nothing got out, but it cannot report
green. The offline run is also weaker than the Chrome suite's interception for which requests were attempted.
those calls: it proves nothing got out, but it cannot report which requests
were attempted.
- **The site-connection prompt always opens in a window of its own.** The - **The site-connection prompt always opens in a window of its own.** The
profile turns off `extensions.openPopupWithoutUserGesture.enabled`, so profile turns off `extensions.openPopupWithoutUserGesture.enabled`, so
`src/background/index.js` falls back from the toolbar popup, which WebDriver `src/background/index.js` falls back from the toolbar popup, which WebDriver
@@ -875,6 +893,8 @@ src/
prices.js — ETH/USD and token/USD via CoinDesk API prices.js — ETH/USD and token/USD via CoinDesk API
scamlist.js — known fraud contract addresses scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage) state.js — persisted state (extension storage)
storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan",
read from storage for the requests those settings gate
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
tokenList.js — top ERC-20 tokens by market cap (hardcoded) tokenList.js — top ERC-20 tokens by market cap (hardcoded)
transactions.js — tx history fetching + anti-poisoning filters transactions.js — tx history fetching + anti-poisoning filters
@@ -1145,25 +1165,39 @@ with the same floor (`.5` reads `.5000`).
**Specific Exception — unknown token scale:** Calldata carries base units and no **Specific Exception — unknown token scale:** Calldata carries base units and no
scale, so every amount on the dApp approval screen needs the token's `decimals`. scale, so every amount on the dApp approval screen needs the token's `decimals`.
It is resolved from the bundled token list, then from the tokens the user tracks The wallet takes the `decimals` from these sources, in this order
on the network the transaction is for, then, when that is the active network, (`resolveTokenDecimals()` in `src/shared/approvalAmount.js`):
from what the block explorer reported for the contract in the wallet's balances,
which are the active network's only (`resolveTokenDecimals()` in - the bundled token list
`src/shared/approvalAmount.js`). Where none of them answers, the amount is not - the tokens the user tracks on the network the transaction is for
formatted: the line reads `5000000000 base units (decimals unknown)`. A guessed - what the block explorer reported for the contract in the wallet's balances.
scale is not an approximation but a different number — 1,000 units of a Those balances cover the active network only. This source is used only while
6-decimal token formatted at 18 decimals reads `0.000000001` — on the screen "Use Blockscout" is on (`tokenSourcesByChainId()` in
whose only job is to state what is being authorized. Both amount paths of that `src/popup/views/helpers.js`).
screen take this rule: the ERC-20 `transfer`/`approve` line
(`src/popup/views/approval.js`) and the swap's `Amount`, `Min. received`, The token's name comes from the same sources. While "Use Blockscout" is off, a
`Moved from the sender` and `Permit2 allowance` lines and the `Paid to` line of token gets a line under it when the bundled list and the tracked tokens lack its
a move into the router (`src/shared/uniswap.js`). The token permission warning name or decimals. The line says "Blockscout's report of your balances is not
on the signature screen takes the same rule for its amounts. An unbounded used to name this token, because "Use Blockscout" is off in Settings." These
allowance or permit needs no scale to describe and is still shown as screens show the line:
`Unlimited`. A source's answer counts only if it is a whole number from 0 to 80:
`decimals()` returns a `uint8`, but `formatUnits()` cannot format more than 80 - TxApproval and SignApproval
decimal places, so a token that reports 81 to 255 is shown as one whose scale - WaitTx, SuccessTx and ErrorTx, which follow TxApproval
nothing knows.
Where none of the sources answers, the amount is not formatted. The amount line
then reads `5000000000 base units (decimals unknown)`. A guessed scale is not an
approximation but a different number — 1,000 units of a 6-decimal token
formatted at 18 decimals reads `0.000000001` — on the screen whose only job is
to state what is being authorized. Both amount paths of that screen take this
rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
the swap's `Amount`, `Min. received`, `Moved from the sender` and
`Permit2 allowance` lines and the `Paid to` line of a move into the router
(`src/shared/uniswap.js`). The token permission warning on the signature screen
takes the same rule for its amounts. An unbounded allowance or permit needs no
scale to describe and is still shown as `Unlimited`. A source's answer counts
only if it is a whole number from 0 to 80: `decimals()` returns a `uint8`, but
`formatUnits()` cannot format more than 80 decimal places, so a token that
reports 81 to 255 is shown as one whose scale nothing knows.
The rule holds only if nothing invents a scale UPSTREAM of it. Those three The rule holds only if nothing invents a scale UPSTREAM of it. Those three
sources are read as authoritative, so a value written into one of them cannot be sources are read as authoritative, so a value written into one of them cannot be
@@ -1186,12 +1220,13 @@ and compare against. Reading the stored field directly instead answers `null`
for a bundled or tracked token the explorer merely omitted, which is not a for a bundled or tracked token the explorer merely omitted, which is not a
refusal the wallet has any reason to make. refusal the wallet has any reason to make.
The Send screen also consults every address's explorer reports, so a contract While "Use Blockscout" is on, the Send screen also consults every address's
two addresses report different `decimals` for has no scale there, and the stored explorer reports. The reports for two addresses may give different `decimals`
balance, formatted at one of those scales, is withdrawn with it. The Send for one contract. That token then has no scale on Send. Its stored balance is
screen's `Current balance` and the confirmation screen's balance line then both not shown either. The Send screen's `Current balance` and the confirmation
read `unknown (SYMBOL)`. The balance list formats each explorer row as it is screen's balance line then both read `unknown (SYMBOL)`. The balance list
fetched, without that cross-address check, and shows the row's figure. formats each explorer row as it is fetched, without that cross-address check,
and shows the row's figure.
**Decoded amount lines on the transaction approval screen:** the `Amount` line **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 of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
@@ -1411,12 +1446,23 @@ 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 Blockscout's entry from the same refresh when there is none. Every other token
comes from the Blockscout `token-balances` response (`fetchTokenBalances()`), so comes from the Blockscout `token-balances` response (`fetchTokenBalances()`), so
tokens do appear without the user adding them, and they keep their previous 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 figures while Blockscout fails.
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 The refresh asks Blockscout only while the user has "Use Blockscout" on. The
its balance is stated at the scale the bundled list or the tracked token setting is off until the user turns it on. While it is off:
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 - Only tracked tokens are listed. Turning the setting off drops every other
token from every address at once.
- A tracked token's entry has no holder count and no `decimals` from Blockscout.
- A tracked token's balance uses the decimals from the bundled list, or else the
decimals saved when the token was tracked.
Blockscout may answer just before the user turns the setting off. So the popup's
refresh and the background's both check the stored setting again when they save.
If the setting is off by then, the save drops every token the user has not
tracked.
A tracked token is shown when its balance is nonzero, and any other ERC-20 when
its balance is nonzero and it is in the bundled known-token list or has 1,000 or its balance is nonzero and it is in the bundled known-token list or has 1,000 or
more holders. A token claiming a symbol from the bundled list from any other 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 contract address is always dropped, tracked or not, and so is any token claiming
@@ -1435,7 +1481,9 @@ send-screen token selector, the address total and the remove-address warning
(`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked confirmation screens show it when its token is the one being sent. Tracked
tokens with a zero balance are listed as well while "Show tracked tokens with tokens with a zero balance are listed as well while "Show tracked tokens with
zero balance" is on. zero balance" is on. While that setting is on, a token just added reads
`quantity unknown` until a refresh has read its balance at every address (see
AddToken).
#### Stored state and its version #### Stored state and its version
@@ -1448,11 +1496,12 @@ wrote last, the record says which build's shape it is in.
Each write reads the record, changes it and writes it back, holding a lock from Each write reads the record, changes it and writes it back, holding a lock from
the read to the write: the browser's own (`navigator.locks`), taken through the read to the write: the browser's own (`navigator.locks`), taken through
`withLock()` in `src/shared/browserApi.js`. Every page of the extension and its `withLock()` in `src/shared/browserApi.js`. Every page of the extension and its
background share it, except a page in a Firefox private window, which Firefox background share it, except a page in a Firefox private window, which has locks
gives locks of its own (see https://git.eeqj.de/sneak/AutistMask/issues/646). of its own. `manifest/firefox.json` sets `"incognito": "not_allowed"`, so such a
The popup's `saveState()`, the background's `updateState()` and the page cannot read or write extension storage. In Chrome, a page opened from an
StateRecovery reset all take it, so among the pages and the background that incognito window still runs outside incognito and shares the lock. The popup's
share it, no write lands between another write's read and its write, where it `saveState()`, the background's `updateState()` and the StateRecovery reset all
take it, so no write lands between another write's read and its write, where it
would be lost. Chrome has it from version 69, older than any Chrome that runs a would be lost. Chrome has it from version 69, older than any Chrome that runs a
Manifest V3 extension, in pages and in the service worker; Firefox from version Manifest V3 extension, in pages and in the service worker; Firefox from version
96, older than the 115 `manifest/firefox.json` declares. Where it is missing, a 96, older than the 115 `manifest/firefox.json` declares. Where it is missing, a
@@ -1604,10 +1653,26 @@ 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, 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 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 "Back" always re-renders, so the wallet list reflects anything that changed
while the user was away from it. Changing "Use Blockscout" or "Check recipients while the user was away from it.
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 Changing "Use Blockscout" or "Check recipients on Etherscan" in Settings makes
stack is left as it is. every screen but Settings count as not yet rendered. "Back" then renders again
any screen that a reopened popup can render. A change to "Use Blockscout" also
cuts the stack, as a network switch does (below). The cut drops Send, whose
token dropdown was filled under the old setting. "Back" cannot render Send
again. A change to "Check recipients on Etherscan" leaves the stack as it is.
Any other open popup takes either change from the stored record, as it takes a
network switch (below). That popup then redraws its top screen as a reopened
popup would. A screen that a reopened popup cannot render, such as Send, is
replaced by Home.
The popup refreshes balances every ten seconds while it is open
(`doRefreshAndRender()` in `src/popup/index.js`). Each refresh that returns
draws the wallet list again. When AddressDetail or AddressToken is up, the
refresh also draws that screen's USD total and balance lines again, but not its
transaction list. A refresh draws no other screen again, DeleteAddress and its
balance lines included.
A network switch leaves every screen on the page showing the previous 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 The user makes it in Settings, or approves a site's request and the background
@@ -1650,11 +1715,13 @@ view would leave a wallet one click from deletion.
screen in that case. screen in that case.
- **Elements**: - **Elements**:
- "Welcome! To get started, add a wallet." text - "Welcome! To get started, add a wallet." text
- A well saying that the wallet sends the user's data only to the Ethereum - A well opening with "This wallet sends your data only to the Ethereum RPC
RPC endpoint unless one of the two settings in it is turned on, here or endpoint, unless you turn on one of these. You can change them later in
later in Settings: the "Use Blockscout" and "Check recipients on Settings."
Etherscan" checkboxes, the same two as in Settings, each with its sentence - In the well, the "Use Blockscout" and "Check recipients on Etherscan"
on what it sends and to whom. Both are unticked until the user ticks them checkboxes, the same two as in Settings. Under each, a sentence says what
the setting sends and to which service. Both are unticked until the user
ticks them
- "Add wallet" button - "Add wallet" button
- **Transitions**: - **Transitions**:
- Ticking or unticking either checkbox → stores the setting, as Settings - Ticking or unticking either checkbox → stores the setting, as Settings
@@ -1690,7 +1757,7 @@ view would leave a wallet one click from deletion.
says "This transaction creates a new contract. It has no recipient." The 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, 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 Blockscout is not asked. This list then shows only the line "No
transaction history is shown, because "Use Blockscout" is off in transaction history is shown, because "Use Blockscout" is off in
Settings." Settings."
- "Add additional wallet..." link at bottom - "Add additional wallet..." link at bottom
@@ -1730,18 +1797,20 @@ view would leave a wallet one click from deletion.
without it, the lost-password route on DeleteWallet is the first they without it, the lost-password route on DeleteWallet is the first they
would hear of it. The hint line reserves its height, so switching tabs would hear of it. The hint line reserves its height, so switching tabs
cannot move the password fields under the pointer. cannot move the password fields under the pointer.
- The password needs at least 12 characters. With "Allow weak passwords"
ticked in Settings, one character is enough.
- "Import" button, with the error line beside it so that it adds no height - "Import" button, with the error line beside it so that it adds no height
to a screen whose button already starts near the bottom of the popup to a screen whose button already starts near the bottom of the popup
- **Transitions**: - **Transitions**:
- "Import" with a valid entry and a matching password of at least 12 - "Import" with a valid entry and a matching password that is long enough →
characters → creates the wallet, clears the navigation stack, and → creates the wallet, clears the navigation stack, and → **Home**. When no
**Home**. When no address is active yet, as on the first wallet, the new address is active yet, as on the first wallet, the new wallet's first
wallet's first address becomes the active one. The phrase and xprv modes address becomes the active one. The phrase and xprv modes then scan for
then scan for further used addresses and report the count as a flash further used addresses and report the count as a flash message. When the
message. When the scan finds two or more used addresses, they replace the scan finds two or more used addresses, they replace the wallet's list,
wallet's list, which then holds the first address only if it was used. If which then holds the first address only if it was used. If the first
the first address was active and is not in the new list, the new list's address was active and is not in the new list, the new list's first
first address becomes the active one and `AUTISTMASK_ACTIVE_CHANGED` is address becomes the active one and `AUTISTMASK_ACTIVE_CHANGED` is
broadcast. broadcast.
- "Import" with a missing, short or mismatched password → full-sentence - "Import" with a missing, short or mismatched password → full-sentence
error on the error line, no screen change error on the error line, no screen change
@@ -1760,13 +1829,14 @@ view would leave a wallet one click from deletion.
- Full address (color dot, etherscan link, tap to copy) - Full address (color dot, etherscan link, tap to copy)
- USD total for address (see [Partial USD totals](#partial-usd-totals)) - USD total for address (see [Partial USD totals](#partial-usd-totals))
- Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal - 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 - While "Use Blockscout" is off, a line under the balance list: "Only
follows it tracked tokens are listed, because "Use Blockscout" is off in Settings."
- Send / Receive / + Token buttons and a "···" menu button - Send / Receive / + Token buttons and a "···" menu button
- "···" dropdown containing a single "Export Private Key" entry - "···" dropdown containing a single "Export Private Key" entry
- Transaction list (with ENS resolution for counterparties), or the line - Transaction list (with ENS resolution for counterparties). While "Use
Home's list shows while "Use Blockscout" is off Blockscout" is off, it shows only the line "No transaction history is
shown, because "Use Blockscout" is off in Settings."
- **Transitions**: - **Transitions**:
- Tap balance row → **AddressToken** (for that token) - Tap balance row → **AddressToken** (for that token)
- "Send" → **Send** (refuses with a flash message on a zero balance) - "Send" → **Send** (refuses with a flash message on a zero balance)
@@ -1834,11 +1904,13 @@ view would leave a wallet one click from deletion.
- Token contract well (ERC-20 only): full contract address (tap to copy, - Token contract well (ERC-20 only): full contract address (tap to copy,
etherscan link) plus name, symbol, decimals, holder count and project etherscan link) plus name, symbol, decimals, holder count and project
website where known. The "Holders:" row is left out, not shown as 0, when 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 the token's balance-list entry has no holder count. That happens when:
report a readable one, "Use Blockscout" is off, or the token is not in the - the explorer did not report a readable one
balance list - "Use Blockscout" is off
- Token-filtered transaction list (only this token's transfers), or the line - the token is not in the balance list
Home's list shows while "Use Blockscout" is off - Token-filtered transaction list (only this token's transfers). While "Use
Blockscout" is off, it shows only the line "No transaction history is
shown, because "Use Blockscout" is off in Settings."
- **Transitions**: - **Transitions**:
- "Send" → **Send** (token locked: the dropdown is replaced by a static - "Send" → **Send** (token locked: the dropdown is replaced by a static
symbol and contract address) symbol and contract address)
@@ -1855,6 +1927,9 @@ view would leave a wallet one click from deletion.
- From: address with color dot + etherscan link - From: address with color dot + etherscan link
- What to send: token dropdown (or static display with contract address when - What to send: token dropdown (or static display with contract address when
locked from AddressToken) locked from AddressToken)
- While "Use Blockscout" is off, a line under the token dropdown: "Only
tracked tokens are listed, because "Use Blockscout" is off in Settings."
The line is hidden with the dropdown when Send is opened from AddressToken
- To: address or ENS name input, with an inline validation message - To: address or ENS name input, with an inline validation message
- Amount input with current balance display, which reads - Amount input with current balance display, which reads
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as `Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
@@ -1874,13 +1949,14 @@ view would leave a wallet one click from deletion.
- **Transitions**: - **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx** - "Review" (valid inputs, ENS resolved) → **ConfirmTx**
- "Review" with an unresolvable ENS name or an invalid amount → flash - "Review" with an unresolvable ENS name or an invalid amount → flash
message, no screen change. An amount is valid when it is a number above message, no screen change. A name with no address, or one resolved
zero, written as digits with at most one decimal point, with nothing but off-chain, gets "That ENS name has no on-chain address." An amount is
zeros after the token's last decimal place. ETH has 18 places, and a token valid when it is a number above zero, written as digits with at most one
with more, or with an unknown number, is held to 18. Zeros past those decimal point, with nothing but zeros after the token's last decimal
places leave the amount unchanged, so `1.0000000` of a 6-place token place. ETH has 18 places, and a token with more, or with an unknown
passes and is sent as exactly 1. ConfirmTx makes the same check before number, is held to 18. Zeros past those places leave the amount unchanged,
anything can be sent so `1.0000000` of a 6-place token passes and is sent as exactly 1.
ConfirmTx makes the same check before anything can be sent
- "Back" → previous screen (Home, AddressDetail, or AddressToken) - "Back" → previous screen (Home, AddressDetail, or AddressToken)
#### ConfirmTx (`confirm-tx`) #### ConfirmTx (`confirm-tx`)
@@ -1925,11 +2001,11 @@ view would leave a wallet one click from deletion.
a contract, burn address, and an Etherscan phishing/scam label. An address 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, that has received ETH but never sent a transaction still holds that ETH,
so it gets no warning. The Etherscan label check is made only while "Check 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 recipients on Etherscan" is on. While the setting is off, the Etherscan
out and a line in its place says "This address was not checked on box is left out. A line in its place says "This address was not checked on
Etherscan, because "Check recipients on Etherscan" is off in Settings." 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 The box or the line is chosen when the screen is drawn, so neither moves
moves anything later anything later
- Errors (an invalid amount), plus five reserved error boxes, so the balance - Errors (an invalid amount), plus five reserved error boxes, so the balance
and the fee arriving later move nothing — the amount exceeds 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 (for a token whose scale is unknown it says the token's balance is
@@ -1975,8 +2051,8 @@ view would leave a wallet one click from deletion.
are that network's, so a testnet transaction shows none, its From, To and 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 token contract links open that network's explorer, the balance read, the
fee estimate and the contract and never-sent recipient checks ask that fee estimate and the contract and never-sent recipient checks ask that
network's RPC endpoint, and the Etherscan label check, when it is made, network's RPC endpoint, and the Etherscan label check asks that network's
asks that network's explorer explorer
- "Sign & Send" (correct password) when the gas limit, a fee per gas or the - "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 total fee the RPC endpoint fills in at signing is over its bound in
`assertWithinCeilings()` (`src/shared/approvalVerify.js`) → nothing is `assertWithinCeilings()` (`src/shared/approvalVerify.js`) → nothing is
@@ -2006,6 +2082,9 @@ view would leave a wallet one click from deletion.
`Unknown (could not be decoded)` with no symbol, not the transaction's ETH `Unknown (could not be decoded)` with no symbol, not the transaction's ETH
value. When that swap's `Token In` is `ETH (native)`, the transaction's value. When that swap's `Token In` is `ETH (native)`, the transaction's
ETH value in the network's currency, as for any other transaction ETH value in the network's currency, as for any other transaction
- A line under the amount, worded as on TxApproval. While "Use Blockscout"
is off, the line shows when the bundled list and the tracked tokens lack
the token's name or decimals.
- To: color dot + full address + etherscan link; for a contract creation, - To: color dot + full address + etherscan link; for a contract creation,
which has no recipient, "This transaction creates a new contract. It has which has no recipient, "This transaction creates a new contract. It has
no recipient." instead no recipient." instead
@@ -2038,7 +2117,9 @@ view would leave a wallet one click from deletion.
- **Elements**: - **Elements**:
- "Transaction Confirmed" heading - "Transaction Confirmed" heading
- Decoded action well (shown when the transaction carried recognized - Decoded action well (shown when the transaction carried recognized
calldata; the top-level Amount and To are hidden in that case) calldata; the top-level Amount and To are hidden in that case). Each token
in the well gets TxApproval's line under it whenever TxApproval would show
that line.
- Amount + symbol - Amount + symbol
- To: color dot + full address + etherscan link, or for a contract creation - To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx the same sentence as on WaitTx
@@ -2061,6 +2142,8 @@ view would leave a wallet one click from deletion.
- Amount + symbol, or for a swap the approval screen showed no `Amount` for, - Amount + symbol, or for a swap the approval screen showed no `Amount` for,
the same as on WaitTx: `Unknown (could not be decoded)`, unless its the same as on WaitTx: `Unknown (could not be decoded)`, unless its
`Token In` is `ETH (native)` `Token In` is `ETH (native)`
- The same line under the amount as on WaitTx, whenever WaitTx would show
it.
- To: color dot + full address + etherscan link, or for a contract creation - To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx the same sentence as on WaitTx
- Error message (dashed border box) - Error message (dashed border box)
@@ -2095,12 +2178,11 @@ view would leave a wallet one click from deletion.
AddressToken. The screen describes the transaction on the network it was sent AddressToken. The screen describes the transaction on the network it was sent
on, whichever network is active: its block, network details, decoded details 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 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. They are read only it, and its etherscan links open that network's explorer. While "Use
while "Use Blockscout" is on. The screen can still be shown with it off, when Blockscout" is off, Blockscout is not asked. One line then takes the place of
the popup reopens onto it after the user turned the setting off; then the block, network details, decoded details and raw data: "This screen shows
Blockscout is not asked, and a line in their place says "No block, decoded no block, decoded details, network details or raw data, because "Use
details, network details or raw data are shown, because "Use Blockscout" is Blockscout" is off in Settings."
off in Settings."
- **Elements** (grouped into logical blocks using light well containers; field - **Elements** (grouped into logical blocks using light well containers; field
labels are self-explanatory so groups have no headings): labels are self-explanatory so groups have no headings):
- "Transaction" heading, "Back" button - "Transaction" heading, "Back" button
@@ -2148,7 +2230,15 @@ view would leave a wallet one click from deletion.
- "Add" button - "Add" button
- **Transitions**: - **Transitions**:
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders - "Add" (valid contract) → tracks the token, pops the stack, and re-renders
**AddressDetail** **AddressDetail**. Until a balance refresh on the token's network has read
the token's balance at every address, its line never reads `0.0000`. The
line reads `quantity unknown` instead, or is left out while "Show tracked
tokens with zero balance" is off. Where Blockscout already listed a
holding of the token, the line shows that holding. Until that refresh, the
tracked token's record carries `balanceNotFetched`, so the line still
reads `quantity unknown` after the popup closes and opens again. A refresh
that returns draws AddressDetail's balances again (see Navigation), so the
fetched balance shows without the screen being reopened
- "Add" with a token already tracked, a scam-listed address, or a failed - "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change contract lookup → flash message, no screen change
- "Add", then a network switch in Settings before the contract lookup - "Add", then a network switch in Settings before the contract lookup
@@ -2165,6 +2255,10 @@ view would leave a wallet one click from deletion.
- Wallets: one row per wallet with its name (tap to rename inline), a - Wallets: one row per wallet with its name (tap to rename inline), a
`[recovery phrase]` button on HD wallets only, and an `[x]` delete button, `[recovery phrase]` button on HD wallets only, and an `[x]` delete button,
plus a "+ Add wallet" button plus a "+ Add wallet" button
- Passwords:
- "Allow weak passwords" checkbox, unticked until the user ticks it
- A sentence saying the setting lets the user choose a password shorter
than 12 characters on AddWallet
- Tracked Tokens: one row per token tracked on the active network with an - Tracked Tokens: one row per token tracked on the active network with an
`[x]` remove button, plus a "+ Add token" button `[x]` remove button, plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox, "UTC - Display: "Show tracked tokens with zero balance" checkbox, "UTC
@@ -2183,18 +2277,21 @@ view would leave a wallet one click from deletion.
already active changes nothing and sends nothing already active changes nothing and sends nothing
- Ethereum RPC: endpoint URL input + "Save" button (validated against - Ethereum RPC: endpoint URL input + "Save" button (validated against
`eth_chainId` before being saved) `eth_chainId` before being saved)
- Blockscout API: a "Use Blockscout" checkbox, unticked until the user ticks - Blockscout API:
it, with a sentence saying it sends the user's addresses, and the hash of - "Use Blockscout" checkbox, unticked until the user ticks it
each transaction opened, to the Blockscout API below, for transaction - A sentence saying what the setting shows and what it sends. With the
history, tokens not tracked, and transaction details; then the endpoint setting on, the wallet sends the Blockscout API the user's addresses
URL input + "Save" button (validated against `/stats` before being saved, and the hash of each transaction opened
a request the user presses for that carries no user data, made whether or - Endpoint URL input + "Save" button. "Save" asks the URL for `/stats`
not the checkbox is ticked) and saves the URL only if that request succeeds. The request carries
- Etherscan: a "Check recipients on Etherscan" checkbox, unticked until the no user data and is made whether or not "Use Blockscout" is ticked.
user ticks it, with a sentence saying it sends the address about to be - Etherscan:
sent to, to Etherscan, to look for a phishing or scam label on it. Both - "Check recipients on Etherscan" checkbox, unticked until the user
checkboxes are one setting each for every network, and Welcome offers the ticks it
same two - A sentence saying the setting sends each recipient's address to
Etherscan, to check the address for a phishing or scam label
- Each of the two settings applies to every network. Welcome offers the same
two checkboxes
- Token Spam Protection: - Token Spam Protection:
- "Hide fake tokens impersonating a known symbol" checkbox - "Hide fake tokens impersonating a known symbol" checkbox
- "Hide tokens with fewer than 1,000 holders" checkbox - "Hide tokens with fewer than 1,000 holders" checkbox
@@ -2219,8 +2316,9 @@ view would leave a wallet one click from deletion.
turns on the red banner and verbose logging turns on the red banner and verbose logging
- **Transitions**: - **Transitions**:
- Ticking or unticking "Use Blockscout" or "Check recipients on Etherscan" → - Ticking or unticking "Use Blockscout" or "Check recipients on Etherscan" →
stores the setting (no screen change). The next balance refresh and every stores the setting (no screen change). From then on, every screen drawn
screen drawn after that follow it and every balance refresh uses the new setting. Unticking "Use Blockscout"
also drops every token the user has not tracked from every address
- "+ Add wallet" → **AddWallet** - "+ Add wallet" → **AddWallet**
- "+ Add token" → **SettingsAddToken** - "+ Add token" → **SettingsAddToken**
- `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase** - `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase**
@@ -2239,7 +2337,8 @@ view would leave a wallet one click from deletion.
nothing and tells the background nothing nothing and tells the background nothing
- Ten clicks on the version → reveals the Debug well (no screen change) - Ten clicks on the version → reveals the Debug well (no screen change)
- "Back" (or Settings gear again) → previous screen (Home); after a network - "Back" (or Settings gear again) → previous screen (Home); after a network
switch, the stack is cut first (see the top of Screen Map) switch or a change to "Use Blockscout", the stack is cut first (see the
top of Screen Map)
#### ShowRecoveryPhrase (`show-phrase`) #### ShowRecoveryPhrase (`show-phrase`)
@@ -2396,6 +2495,8 @@ view would leave a wallet one click from deletion.
money. The predicate is `addressHoldsFunds()` in money. The predicate is `addressHoldsFunds()` in
`src/popup/views/helpers.js`, unrounded and token-aware. A balance is a `src/popup/views/helpers.js`, unrounded and token-aware. A balance is a
warning, never a refusal. warning, never a refusal.
- While "Use Blockscout" is off, a line under the warning: "Only tracked
tokens are listed, because "Use Blockscout" is off in Settings."
- The rule that a wallet always keeps at least one address, and that - The rule that a wallet always keeps at least one address, and that
removing the last one means deleting the wallet from Settings removing the last one means deleting the wallet from Settings
- Error line - Error line
@@ -2438,7 +2539,9 @@ view would leave a wallet one click from deletion.
add path add path
- **Transitions**: - **Transitions**:
- Any of the add paths shown, on success → adds the token and shows an - Any of the add paths shown, on success → adds the token and shows an
"Added SYMBOL" flash message (no screen change) "Added SYMBOL" flash message (no screen change). Until a refresh has read
the token's balance, its line reads `quantity unknown`, as after "Add" on
AddToken
- A duplicate, a scam-listed address, or a failed contract lookup → flash - A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change message, no screen change
- "Add" by contract address, then a network switch in Settings before the - "Add" by contract address, then a network switch in Settings before the
@@ -2528,6 +2631,12 @@ view would leave a wallet one click from deletion.
a swap pays only the From address, a `Paid to` line for each other address 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 it pays, or saying that a token stays in the router or who it pays is
unknown (see Transaction Decoding) unknown (see Transaction Decoding)
- A line under each token of the decoded action: "Blockscout's report of
your balances is not used to name this token, because "Use Blockscout" is
off in Settings." While "Use Blockscout" is off, the line shows when the
bundled list and the tracked tokens lack the token's name or decimals.
While the setting is on, Blockscout's report of the user's balances is a
third source of a token's name and decimals (see Unknown token scale).
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if - Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx known; for a contract creation, the same sentence as on WaitTx
@@ -2607,7 +2716,10 @@ view would leave a wallet one click from deletion.
the site puts in the message, except a `Permit`'s token, which is the the site puts in the message, except a `Permit`'s token, which is the
domain's `verifyingContract`; any those fields do not give is shown as domain's `verifyingContract`; any those fields do not give is shown as
`Unknown`, and the domain, type and message lines still follow. Only typed `Unknown`, and the domain, type and message lines still follow. Only typed
data that cannot be read at all is shown as raw text. data that cannot be read at all is shown as raw text. A token gets the
same line under it as on TxApproval, whenever TxApproval would show that
line. As there, Blockscout's report of the user's balances is a source of
a token's name and decimals only while "Use Blockscout" is on.
- Raw data (`personal_sign` and `eth_sign`): the message's hex exactly as - Raw data (`personal_sign` and `eth_sign`): the message's hex exactly as
the site sent it. The bytes it encodes are what is signed, as an EIP-191 the site sent it. The bytes it encodes are what is signed, as an EIP-191
personal message. personal message.
@@ -2738,12 +2850,22 @@ view would leave a wallet one click from deletion.
### External Services ### External Services
AutistMask is not a fully self-contained offline tool. By default it sends the 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, user's data only to the Ethereum RPC endpoint. It also asks the CoinDesk price
the CoinDesk price API, for prices with nothing but token symbols. Two more API for prices, sending token symbols and nothing else.
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") Two more services are sent none of the user's data until the user turns on a
and Etherscan ("Check recipients on Etherscan"). Both settings are off in a new setting for each:
profile, and each is one setting for every network.
- Blockscout, with "Use Blockscout"
- Etherscan, with "Check recipients on Etherscan"
Both settings are off in a new install. Welcome and Settings both offer them.
Each setting applies to every network.
Before each request that sends the user's data to Blockscout or Etherscan, the
wallet reads that service's setting from storage. The wallet reads the setting
again when the answer lands. While the setting is off, no such request is made,
and an answer that lands after the setting went off is dropped.
Contacted by default: Contacted by default:
@@ -2752,9 +2874,11 @@ Contacted by default:
them the balance of every tracked token at every address (`balanceOf`), them the balance of every tracked token at every address (`balanceOf`),
estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`), estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
broadcast transactions (`eth_sendRawTransaction`), and check transaction broadcast transactions (`eth_sendRawTransaction`), and check transaction
receipts. The default endpoint is a public RPC (configurable by the user to receipts. Every ENS lookup is made through the RPC endpoint alone. So a name
any endpoint they prefer, including a local node). By default the extension does not resolve when its resolver says the name is resolved off-chain. The
talks to `https://ethereum-rpc.publicnode.com`. 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 - **Data sent**: Ethereum addresses, transaction data, contract call
parameters. The RPC endpoint can see all on-chain queries and submitted parameters. The RPC endpoint can see all on-chain queries and submitted
transactions. transactions.
@@ -2766,36 +2890,44 @@ Contacted by default:
- **Data sent**: Token symbol strings only (e.g. "ETH", "USDC"). No - **Data sent**: Token symbol strings only (e.g. "ETH", "USDC"). No
addresses or user-specific data. addresses or user-specific data.
Sent the user's data only while the user has turned the setting on: Sent the user's data only while its setting is on:
- **Blockscout block-explorer API** ("Use Blockscout"): Used to fetch - **Blockscout block-explorer API** ("Use Blockscout"): The default endpoint is
transaction history (normal transactions and ERC-20 token transfers) for Home, `https://eth.blockscout.com/api/v2` (configurable by the user in Settings).
AddressDetail and AddressToken, the balances of the ERC-20 tokens the user has - **Used for**:
not tracked, token holder counts (for spam filtering), and a transaction's - transaction history (normal transactions and ERC-20 token transfers),
block, network details and calldata for TransactionDetail. The default for Home, AddressDetail and AddressToken
endpoint is `https://eth.blockscout.com/api/v2` (configurable by the user in - the balances of the ERC-20 tokens the user has not tracked, for Home,
Settings). While the setting is off, those screens say in a plain line what is AddressDetail, Send and DeleteAddress
not shown and that this setting turns it on, and only tracked tokens are - the names and decimals of those tokens, for TxApproval, SignApproval,
listed, their balances read through the RPC endpoint. The endpoint check WaitTx, SuccessTx and ErrorTx
Settings makes when the user presses "Save" beside the URL asks only for - token holder counts, for spam filtering
`/stats` and carries no user data; it is made whether or not the setting is - a transaction's block, network details and calldata, for
on. TransactionDetail
- **Contacted**, while the setting is on:
- on every balance refresh, in the popup and in the background
- when Home, AddressDetail or AddressToken loads its history
- when TransactionDetail opens
- **While the setting is off**: Each screen that would show Blockscout's
data says instead that the setting is off. Only tracked tokens are listed.
- **Saving the URL**: pressing "Save" beside the URL in Settings asks that
URL for `/stats`, whether or not the setting is on. This request carries
no user data.
- **Data sent**: Ethereum addresses, and the hash of each transaction the - **Data sent**: Ethereum addresses, and the hash of each transaction the
user opens. Blockscout receives the user's addresses to query their user opens. Blockscout receives the user's addresses to query their
transaction history and token balances. No private keys, passwords, or transaction history and token balances. No private keys, passwords, or
signing operations are sent. signing operations are sent.
- **Etherscan address labels** ("Check recipients on Etherscan"): When - **Etherscan address labels** ("Check recipients on Etherscan"): While the
confirming a transaction, the extension performs a best-effort lookup of the setting is on, ConfirmTx looks up the recipient's address on Etherscan each
recipient address on Etherscan to check for phishing/scam labels. This is a time the screen is shown. The lookup checks for a phishing or scam label,
direct page fetch with no API key; the user's browser makes the request. While best-effort. This is a direct page fetch with no API key; the user's browser
the setting is off, ConfirmTx says that no Etherscan check was made. makes the request. While the setting is off, ConfirmTx says that no Etherscan
- **Data sent**: the recipient address. The user's own addresses are not check was made.
sent. - **Data sent**: the recipient's address and nothing else.
The etherscan links on the screens open a page in the browser when the user 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 clicks one. They are not requests the wallet makes.
them until then.
What the extension does NOT do: What the extension does NOT do:
@@ -2804,9 +2936,9 @@ What the extension does NOT do:
- No Infura/Alchemy dependency (any JSON-RPC endpoint works) - No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer - No backend servers operated by the developer
Users who want maximum privacy can point the RPC URL at their own node, and, Users who want maximum privacy can point the RPC URL at their own node. With
when they turn Blockscout on, the Blockscout URL at their own instance (price "Use Blockscout" on, they can point the Blockscout URL at their own instance
fetching can be disabled in a future version). too.
### Dependencies ### Dependencies
@@ -2955,6 +3087,11 @@ logged.
- The user's password is run through Argon2id (`crypto_pwhash`) to derive a - The user's password is run through Argon2id (`crypto_pwhash`) to derive a
256-bit encryption key. Argon2id is memory-hard, making GPU/ASIC brute 256-bit encryption key. Argon2id is memory-hard, making GPU/ASIC brute
force attacks expensive. force attacks expensive.
- Each use of the password runs one Argon2id derivation at libsodium's
interactive setting: `OPSLIMIT_INTERACTIVE` and `MEMLIMIT_INTERACTIVE`, 2
passes over 64 MiB. Every signature asks for the password, so a light
setting keeps signing quick. The password's length, not the derivation's
cost, is what protects the stored secret.
- The derived key encrypts the secret material using XSalsa20-Poly1305 - The derived key encrypts the secret material using XSalsa20-Poly1305
(`crypto_secretbox`), which provides authenticated encryption (the (`crypto_secretbox`), which provides authenticated encryption (the
ciphertext cannot be tampered with without detection). ciphertext cannot be tampered with without detection).
@@ -2988,9 +3125,10 @@ logged.
- Add multiple addresses within an HD wallet - Add multiple addresses within an HD wallet
- Manage multiple wallets simultaneously - Manage multiple wallets simultaneously
- View ETH balance per address - View ETH balance per address
- View ERC-20 token balances (tokens the user adds by contract address, and, - View ERC-20 token balances:
while "Use Blockscout" is on, tokens on the bundled known-token list and - tokens the user adds by contract address
tokens with 1,000 or more holders) - 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 - View transaction history and transaction details, while "Use Blockscout" is on
- Send ETH to an address - Send ETH to an address
- Send ERC-20 tokens to an address - Send ERC-20 tokens to an address
+84 -53
View File
@@ -58,7 +58,7 @@ 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 NFT galleries or portfolio views.** This is a wallet, not a dashboard.
- **No third-party token list APIs.** The balances of the tokens you track are - **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 read from your RPC endpoint. While "Use Blockscout" is on, the other tokens
come from the same block explorer you configure for transaction history. The 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 extension ships its own hardcoded list of top ERC-20 contract addresses for
symbol-spoofing detection. Any token you want tracked across all your symbol-spoofing detection. Any token you want tracked across all your
@@ -71,10 +71,12 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
AutistMask is a browser extension that runs entirely in your browser. It does AutistMask is a browser extension that runs entirely in your browser. It does
not have a backend server. Out of the box it sends your data only to the 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 Ethereum RPC endpoint. It also asks the CoinDesk price API for prices, sending
but token symbols. Blockscout and Etherscan are sent none of your data until you token symbols and nothing else.
turn on "Use Blockscout" or "Check recipients on Etherscan", on the Welcome
screen or later in Settings. Both are off in a new install. Blockscout and Etherscan are sent none of your data until you turn on a setting
for each. The settings are "Use Blockscout" and "Check recipients on Etherscan".
Both are off in a new install. Welcome and Settings both offer them.
### External Services ### External Services
@@ -87,11 +89,14 @@ verify confirmations. The default is a free public RPC endpoint. You can change
this in Settings to any Ethereum JSON-RPC endpoint, including your own local this in Settings to any Ethereum JSON-RPC endpoint, including your own local
node. node.
When it is contacted: on every balance refresh (every 10 seconds while the popup When it is contacted:
is open, every 60 seconds in the background), when you type an ENS name into the
Send screen, when a send is prepared and broadcast, while a pending transaction - on every balance refresh (every 10 seconds while the popup is open, every 60
is polled for its receipt, and for the reverse ENS lookups used to label seconds in the background)
addresses (cached for 12 hours). - when you review a send to an ENS name
- when a send is prepared and broadcast
- while a pending transaction is polled for its receipt
- for the reverse ENS lookups used to label addresses (cached for 12 hours)
What gets sent: standard Ethereum JSON-RPC requests (balance queries, What gets sent: standard Ethereum JSON-RPC requests (balance queries,
transaction broadcasts, gas estimates, ENS lookups, contract-code checks). Your transaction broadcasts, gas estimates, ENS lookups, contract-code checks). Your
@@ -100,20 +105,28 @@ addresses are necessarily visible to the RPC provider when querying balances.
**Blockscout API** (default: `eth.blockscout.com/api/v2`; **Blockscout API** (default: `eth.blockscout.com/api/v2`;
`eth-sepolia.blockscout.com/api/v2` on Sepolia) `eth-sepolia.blockscout.com/api/v2` on Sepolia)
Off until you turn on "Use Blockscout". Used to fetch transaction history, the Blockscout is an open-source blockchain explorer. AutistMask sends it your data
balances of the tokens you have not tracked, and the details of a transaction only while "Use Blockscout" is on. AutistMask then asks Blockscout for:
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: while "Use Blockscout" is on, on every balance refresh, - your recent transactions and token transfers
whenever a screen showing transaction history is opened, and when you open a - the balances of the tokens you have not tracked, with the holder counts used
transaction. The one exception, whether or not the setting is on, is pressing for spam filtering
"Save" beside the Blockscout URL in Settings, which checks that URL by asking it - the block, fees and calldata of a transaction you open
for `/stats` and sends none of your data.
You can change the endpoint in Settings to a self-hosted Blockscout instance.
While the setting is off, each screen that would show Blockscout's data says
instead that the setting is off. Only the tokens you track are listed. When you
turn the setting off, every other token leaves the lists at once.
When it is contacted, while "Use Blockscout" is on:
- on every balance refresh
- whenever a screen showing transaction history is opened
- when you open a transaction
Pressing "Save" beside the Blockscout URL in Settings checks the URL by asking
it for `/stats`. That happens whether or not the setting is on. The request
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. and the hash of each transaction you open.
@@ -133,19 +146,21 @@ address.
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia) **Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
Off until you turn on "Check recipients on Etherscan". When you review a send, While "Check recipients on Etherscan" is on, AutistMask fetches the recipient's
AutistMask fetches the recipient's public Etherscan address page and looks for a public Etherscan address page when you review a send. AutistMask looks for a
"Fake_Phishing"/"Phish/Hack" label or a scam warning, and shows a red warning if "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 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 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 user-configurable.
Etherscan check was made.
The setting is off in a new install. While it is off, the confirmation screen
says that no Etherscan check was made.
When it is contacted: only while "Check recipients on Etherscan" is on, each When it is contacted: only while "Check recipients on Etherscan" is on, each
time you reach the send confirmation screen. time you reach the send confirmation screen.
What gets sent: the recipient address you are about to send to, and your IP What gets sent: the recipient's address and nothing else. As with any request,
address. Your own addresses are not sent. Etherscan sees your IP address.
Etherscan links shown elsewhere in the UI (on addresses, transactions, and token Etherscan links shown elsewhere in the UI (on addresses, transactions, and token
contracts) are ordinary links. They contact nothing until you click them. contracts) are ordinary links. They contact nothing until you click them.
@@ -193,15 +208,18 @@ password.
3. Click "Load Temporary Add-on". 3. Click "Load Temporary Add-on".
4. Select `dist/firefox/manifest.json`. 4. Select `dist/firefox/manifest.json`.
AutistMask does not work in Firefox private windows. If Firefox is set to "Never
remember history", every window is private, so AutistMask does not work at all.
## Getting Started ## Getting Started
### Creating a New Wallet ### Creating a New Wallet
1. Click the AutistMask icon in your browser toolbar. 1. Click the AutistMask icon in your browser toolbar.
2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet". 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 On first use, the screen with "Add wallet" also offers "Use Blockscout" and
Etherscan", both off; tick either if you want what it does (see External "Check recipients on Etherscan", both off. External Services above says what
Services). You can change both later in Settings. each one does. You can change both later in Settings.
3. On the "From Phrase" tab, click the die button to generate a random 12-word 3. On the "From Phrase" tab, click the die button to generate a random 12-word
recovery phrase. recovery phrase.
4. **Write down the recovery phrase and store it safely.** Anyone with these 4. **Write down the recovery phrase and store it safely.** Anyone with these
@@ -237,11 +255,14 @@ always produce the same sequence of addresses.
### Adding ERC-20 Tokens ### Adding ERC-20 Tokens
Tokens you hold show up automatically only while "Use Blockscout" is on, and The wallet looks for tokens you hold only while "Use Blockscout" is on. Even
then only if they are in the extension's bundled list of well-known tokens or then, a token shows up only if it is in the extension's bundled list of
have at least 1,000 holders; everything else is treated as spam and hidden. To well-known tokens or has at least 1,000 holders. Everything else is treated as
track a token explicitly (which also shows it at zero balance, and shows it spam and hidden.
whether or not "Use Blockscout" is on), add it by contract address:
The wallet shows every token you track, whether or not "Use Blockscout" is on.
By default a tracked token is shown at zero balance too (see Display in
Settings). To track a token, add it by contract address:
1. Go to an address detail view (click `[info]` on any address). 1. Go to an address detail view (click `[info]` on any address).
2. Click "+ Token". 2. Click "+ Token".
@@ -255,9 +276,12 @@ screen. Tokens can also be added from Settings, under "Tracked Tokens".
## Sending ## Sending
1. Click "Send" from the home screen or an address detail view. 1. Click "Send" from the home screen or an address detail view.
2. Select what to send (ETH, or any ERC-20 token with a balance on this address 2. Select what to send: ETH, or a token you track that this address holds. While
that survives the spam filters). "Use Blockscout" is on, Send also offers other tokens with a balance on this
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`). address that survive the spam filters.
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`). AutistMask does
not look up a name resolved off-chain. For such a name, enter the address
instead.
4. Enter the amount, or click "Max" to fill it in: a token's balance, cut to 18 4. Enter the amount, or click "Max" to fill it in: a token's balance, cut to 18
decimal places, or your ETH balance minus the amount reserved for the network decimal places, or your ETH balance minus the amount reserved for the network
fee. fee.
@@ -274,9 +298,13 @@ The confirmation screen shows:
estimate, and below it the larger amount reserved until it confirms. The estimate, and below it the larger amount reserved until it confirms. The
reserve is what the network requires up front and what the balance check gates reserve is what the network requires up front and what the balance check gates
on; the refund of the difference is why the two differ on; the refund of the difference is why the two differ
- **Warnings** if the recipient is a contract, a burn address, one of your own - **Warnings** if the recipient is:
addresses, on the bundled scam-address list, or labelled as a phisher on - a contract
Etherscan - a burn address
- one of your own addresses
- on the bundled scam-address list
- while "Check recipients on Etherscan" is on, labelled as a phisher on
Etherscan
After reviewing, enter your password and click "Sign & Send". The transaction After reviewing, enter your password and click "Sign & Send". The transaction
will be broadcast to the network and you will see a waiting screen with a timer. will be broadcast to the network and you will see a waiting screen with a timer.
@@ -287,8 +315,9 @@ screen with the transaction hash and an Etherscan link.
You can also send from a token-specific view: on the address detail screen, You can also send from a token-specific view: on the address detail screen,
click any token balance row. This opens a focused view showing only that token's click any token balance row. This opens a focused view showing only that token's
transactions. Clicking "Send" from here pre-selects and locks the token so you transactions. While "Use Blockscout" is off, the view says instead that no
cannot accidentally switch to a different one. transaction history is shown. Clicking "Send" from here pre-selects and locks
the token so you cannot accidentally switch to a different one.
## Receiving ## Receiving
@@ -362,11 +391,12 @@ balances unless they are on the bundled known-token list or you added them
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
for address poisoning typically have zero. When the explorer reports no holder for address poisoning typically have zero. When the explorer reports no holder
count for a token, or reports something other than a whole number in plain count for a token, or reports something other than a whole number in plain
digits (such as "1,000"), the count is unknown. An unknown count does not hide a digits (such as "1,000"), the count is unknown. While "Use Blockscout" is off,
token from your transaction history or the send token list, and it does not get no holder count is known. An unknown count does not hide a token from your
a token into your balances either: such a token is listed only if it is on the transaction history or the send token list, and it does not get a token into
bundled known-token list or you added it yourself. The screen you reach by your balances either: such a token is listed only if it is on the bundled
clicking a token balance shows a "Holders:" line only when the count is known. known-token list or you added it yourself. The screen you reach by clicking a
token balance shows a "Holders:" line only when the count is known.
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it **Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
adds the contract address to a local blocklist. Future transactions from that adds the contract address to a local blocklist. Future transactions from that
@@ -398,6 +428,8 @@ see everything unfiltered.
Click the gear icon on the home screen to access settings: Click the gear icon on the home screen to access settings:
- **Wallets**: Your wallets, and "+ Add wallet". - **Wallets**: Your wallets, and "+ Add wallet".
- **Passwords**: Tick "Allow weak passwords" to choose a password shorter than
12 characters when you add a wallet.
- **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add - **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add
token". token".
- **Display**: Toggle whether tracked tokens with zero balance are shown, switch - **Display**: Toggle whether tracked tokens with zero balance are shown, switch
@@ -407,9 +439,8 @@ Click the gear icon on the home screen to access settings:
that network's defaults if you have not used it before. that network's defaults if you have not used it before.
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC. - **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
You can use your own node for maximum privacy. You can use your own node for maximum privacy.
- **Blockscout API**: "Use Blockscout", off until you tick it, and the - **Blockscout API**: the "Use Blockscout" checkbox, off until you tick it, and
Blockscout instance used for transaction history, the tokens you have not the URL of the Blockscout instance. You can use a self-hosted instance.
tracked and transaction details. You can use a self-hosted instance.
- **Etherscan**: "Check recipients on Etherscan", off until you tick it. - **Etherscan**: "Check recipients on Etherscan", off until you tick it.
- **Token Spam Protection**: Toggle individual scam filters and set the dust - **Token Spam Protection**: Toggle individual scam filters and set the dust
transaction threshold. transaction threshold.
+1
View File
@@ -4,6 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox", "description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"], "permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"incognito": "not_allowed",
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'", "content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
"icons": { "icons": {
"16": "icons/icon16.png", "16": "icons/icon16.png",
+12 -2
View File
@@ -17,7 +17,11 @@ const { applyChainSwitchFields } = require("../shared/chainSwitchFields");
// the same prohibition reported early, not the guarantee. // the same prohibition reported early, not the guarantee.
const { getState, updateState } = require("./state"); const { getState, updateState } = require("./state");
const { StateUnusableError } = require("../shared/stateSchema"); const { StateUnusableError } = require("../shared/stateSchema");
const { refreshBalances, getProvider } = require("../shared/balances"); const {
refreshBalances,
dropUntrackedTokens,
getProvider,
} = require("../shared/balances");
const { debugFetch, log } = require("../shared/log"); const { debugFetch, log } = require("../shared/log");
const { const {
verifySignedTx, verifySignedTx,
@@ -1386,6 +1390,10 @@ const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
// back over them (https://git.eeqj.de/sneak/AutistMask/issues/577). // back over them (https://git.eeqj.de/sneak/AutistMask/issues/577).
// lastBalanceRefresh is left as the switch set it, so the next tick refreshes // lastBalanceRefresh is left as the switch set it, so the next tick refreshes
// the new network. // the new network.
//
// Blockscout may have answered just before the user turned "Use Blockscout"
// off. So the save reads the stored setting again, and drops every token the
// user has not tracked if the setting is off.
async function backgroundRefresh() { async function backgroundRefresh() {
const s = await getState(); const s = await getState();
const now = Date.now(); const now = Date.now();
@@ -1397,7 +1405,6 @@ async function backgroundRefresh() {
wallets, wallets,
s.rpcUrl, s.rpcUrl,
s.blockscoutUrl, s.blockscoutUrl,
s.useBlockscout,
s.trackedTokens, s.trackedTokens,
s.networkId, s.networkId,
); );
@@ -1424,6 +1431,9 @@ async function backgroundRefresh() {
} }
} }
} }
if (!fresh.useBlockscout) {
dropUntrackedTokens(fresh.wallets, fresh.trackedTokens);
}
fresh.lastBalanceRefresh = now; fresh.lastBalanceRefresh = now;
}); });
} }
+5 -7
View File
@@ -18,11 +18,10 @@
// updateState(fn) read fresh, apply fn to that fresh record, write it // updateState(fn) read fresh, apply fn to that fresh record, write it
// back — all holding the lock every write of the stored // back — all holding the lock every write of the stored
// record takes, so no other write lands in between, by // record takes, so no other write lands in between, by
// the background or by any page except a page in a // the background or by any page (README, "Stored state
// Firefox private window (README, "Stored state and its // and its version"). The read is one storage round trip
// version"). The read is one storage round trip ahead of // ahead of the write rather than a page lifetime ahead of
// the write rather than a page lifetime ahead of it // it (which is what made the popup's saveState() need a
// (which is what made the popup's saveState() need a
// per-field merge against a baseline at all). // per-field merge against a baseline at all).
// //
// A handler that must both read and write therefore does its network work // A handler that must both read and write therefore does its network work
@@ -73,8 +72,7 @@ async function updateStateOnce(mutate) {
// src/shared/browserApi.js) is held from the read to the write: two // src/shared/browserApi.js) is held from the read to the write: two
// background turns, or a turn and a page's save, never interleave // background turns, or a turn and a page's save, never interleave
// (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits // (https://git.eeqj.de/sneak/AutistMask/issues/641). Every page's save waits
// while it is held, except a page in a Firefox private window, which Firefox // while it is held (README, "Stored state and its version").
// gives locks of its own (README, "Stored state and its version").
// //
// `mutate` must also not call updateState() itself, directly or through // `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the inner turn waits for the lock the outer one holds, // anything it awaits: the inner turn waits for the lock the outer one holds,
+61 -23
View File
@@ -46,8 +46,8 @@
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
<p class="text-xs mb-2"> <p class="text-xs mb-2">
This wallet sends your data only to the Ethereum RPC This wallet sends your data only to the Ethereum RPC
endpoint, unless you turn on either of these, here or endpoint, unless you turn on one of these. You can
later in Settings. change them later in Settings.
</p> </p>
<label <label
class="text-xs flex items-center gap-1 cursor-pointer mb-1" class="text-xs flex items-center gap-1 cursor-pointer mb-1"
@@ -56,10 +56,10 @@
Use Blockscout Use Blockscout
</label> </label>
<p class="text-xs text-muted mb-2"> <p class="text-xs text-muted mb-2">
Sends your addresses, and the hash of each transaction Shows transaction history, transaction details and the
you open, to the Blockscout API, for transaction tokens you have not tracked. Sends your addresses, and
history, tokens you have not tracked, and transaction the hash of each transaction you open, to the Blockscout
details. API.
</p> </p>
<label <label
class="text-xs flex items-center gap-1 cursor-pointer mb-1" class="text-xs flex items-center gap-1 cursor-pointer mb-1"
@@ -71,8 +71,8 @@
Check recipients on Etherscan Check recipients on Etherscan
</label> </label>
<p class="text-xs text-muted"> <p class="text-xs text-muted">
Sends the address you are about to send to, to Sends each recipient's address to Etherscan to check it
Etherscan, to look for a phishing or scam label on it. for a phishing or scam label.
</p> </p>
</div> </div>
<button <button
@@ -569,12 +569,23 @@
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1">What to send</label> <label class="block mb-1">What to send</label>
<select <!-- The token dropdown and the line under it. Both are
id="send-token" hidden when AddressToken opens Send for its own token. -->
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg" <div id="send-token-dropdown">
> <select
<option value="ETH">ETH</option> id="send-token"
</select> class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
>
<option value="ETH">ETH</option>
</select>
<p
id="send-tokens-note"
class="text-xs text-muted hidden"
>
Only tracked tokens are listed, because "Use
Blockscout" is off in Settings.
</p>
</div>
<div id="send-token-static" class="text-sm hidden"></div> <div id="send-token-static" class="text-sm hidden"></div>
</div> </div>
<div class="mb-2"> <div class="mb-2">
@@ -800,6 +811,7 @@
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div> <div class="text-xs text-muted mb-1">Amount</div>
<div id="wait-tx-summary" class="font-bold"></div> <div id="wait-tx-summary" class="font-bold"></div>
<div id="wait-tx-token-note"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">To</div> <div class="text-xs text-muted mb-1">To</div>
@@ -846,6 +858,7 @@
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">Amount</div> <div class="text-xs text-muted mb-1">Amount</div>
<div id="error-tx-summary" class="font-bold"></div> <div id="error-tx-summary" class="font-bold"></div>
<div id="error-tx-token-note"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">To</div> <div class="text-xs text-muted mb-1">To</div>
@@ -967,6 +980,23 @@
</button> </button>
</div> </div>
<div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Passwords</h3>
<label
class="text-xs flex items-center gap-1 cursor-pointer mb-1"
>
<input
type="checkbox"
id="settings-allow-weak-passwords"
/>
Allow weak passwords
</label>
<p class="text-xs text-muted">
Lets you choose a password shorter than 12 characters
when you add a wallet.
</p>
</div>
<div class="bg-well p-3 mx-1 mb-3"> <div class="bg-well p-3 mx-1 mb-3">
<h3 class="font-bold mb-1">Tracked Tokens</h3> <h3 class="font-bold mb-1">Tracked Tokens</h3>
<p class="text-xs text-muted mb-2"> <p class="text-xs text-muted mb-2">
@@ -1059,13 +1089,14 @@
Use Blockscout Use Blockscout
</label> </label>
<p class="text-xs text-muted mb-2"> <p class="text-xs text-muted mb-2">
Sends your addresses, and the hash of each transaction Shows transaction history, transaction details and the
you open, to the Blockscout API below, for transaction tokens you have not tracked. Sends your addresses, and
history, tokens you have not tracked, and transaction the hash of each transaction you open, to the Blockscout
details. API below.
</p> </p>
<p class="text-xs text-muted mb-1"> <p class="text-xs text-muted mb-1">
Change this if you run your own Blockscout instance. Change the URL below if you run your own Blockscout
instance.
</p> </p>
<input <input
type="text" type="text"
@@ -1092,8 +1123,8 @@
Check recipients on Etherscan Check recipients on Etherscan
</label> </label>
<p class="text-xs text-muted"> <p class="text-xs text-muted">
Sends the address you are about to send to, to Sends each recipient's address to Etherscan to check it
Etherscan, to look for a phishing or scam label on it. for a phishing or scam label.
</p> </p>
</div> </div>
@@ -1369,6 +1400,13 @@
> >
&nbsp; &nbsp;
</div> </div>
<p
id="delete-address-tokens-note"
class="text-xs text-muted mb-2 hidden"
>
Only tracked tokens are listed, because "Use Blockscout" is
off in Settings.
</p>
<p class="text-xs text-muted mb-3"> <p class="text-xs text-muted mb-3">
A wallet always keeps at least one address. To remove the A wallet always keeps at least one address. To remove the
last one, delete the whole wallet from Settings instead. last one, delete the whole wallet from Settings instead.
@@ -1588,8 +1626,8 @@
id="tx-detail-blockscout-note" id="tx-detail-blockscout-note"
class="text-xs text-muted mx-1 mb-3 hidden" class="text-xs text-muted mx-1 mb-3 hidden"
> >
No block, decoded details, network details or raw data are This screen shows no block, decoded details, network details
shown, because "Use Blockscout" is off in Settings. or raw data, because "Use Blockscout" is off in Settings.
</p> </p>
<!-- ── Decoded details ── --> <!-- ── Decoded details ── -->
+70 -19
View File
@@ -11,7 +11,7 @@ const {
const { StateUnusableError } = require("../shared/stateSchema"); const { StateUnusableError } = require("../shared/stateSchema");
const { log, setRuntimeDebug } = require("../shared/log"); const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices"); const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances"); const { refreshBalances, dropUntrackedTokens } = require("../shared/balances");
const { clearPopupCaches } = require("../shared/chainSwitch"); const { clearPopupCaches } = require("../shared/chainSwitch");
const { const {
$, $,
@@ -71,30 +71,44 @@ let refreshInFlight = false;
// the recovery screen: there is no profile left to refresh. // the recovery screen: there is no profile left to refresh.
let refreshTimer = null; let refreshTimer = null;
// The refresh first takes the stored network, "Use Blockscout" and "Check
// recipients on Etherscan" (followStoredSettings() below). So the screens it
// draws reflect any change saved elsewhere.
//
// The refresh writes into a copy of the wallets. Inside the save, under the // The refresh writes into a copy of the wallets. Inside the save, under the
// lock, its results are copied onto the address records in `state` only if the // 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 // 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, // record; the background's switch, after the user approves a site's request,
// reaches this page only after the stored record (followNetworkSwitch() below). // reaches this page only after the stored record (followStoredSettings()).
// A switch while the refresh ran cleared every address's balances // A switch while the refresh ran cleared every address's balances
// (applyChainSwitchFields() in src/shared/chainSwitchFields.js), and the // (applyChainSwitchFields() in src/shared/chainSwitchFields.js), and the
// previous network's must not come back over them // previous network's must not come back over them
// (https://git.eeqj.de/sneak/AutistMask/issues/577). A dropped refresh is not // (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 // recorded as done. Results are copied by address, so an address added or
// removed meanwhile is left as it is. // removed meanwhile is left as it is. Blockscout may have answered before the
// user turned "Use Blockscout" off. So the save drops every token the user has
// not tracked if the stored setting is off by then.
//
// The save also deletes `balanceNotFetched` from each tracked token whose
// balance this refresh read at every address. trackToken() in
// src/popup/views/helpers.js sets that field.
//
// Once the save is done, the wallet list is drawn again. So are the balances
// on AddressDetail or AddressToken when that screen is up. No other screen is
// drawn again, DeleteAddress and its balance lines included.
async function doRefreshAndRender() { async function doRefreshAndRender() {
if (refreshInFlight) return; if (refreshInFlight) return;
refreshInFlight = true; refreshInFlight = true;
try { try {
await followStoredSettings();
const networkId = state.networkId; const networkId = state.networkId;
const wallets = structuredClone(state.wallets); const wallets = structuredClone(state.wallets);
await Promise.all([ const [, readTokens] = await Promise.all([
refreshPrices(), refreshPrices(),
refreshBalances( refreshBalances(
wallets, wallets,
state.rpcUrl, state.rpcUrl,
state.blockscoutUrl, state.blockscoutUrl,
state.useBlockscout,
state.trackedTokens, state.trackedTokens,
networkId, networkId,
pageClosed.signal, pageClosed.signal,
@@ -118,10 +132,20 @@ async function doRefreshAndRender() {
} }
} }
} }
if (!stored.useBlockscout) {
dropUntrackedTokens(state.wallets, stored.trackedTokens);
}
for (const token of state.trackedTokens) {
if (readTokens.includes(token.address.toLowerCase())) {
delete token.balanceNotFetched;
}
}
state.lastBalanceRefresh = Date.now(); state.lastBalanceRefresh = Date.now();
return true; return true;
}); });
renderWalletList(); renderWalletList();
if (state.currentView === "address") addressDetail.renderBalances();
if (state.currentView === "address-token") addressToken.renderBalance();
} catch (e) { } catch (e) {
// Every call site fires this and walks away — the boot below, the ten // Every call site fires this and walks away — the boot below, the ten
// second interval, and eight views through ctx — so it must never // second interval, and eight views through ctx — so it must never
@@ -229,17 +253,36 @@ function fallbackView() {
// src/shared/chainSwitch.js), so a record naming a network other than this // 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 // 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. // here while this waits for the lock is made after it.
async function followNetworkSwitch() { //
let switched = false; // Another page may change "Use Blockscout" or "Check recipients on Etherscan".
// This page takes either change the same way and draws its screens again. This
// page's own changes to the two settings are made under the lock too
// (views/settings.js, views/welcome.js).
async function followStoredSettings() {
let changed = false;
// A failed save is reported by saveState() itself: see onSaveFailure(). // A failed save is reported by saveState() itself: see onSaveFailure().
await saveState((stored) => { await saveState((stored) => {
if (stored.networkId === state.networkId) return false; if (stored.networkId !== state.networkId) {
settings.cutViewStack(); settings.cutViewStack();
takeStoredNetwork(stored); takeStoredNetwork(stored);
clearPopupCaches(); clearPopupCaches();
switched = true; changed = true;
}
if (stored.useBlockscout !== state.useBlockscout) {
settings.setUseBlockscout(stored.useBlockscout);
changed = true;
}
if (
stored.checkRecipientsOnEtherscan !==
state.checkRecipientsOnEtherscan
) {
state.checkRecipientsOnEtherscan =
stored.checkRecipientsOnEtherscan;
changed = true;
}
if (!changed) return false;
}).catch(() => {}); }).catch(() => {});
if (switched) settings.showNewNetwork(); if (changed) settings.showNewSettings();
} }
// The background opens the approval window as wide as the page, but the // The background opens the approval window as wide as the page, but the
@@ -386,16 +429,24 @@ async function init() {
settingsAddToken.init(ctx); settingsAddToken.init(ctx);
deleteAddress.init(ctx); deleteAddress.init(ctx);
// Only a record naming a network other than this page's is followed: the // This page follows a record only when its network, "Use Blockscout" or
// one this page's own switch in Settings saves names the network it // "Check recipients on Etherscan" differs from this page's. A record
// already has, and a record naming none is no switch. // written by this page's own save matches this page. A record naming no
// network is no switch.
onStorageChanged((changes) => { onStorageChanged((changes) => {
const written = changes.autistmask && changes.autistmask.newValue; const written = changes.autistmask && changes.autistmask.newValue;
if (!written || !written.networkId) return; if (!written || !written.networkId) return;
if (written.networkId !== state.networkId) followNetworkSwitch(); if (
written.networkId !== state.networkId ||
written.useBlockscout !== state.useBlockscout ||
written.checkRecipientsOnEtherscan !==
state.checkRecipientsOnEtherscan
) {
followStoredSettings();
}
}); });
// A switch saved since loadState() above, before there was a listener. // A change saved since loadState() above, before there was a listener.
followNetworkSwitch(); followStoredSettings();
if (!state.hasWallet) { if (!state.hasWallet) {
welcome.show(); welcome.show();
+10 -7
View File
@@ -38,13 +38,16 @@ function markViewRendered(view) {
if (view) renderedViews.add(view); if (view) renderedViews.add(view);
} }
// Begin a fresh page-load scope. The popup gets one by being loaded, and // Begin a fresh page-load scope. The popup gets one by being loaded. It also
// asks for one after a network switch, because every screen then on the page // asks for one when every screen on the page is out of date:
// shows the previous network (showNewNetwork() in views/settings.js), and //
// after "Use Blockscout" or "Check recipients on Etherscan" changes in // - after a network switch, since those screens show the previous network
// Settings, because every screen then on the page was drawn under the old // (showNewSettings() in views/settings.js)
// setting; the unit tests, which simulate several page loads against one // - after "Use Blockscout" or "Check recipients on Etherscan" changes, here
// module instance, ask for one too. // or on another page, since those screens were drawn under the old setting
//
// The unit tests ask for one too. They simulate several page loads against one
// module instance.
function resetRenderedViews() { function resetRenderedViews() {
renderedViews.clear(); renderedViews.clear();
} }
+9 -2
View File
@@ -1,4 +1,11 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers"); const {
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -72,7 +79,7 @@ function init(ctx) {
return; return;
} }
log.infof("Adding token", info.symbol, contractAddr); log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({ trackToken({
address: contractAddr, address: contractAddr,
symbol: info.symbol, symbol: info.symbol,
decimals: info.decimals, decimals: info.decimals,
+2 -1
View File
@@ -132,7 +132,8 @@ function validatePassword() {
showError("add-wallet-password-error", "Please choose a password."); showError("add-wallet-password-error", "Please choose a password.");
return null; return null;
} }
if (pw.length < 12) { // "Allow weak passwords" in Settings lifts the 12-character minimum.
if (!state.allowWeakPasswords && pw.length < 12) {
showError( showError(
"add-wallet-password-error", "add-wallet-password-error",
"Password must be at least 12 characters.", "Password must be at least 12 characters.",
+20 -12
View File
@@ -66,11 +66,27 @@ function show() {
}); });
$("address-line").dataset.full = addr.address; $("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line")); attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
const ensEl = $("address-ens"); const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element // ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden"); ensEl.classList.add("hidden");
renderBalances();
// Only Blockscout reports the tokens the user has not tracked.
$("address-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
renderSendTokenSelect(addr);
$("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Loading...</div>';
showView("address");
loadTransactions(addr.address);
}
// The USD total and the balance list. Drawn again by each balance refresh
// that returns while this screen is up (doRefreshAndRender() in
// src/popup/index.js), which leaves the rest of the screen as it is.
function renderBalances() {
const addr =
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
const usdTotal = formatAddressTotal(getAddressValue(addr));
$("address-usd-total").innerHTML = escapeHtml(usdTotal) || "&nbsp;";
$("address-balances").innerHTML = balanceLinesForAddress( $("address-balances").innerHTML = balanceLinesForAddress(
addr, addr,
state.trackedTokens, state.trackedTokens,
@@ -84,13 +100,6 @@ function show() {
ctx.showAddressToken(); 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>';
showView("address");
loadTransactions(addr.address);
} }
let loadedTxs = []; let loadedTxs = [];
@@ -102,7 +111,6 @@ async function loadTransactions(address) {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
address, address,
state.blockscoutUrl, state.blockscoutUrl,
state.useBlockscout,
currentNetwork().chainId, currentNetwork().chainId,
); );
const result = filterTransactions(rawTxs, { const result = filterTransactions(rawTxs, {
@@ -237,7 +245,7 @@ function init(_ctx) {
} }
$("send-to").value = ""; $("send-to").value = "";
$("send-amount").value = ""; $("send-amount").value = "";
$("send-token").classList.remove("hidden"); $("send-token-dropdown").classList.remove("hidden");
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
updateSendBalance(); updateSendBalance();
resetSendValidation(); resetSendValidation();
@@ -288,4 +296,4 @@ function init(_ctx) {
exportPrivkey.init(); exportPrivkey.init();
} }
module.exports = { init, show }; module.exports = { init, show, renderBalances };
+49 -34
View File
@@ -49,37 +49,18 @@ function show() {
const addr = wallet.addresses[state.selectedAddress]; const addr = wallet.addresses[state.selectedAddress];
const ai = state.selectedAddress; const ai = state.selectedAddress;
const tokenId = state.selectedToken; const tokenId = state.selectedToken;
// Determine token symbol and balance
let symbol, amount, price;
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase()); const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
if (tokenId === "ETH") { currentSymbol =
symbol = nativeCurrency(); tokenId === "ETH"
amount = parseFloat(addr.balance || "0"); ? nativeCurrency()
price = getPrice("ETH"); : resolveSymbol(tokenId, addr.tokenBalances, state.trackedTokens);
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === tokenId.toLowerCase(),
);
symbol = resolveSymbol(
tokenId,
addr.tokenBalances,
state.trackedTokens,
);
// null when the scale is unknown: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000.
amount = tb ? unknownableAmount(tb.balance) : 0;
price = getPrice(symbol);
}
currentSymbol = symbol;
$("address-token-title").textContent = $("address-token-title").textContent =
wallet.name + wallet.name +
" \u2014 Address " + " \u2014 Address " +
(ai + 1) + (ai + 1) +
" \u2014 " + " \u2014 " +
displaySymbol(symbol); displaySymbol(currentSymbol);
// Blockie // Blockie
const blockieEl = $("address-token-jazzicon"); const blockieEl = $("address-token-jazzicon");
@@ -101,13 +82,7 @@ function show() {
$("address-token-line").dataset.full = addr.address; $("address-token-line").dataset.full = addr.address;
attachCopyHandlers($("address-token-line")); attachCopyHandlers($("address-token-line"));
// USD total for this token only renderBalance();
const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;";
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
// Token contract details (ERC-20 only) // Token contract details (ERC-20 only)
const contractInfo = $("address-token-contract-info"); const contractInfo = $("address-token-contract-info");
@@ -169,12 +144,52 @@ function show() {
loadTransactions(addr.address, tokenId); loadTransactions(addr.address, tokenId);
} }
// The USD total and the balance line of the selected token. Drawn again by
// each balance refresh that returns while this screen is up
// (doRefreshAndRender() in src/popup/index.js), which leaves the rest of the
// screen as it is.
function renderBalance() {
const addr =
state.wallets[state.selectedWallet].addresses[state.selectedAddress];
const tokenId = state.selectedToken;
let amount, price;
if (tokenId === "ETH") {
amount = parseFloat(addr.balance || "0");
price = getPrice("ETH");
} else {
const id = tokenId.toLowerCase();
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === id,
);
const tracked = state.trackedTokens.find(
(t) => t.address.toLowerCase() === id,
);
// null when the scale is unknown, or when no refresh has read the
// balance of a token just added: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000.
if (tb) amount = unknownableAmount(tb.balance);
else amount = tracked && tracked.balanceNotFetched ? null : 0;
price = getPrice(currentSymbol);
}
// USD total for this token only
const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = escapeHtml(usdStr) || "&nbsp;";
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(
currentSymbol,
amount,
price,
);
}
async function loadTransactions(address, tokenId) { async function loadTransactions(address, tokenId) {
try { try {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
address, address,
state.blockscoutUrl, state.blockscoutUrl,
state.useBlockscout,
currentNetwork().chainId, currentNetwork().chainId,
); );
const result = filterTransactions(rawTxs, { const result = filterTransactions(rawTxs, {
@@ -330,7 +345,7 @@ function init(_ctx) {
$("send-token").value = tokenId; $("send-token").value = tokenId;
} }
// Hide dropdown, show static token display // Hide dropdown, show static token display
$("send-token").classList.add("hidden"); $("send-token-dropdown").classList.add("hidden");
let staticHtml = `<div class="font-bold">${escapeHtml(displaySymbol(currentSymbol))}</div>`; let staticHtml = `<div class="font-bold">${escapeHtml(displaySymbol(currentSymbol))}</div>`;
if (tokenId !== "ETH") { if (tokenId !== "ETH") {
staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`; staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`;
@@ -349,4 +364,4 @@ function init(_ctx) {
}); });
} }
module.exports = { init, show }; module.exports = { init, show, renderBalance };
+7 -4
View File
@@ -11,6 +11,7 @@ const {
onViewLeave, onViewLeave,
formatFee, formatFee,
tokenLabel, tokenLabel,
blockscoutOffTokenNoteHtml,
tokenSourcesByChainId, tokenSourcesByChainId,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
@@ -401,6 +402,10 @@ function showTxApproval(details) {
if (d.address) { if (d.address) {
if (d.isToken) { if (d.isToken) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address, approvedTx.chainId) || "Unknown token")}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address, approvedTx.chainId) || "Unknown token")}</div>`;
detailsHtml += blockscoutOffTokenNoteHtml(
d.address,
approvedTx.chainId,
);
} }
detailsHtml += approvalAddressHtml( detailsHtml += approvalAddressHtml(
d.address, d.address,
@@ -623,10 +628,7 @@ function permitWarningHtml(types, primaryType, domain, message) {
return ""; return "";
} }
const sources = { const sources = tokenSourcesByChainId();
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
const spender = addressOrNull( const spender = addressOrNull(
declaredField(types, primaryType, message, "spender")?.value, declaredField(types, primaryType, message, "spender")?.value,
); );
@@ -655,6 +657,7 @@ function permitWarningHtml(types, primaryType, domain, message) {
html += `<div class="mb-2"><div>Token</div>`; html += `<div class="mb-2"><div>Token</div>`;
if (token) { if (token) {
html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`; html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`;
html += blockscoutOffTokenNoteHtml(token);
html += approvalAddressHtml(token); html += approvalAddressHtml(token);
} else { } else {
html += `<div>Unknown</div>`; html += `<div>Unknown</div>`;
+2 -3
View File
@@ -334,8 +334,8 @@ function show(txInfo) {
$("confirm-contract-warning").style.visibility = "hidden"; $("confirm-contract-warning").style.visibility = "hidden";
$("confirm-burn-warning").style.visibility = "hidden"; $("confirm-burn-warning").style.visibility = "hidden";
$("confirm-etherscan-warning").style.visibility = "hidden"; $("confirm-etherscan-warning").style.visibility = "hidden";
// While "Check recipients on Etherscan" is off there is no Etherscan // While "Check recipients on Etherscan" is off, there is no Etherscan
// warning to reserve space for, and a line in its place says so. // warning to reserve space for. A line in its place says the check is off.
const etherscanOn = !!state.checkRecipientsOnEtherscan; const etherscanOn = !!state.checkRecipientsOnEtherscan;
$("confirm-etherscan-warning").classList.toggle("hidden", !etherscanOn); $("confirm-etherscan-warning").classList.toggle("hidden", !etherscanOn);
$("confirm-etherscan-note").classList.toggle("hidden", etherscanOn); $("confirm-etherscan-note").classList.toggle("hidden", etherscanOn);
@@ -617,7 +617,6 @@ async function checkRecipientHistory(txInfo) {
const asyncWarnings = await getFullWarnings(txInfo.to, provider, { const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from, fromAddress: txInfo.from,
chainId: txInfo.chainId, chainId: txInfo.chainId,
checkRecipientsOnEtherscan: state.checkRecipientsOnEtherscan,
}); });
// The user may have left this transaction while the checks were in // The user may have left this transaction while the checks were in
// flight; its warnings must not reach another recipient's screen. // flight; its warnings must not reach another recipient's screen.
+5
View File
@@ -119,6 +119,11 @@ function show(walletIdx, addrIdx) {
$("delete-address-recovery").textContent = recoveryPathText(wallet); $("delete-address-recovery").textContent = recoveryPathText(wallet);
$("delete-address-balance").innerHTML = balanceWarningHtml(addr); $("delete-address-balance").innerHTML = balanceWarningHtml(addr);
// Only Blockscout reports the tokens the user has not tracked.
$("delete-address-tokens-note").classList.toggle(
"hidden",
!!state.useBlockscout,
);
setFlash(""); setFlash("");
showView("delete-address-confirm"); showView("delete-address-confirm");
+59 -10
View File
@@ -21,7 +21,10 @@ const {
truncateAmountNeverZero, truncateAmountNeverZero,
isBelowOneMillionth, isBelowOneMillionth,
} = require("../../shared/amountDisplay"); } = require("../../shared/amountDisplay");
const { resolveTokenSymbol } = require("../../shared/approvalAmount"); const {
resolveTokenDecimals,
resolveTokenSymbol,
} = require("../../shared/approvalAmount");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { DEBUG } = require("../../shared/constants"); const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html"); const { escapeHtml } = require("../../shared/html");
@@ -306,10 +309,16 @@ function nativeCurrency() {
// names a token on one network only, and every token balance held is the // names a token on one network only, and every token balance held is the
// active network's, so another network gets the tokens tracked on it // active network's, so another network gets the tokens tracked on it
// (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances. // (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances.
//
// The wallets' balances hold what Blockscout reported for each token. So the
// wallets are returned only while "Use Blockscout" is on.
function tokenSourcesByChainId(chainId) { function tokenSourcesByChainId(chainId) {
const net = chainId ? networkByChainId(chainId) : currentNetwork(); const net = chainId ? networkByChainId(chainId) : currentNetwork();
if (net && net.id === state.networkId) { if (net && net.id === state.networkId) {
return { trackedTokens: state.trackedTokens, wallets: state.wallets }; return {
trackedTokens: state.trackedTokens,
wallets: state.useBlockscout ? state.wallets : [],
};
} }
return { return {
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [], trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
@@ -317,11 +326,39 @@ function tokenSourcesByChainId(chainId) {
}; };
} }
// The symbol shown for a token line on the network with `chainId`, or on the // The line shown under a token while "Use Blockscout" is off. The token gets
// active network when none is given: from the bundled list, the tokens the // the line only when the bundled list and the tracked tokens lack its name or
// user tracks, and the explorer's report, the sources the amount line's scale // decimals. Otherwise this returns "". The token is a contract address on the
// comes from. Null when no source names one, so the token lines keep saying // network with `chainId`, or on the active network when none is given. "ETH"
// `Unknown token` for a token nothing knows. // and null are no contract, and get "". A token that is not text also gets "":
// WaitTx and ErrorTx pass a token restored from storage unchecked.
function blockscoutOffTokenNoteHtml(token, chainId) {
if (typeof token !== "string") return "";
if (state.useBlockscout || !token || token === "ETH") return "";
const sources = tokenSourcesByChainId(chainId);
if (
resolveTokenSymbol(token, sources) !== null &&
resolveTokenDecimals(token, sources) !== null
) {
return "";
}
return (
'<div class="text-xs text-muted">Blockscout\'s report of your balances' +
' is not used to name this token, because "Use Blockscout" is off in' +
" Settings.</div>"
);
}
// The symbol shown for a token line, from these sources in order:
//
// - the bundled list
// - the tokens the user tracks
// - the explorer's report, while "Use Blockscout" is on
//
// The network is the one with `chainId`, or the active network when none is
// given. The amount line's scale comes from the same sources. Null when no
// source names one, so the token lines keep saying `Unknown token` for a token
// nothing knows.
function tokenLabel(address, chainId) { function tokenLabel(address, chainId) {
return resolveTokenSymbol(address, tokenSourcesByChainId(chainId)); return resolveTokenSymbol(address, tokenSourcesByChainId(chainId));
} }
@@ -380,6 +417,15 @@ function balanceLine(symbol, amount, price, tokenId) {
); );
} }
// Tracks `token` on the active network, as every add path on AddToken and
// SettingsAddToken does. The tracked record carries `balanceNotFetched: true`
// until a balance refresh has read the token's balance at every address
// (doRefreshAndRender() in src/popup/index.js). Until then, an address with no
// balance row for the token shows `quantity unknown` for it, not 0.0000.
function trackToken(token) {
state.trackedTokens.push({ ...token, balanceNotFetched: true });
}
function balanceLinesForAddress(addr, trackedTokens, showZero) { function balanceLinesForAddress(addr, trackedTokens, showZero) {
let html = balanceLine( let html = balanceLine(
nativeCurrency(), nativeCurrency(),
@@ -412,7 +458,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
if (seen.has(t.address.toLowerCase())) continue; if (seen.has(t.address.toLowerCase())) continue;
html += balanceLine( html += balanceLine(
t.symbol, t.symbol,
0, t.balanceNotFetched ? null : 0,
getPrice(t.symbol), getPrice(t.symbol),
t.address.toLowerCase(), t.address.toLowerCase(),
); );
@@ -534,8 +580,9 @@ function addressTitle(address, wallets) {
const CONTRACT_CREATION_TEXT = const CONTRACT_CREATION_TEXT =
"This transaction creates a new contract. It has no recipient."; "This transaction creates a new contract. It has no recipient.";
// What the transaction lists on Home, AddressDetail and AddressToken say while // The line shown in place of the transaction history on Home, AddressDetail and
// the user has "Use Blockscout" off: the history comes from Blockscout alone. // AddressToken while "Use Blockscout" is off. That history comes from
// Blockscout alone.
const HISTORY_OFF_TEXT = const HISTORY_OFF_TEXT =
'No transaction history is shown, because "Use Blockscout" is off in Settings.'; 'No transaction history is shown, because "Use Blockscout" is off in Settings.';
@@ -791,11 +838,13 @@ module.exports = {
flashCopyFeedback, flashCopyFeedback,
balanceLine, balanceLine,
balanceLinesForAddress, balanceLinesForAddress,
trackToken,
addressHoldsFunds, addressHoldsFunds,
keyMaterialName, keyMaterialName,
unknownableAmount, unknownableAmount,
nativeCurrency, nativeCurrency,
tokenSourcesByChainId, tokenSourcesByChainId,
blockscoutOffTokenNoteHtml,
tokenLabel, tokenLabel,
formatFee, formatFee,
addressDotHtml, addressDotHtml,
+2 -3
View File
@@ -197,7 +197,6 @@ async function loadHomeTxs(ctx) {
fetchRecentTransactions( fetchRecentTransactions(
addr, addr,
state.blockscoutUrl, state.blockscoutUrl,
state.useBlockscout,
currentNetwork().chainId, currentNetwork().chainId,
), ),
); );
@@ -306,7 +305,7 @@ function walletListHtml() {
function render(ctx) { function render(ctx) {
const container = $("wallet-list"); const container = $("wallet-list");
// Tokens the user has not tracked are Blockscout's to report. // Only Blockscout reports the tokens the user has not tracked.
$("home-tokens-note").classList.toggle("hidden", !!state.useBlockscout); $("home-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
if (state.wallets.length === 0) { if (state.wallets.length === 0) {
container.innerHTML = container.innerHTML =
@@ -455,7 +454,7 @@ function init(ctx) {
} }
$("send-to").value = ""; $("send-to").value = "";
$("send-amount").value = ""; $("send-amount").value = "";
$("send-token").classList.remove("hidden"); $("send-token-dropdown").classList.remove("hidden");
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
renderSendTokenSelect(addr); renderSendTokenSelect(addr);
updateSendBalance(); updateSendBalance();
+12 -7
View File
@@ -10,6 +10,7 @@ const {
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
tokenSourcesByChainId,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress } = require("../../shared/state"); const { state, currentAddress } = require("../../shared/state");
let ctx; let ctx;
@@ -141,6 +142,8 @@ function updateToValidation() {
} }
function renderSendTokenSelect(addr) { function renderSendTokenSelect(addr) {
// Only Blockscout reports the tokens the user has not tracked.
$("send-tokens-note").classList.toggle("hidden", !!state.useBlockscout);
const sel = $("send-token"); const sel = $("send-token");
sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`; sel.innerHTML = `<option value="ETH">${escapeHtml(nativeCurrency())}</option>`;
const fraudSet = new Set( const fraudSet = new Set(
@@ -185,7 +188,9 @@ function renderSendTokenSelect(addr) {
// so the transfer is encoded with the number the user read rather than with // so the transfer is encoded with the number the user read rather than with
// whatever the contract answers at signing time (see // whatever the contract answers at signing time (see
// src/shared/transferAmount.js). balances.js is formatting one explorer row at // src/shared/transferAmount.js). balances.js is formatting one explorer row at
// fetch time and cannot consult a state it is in the middle of replacing. // fetch time and cannot consult a state it is in the middle of replacing. As on
// the approval screens, tokenSourcesByChainId() gives the wallets only while
// "Use Blockscout" is on.
// //
// The two resolutions can therefore differ, and where they do, the stored // The two resolutions can therefore differ, and where they do, the stored
// `balance` is a quantity computed at a scale this screen has just declined to // `balance` is a quantity computed at a scale this screen has just declined to
@@ -198,10 +203,7 @@ function tokenBalanceAndDecimals(addr, token) {
const tb = (addr.tokenBalances || []).find( const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(), (t) => t.address.toLowerCase() === token.toLowerCase(),
); );
const tokenDecimals = resolveTokenDecimals(token, { const tokenDecimals = resolveTokenDecimals(token, tokenSourcesByChainId());
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
if (!tb) return { tokenBalance: "0", tokenDecimals }; if (!tb) return { tokenBalance: "0", tokenDecimals };
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals }; if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
return { tokenBalance: tb.balance ?? null, tokenDecimals }; return { tokenBalance: tb.balance ?? null, tokenDecimals };
@@ -401,8 +403,11 @@ function init(_ctx) {
try { try {
const provider = getProvider(state.rpcUrl, state.networkId); const provider = getProvider(state.rpcUrl, state.networkId);
const resolved = await provider.resolveName(to); const resolved = await provider.resolveName(to);
// getProvider() does not look up a name resolved off-chain,
// so such a name comes back with no address too. The message
// covers both, in the one line the flash area shows.
if (!resolved) { if (!resolved) {
showFlash("That ENS name has no address."); showFlash("That ENS name has no on-chain address.");
return; return;
} }
resolvedTo = resolved; resolvedTo = resolved;
@@ -426,7 +431,7 @@ function init(_ctx) {
}); });
$("btn-send-back").addEventListener("click", () => { $("btn-send-back").addEventListener("click", () => {
$("send-token").classList.remove("hidden"); $("send-token-dropdown").classList.remove("hidden");
$("send-token-static").classList.add("hidden"); $("send-token-static").classList.add("hidden");
goBack(); goBack();
}); });
+61 -25
View File
@@ -17,6 +17,7 @@ const {
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { onChainSwitch } = require("../../shared/chainSwitch"); const { onChainSwitch } = require("../../shared/chainSwitch");
const { restorableStack } = require("../../shared/persistedState"); const { restorableStack } = require("../../shared/persistedState");
const { dropUntrackedTokens } = require("../../shared/balances");
const { resetRenderedViews, markViewRendered } = require("../viewRouter"); const { resetRenderedViews, markViewRendered } = require("../viewRouter");
const { const {
log, log,
@@ -251,16 +252,20 @@ async function renderSiteLists() {
// A network switch, made by the user here or saved by the background once the // 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 // 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 // the page showing the previous network. Two functions leave the page as
// Screen Map describes, the first before the switch is saved, so that save // README's Screen Map describes:
// keeps the cut for a popup closed straight after, the second once `state` //
// holds the new network. // - cutViewStack(), before the switch is saved, so that save keeps the cut for
// a popup closed straight after
// - showNewSettings(), once `state` holds the new network
// //
// The stack is cut at the first screen that cannot be rendered from saved // 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 // 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 // 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 // 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. // Welcome, which that cut would drop, and Welcome shows nothing from a network.
// A change to "Use Blockscout" cuts the stack the same way (setUseBlockscout()
// below).
function cutViewStack() { function cutViewStack() {
if (!state.hasWallet) return; if (!state.hasWallet) return;
if (state.selectedToken !== "ETH") { if (state.selectedToken !== "ETH") {
@@ -273,18 +278,40 @@ function cutViewStack() {
state.viewStack = restorableStack(state.viewStack, state.currentView); state.viewStack = restorableStack(state.viewStack, state.currentView);
} }
// Every screen counts as not yet rendered, so "Back" renders the one it lands // Sets "Use Blockscout" to `on` on this page. It is called inside a save,
// on for the new network. Settings on top is brought up to date in place; // holding the lock, for the user's change here or another page's
// any other screen is drawn again as a reopened popup draws it, which is Home // (followStoredSettings() in src/popup/index.js).
// 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. // Send's token dropdown was filled under the old setting, and Back cannot draw
function showNewNetwork() { // Send again. So the stack is cut, as for a network switch. Turning the setting
// off also drops every token the user has not tracked.
function setUseBlockscout(on) {
state.useBlockscout = on;
cutViewStack();
if (!on) dropUntrackedTokens(state.wallets, state.trackedTokens);
}
// Draws the page again after a network switch, or after another page changed
// "Use Blockscout" or "Check recipients on Etherscan". Every screen counts as
// not yet rendered, so "Back" renders again any screen that a reopened popup
// can render. 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. Back only unhides Welcome, so its two checkboxes are
// set here.
function showNewSettings() {
resetRenderedViews(); resetRenderedViews();
$("welcome-use-blockscout").checked = state.useBlockscout;
$("welcome-check-recipients-on-etherscan").checked =
state.checkRecipientsOnEtherscan;
if (state.currentView === "settings") { if (state.currentView === "settings") {
markViewRendered("settings"); markViewRendered("settings");
$("settings-network").value = state.networkId; $("settings-network").value = state.networkId;
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
$("settings-use-blockscout").checked = state.useBlockscout;
$("settings-check-recipients-on-etherscan").checked =
state.checkRecipientsOnEtherscan;
renderTrackedTokens(); renderTrackedTokens();
} else if (state.hasWallet) { } else if (state.hasWallet) {
ctx.restoreView(); ctx.restoreView();
@@ -367,30 +394,32 @@ function init(_ctx) {
showFlash("Saved."); showFlash("Saved.");
}); });
// The screens under Settings were drawn under the old setting, so each // Each change is made inside the save, holding the lock. So
// followStoredSettings() in src/popup/index.js never mistakes this page's
// unsaved change for one that another page 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 // 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 // network switch does. Back then draws again any screen that a reopened
// offers the same two settings and is only unhidden on Back, so its boxes // popup can draw. Back only unhides Welcome, so its boxes are set here too.
// are set here too.
$("settings-use-blockscout").addEventListener("change", async () => { $("settings-use-blockscout").addEventListener("change", async () => {
state.useBlockscout = $("settings-use-blockscout").checked; const on = $("settings-use-blockscout").checked;
$("welcome-use-blockscout").checked = state.useBlockscout; await saveState(() => setUseBlockscout(on));
$("welcome-use-blockscout").checked = on;
resetRenderedViews(); resetRenderedViews();
markViewRendered("settings"); markViewRendered("settings");
await saveState();
}); });
$("settings-check-recipients-on-etherscan").addEventListener( $("settings-check-recipients-on-etherscan").addEventListener(
"change", "change",
async () => { async () => {
state.checkRecipientsOnEtherscan = $( const on = $("settings-check-recipients-on-etherscan").checked;
"settings-check-recipients-on-etherscan", await saveState(() => {
).checked; state.checkRecipientsOnEtherscan = on;
$("welcome-check-recipients-on-etherscan").checked = });
state.checkRecipientsOnEtherscan; $("welcome-check-recipients-on-etherscan").checked = on;
resetRenderedViews(); resetRenderedViews();
markViewRendered("settings"); markViewRendered("settings");
await saveState();
}, },
); );
@@ -407,7 +436,7 @@ function init(_ctx) {
// Open pages are told by the background, as after a site's approved // Open pages are told by the background, as after a site's approved
// switch request. // switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId }); notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
showNewNetwork(); showNewSettings();
showFlash("Switched to " + net.name + "."); showFlash("Switched to " + net.name + ".");
}); });
@@ -470,6 +499,12 @@ function init(_ctx) {
await saveState(); await saveState();
}); });
$("settings-allow-weak-passwords").checked = state.allowWeakPasswords;
$("settings-allow-weak-passwords").addEventListener("change", async () => {
state.allowWeakPasswords = $("settings-allow-weak-passwords").checked;
await saveState();
});
$("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView); $("btn-main-add-wallet").addEventListener("click", ctx.showAddWalletView);
$("btn-settings-add-token").addEventListener( $("btn-settings-add-token").addEventListener(
@@ -547,5 +582,6 @@ module.exports = {
show, show,
renderSiteLists, renderSiteLists,
cutViewStack, cutViewStack,
showNewNetwork, setUseBlockscout,
showNewSettings,
}; };
+11 -4
View File
@@ -1,4 +1,11 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers"); const {
$,
showView,
showFlash,
escapeHtml,
goBack,
trackToken,
} = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -43,7 +50,7 @@ function renderTop10() {
decimals: parseInt(btn.dataset.decimals, 10), decimals: parseInt(btn.dataset.decimals, 10),
name: btn.dataset.name || btn.dataset.symbol, name: btn.dataset.name || btn.dataset.symbol,
}; };
state.trackedTokens.push(token); trackToken(token);
await saveState(); await saveState();
showFlash("Added " + token.symbol); showFlash("Added " + token.symbol);
renderTop10(); renderTop10();
@@ -110,7 +117,7 @@ function init(_ctx) {
decimals: parseInt(opt.dataset.decimals, 10), decimals: parseInt(opt.dataset.decimals, 10),
name: opt.dataset.name || opt.dataset.symbol, name: opt.dataset.name || opt.dataset.symbol,
}; };
state.trackedTokens.push(token); trackToken(token);
await saveState(); await saveState();
showFlash("Added " + token.symbol); showFlash("Added " + token.symbol);
renderTop10(); renderTop10();
@@ -156,7 +163,7 @@ function init(_ctx) {
return; return;
} }
log.infof("Adding token", info.symbol, addr); log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({ trackToken({
address: addr, address: addr,
symbol: info.symbol, symbol: info.symbol,
decimals: info.decimals, decimals: info.decimals,
-2
View File
@@ -143,8 +143,6 @@ async function resetProfile() {
try { try {
// Under the lock every write of the stored record takes, so the erase // Under the lock every write of the stored record takes, so the erase
// cannot land between another write's read and its write and be undone. // cannot land between another write's read and its write and be undone.
// A page in a Firefox private window is the exception: Firefox gives
// it locks of its own (README, "Stored state and its version").
await withLock("autistmask", () => storageRemove("autistmask")); await withLock("autistmask", () => storageRemove("autistmask"));
} catch (e) { } catch (e) {
log.errorf("state recovery: reset failed:", e); log.errorf("state recovery: reset failed:", e);
+11 -6
View File
@@ -26,6 +26,7 @@ const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks"); const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const { storedSettingIsOn } = require("../../shared/storedSettings");
const { decodeCalldata } = require("./approval"); const { decodeCalldata } = require("./approval");
/** /**
@@ -172,7 +173,8 @@ function render() {
} }
} }
// Hide calldata and raw data sections; always fetch full tx details // Hide the calldata and raw data sections. loadFullTxDetails() shows them
// once it has their data.
const calldataSection = $("tx-detail-calldata-section"); const calldataSection = $("tx-detail-calldata-section");
if (calldataSection) calldataSection.classList.add("hidden"); if (calldataSection) calldataSection.classList.add("hidden");
const rawDataSection = $("tx-detail-rawdata-section"); const rawDataSection = $("tx-detail-rawdata-section");
@@ -191,7 +193,8 @@ function render() {
if (el) el.classList.add("hidden"); if (el) el.classList.add("hidden");
} }
// Those details are Blockscout's; this line says why there are none. // The block, network details, decoded details and raw data come from
// Blockscout. While the setting is off, this line says why none is shown.
$("tx-detail-blockscout-note").classList.toggle( $("tx-detail-blockscout-note").classList.toggle(
"hidden", "hidden",
!!state.useBlockscout, !!state.useBlockscout,
@@ -308,11 +311,12 @@ function populateOnChainDetails(txData, chainId) {
} }
} }
// The block, network details, calldata and its decoding, from Blockscout. While // Reads the block, network details and calldata from Blockscout, and decodes
// the user has "Use Blockscout" off, Blockscout is not asked and none of them // the calldata. While the stored "Use Blockscout" is off, Blockscout is not
// is shown. // asked. An answer that lands after the setting went off is dropped
// (src/shared/storedSettings.js). Either way none of them is shown.
async function loadFullTxDetails(txHash, chainId) { async function loadFullTxDetails(txHash, chainId) {
if (!state.useBlockscout) return; if (!(await storedSettingIsOn("useBlockscout"))) return;
const section = $("tx-detail-calldata-section"); const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action"); const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details"); const detailsEl = $("tx-detail-calldata-details");
@@ -328,6 +332,7 @@ async function loadFullTxDetails(txHash, chainId) {
); );
if (!resp.ok) return; if (!resp.ok) return;
const txData = await resp.json(); const txData = await resp.json();
if (!(await storedSettingIsOn("useBlockscout"))) return;
// Populate on-chain detail fields (block, nonce, gas, fee) // Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData, chainId); populateOnChainDetails(txData, chainId);
+15 -3
View File
@@ -14,6 +14,7 @@ const {
displaySymbol, displaySymbol,
clearViewStack, clearViewStack,
tokenLabel, tokenLabel,
blockscoutOffTokenNoteHtml,
reviewedNetworkProvider, reviewedNetworkProvider,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
@@ -108,6 +109,10 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
} }
$("wait-tx-summary").textContent = $("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : ""); txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
txInfo.token,
txInfo.chainId,
);
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId); $("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId); $("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
attachCopyHandlers("view-wait-tx"); attachCopyHandlers("view-wait-tx");
@@ -215,9 +220,10 @@ function showWait(txInfo, txHash) {
// init and leaving wait-tx on screen with no back control. A non-numeric // init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks // broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks
// the node the receipt is asked of and the explorer the links open, so it has // the node the receipt is asked of and the explorer the links open, so it has
// to name a network. txInfo.token and txInfo.tokenSymbol are deliberately // to name a network. txInfo.token and txInfo.tokenSymbol are not checked here.
// unchecked: they are compared and coalesced rather than dereferenced, and // blockscoutOffTokenNoteHtml() shows no line for a token that is not text.
// tokenSymbol is null for ETH. // Everything else only compares and coalesces them, and tokenSymbol is null for
// ETH.
function restoreWait() { function restoreWait() {
const d = state.viewData; const d = state.viewData;
if (!d || !d.pendingWait) return false; if (!d || !d.pendingWait) return false;
@@ -282,6 +288,7 @@ function decodedDetailsHtml(decoded, chainId) {
if (d.isToken) { if (d.isToken) {
const sym = tokenLabel(d.address, chainId) || "Unknown token"; const sym = tokenLabel(d.address, chainId) || "Unknown token";
html += `<div class="font-bold">${escapeHtml(sym)}</div>`; html += `<div class="font-bold">${escapeHtml(sym)}</div>`;
html += blockscoutOffTokenNoteHtml(d.address, chainId);
html += toAddressHtml(d.address, chainId); html += toAddressHtml(d.address, chainId);
} else { } else {
html += toAddressHtml(d.address, chainId); html += toAddressHtml(d.address, chainId);
@@ -345,6 +352,7 @@ function showError(txInfo, txHash, message) {
state.viewData = { state.viewData = {
amount: txInfo.amount, amount: txInfo.amount,
symbol: symbol, symbol: symbol,
token: txInfo.token,
to: txInfo.to, to: txInfo.to,
hash: txHash || null, hash: txHash || null,
message: message, message: message,
@@ -358,6 +366,10 @@ function renderError() {
if (!d || !d.message) return; if (!d || !d.message) return;
$("error-tx-summary").textContent = $("error-tx-summary").textContent =
d.amount + (d.symbol ? " " + d.symbol : ""); d.amount + (d.symbol ? " " + d.symbol : "");
$("error-tx-token-note").innerHTML = blockscoutOffTokenNoteHtml(
d.token,
d.chainId,
);
$("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId); $("error-tx-to").innerHTML = toAddressHtml(d.to, d.chainId);
$("error-tx-message").textContent = d.message; $("error-tx-message").textContent = d.message;
+12 -9
View File
@@ -1,6 +1,6 @@
// The first screen, shown while there is no wallet. It offers the two lookups // The first screen, shown while there is no wallet. It offers "Use Blockscout"
// at services other than the RPC endpoint, both off until the user ticks them, // and "Check recipients on Etherscan", which Settings offers too. Both are off
// which Settings offers as well. // until the user ticks them.
const { $, showView } = require("./helpers"); const { $, showView } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
@@ -12,21 +12,24 @@ function show() {
showView("welcome"); showView("welcome");
} }
// Each change is made inside the save, holding the lock, as Settings makes it.
function init(ctx) { function init(ctx) {
$("btn-welcome-add").addEventListener("click", ctx.showAddWalletView); $("btn-welcome-add").addEventListener("click", ctx.showAddWalletView);
$("welcome-use-blockscout").addEventListener("change", async () => { $("welcome-use-blockscout").addEventListener("change", async () => {
state.useBlockscout = $("welcome-use-blockscout").checked; const on = $("welcome-use-blockscout").checked;
await saveState(); await saveState(() => {
state.useBlockscout = on;
});
}); });
$("welcome-check-recipients-on-etherscan").addEventListener( $("welcome-check-recipients-on-etherscan").addEventListener(
"change", "change",
async () => { async () => {
state.checkRecipientsOnEtherscan = $( const on = $("welcome-check-recipients-on-etherscan").checked;
"welcome-check-recipients-on-etherscan", await saveState(() => {
).checked; state.checkRecipientsOnEtherscan = on;
await saveState(); });
}, },
); );
} }
-3
View File
@@ -60,8 +60,6 @@ function getLocalWarnings(address, options = {}) {
* @param {string} [options.fromAddress] - Sender address (for self-send check). * @param {string} [options.fromAddress] - Sender address (for self-send check).
* @param {string} [options.chainId] - Chain id of the network whose explorer * @param {string} [options.chainId] - Chain id of the network whose explorer
* the Etherscan label check asks; the active network's when omitted. * 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}>>} * @returns {Promise<Array<{type: string, message: string, severity: string}>>}
*/ */
async function getFullWarnings(address, provider, options = {}) { async function getFullWarnings(address, provider, options = {}) {
@@ -116,7 +114,6 @@ async function getFullWarnings(address, provider, options = {}) {
try { try {
const etherscanWarning = await checkEtherscanLabel( const etherscanWarning = await checkEtherscanLabel(
address, address,
options.checkRecipientsOnEtherscan,
options.chainId, options.chainId,
); );
if (etherscanWarning) { if (etherscanWarning) {
+65 -22
View File
@@ -1,7 +1,10 @@
// Balance fetching: ETH balances and the balances of the tokens the user // Balance fetching:
// 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 // - ETH balances, and the balances of the tokens the user tracks, via RPC
// RPC. // - the other ERC-20 token balances and the holder counts via Blockscout,
// while the user has "Use Blockscout" on. Those balances are dropped when
// the setting is off.
// - ENS reverse lookup via RPC
const { const {
JsonRpcProvider, JsonRpcProvider,
@@ -19,6 +22,7 @@ const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof"); const { isSpoofedSymbol } = require("./symbolSpoof");
const { toDecimals } = require("./transferAmount"); const { toDecimals } = require("./transferAmount");
const { resolveTokenDecimals } = require("./approvalAmount"); const { resolveTokenDecimals } = require("./approvalAmount");
const { storedSettingIsOn } = require("./storedSettings");
// Use a static network to skip auto-detection (which can fail and cause // Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare). // "could not coalesce error" on some RPC endpoints like Cloudflare).
@@ -38,9 +42,18 @@ const { resolveTokenDecimals } = require("./approvalAmount");
// ethers knows chains this wallet does not, so an id that is not one of ours // 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 // is a caller bug and must not resolve to a working provider for some other
// chain. // chain.
//
// `disableCcipRead` keeps every ENS lookup on the RPC endpoint. A resolver can
// say a name is resolved off-chain and give a web address for it. By default
// ethers then fetches that address straight from the browser, sending the name
// being looked up. For the reverse lookup of the user's addresses, the name sent
// is the user's own (https://git.eeqj.de/sneak/AutistMask/issues/668). With the
// flag set, such a name does not resolve.
function getProvider(rpcUrl, networkId) { function getProvider(rpcUrl, networkId) {
const net = Network.from(requireNetworkId(networkId).id); 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) { function requireNetworkId(networkId) {
@@ -83,9 +96,10 @@ function rawUnits(value) {
// Fetch token balances for a single address from Blockscout. // Fetch token balances for a single address from Blockscout.
// Returns [{ address, name, symbol, decimals, balance, holders }]. // Returns [{ address, name, symbol, decimals, balance, holders }].
// //
// `useBlockscout` is the user's "Use Blockscout" setting. While it is off, // While the stored "Use Blockscout" is off, Blockscout is not asked. An answer
// Blockscout is not asked and the answer is an empty list: no token the user // that lands after the setting went off is dropped
// has not tracked is listed. // (src/shared/storedSettings.js). Either way this returns an empty list, so
// only tracked tokens are listed.
// //
// Filters out spam: only shows tokens that are in the known token list, // Filters out spam: only shows tokens that are in the known token list,
// explicitly tracked by the user, or have >= 1000 holders. A tracked token's // explicitly tracked by the user, or have >= 1000 holders. A tracked token's
@@ -102,11 +116,10 @@ function rawUnits(value) {
async function fetchTokenBalances( async function fetchTokenBalances(
address, address,
blockscoutUrl, blockscoutUrl,
useBlockscout,
trackedTokens, trackedTokens,
signal, signal,
) { ) {
if (!useBlockscout) return []; if (!(await storedSettingIsOn("useBlockscout"))) return [];
try { try {
const resp = await debugFetch( const resp = await debugFetch(
blockscoutUrl + "/addresses/" + address + "/token-balances", blockscoutUrl + "/addresses/" + address + "/token-balances",
@@ -116,6 +129,7 @@ async function fetchTokenBalances(
return null; return null;
} }
const items = await resp.json(); const items = await resp.json();
if (!(await storedSettingIsOn("useBlockscout"))) return [];
if (!Array.isArray(items)) return null; if (!Array.isArray(items)) return null;
const trackedSet = new Set( const trackedSet = new Set(
@@ -233,7 +247,11 @@ async function fetchTokenBalances(
// it has none. The failure is logged. A balance of zero gets no entry, as a // 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 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 // zero from the tracked list. A token whose symbol the spoof rule refuses gets
// no entry either, as in fetchTokenBalances(). // no entry either, as in fetchTokenBalances(), and its balance is not read.
//
// Resolves with `rows`, the entries, and `unread`, the tracked tokens whose
// balance was not read, as lowercased contract addresses. A token is unread
// when the spoof rule refuses it or its balanceOf fails.
// //
// `signal` is as for fetchTokenBalances(). // `signal` is as for fetchTokenBalances().
async function fetchTrackedTokenBalances( async function fetchTrackedTokenBalances(
@@ -274,12 +292,16 @@ async function fetchTrackedTokenBalances(
const reported = explorerRows ?? previous; const reported = explorerRows ?? previous;
const rows = []; const rows = [];
const unread = trackedTokens
.filter((token) => !listed.includes(token))
.map((token) => token.address.toLowerCase());
listed.forEach((token, i) => { listed.forEach((token, i) => {
const lower = token.address.toLowerCase(); const lower = token.address.toLowerCase();
const entryIn = (list) => const entryIn = (list) =>
list.find((row) => String(row.address).toLowerCase() === lower); list.find((row) => String(row.address).toLowerCase() === lower);
const raw = balances[i]; const raw = balances[i];
if (raw === null) { if (raw === null) {
unread.push(lower);
const kept = entryIn(previous) || entryIn(explorerRows || []); const kept = entryIn(previous) || entryIn(explorerRows || []);
if (kept) rows.push(kept); if (kept) rows.push(kept);
return; return;
@@ -300,17 +322,19 @@ async function fetchTrackedTokenBalances(
holders: entry ? entry.holders : null, holders: entry ? entry.holders : null,
}); });
}); });
return rows; return { rows, unread };
} }
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses. // Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
// `trackedTokens` are the tokens tracked on the active network. // `trackedTokens` are the tokens tracked on the active network. `signal` is as
// `useBlockscout` and `signal` are as for fetchTokenBalances(). // for fetchTokenBalances().
//
// Resolves with the lowercased contract addresses of the tracked tokens whose
// balance it read at every address.
async function refreshBalances( async function refreshBalances(
wallets, wallets,
rpcUrl, rpcUrl,
blockscoutUrl, blockscoutUrl,
useBlockscout,
trackedTokens, trackedTokens,
networkId, networkId,
signal, signal,
@@ -319,6 +343,8 @@ async function refreshBalances(
const provider = getProvider(rpcUrl, networkId); const provider = getProvider(rpcUrl, networkId);
const tracked = trackedTokens || []; const tracked = trackedTokens || [];
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase())); const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
// The tracked tokens whose balance was not read at some address.
const unread = new Set();
const updates = []; const updates = [];
for (const wallet of wallets) { for (const wallet of wallets) {
@@ -371,15 +397,14 @@ async function refreshBalances(
// ERC-20 token balances: the tracked tokens' through the RPC // ERC-20 token balances: the tracked tokens' through the RPC
// endpoint, and from Blockscout the tokens the user has not // endpoint, and from Blockscout the tokens the user has not
// tracked. When Blockscout fails, the untracked rows from before // tracked. When Blockscout fails, the untracked rows from before
// this refresh stay; while "Use Blockscout" is off there are none. // this refresh stay. While "Use Blockscout" is off, there are no
// The Blockscout request is started first and handed to // untracked rows. The Blockscout request is started first and
// fetchTrackedTokenBalances() too, which waits for it after // handed to fetchTrackedTokenBalances() too, which waits for it
// sending its balanceOf reads, so the two run side by side. // after sending its balanceOf reads, so the two run side by side.
const previous = addr.tokenBalances || []; const previous = addr.tokenBalances || [];
const explorerRequest = fetchTokenBalances( const explorerRequest = fetchTokenBalances(
addr.address, addr.address,
blockscoutUrl, blockscoutUrl,
useBlockscout,
tracked, tracked,
signal, signal,
); );
@@ -394,12 +419,14 @@ async function refreshBalances(
signal, signal,
), ),
explorerRequest, explorerRequest,
]).then(([trackedRows, explorerRows]) => { ]).then(([trackedResult, explorerRows]) => {
for (const lower of trackedResult.unread) unread.add(lower);
const untrackedRows = (explorerRows ?? previous).filter( const untrackedRows = (explorerRows ?? previous).filter(
(row) => (row) =>
!trackedSet.has(String(row.address).toLowerCase()), !trackedSet.has(String(row.address).toLowerCase()),
); );
addr.tokenBalances = trackedRows.concat(untrackedRows); addr.tokenBalances =
trackedResult.rows.concat(untrackedRows);
log.debugf( log.debugf(
"Token balances", "Token balances",
addr.address, addr.address,
@@ -413,6 +440,21 @@ async function refreshBalances(
await Promise.all(updates); await Promise.all(updates);
log.debugf("refreshBalances done"); log.debugf("refreshBalances done");
return [...trackedSet].filter((lower) => !unread.has(lower));
}
// Drops the balance of each token not in `trackedTokens` from every address.
// Callers run it while "Use Blockscout" is off, since only Blockscout reports
// those tokens.
function dropUntrackedTokens(wallets, trackedTokens) {
const tracked = new Set(trackedTokens.map((t) => t.address.toLowerCase()));
for (const wallet of wallets) {
for (const addr of wallet.addresses || []) {
addr.tokenBalances = (addr.tokenBalances || []).filter((row) =>
tracked.has(String(row.address).toLowerCase()),
);
}
}
} }
// Look up token metadata from its contract. // Look up token metadata from its contract.
@@ -533,6 +575,7 @@ async function scanForAddresses(xpub, rpcUrl, networkId, signal, gapLimit = 5) {
module.exports = { module.exports = {
fetchTokenBalances, fetchTokenBalances,
refreshBalances, refreshBalances,
dropUntrackedTokens,
lookupTokenInfo, lookupTokenInfo,
getProvider, getProvider,
scanForAddresses, scanForAddresses,
+2 -2
View File
@@ -4,7 +4,7 @@
// //
// - onChainSwitch() below makes the switch the user picks in Settings, with // - onChainSwitch() below makes the switch the user picks in Settings, with
// applyChainSwitchFields() (chainSwitchFields.js). // applyChainSwitchFields() (chainSwitchFields.js).
// - followNetworkSwitch() in src/popup/index.js takes a switch the background // - followStoredSettings() in src/popup/index.js takes a switch the background
// or another popup page saved, from the stored record, with // or another popup page saved, from the stored record, with
// takeStoredNetwork() (state.js), which copies it with // takeStoredNetwork() (state.js), which copies it with
// copyChainSwitchFields() (chainSwitchFields.js). // copyChainSwitchFields() (chainSwitchFields.js).
@@ -45,7 +45,7 @@ function clearPopupCaches() {
// //
// `state` is switched inside the save that stores the switch, holding the // `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 // 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 // waits for the lock (followStoredSettings() in src/popup/index.js): that
// follow runs first, and this switch is made after it. // follow runs first, and this switch is made after it.
async function onChainSwitch(newNetworkId) { async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state"); const { state, saveState } = require("./state");
+11 -9
View File
@@ -3,6 +3,7 @@
// This is a best-effort enrichment: network failures return null silently. // This is a best-effort enrichment: network failures return null silently.
const { networkByChainId } = require("./networks"); const { networkByChainId } = require("./networks");
const { storedSettingIsOn } = require("./storedSettings");
// Patterns in the page title that indicate a flagged address. // Patterns in the page title that indicate a flagged address.
// Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan" // Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan"
@@ -78,21 +79,19 @@ function parseEtherscanPage(html) {
* when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on * when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on
* Sepolia. * Sepolia.
* *
* While the user's "Check recipients on Etherscan" setting is off, Etherscan * While the stored "Check recipients on Etherscan" is off, Etherscan is not
* is not asked and the answer is null. * asked. An answer that lands after the setting went off is dropped
* (src/shared/storedSettings.js). Either way the answer is null.
* *
* @param {string} address - Ethereum address to check. * @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. * @param {string} [chainId] - Chain id of the network to look it up on.
* @returns {Promise<{type: string, message: string, severity: string}|null>} * @returns {Promise<{type: string, message: string, severity: string}|null>}
*/ */
async function checkEtherscanLabel( async function checkEtherscanLabel(address, chainId) {
address,
checkRecipientsOnEtherscan,
chainId,
) {
if (!checkRecipientsOnEtherscan) return null;
try { try {
if (!(await storedSettingIsOn("checkRecipientsOnEtherscan"))) {
return null;
}
// Lazy require to avoid pulling in chrome.storage at module scope // Lazy require to avoid pulling in chrome.storage at module scope
// (which breaks unit tests that only exercise parseEtherscanPage). // (which breaks unit tests that only exercise parseEtherscanPage).
const { currentNetwork } = require("./state"); const { currentNetwork } = require("./state");
@@ -103,6 +102,9 @@ async function checkEtherscanLabel(
}); });
if (!resp.ok) return null; if (!resp.ok) return null;
const html = await resp.text(); const html = await resp.text();
if (!(await storedSettingIsOn("checkRecipientsOnEtherscan"))) {
return null;
}
const result = parseEtherscanPage(html); const result = parseEtherscanPage(html);
if (result.isPhishing) { if (result.isPhishing) {
return { return {
+16 -10
View File
@@ -48,12 +48,17 @@ const DEFAULT_STATE = {
hideDustTransactions: true, hideDustTransactions: true,
dustThresholdGwei: 100000, dustThresholdGwei: 100000,
utcTimestamps: false, utcTimestamps: false,
// "Use Blockscout" and "Check recipients on Etherscan" in Settings, one // "Use Blockscout" and "Check recipients on Etherscan" in Settings. Each
// setting each for every network. Both off until the user turns them on: // setting applies to every network. Both are off until the user turns them
// the requests they allow send the user's addresses to a service other // on. With "Use Blockscout" on, Blockscout is sent the user's addresses and
// than the RPC endpoint. // the hash of each transaction opened. With "Check recipients on
// Etherscan" on, Etherscan is sent the recipient's address and nothing
// else.
useBlockscout: false, useBlockscout: false,
checkRecipientsOnEtherscan: false, checkRecipientsOnEtherscan: false,
// "Allow weak passwords" in Settings. While it is off, AddWallet refuses a
// password shorter than 12 characters.
allowWeakPasswords: false,
fraudContracts: [], fraudContracts: [],
tokenHolderCache: {}, tokenHolderCache: {},
theme: "system", theme: "system",
@@ -412,12 +417,13 @@ function normalizePersisted(saved) {
: 100000; : 100000;
out.utcTimestamps = out.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false; saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
out.useBlockscout = // Anything but true is off: each setting lets a request reach another
saved.useBlockscout !== undefined ? saved.useBlockscout : false; // service.
out.checkRecipientsOnEtherscan = out.useBlockscout = saved.useBlockscout === true;
saved.checkRecipientsOnEtherscan !== undefined out.checkRecipientsOnEtherscan = saved.checkRecipientsOnEtherscan === true;
? saved.checkRecipientsOnEtherscan // Anything but true is off: the setting lets AddWallet accept a short
: false; // password.
out.allowWeakPasswords = saved.allowWeakPasswords === true;
// A list of contract addresses, floored the same way: send.js builds its // A list of contract addresses, floored the same way: send.js builds its
// fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())` // fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())`
// and filterTransactions() maps the same list through normalizeAddress(), // and filterTransactions() maps the same list through normalizeAddress(),
+7 -7
View File
@@ -469,9 +469,11 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// exists to remove, just delayed and confined to one page instead of two // 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 // (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 // does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current. The // before this fix; only the persisted record is guaranteed current. Three
// network is the exception: the popup takes a switch another context saved // fields are the exceptions: the network, "Use Blockscout" and "Check
// through `beforeWrite` (followNetworkSwitch() in src/popup/index.js). // recipients on Etherscan". Through `beforeWrite`, the popup takes a change to
// any of them that another context saved (followStoredSettings() in
// src/popup/index.js).
async function saveStateOnce(beforeWrite) { async function saveStateOnce(beforeWrite) {
const result = await storageGet("autistmask"); const result = await storageGet("autistmask");
// The record in storage right now is about to be merged into and written // The record in storage right now is about to be merged into and written
@@ -579,9 +581,7 @@ function reportSaveFailure(err) {
// Each save runs start to finish holding the lock every write of the stored // Each save runs start to finish holding the lock every write of the stored
// record takes (withLock() in src/shared/browserApi.js), so no write by // record takes (withLock() in src/shared/browserApi.js), so no write by
// another page or the background lands between its read and its write and is // another page or the background lands between its read and its write and is
// lost (https://git.eeqj.de/sneak/AutistMask/issues/641). A page in a Firefox // lost (https://git.eeqj.de/sneak/AutistMask/issues/641).
// private window is the exception: Firefox gives it locks of its own (README,
// "Stored state and its version").
// //
// The same lock orders this page's own saves. showView() calls saveState() on // The same lock orders this page's own saves. showView() calls saveState() on
// every navigation without awaiting it, so two saves from one page can be // every navigation without awaiting it, so two saves from one page can be
@@ -603,7 +603,7 @@ function saveState(beforeWrite) {
} }
// Give this page the network switch held by `stored`, a record a save of this // 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 // page has just read (followStoredSettings() in src/popup/index.js calls this
// from `beforeWrite`), in `state` and in `baseline` alike, as loadState() does // 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 // 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 // that save fails to write: a later switch back to the previous network still
+27
View File
@@ -0,0 +1,27 @@
// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
// profile.
//
// Before each request that sends the user's data to Blockscout or Etherscan, the
// wallet reads that service's setting from storage. The wallet reads the setting
// again when the answer lands. While the setting is off, no such request is
// made, and an answer that lands after the setting went off is dropped. A page's
// own copy of the setting is not used, because another page may have just
// changed it.
const { storageGet } = require("./browserApi");
const { normalizePersisted } = require("./persistedState");
// Whether the setting `name` is on in the stored profile. `name` is
// "useBlockscout" or "checkRecipientsOnEtherscan". The profile is read through
// normalizePersisted(), as a load reads it, so only a stored true is on. A
// failed read counts as off.
async function storedSettingIsOn(name) {
try {
const { autistmask } = await storageGet("autistmask");
return normalizePersisted(autistmask)[name];
} catch {
return false;
}
}
module.exports = { storedSettingIsOn };
+6 -5
View File
@@ -21,6 +21,7 @@ const { toDecimals } = require("./transferAmount");
// detail view is the authoritative record and already shows exact precision. // detail view is the authoritative record and already shows exact precision.
const { truncateAmount: formatTxValue } = require("./amountDisplay"); const { truncateAmount: formatTxValue } = require("./amountDisplay");
const { routerRelease } = require("./uniswap"); const { routerRelease } = require("./uniswap");
const { storedSettingIsOn } = require("./storedSettings");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum // Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in // over the address, not part of its identity. Every address comparison in
@@ -236,17 +237,16 @@ function mergeTransactions(txs, tokenTransfers) {
// carries it, and a native entry is labelled with that network's // carries it, and a native entry is labelled with that network's
// `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`). // `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`).
// //
// `useBlockscout` is the user's "Use Blockscout" setting. While it is off, // While the stored "Use Blockscout" is off, Blockscout is not asked. An answer
// Blockscout is not asked and there is no history: the answer is an empty // that lands after the setting went off is dropped
// list. // (src/shared/storedSettings.js). Either way the answer is an empty list.
async function fetchRecentTransactions( async function fetchRecentTransactions(
address, address,
blockscoutUrl, blockscoutUrl,
useBlockscout,
chainId, chainId,
count = 25, count = 25,
) { ) {
if (!useBlockscout) return []; if (!(await storedSettingIsOn("useBlockscout"))) return [];
log.debugf("fetchRecentTransactions", address); log.debugf("fetchRecentTransactions", address);
const addrLower = normalizeAddress(address); const addrLower = normalizeAddress(address);
@@ -277,6 +277,7 @@ async function fetchRecentTransactions(
const txJson = txResp.ok ? await txResp.json() : {}; const txJson = txResp.ok ? await txResp.json() : {};
const ttJson = ttResp.ok ? await ttResp.json() : {}; const ttJson = ttResp.ok ? await ttResp.json() : {};
if (!(await storedSettingIsOn("useBlockscout"))) return [];
const txs = mergeTransactions( const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)), (txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)),
+382
View File
@@ -0,0 +1,382 @@
// After "Add" on AddToken, AddressDetail lists the new token. Its line reads
// `quantity unknown`, not 0.0000, until a balance refresh has read the token's
// balance at every address, even if the popup closes and opens again
// meanwhile. A token added on SettingsAddToken reads the same wherever it is
// listed. A refresh that returns draws the balances on AddressDetail or
// AddressToken again, so the fetched balance shows without the screen being
// reopened (https://git.eeqj.de/sneak/AutistMask/issues/516).
//
// The tracked token's record carries `balanceNotFetched` until a refresh has
// read the token's balance. Removing the token removes that record. A network
// switch keeps each network's records apart.
//
// Driven through the real popup over a stored profile on Ethereum Mainnet,
// with "Show tracked tokens with zero balance" on, as it is by default.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
// Tracked by nobody until a test adds it.
const NEW_TOKEN = "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
// A holding of a million NEW_TOKEN, as refreshBalances() stores it.
const MILLION = {
address: NEW_TOKEN,
name: "New Token",
symbol: "NEW",
decimals: 18,
balance: "1000000.0",
holders: 3,
};
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressDetail for the one address.
function bootOnAddress(extra) {
return bootPopup(
unversionedValidProfile({
currentView: "address",
selectedWallet: 0,
selectedAddress: 0,
...extra,
}),
);
}
// Holds the next balance refresh open. The returned function lets it finish
// with the address's ETH balance at 2 and `tokenBalances` as its token
// entries, having read the balance of every token tracked when it started.
// Given null, it finishes having read no token's balance, as when every
// balanceOf fails, and leaves the token entries as they were.
function holdNextRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let release;
refreshBalances.mockImplementationOnce(
async (wallets, rpcUrl, explorerUrl, trackedTokens) => {
const read = trackedTokens.map((t) => t.address.toLowerCase());
const tokenBalances = await new Promise((resolve) => {
release = resolve;
});
wallets[0].addresses[0].balance = "2.0";
if (tokenBalances === null) return [];
wallets[0].addresses[0].tokenBalances = tokenBalances;
return read;
},
);
return async (tokenBalances) => {
release(tokenBalances);
await page.settle();
};
}
// Runs the ten-second refresh to the end, finishing it as holdNextRefresh()
// does with `tokenBalances`.
async function refreshOnce(page, tokenBalances) {
const finish = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await finish(tokenBalances);
await ticking;
}
// "+ Token", NEW_TOKEN typed in, and "Add", with the contract lookup
// answering at once.
async function addNewToken(page) {
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
await page.click("btn-add-token");
page.node("add-token-address").value = NEW_TOKEN;
await page.click("btn-add-token-confirm");
}
// Settings' "+ Add token", NEW_TOKEN typed in, and "Add", with the contract
// lookup answering at once. It ends on SettingsAddToken.
async function addOnSettingsAddToken(page) {
const { lookupTokenInfo } = require("../src/shared/balances");
lookupTokenInfo.mockResolvedValueOnce({
symbol: "NEW",
decimals: 18,
name: "New Token",
});
await page.click("btn-settings-add-token");
page.node("settings-addtoken-address").value = NEW_TOKEN;
await page.click("btn-settings-addtoken-manual");
}
// The "[x]" button Settings draws beside the first tracked token. The page
// stub does not turn the markup Settings draws into elements. So the list
// hands Settings a stand-in for that button each time it is drawn, and
// `press()` presses the last one drawn. Call it before Settings opens.
function firstRemoveButton(page) {
const button = { press: null };
page.node("settings-tracked-tokens").querySelectorAll = () => [
{
dataset: { idx: "0" },
addEventListener: (name, fn) => {
button.press = fn;
},
},
];
return button;
}
// Picks `networkId` in the Settings network selector, as the user does.
async function chooseNetwork(page, networkId) {
const select = page.node("settings-network");
select.value = networkId;
for (const fn of select.listeners.change) await fn();
await page.settle();
}
// NEW_TOKEN's row in the balance list in element `id`, AddressDetail's unless
// another is named, as markup.
function newTokenRow(page, id = "address-balances") {
return page
.node(id)
.innerHTML.split("<div ")
.find((row) =>
row.includes('data-token="' + NEW_TOKEN.toLowerCase() + '"'),
);
}
// AddressDetail for the one address opened from Home, as its `[info]` button
// does.
async function openAddress(page) {
const { state } = require("../src/shared/state");
state.selectedWallet = 0;
state.selectedAddress = 0;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressDetail").show();
await page.settle();
}
// AddressToken for `token` opened from AddressDetail, as tapping the token's
// row there does.
async function openToken(page, token) {
const { state } = require("../src/shared/state");
state.selectedToken = token;
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/addressToken").show();
await page.settle();
}
test("until the refresh Add started returns, the new token's line reads quantity unknown", async () => {
const page = await bootOnAddress();
expect(page.pageErrors).toEqual([]);
const finish = holdNextRefresh(page);
await addNewToken(page);
expect(page.visibleViews()).toEqual(["address"]);
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
await finish([MILLION]);
});
test("once that refresh returns, AddressDetail shows the fetched balance without being reopened", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
// Stands for the transactions on the screen, which only show() replaces.
page.node("tx-list").innerHTML = "transactions on the screen";
await finish([MILLION]);
expect(page.visibleViews()).toEqual(["address"]);
expect(newTokenRow(page)).toContain("1000000.0000");
// Only the balances are drawn again, not the transaction list.
expect(page.node("tx-list").innerHTML).toBe("transactions on the screen");
});
test("a refresh already running when the token is added does not count as reading its balance", async () => {
const page = await bootOnAddress();
const finishRunning = holdNextRefresh(page);
const ticking = page.tick();
await page.settle();
await addNewToken(page);
await finishRunning([]);
await ticking;
expect(newTokenRow(page)).toContain("quantity unknown");
// The next one reads the token's balance, which is zero.
await refreshOnce(page, []);
expect(newTokenRow(page)).toContain("0.0000");
});
test("closed and opened again before any refresh has read its balance, the new token's line still reads quantity unknown", async () => {
const closed = await bootOnAddress();
holdNextRefresh(closed);
await addNewToken(closed);
cleanupPopup();
// The refresh this boot starts reads no token's balance: the boot's stub
// of refreshBalances() reads none.
const page = await bootPopup(undefined, { storage: closed.storage });
expect(page.visibleViews()).toEqual(["address"]);
const row = newTokenRow(page);
expect(row).toContain("quantity unknown");
expect(row).not.toContain("0.0000");
await refreshOnce(page, [MILLION]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh that could not read the new token's balance leaves its line quantity unknown", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
await finish(null);
// Drawn again: the ETH balance it fetched is shown.
expect(page.node("address-balances").innerHTML).toContain("2.0000");
expect(newTokenRow(page)).toContain("quantity unknown");
});
test("AddressToken for the new token reads quantity unknown until the refresh returns, then the fetched balance", async () => {
const page = await bootOnAddress();
const finish = holdNextRefresh(page);
await addNewToken(page);
await openToken(page, NEW_TOKEN.toLowerCase());
expect(page.visibleViews()).toEqual(["address-token"]);
const before = page.node("address-token-balance").innerHTML;
expect(before).toContain("quantity unknown");
expect(before).not.toContain("0.0000");
await finish([MILLION]);
expect(page.node("address-token-balance").innerHTML).toContain(
"1000000.0000",
);
});
test("a token added by contract address on SettingsAddToken reads quantity unknown until the refresh returns", async () => {
const page = await bootPopup(unversionedValidProfile());
expect(page.visibleViews()).toEqual(["main"]);
const finish = holdNextRefresh(page);
await page.click("btn-settings");
await addOnSettingsAddToken(page);
await page.click("btn-settings-addtoken-back");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["main"]);
const onHome = newTokenRow(page, "wallet-list");
expect(onHome).toContain("quantity unknown");
expect(onHome).not.toContain("0.0000");
await openAddress(page);
const onAddress = newTokenRow(page);
expect(onAddress).toContain("quantity unknown");
expect(onAddress).not.toContain("0.0000");
await finish([MILLION]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
test("a refresh that returns while AddressToken is up draws its balance again", async () => {
const page = await bootPopup(
unversionedValidProfile({
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: "ETH",
}),
);
expect(page.visibleViews()).toEqual(["address-token"]);
expect(page.node("address-token-balance").innerHTML).toContain("1.5000");
await refreshOnce(page, null);
expect(page.node("address-token-balance").innerHTML).toContain("2.0000");
});
test("a token removed before any refresh has read its balance leaves nothing stored, and adding it again tracks it once", async () => {
const page = await bootPopup(unversionedValidProfile());
const removeButton = firstRemoveButton(page);
const stored = () => page.storage.read("autistmask");
await page.click("btn-settings");
await addOnSettingsAddToken(page);
// Settings opened again from Home draws its list with the token in it.
await page.click("btn-settings-addtoken-back");
await page.click("btn-settings-back");
await page.click("btn-settings");
await removeButton.press();
await page.settle();
expect(stored().trackedTokens).toEqual([]);
// Nothing in the stored record names the token any more.
expect(JSON.stringify(stored()).toLowerCase()).not.toContain(
NEW_TOKEN.toLowerCase(),
);
await addOnSettingsAddToken(page);
// A second add of the same token is refused.
page.node("settings-addtoken-address").value = NEW_TOKEN;
await page.click("btn-settings-addtoken-manual");
const added = { address: NEW_TOKEN, symbol: "NEW", decimals: 18 };
expect(stored().trackedTokens).toEqual([
{ ...added, name: "New Token", balanceNotFetched: true },
]);
await refreshOnce(page, []);
expect(stored().trackedTokens).toEqual([{ ...added, name: "New Token" }]);
});
test("a refresh on another network leaves the new token reading quantity unknown on its own", async () => {
// Sepolia tracks a token at the same contract address, also just added.
const sepoliaToken = {
address: NEW_TOKEN,
symbol: "SEP",
name: "Sepolia Token",
decimals: 18,
};
const page = await bootOnAddress({
networkTrackedTokens: {
sepolia: [{ ...sepoliaToken, balanceNotFetched: true }],
},
});
const finishMainnet = holdNextRefresh(page);
await addNewToken(page);
await page.click("btn-settings");
await chooseNetwork(page, "sepolia");
// The refresh Add started returns after the switch and writes nothing.
await finishMainnet([MILLION]);
// Sepolia's refresh reads the balance of its own token.
await refreshOnce(page, []);
const stored = page.storage.read("autistmask");
expect(stored.trackedTokens).toEqual([sepoliaToken]);
expect(stored.networkTrackedTokens.mainnet[0].balanceNotFetched).toBe(true);
await chooseNetwork(page, "mainnet");
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
expect(newTokenRow(page)).toContain("quantity unknown");
await refreshOnce(page, [MILLION]);
expect(newTokenRow(page)).toContain("1000000.0000");
});
+2
View File
@@ -41,6 +41,8 @@ jest.mock("../src/shared/balances", () => ({
refreshBalances: jest.fn(async () => { refreshBalances: jest.fn(async () => {
mockBalanceRefreshAt.push(Date.now()); mockBalanceRefreshAt.push(Date.now());
}), }),
dropUntrackedTokens: jest.requireActual("../src/shared/balances")
.dropUntrackedTokens,
getProvider: jest.fn(() => ({})), getProvider: jest.fn(() => ({})),
})); }));
+87
View File
@@ -0,0 +1,87 @@
// "Allow weak passwords" in Settings is off until the user ticks it. While it
// is off, AddWallet refuses a password shorter than 12 characters. While it is
// on, AddWallet accepts any password that is not empty
// (https://git.eeqj.de/sneak/AutistMask/issues/674). These boot the real popup,
// set the checkbox in Settings as the user does, and then add a wallet.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
encryptWithPassword: jest.fn(async () => ({
salt: "salt",
nonce: "nonce",
ciphertext: "ciphertext",
})),
}));
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const CHECKBOX = "settings-allow-weak-passwords";
const ERROR = "add-wallet-password-error";
afterEach(() => {
cleanupPopup();
});
// Boots a popup that holds one wallet and opens Settings, where the checkbox
// starts unticked. Ticks it when `allow` is true. Then opens AddWallet and
// presses the die for a recovery phrase.
async function openAddWallet(allow) {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const checkbox = page.node(CHECKBOX);
expect(checkbox.checked).toBe(false);
if (allow) {
checkbox.checked = true;
for (const fn of checkbox.listeners.change) await fn();
expect(page.storage.read("autistmask").allowWeakPasswords).toBe(true);
}
await page.click("btn-main-add-wallet");
await page.click("btn-generate-phrase");
return page;
}
// Types `password` into both password fields and presses Import.
async function importWith(page, password) {
page.node("add-wallet-password").value = password;
page.node("add-wallet-password-confirm").value = password;
await page.click("btn-add-wallet-confirm");
}
function storedWalletCount(page) {
return page.storage.read("autistmask").wallets.length;
}
describe("Allow weak passwords", () => {
test("unticked: a password shorter than 12 characters is refused", async () => {
const page = await openAddWallet(false);
await importWith(page, "short");
expect(page.text(ERROR)).toBe(
"Password must be at least 12 characters.",
);
expect(storedWalletCount(page)).toBe(1);
expect(page.visibleViews()).toEqual(["add-wallet"]);
});
test("ticked: a password shorter than 12 characters is accepted", async () => {
const page = await openAddWallet(true);
await importWith(page, "short");
expect(page.text(ERROR)).toBe("");
expect(storedWalletCount(page)).toBe(2);
expect(page.visibleViews()).toEqual(["main"]);
});
test.each([false, true])(
"an empty password is refused (ticked: %s)",
async (allow) => {
const page = await openAddWallet(allow);
await importWith(page, "");
expect(page.text(ERROR)).toBe("Please choose a password.");
expect(storedWalletCount(page)).toBe(1);
expect(page.visibleViews()).toEqual(["add-wallet"]);
},
);
});
+3
View File
@@ -84,6 +84,9 @@ function walletsHolding(token, decimals) {
beforeEach(() => { beforeEach(() => {
state.trackedTokens = []; state.trackedTokens = [];
state.wallets = []; state.wallets = [];
// The approval screen reads the explorer's report only while "Use
// Blockscout" is on.
state.useBlockscout = true;
}); });
describe("resolveTokenDecimals", () => { describe("resolveTokenDecimals", () => {
+308
View File
@@ -0,0 +1,308 @@
// While "Use Blockscout" is off, the approval screens, and the screens that
// follow TxApproval, take a token's name and scale from the bundled list and
// the tracked tokens alone (https://git.eeqj.de/sneak/AutistMask/issues/668).
// A token missing from both gets a line under it. The line says Blockscout's
// report of the user's balances is not used to name the token. While the
// setting is on, Blockscout's report of the user's holding is a source too, as
// before.
//
// Booted through the real popup entry point, as the approval window or as a
// popup reopened onto SuccessTx, WaitTx or ErrorTx. The vault is mocked so that
// no password has to be hashed: the stored secret decrypts to the test key
// below.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
encryptWithPassword: jest.fn(),
}));
const { Interface, Wallet } = require("ethers");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
// A published test key, never used for real funds.
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const ORIGIN = "https://dapp.example";
const RECIPIENT = "0x2222222222222222222222222222222222222222";
const TX_HASH = "0x" + "ab".repeat(32);
// Outside the bundled list, and not tracked.
const TOKEN = "0xE2E0000000000000000000000000000000000E2e";
// In the bundled list, at 6 decimals.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const LINE =
'Blockscout\'s report of your balances is not used to name this token, because "Use Blockscout" is off in Settings.';
// TOKEN as Blockscout reports the user's holding of it.
const TOKEN_ROW = {
address: TOKEN,
name: "Novel",
symbol: "NOVEL",
decimals: "6",
balance: "5000.0",
holders: 5000,
};
// A transfer of 5,000 of a 6-decimal token to RECIPIENT.
const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 5000000000n]);
// An EIP-2612 permit letting RECIPIENT take 5,000 of TOKEN.
const PERMIT = {
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "chainId", type: "uint256" },
{ name: "verifyingContract", type: "address" },
],
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
primaryType: "Permit",
domain: { name: "Novel", chainId: 1, verifyingContract: TOKEN },
message: {
owner: signer.address,
spender: RECIPIENT,
value: "5000000000",
nonce: "0",
deadline: "1790000000",
},
};
// The stored profile: the user's one address holds `tokenBalances`.
function profile(useBlockscout, tokenBalances, extra) {
return unversionedValidProfile({
useBlockscout,
wallets: [
{
type: "key",
name: "Wallet 1",
encryptedSecret: "encrypted-key",
addresses: [
{
address: signer.address,
balance: "1.0",
tokenBalances,
},
],
},
],
activeAddress: signer.address,
allowedSites: { [signer.address]: [ORIGIN] },
...extra,
});
}
// The approval window for the site's request. The request is a transfer sent to
// `token`, or a signature request when `signParams` is given. The background
// answers the signed transaction with `txResponse`.
function bootApproval(stored, { token = TOKEN, signParams, txResponse } = {}) {
return bootPopup(stored, {
search: "?approval=approval-1",
runtime: {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
reply({
type: signParams ? "sign" : "tx",
origin: ORIGIN,
isPhishingDomain: false,
approvedFrom: signer.address,
signParams,
approvedTx: {
type: 2,
from: signer.address,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: token,
value: "0x0",
data: TRANSFER_DATA,
accessList: [],
},
});
} else if (msg.type === "AUTISTMASK_TX_RESPONSE") {
reply(txResponse);
} else {
reply(undefined);
}
},
},
});
}
beforeEach(() => {
// The page logs its failed RPC reads; the stub node in popupBoot.js
// answers none.
jest.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
cleanupPopup();
jest.restoreAllMocks();
});
describe("TxApproval", () => {
test("has the line under a token it cannot name while Use Blockscout is off", async () => {
const page = await bootApproval(profile(false, []));
const details = page.node("approve-tx-decoded-details").innerHTML;
expect(details).toContain("Unknown token");
expect(details).toContain(LINE);
expect(details).toContain("5000000000 base units (decimals unknown)");
cleanupPopup();
// The bundled list names USDC, so there is nothing to say.
const bundled = await bootApproval(profile(false, []), {
token: USDC,
});
expect(
bundled.node("approve-tx-decoded-details").innerHTML,
).not.toContain(LINE);
cleanupPopup();
const on = await bootApproval(profile(true, [TOKEN_ROW]));
const named = on.node("approve-tx-decoded-details").innerHTML;
expect(named).toContain("5000.0000 NOVEL");
expect(named).not.toContain(LINE);
});
test("names nothing from a Blockscout entry still in the balances while Use Blockscout is off", async () => {
const page = await bootApproval(profile(false, [TOKEN_ROW]));
const details = page.node("approve-tx-decoded-details").innerHTML;
expect(details).not.toContain("NOVEL");
expect(details).toContain("Unknown token");
expect(details).toContain(LINE);
});
test.each([
["WaitTx", { txHash: TX_HASH }, "wait-tx"],
[
"ErrorTx",
{ error: "The transaction could not be sent.", retryable: false },
"error-tx",
],
])(
"%s after it says so under the amount while Use Blockscout is off",
async (_name, txResponse, view) => {
const page = await bootApproval(profile(false, []), {
txResponse,
});
const { decryptWithPassword } = require("../src/shared/vault");
decryptWithPassword.mockResolvedValue(SIGNER_KEY);
page.node("approve-tx-password").value = "any password";
await page.click("btn-approve-tx");
expect(page.visibleViews()).toEqual([view]);
expect(page.node(view + "-token-note").innerHTML).toBe(
`<div class="text-xs text-muted">${LINE}</div>`,
);
},
);
});
test("SuccessTx says so under the decoded token while Use Blockscout is off", async () => {
const page = await bootPopup(
profile(false, [], {
currentView: "success-tx",
viewStack: ["main"],
viewData: {
amount: "5000000000 base units (decimals unknown)",
symbol: "?",
to: RECIPIENT,
hash: TX_HASH,
blockNumber: 21000000,
chainId: "0x1",
decoded: {
name: "Token Transfer",
description: "Transfer ERC-20 token",
details: [
{
label: "Token",
value: TOKEN,
address: TOKEN,
isToken: true,
},
],
},
},
}),
);
expect(page.visibleViews()).toEqual(["success-tx"]);
const decoded = page.node("success-tx-decoded").innerHTML;
expect(decoded).toContain("Unknown token");
expect(decoded).toContain(LINE);
});
test.each([
[
"wait-tx",
{
pendingWait: {
hash: TX_HASH,
txInfo: {
to: RECIPIENT,
amount: "1",
token: 42,
chainId: "0x1",
},
broadcastTime: Date.now(),
},
},
],
[
"error-tx",
{
amount: "1",
symbol: "?",
token: 42,
to: RECIPIENT,
hash: TX_HASH,
message: "The transaction failed.",
chainId: "0x1",
},
],
])(
"a popup reopened onto %s shows no line for a stored token that is not text",
async (view, viewData) => {
const page = await bootPopup(
profile(false, [], {
currentView: view,
viewStack: ["main"],
viewData,
}),
);
expect(page.pageErrors).toEqual([]);
expect(page.visibleViews()).toEqual([view]);
expect(page.node(view + "-token-note").innerHTML).toBe("");
},
);
test("SignApproval says so under a permit's token while Use Blockscout is off", async () => {
const signParams = {
method: "eth_signTypedData_v4",
typedData: JSON.stringify(PERMIT),
from: signer.address,
};
const page = await bootApproval(profile(false, []), { signParams });
const message = page.node("approve-sign-message").innerHTML;
expect(message).toContain("Unknown token");
expect(message).toContain(LINE);
cleanupPopup();
const on = await bootApproval(profile(true, [TOKEN_ROW]), { signParams });
const named = on.node("approve-sign-message").innerHTML;
expect(named).toContain("5000.0000 NOVEL");
expect(named).not.toContain(LINE);
});
+54 -8
View File
@@ -142,6 +142,8 @@ function loadWorker(networkId, opts) {
}, },
refreshBalances: refreshBalances:
options.refreshBalances || jest.fn(async () => undefined), options.refreshBalances || jest.fn(async () => undefined),
dropUntrackedTokens: jest.requireActual("../src/shared/balances")
.dropUntrackedTokens,
})); }));
jest.doMock("../src/shared/phishingDomains", () => ({ jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false, isPhishingDomain: () => false,
@@ -410,14 +412,7 @@ describe("a balance refresh under a network switch", () => {
const ranOn = []; const ranOn = [];
const bg = loadWorker("sepolia", { const bg = loadWorker("sepolia", {
refreshBalances: async ( refreshBalances: async (wallets, rpcUrl, explorer, tokens, id) => {
wallets,
rpcUrl,
explorer,
useBlockscout,
tokens,
id,
) => {
ranOn.push(id); ranOn.push(id);
reachedNetwork.resolve(); reachedNetwork.resolve();
await roundTrip.promise; await roundTrip.promise;
@@ -450,3 +445,54 @@ describe("a balance refresh under a network switch", () => {
); );
}); });
}); });
describe("a balance refresh under a change of Use Blockscout", () => {
// Turning "Use Blockscout" off drops the tokens the user has not tracked
// (src/popup/views/settings.js). Blockscout may have answered a refresh
// just before. That refresh still saves its balances when it returns, but
// not those tokens. The same refresh in the popup is in
// tests/blockscoutEtherscanSettings.test.js.
test("a refresh that returns after Use Blockscout was turned off saves its balances without the untracked token", async () => {
const roundTrip = deferred();
const started = deferred();
const bg = loadWorker("mainnet", {
refreshBalances: async (wallets) => {
started.resolve();
await roundTrip.promise;
wallets[0].addresses[0].balance = "7.0";
wallets[0].addresses[0].tokenBalances = [
{
address: "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
name: "Test Token",
symbol: "TST",
decimals: 18,
balance: "5.0",
holders: 50000,
},
];
},
});
global.chrome.storage.write("autistmask", {
...bg.persisted(),
useBlockscout: true,
});
const refresh = bg.fireBalanceAlarm();
await started.promise;
// The user unticks "Use Blockscout" in Settings while the round trip
// is out.
global.chrome.storage.write("autistmask", {
...bg.persisted(),
useBlockscout: false,
});
roundTrip.resolve();
await refresh;
const after = bg.persisted();
expect(after.wallets[0].addresses[0].balance).toBe("7.0");
expect(after.wallets[0].addresses[0].tokenBalances).toEqual([]);
});
});
+6 -1
View File
@@ -24,6 +24,12 @@ const TRACKED = {
decimals: 6, decimals: 6,
}; };
// Blockscout is asked only while the stored "Use Blockscout" is on.
const { makeStorageStub } = require("./support/storageStub");
globalThis.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
};
const realFetch = globalThis.fetch; const realFetch = globalThis.fetch;
let logged; let logged;
@@ -54,7 +60,6 @@ function refresh(signal) {
wallets, wallets,
RPC_URL, RPC_URL,
EXPLORER_URL, EXPLORER_URL,
true,
[TRACKED], [TRACKED],
"mainnet", "mainnet",
signal, signal,
+2 -1
View File
@@ -41,6 +41,7 @@ async function holdRefresh(page) {
holders: null, holders: null,
}, },
]; ];
return [];
}); });
const ticking = page.tick(); const ticking = page.tick();
await page.settle(); await page.settle();
@@ -91,7 +92,7 @@ test("a refresh that returns after a switch in Settings writes nothing, and the
await page.tick(); await page.tick();
expect(refreshBalances.mock.lastCall[5]).toBe("sepolia"); expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
expect(page.storage.read("autistmask").lastBalanceRefresh).toBeGreaterThan( expect(page.storage.read("autistmask").lastBalanceRefresh).toBeGreaterThan(
0, 0,
); );
+508 -128
View File
@@ -1,17 +1,26 @@
// Blockscout and Etherscan are sent none of the user's data until the user // 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 // turns on a setting for each
// profile has "Use Blockscout" and "Check recipients on Etherscan" both off. // (https://git.eeqj.de/sneak/AutistMask/issues/668). A new profile has "Use
// Each request to either service is skipped where it is made while its setting // Blockscout" and "Check recipients on Etherscan" both off. While a setting is
// is off, and made while it is on; the popup and the background hand the // off, no request sends its service the user's data.
// 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 // One request still reaches Blockscout while "Use Blockscout" is off: the check
// Blockscout URL. It asks only for `/stats` and carries no user data; it is // behind "Save" beside the Blockscout URL in Settings. That check asks the URL
// made whether or not the setting is on. // only for `/stats` and sends none of the user's data.
//
// Before any other request to Blockscout or Etherscan, the wallet reads that
// service's setting from storage, not the page's copy. The wallet reads the
// setting again when the answer lands. So a change saved by another page
// applies at once. An open page also takes such a change and draws its screens
// again.
//
// Only Blockscout reports the tokens the user has not tracked. While "Use
// Blockscout" is off, no address keeps them and no screen lists them.
// //
// The functions that make the requests are driven directly, against a fetch() // 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 // 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() // real popup over a stored profile (tests/support/popupBoot.js). The fetch()
// stub records the same way. // stub in popupBoot.js records the same way.
"use strict"; "use strict";
@@ -21,6 +30,7 @@ const {
cleanupPopup, cleanupPopup,
unversionedValidProfile, unversionedValidProfile,
ADDRESS, ADDRESS,
TOKEN_ADDRESS,
} = require("./support/popupBoot"); } = require("./support/popupBoot");
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
const { const {
@@ -33,6 +43,7 @@ const {
} = require("../src/shared/balances"); } = require("../src/shared/balances");
const { fetchRecentTransactions } = require("../src/shared/transactions"); const { fetchRecentTransactions } = require("../src/shared/transactions");
const { getFullWarnings } = require("../src/shared/addressWarnings"); const { getFullWarnings } = require("../src/shared/addressWarnings");
const { checkEtherscanLabel } = require("../src/shared/etherscanLabels");
const { HISTORY_OFF_TEXT, escapeHtml } = require("../src/popup/views/helpers"); const { HISTORY_OFF_TEXT, escapeHtml } = require("../src/popup/views/helpers");
const RPC_URL = "https://rpc.example.invalid"; const RPC_URL = "https://rpc.example.invalid";
@@ -54,6 +65,25 @@ const USDC_ROW = {
}, },
}; };
// A transfer of 1 ETH from RECIPIENT to ADDRESS, as Blockscout lists it in
// ADDRESS's history.
const HISTORY_ITEM = {
hash: TX_HASH,
block_number: 21000000,
timestamp: "2026-02-27T12:00:00.000Z",
from: { hash: RECIPIENT },
to: { hash: ADDRESS, is_contract: false },
value: "1000000000000000000",
method: null,
status: "ok",
};
// Etherscan's page for RECIPIENT, which labels it a phishing address.
const PHISHING_PAGE =
"<html><head><title>Fake_Phishing1 | Address: " +
RECIPIENT +
" | Etherscan</title></head><body></body></html>";
const realFetch = globalThis.fetch; const realFetch = globalThis.fetch;
afterEach(() => { afterEach(() => {
@@ -81,36 +111,58 @@ describe("a new profile", () => {
}); });
describe("where each request is made", () => { describe("where each request is made", () => {
// Every URL fetch() was asked for, in order. // Every URL that fetch() was asked for, in order.
let asked; let asked;
// What Blockscout and Etherscan answer: USDC among ADDRESS's tokens,
// HISTORY_ITEM in its history, and PHISHING_PAGE for RECIPIENT.
function answer(url) {
return {
ok: true,
status: 200,
json: async () => {
if (url.endsWith("/token-balances")) return [USDC_ROW];
if (url.endsWith("/transactions")) {
return { items: [HISTORY_ITEM] };
}
return { items: [] };
},
text: async () => PHISHING_PAGE,
};
}
async function recordAndAnswer(url) {
asked.push(String(url));
return answer(String(url));
}
beforeEach(() => { beforeEach(() => {
asked = []; asked = [];
globalThis.fetch = jest.fn(async (url) => { globalThis.fetch = jest.fn(recordAndAnswer);
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(() => { afterEach(() => {
globalThis.fetch = realFetch; globalThis.fetch = realFetch;
delete globalThis.chrome;
}); });
// Storage holds `profile` as the stored profile, or nothing when it is
// undefined.
function storing(profile) {
globalThis.chrome = {
storage: makeStorageStub(
profile === undefined ? {} : { autistmask: profile },
),
};
}
test("Blockscout's token balances are asked for only while Use Blockscout is on", async () => { test("Blockscout's token balances are asked for only while Use Blockscout is on", async () => {
expect( storing({ useBlockscout: false });
await fetchTokenBalances(ADDRESS, BLOCKSCOUT, false, []), expect(await fetchTokenBalances(ADDRESS, BLOCKSCOUT, [])).toEqual([]);
).toEqual([]);
expect(asked).toEqual([]); expect(asked).toEqual([]);
const rows = await fetchTokenBalances(ADDRESS, BLOCKSCOUT, true, []); storing({ useBlockscout: true });
const rows = await fetchTokenBalances(ADDRESS, BLOCKSCOUT, []);
expect(rows.map((row) => row.symbol)).toEqual(["USDC"]); expect(rows.map((row) => row.symbol)).toEqual(["USDC"]);
expect(asked).toEqual([ expect(asked).toEqual([
BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-balances", BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-balances",
@@ -118,12 +170,14 @@ describe("where each request is made", () => {
}); });
test("the transaction history is asked for only while Use Blockscout is on", async () => { test("the transaction history is asked for only while Use Blockscout is on", async () => {
storing({ useBlockscout: false });
expect( expect(
await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, false, "0x1"), await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, "0x1"),
).toEqual([]); ).toEqual([]);
expect(asked).toEqual([]); expect(asked).toEqual([]);
await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, true, "0x1"); storing({ useBlockscout: true });
await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, "0x1");
expect(asked.sort()).toEqual([ expect(asked.sort()).toEqual([
BLOCKSCOUT + BLOCKSCOUT +
"/addresses/" + "/addresses/" +
@@ -133,29 +187,91 @@ describe("where each request is made", () => {
]); ]);
}); });
test("the Etherscan label check is made only while Check recipients on Etherscan is on", async () => { test("the Etherscan label check is made only while the stored profile has Check recipients on Etherscan on", async () => {
const provider = { const provider = {
getCode: async () => "0x", getCode: async () => "0x",
getTransactionCount: async () => 1, getTransactionCount: async () => 1,
getBalance: async () => 0n, getBalance: async () => 0n,
}; };
await getFullWarnings(RECIPIENT, provider, { chainId: "0x1" }); const checkWith = async (profile) => {
await getFullWarnings(RECIPIENT, provider, { storing(profile);
chainId: "0x1", await getFullWarnings(RECIPIENT, provider, { chainId: "0x1" });
checkRecipientsOnEtherscan: false, };
});
await checkWith(undefined);
await checkWith({ checkRecipientsOnEtherscan: false });
// Only a stored true is on, as when the profile is loaded.
await checkWith({ checkRecipientsOnEtherscan: "true" });
await checkWith({ checkRecipientsOnEtherscan: 42 });
expect(asked).toEqual([]); expect(asked).toEqual([]);
await getFullWarnings(RECIPIENT, provider, { await checkWith({ checkRecipientsOnEtherscan: true });
chainId: "0x1",
checkRecipientsOnEtherscan: true,
});
expect(asked).toEqual(["https://etherscan.io/address/" + RECIPIENT]); expect(asked).toEqual(["https://etherscan.io/address/" + RECIPIENT]);
}); });
// Asks with `setting` on in storage. Once every request is out, storage
// holds `after` for the setting, as when the user changes it here or on
// another page. Only then does each request get its answer.
async function answeredAfter(setting, after, ask) {
storing({ [setting]: true });
let release;
const held = new Promise((resolve) => {
release = resolve;
});
let requested;
const out = new Promise((resolve) => {
requested = resolve;
});
globalThis.fetch = jest.fn(async (url) => {
requested();
await held;
return recordAndAnswer(url);
});
const result = ask();
await out;
globalThis.chrome.storage.write("autistmask", { [setting]: after });
release();
return result;
}
// One row per request, giving:
//
// - its name
// - the setting that gates it
// - how to make it
// - its answer while that setting is off
test.each([
[
"Blockscout's token balances",
"useBlockscout",
() => fetchTokenBalances(ADDRESS, BLOCKSCOUT, []),
[],
],
[
"the transaction history",
"useBlockscout",
() => fetchRecentTransactions(ADDRESS, BLOCKSCOUT, "0x1"),
[],
],
[
"the Etherscan label check",
"checkRecipientsOnEtherscan",
() => checkEtherscanLabel(RECIPIENT, "0x1"),
null,
],
])(
"%s keeps an answer that lands while its setting is on, and drops one that lands after it went off",
async (_what, setting, ask, dropped) => {
expect(await answeredAfter(setting, true, ask)).not.toEqual(
dropped,
);
expect(await answeredAfter(setting, false, ask)).toEqual(dropped);
},
);
describe("a balance refresh", () => { describe("a balance refresh", () => {
// The RPC endpoint answers every balance with zero, and every other // The RPC endpoint answers every balance with zero. The ENS reverse
// call, the ENS reverse lookup's, with no resolver. // lookup makes the only other calls, and it finds no resolver.
beforeEach(() => { beforeEach(() => {
FetchRequest.registerGetUrl(async (req) => { FetchRequest.registerGetUrl(async (req) => {
const body = JSON.parse(Buffer.from(req.body).toString("utf8")); const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
@@ -180,8 +296,8 @@ describe("where each request is made", () => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc()); FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
}); });
// An address that was refreshed while Use Blockscout was on, and holds // An address refreshed while "Use Blockscout" was on. It holds the USDC
// the USDC Blockscout reported then. // that Blockscout reported then.
function addressWithUsdc() { function addressWithUsdc() {
return { return {
address: ADDRESS, address: ADDRESS,
@@ -199,11 +315,11 @@ describe("where each request is made", () => {
} }
async function refresh(addr, useBlockscout) { async function refresh(addr, useBlockscout) {
storing({ useBlockscout });
await refreshBalances( await refreshBalances(
[{ addresses: [addr] }], [{ addresses: [addr] }],
RPC_URL, RPC_URL,
BLOCKSCOUT, BLOCKSCOUT,
useBlockscout,
[], [],
"mainnet", "mainnet",
); );
@@ -245,29 +361,6 @@ describe("the popup", () => {
jest.spyOn(console, "error").mockImplementation(() => {}); 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 () => { test("Home says why there is no history and only tracked tokens while Use Blockscout is off", async () => {
const page = await bootPopup(unversionedValidProfile()); const page = await bootPopup(unversionedValidProfile());
expect(page.node("home-tx-list").innerHTML).toContain( expect(page.node("home-tx-list").innerHTML).toContain(
@@ -328,6 +421,51 @@ describe("the popup", () => {
expect(on.hidden("tx-detail-blockscout-note")).toBe(true); expect(on.hidden("tx-detail-blockscout-note")).toBe(true);
}); });
// Another page turns "Use Blockscout" off while TransactionDetail's request
// to Blockscout is out. This page draws TransactionDetail again with its
// line, and only then does Blockscout answer.
test("TransactionDetail shows nothing from an answer that lands after another page turned Use Blockscout off", async () => {
const page = await bootPopup(
onTransactionScreen({ useBlockscout: true }),
);
let release;
const held = new Promise((resolve) => {
release = resolve;
});
globalThis.fetch = jest.fn(async () => {
await held;
return {
ok: true,
status: 200,
json: async () => ({
block_number: 21000000,
fee: { value: "21000000000000" },
raw_input: "0x12345678",
method: "doSomething",
}),
};
});
require("../src/popup/views/transactionDetail").render();
await page.settle();
expect(fetched()).toEqual([BLOCKSCOUT + "/transactions/" + TX_HASH]);
await anotherPageAnnounces(page, { useBlockscout: false });
release();
await page.settle();
expect(page.visibleViews()).toEqual(["transaction"]);
expect(page.hidden("tx-detail-blockscout-note")).toBe(false);
for (const id of [
"tx-detail-block-section",
"tx-detail-network-section",
"tx-detail-calldata-section",
"tx-detail-rawdata-section",
]) {
expect(page.hidden(id)).toBe(true);
}
expect(fetched()).toHaveLength(1);
});
// The confirmation screen for a send of 0.1 ETH to RECIPIENT, as a // The confirmation screen for a send of 0.1 ETH to RECIPIENT, as a
// reopened popup draws it. // reopened popup draws it.
function onConfirmScreen(extra) { function onConfirmScreen(extra) {
@@ -367,10 +505,10 @@ describe("the popup", () => {
expect(on.hidden("confirm-etherscan-warning")).toBe(false); expect(on.hidden("confirm-etherscan-warning")).toBe(false);
}); });
// Ticks a checkbox on the page, as the user does. // Ticks or unticks a checkbox on the page, as the user does.
async function tick(page, id) { async function setBox(page, id, checked) {
const box = page.node(id); const box = page.node(id);
box.checked = true; box.checked = checked;
for (const fn of box.listeners.change) await fn(); for (const fn of box.listeners.change) await fn();
await page.settle(); await page.settle();
} }
@@ -383,9 +521,9 @@ describe("the popup", () => {
false, false,
); );
await tick(page, "welcome-use-blockscout"); await setBox(page, "welcome-use-blockscout", true);
expect(page.storage.read("autistmask").useBlockscout).toBe(true); expect(page.storage.read("autistmask").useBlockscout).toBe(true);
await tick(page, "welcome-check-recipients-on-etherscan"); await setBox(page, "welcome-check-recipients-on-etherscan", true);
expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe( expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe(
true, true,
); );
@@ -399,72 +537,314 @@ describe("the popup", () => {
page.node("settings-check-recipients-on-etherscan").checked, page.node("settings-check-recipients-on-etherscan").checked,
).toBe(false); ).toBe(false);
await tick(page, "settings-use-blockscout"); await setBox(page, "settings-use-blockscout", true);
expect(page.storage.read("autistmask").useBlockscout).toBe(true); expect(page.storage.read("autistmask").useBlockscout).toBe(true);
await tick(page, "settings-check-recipients-on-etherscan"); await setBox(page, "settings-check-recipients-on-etherscan", true);
expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe( expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe(
true, true,
); );
}); });
});
describe("the background", () => { // A token the user tracks, and an untracked one that Blockscout reported.
afterEach(() => { // Both are written in lower case, because the balance rows' and the
delete globalThis.chrome; // dropdown's token ids are compared in lower case below.
jest.dontMock("../src/shared/balances"); const TRACKED = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
}); const UNTRACKED = TOKEN_ADDRESS.toLowerCase();
const SECOND_ADDRESS = "0x3333333333333333333333333333333333333333";
// Starts the background over `profile`, fires its balance alarm, and function tokenRow(address, symbol) {
// answers with the Use Blockscout setting its refresh was handed. return {
async function settingTheRefreshIsHanded(profile) { address,
jest.resetModules(); name: symbol,
let handed; symbol,
const called = new Promise((resolve) => { decimals: 18,
handed = resolve; balance: "5.0",
}); holders: 50000,
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 () => { // AddressDetail of the first of two addresses, as a reopened popup draws
const profile = { ...unversionedValidProfile(), lastBalanceRefresh: 0 }; // it. Both addresses hold the tracked token. While "Use Blockscout" is on,
expect(await settingTheRefreshIsHanded(profile)).toBe(false); // both hold the untracked one too.
function onAddressDetail(useBlockscout) {
const profile = unversionedValidProfile({
useBlockscout,
currentView: "address",
viewStack: ["main"],
selectedWallet: 0,
selectedAddress: 0,
trackedTokens: [
{ address: TRACKED, name: "TRK", symbol: "TRK", decimals: 18 },
],
});
const wallet = profile.wallets[0];
wallet.nextIndex = 2;
wallet.addresses.push({ address: SECOND_ADDRESS, balance: "0.0" });
for (const addr of wallet.addresses) {
addr.tokenBalances = useBlockscout
? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")]
: [tokenRow(TRACKED, "TRK")];
}
return profile;
}
// The token ids of the options created after the first `from` elements.
// The page stub keeps no children, so this reads the options for Send's
// dropdown from the elements the page created.
function optionsSince(page, from) {
return page.document.created
.slice(from)
.filter((el) => el.tagName === "OPTION")
.map((el) => el.value.toLowerCase());
}
test("unticking Use Blockscout from AddressDetail's gear takes the untracked token off AddressDetail, Send's dropdown and Home on Back", async () => {
const page = await bootPopup(onAddressDetail(true));
expect(page.visibleViews()).toEqual(["address"]);
expect(page.node("address-balances").innerHTML).toContain(UNTRACKED);
await page.click("btn-settings");
await setBox(page, "settings-use-blockscout", false);
const from = page.document.created.length;
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
const balances = page.node("address-balances").innerHTML;
expect(balances).toContain(TRACKED);
expect(balances).not.toContain(UNTRACKED);
expect(page.hidden("address-tokens-note")).toBe(false);
expect(optionsSince(page, from)).toEqual([TRACKED]);
await page.click("btn-send");
expect(page.visibleViews()).toEqual(["send"]);
expect(page.hidden("send-token-dropdown")).toBe(false);
expect(page.hidden("send-tokens-note")).toBe(false);
await page.click("btn-send-back");
await page.click("btn-address-back");
expect(page.visibleViews()).toEqual(["main"]);
const home = page.node("wallet-list").innerHTML;
expect(home).toContain(TRACKED);
expect(home).not.toContain(UNTRACKED);
const stored = page.storage.read("autistmask");
for (const addr of stored.wallets[0].addresses) {
expect(
addr.tokenBalances.map((row) => row.address.toLowerCase()),
).toEqual([TRACKED]);
}
});
// Send's dropdown was filled under the old setting, and Back cannot draw
// Send again. So Back lands on the screen under it.
test.each([
["unticking", true],
["ticking", false],
])(
"%s Use Blockscout from Send's gear lands Back on AddressDetail",
async (_how, before) => {
const page = await bootPopup(onAddressDetail(before));
await page.click("btn-send");
expect(page.visibleViews()).toEqual(["send"]);
await page.click("btn-settings");
await setBox(page, "settings-use-blockscout", !before);
await page.click("btn-settings-back");
expect(page.visibleViews()).toEqual(["address"]);
expect(page.hidden("address-tokens-note")).toBe(!before);
},
);
test("Send says under its token dropdown that only tracked tokens are listed while Use Blockscout is off", async () => {
const off = await bootPopup(onAddressDetail(false));
await off.click("btn-send");
expect(off.hidden("send-token-dropdown")).toBe(false);
expect(off.hidden("send-tokens-note")).toBe(false);
cleanupPopup();
const on = await bootPopup(onAddressDetail(true));
await on.click("btn-send");
expect(on.hidden("send-token-dropdown")).toBe(false);
expect(on.hidden("send-tokens-note")).toBe(true);
});
test("Send opened from AddressToken shows neither the dropdown nor its line", async () => {
const page = await bootPopup({
...onAddressDetail(false),
currentView: "address-token",
viewStack: ["main", "address"],
selectedToken: "ETH",
});
expect(page.visibleViews()).toEqual(["address-token"]);
await page.click("btn-address-token-send");
expect(page.visibleViews()).toEqual(["send"]);
expect(page.hidden("send-token-dropdown")).toBe(true);
expect(page.hidden("send-token-static")).toBe(false);
});
test("DeleteAddress says only tracked tokens are listed while Use Blockscout is off", async () => {
for (const useBlockscout of [false, true]) {
const page = await bootPopup(onAddressDetail(useBlockscout));
require("../src/popup/views/deleteAddress").show(0, 1);
expect(page.visibleViews()).toEqual(["delete-address-confirm"]);
expect(page.hidden("delete-address-tokens-note")).toBe(
useBlockscout,
);
cleanupPopup();
}
});
// The token ids of the first address's balances in the stored profile.
function storedTokens(page) {
return page.storage
.read("autistmask")
.wallets[0].addresses[0].tokenBalances.map((row) =>
row.address.toLowerCase(),
);
}
// Turning the setting off dropped the untracked token. A refresh that
// started while the setting was on still saves its balances when it
// returns, but not that token. The same refresh in the background is in
// tests/backgroundStateIsolation.test.js.
test("a refresh that returns after Use Blockscout was turned off saves its balances without the untracked token", async () => {
const page = await bootPopup(onAddressDetail(true));
const { refreshBalances: held } = require("../src/shared/balances");
let answer;
held.mockImplementationOnce(async (wallets) => {
await new Promise((resolve) => {
answer = resolve;
});
wallets[0].addresses[0].balance = "7.0";
wallets[0].addresses[0].tokenBalances = [
tokenRow(TRACKED, "TRK"),
tokenRow(UNTRACKED, "TST"),
];
return [];
});
const ticking = page.tick();
await page.settle();
await page.click("btn-settings");
await setBox(page, "settings-use-blockscout", false);
answer();
await ticking;
expect( expect(
await settingTheRefreshIsHanded({ page.storage.read("autistmask").wallets[0].addresses[0].balance,
...profile, ).toBe("7.0");
useBlockscout: true, expect(storedTokens(page)).toEqual([TRACKED]);
}), await page.click("btn-settings-back");
).toBe(true); expect(page.node("address-balances").innerHTML).not.toContain(
UNTRACKED,
);
});
// Another page saves `change` over the stored profile. The browser is not
// made to tell this page, so this page sees the change only by reading the
// stored profile.
function anotherPageSaves(page, change) {
page.storage.write("autistmask", {
...page.storage.read("autistmask"),
...change,
});
}
test.each([
["off", true, false],
["on", false, true],
])(
"after another page turns Use Blockscout %s, this page takes the change and its next refresh is saved",
async (_how, before, after) => {
const page = await bootPopup(onAddressDetail(before));
// The refresh answers as it does under the stored setting.
const { refreshBalances: held } = require("../src/shared/balances");
held.mockImplementationOnce(async (wallets) => {
const addr = wallets[0].addresses[0];
addr.balance = "7.0";
addr.tokenBalances = after
? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")]
: [tokenRow(TRACKED, "TRK")];
return [];
});
anotherPageSaves(page, { useBlockscout: after });
await page.tick();
expect(
page.storage.read("autistmask").wallets[0].addresses[0].balance,
).toBe("7.0");
expect(storedTokens(page)).toEqual(
after ? [TRACKED, UNTRACKED] : [TRACKED],
);
expect(page.hidden("address-tokens-note")).toBe(after);
},
);
test.each([
["off", true, false],
["on", false, true],
])(
"after another page turns Check recipients on Etherscan %s, this page's next Etherscan check follows the stored setting",
async (_how, before, after) => {
const page = await bootPopup(
onConfirmScreen({ checkRecipientsOnEtherscan: before }),
);
anotherPageSaves(page, { checkRecipientsOnEtherscan: after });
const from = fetched().length;
require("../src/popup/views/confirmTx").show({
token: "ETH",
from: ADDRESS,
to: RECIPIENT,
amount: "0.1",
chainId: "0x1",
});
await page.settle();
// The page also takes the change and draws ConfirmTx again, which
// checks once more. So the test counts each URL once, however often
// it was asked for.
const asked = new Set(
fetched().slice(from).filter(blockscoutOrEtherscan),
);
expect([...asked]).toEqual(
after ? ["https://etherscan.io/address/" + RECIPIENT] : [],
);
},
);
// Another page's save of the whole profile with `change`, which the
// browser announces to this page as it announces any write.
async function anotherPageAnnounces(page, change) {
await page.storage.local.set({
autistmask: { ...page.storage.read("autistmask"), ...change },
});
await page.settle();
}
test("a page open on AddressDetail draws it again when another page turns Use Blockscout off", async () => {
const page = await bootPopup(onAddressDetail(true));
expect(page.hidden("address-tokens-note")).toBe(true);
await anotherPageAnnounces(page, { useBlockscout: false });
expect(page.visibleViews()).toEqual(["address"]);
expect(page.hidden("address-tokens-note")).toBe(false);
expect(page.node("address-balances").innerHTML).not.toContain(
UNTRACKED,
);
expect(storedTokens(page)).toEqual([TRACKED]);
});
test("a page open on ConfirmTx draws it again when another page turns Check recipients on Etherscan off", async () => {
const page = await bootPopup(
onConfirmScreen({ checkRecipientsOnEtherscan: true }),
);
expect(page.hidden("confirm-etherscan-note")).toBe(true);
await anotherPageAnnounces(page, { checkRecipientsOnEtherscan: false });
expect(page.visibleViews()).toEqual(["confirm-tx"]);
expect(page.hidden("confirm-etherscan-note")).toBe(false);
}); });
}); });
+2
View File
@@ -73,6 +73,8 @@ function loadColdWorker(networkId, opts) {
jest.doMock("../src/shared/balances", () => ({ jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}), getProvider: () => ({}),
refreshBalances: options.refreshBalances || jest.fn(async () => {}), refreshBalances: options.refreshBalances || jest.fn(async () => {}),
dropUntrackedTokens: jest.requireActual("../src/shared/balances")
.dropUntrackedTokens,
})); }));
jest.doMock("../src/shared/phishingDomains", () => ({ jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false, isPhishingDomain: () => false,
+8 -2
View File
@@ -108,7 +108,10 @@ global.fetch = jest.fn(() => {
}); });
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } }; global.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
runtime: { sendMessage() {} },
};
// A stub DOM: every id resolves to a recording element. // A stub DOM: every id resolves to a recording element.
const elements = new Map(); const elements = new Map();
@@ -243,11 +246,14 @@ async function openSend(token, tokenUnits = null) {
]; ];
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 0; state.selectedAddress = 0;
// Blockscout is asked only while the stored "Use Blockscout" is on. The
// screens save this page's copy of "Use Blockscout" over the stored one. So
// the page's copy is turned on here too.
state.useBlockscout = true;
await refreshBalances( await refreshBalances(
state.wallets, state.wallets,
"https://rpc.example.invalid", "https://rpc.example.invalid",
"https://blockscout.example/api/v2", "https://blockscout.example/api/v2",
true,
state.trackedTokens, state.trackedTokens,
"mainnet", "mainnet",
); );
+3 -3
View File
@@ -656,9 +656,9 @@ function traceEnabled(raw) {
* @param {string} [opts.etherscanLabel] the label Etherscan's page gives the * @param {string} [opts.etherscanLabel] the label Etherscan's page gives the
* address asked about, such as "Fake_Phishing1"; none when unset. Read at * address asked about, such as "Fake_Phishing1"; none when unset. Read at
* request time. * request time.
* @param {object[]} [opts.requests] every request this handler sees, from the * @param {object[]} [opts.requests] a list for the requests this handler
* popup's pages and the background worker alike, appended in order as * sees. Each is appended in order as { method, url, fromWorker }, whether it
* { method, url, fromWorker }. * came from a popup page or the background worker.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
* Promise<string|null>}>} * Promise<string|null>}>}
*/ */
+94 -27
View File
@@ -272,23 +272,30 @@ test("the exact confirmation phrase erases the record and reloads into Welcome (
// ------------------ Blockscout and Etherscan are sent no user data (#668) // ------------------ Blockscout and Etherscan are sent no user data (#668)
// //
// A fresh install sends Blockscout and Etherscan none of the user's data until // 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 // the user turns on "Use Blockscout" or "Check recipients on Etherscan". One
// endpoint check Settings makes when the user presses "Save" beside the // request still reaches Blockscout while "Use Blockscout" is off: pressing
// Blockscout URL asks only for `/stats` and carries no user data; it is made // "Save" beside the Blockscout URL asks that URL for `/stats`. That request
// whether or not the setting is on. The route handler in tests/e2e/network.js // carries no user data.
// records every request the extension makes, from its pages and its background //
// worker, in env.routeOpts.requests. Recording starts when launch() in // The route handler in tests/e2e/network.js records in env.routeOpts.requests
// tests/e2e/harness.js installs that handler, before the first popup page // every request the extension makes, from its pages and its background worker.
// opens; a request the background worker makes while it starts up, before // Recording starts when launch() in tests/e2e/harness.js installs that handler,
// then, is not recorded. These tests drive Home, AddressDetail, AddressToken // before the first popup page opens. The background worker starts earlier, but
// and ConfirmTx, and the background's own balance refresh, before either // it makes no request while it starts up.
// setting is changed, then turn each on and watch its requests start. //
// TransactionDetail, which also asks Blockscout, is left to the unit tests in // Before either setting is changed, these tests drive:
// tests/blockscoutEtherscanSettings.test.js. //
// - Home, AddressDetail, AddressToken and ConfirmTx
// - the background's own balance refresh
//
// Then they turn each setting on and watch its requests start. "Use Blockscout"
// is also turned off once and on again, to see an untracked token leave every
// list. 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 // The line shown in place of the three transaction histories while "Use
// out here rather than imported, so the assertion does not compare the popup // Blockscout" is off. Written out here rather than imported, so the assertion
// with itself. // does not compare the popup with itself.
const HISTORY_OFF = const HISTORY_OFF =
'No transaction history is shown, because "Use Blockscout" is off in Settings.'; 'No transaction history is shown, because "Use Blockscout" is off in Settings.';
@@ -300,7 +307,7 @@ function explorerRequests(env) {
}); });
} }
// Waits until the list `listId` says what Use Blockscout being off leaves out. // Waits until the list `listId` shows the line saying "Use Blockscout" is off.
async function waitForHistoryOff(page, listId) { async function waitForHistoryOff(page, listId) {
await page.waitForFunction( await page.waitForFunction(
([id, want]) => document.getElementById(id).textContent.trim() === want, ([id, want]) => document.getElementById(id).textContent.trim() === want,
@@ -395,11 +402,11 @@ test("with neither setting changed, Home, AddressDetail, AddressToken and Confir
} }
}); });
// The background refreshes balances on its alarm, once a minute, and skips a // The background refreshes balances on its alarm, once a minute. It skips a
// refresh when the stored record says one ran in the last 30 seconds, which // refresh when the stored record says one ran in the last 30 seconds. The open
// the open popup's own refresh does every 10. So the popup is closed, and that // popup's own refresh runs every 10. So the popup is closed first. The test then
// record is written back to say no refresh has run, from the background // has the background worker set the stored time of the last refresh to 0, so
// worker, as another extension page saving it would. // its next alarm runs a refresh.
test("with neither setting changed, nothing has reached Blockscout or Etherscan, the background's refresh included (#668)", async (env) => { test("with neither setting changed, nothing has reached Blockscout or Etherscan, the background's refresh included (#668)", async (env) => {
await waitForPersisted( await waitForPersisted(
env.page, env.page,
@@ -432,7 +439,7 @@ test("with neither setting changed, nothing has reached Blockscout or Etherscan,
); );
await sleep(250); await sleep(250);
} }
// Its RPC reads and any Blockscout request start together. // The refresh's RPC reads and any Blockscout request start together.
await sleep(3000); await sleep(3000);
const seen = explorerRequests(env); const seen = explorerRequests(env);
@@ -446,7 +453,7 @@ test("with neither setting changed, nothing has reached Blockscout or Etherscan,
env.routeOpts.requests.some((r) => !r.fromWorker), env.routeOpts.requests.some((r) => !r.fromWorker),
"no request from the popup was recorded", "no request from the popup was recorded",
); );
// It reopens on the address screen the test above left it on. // The popup reopens on the address screen the test above left it on.
env.page = await openPopup(env.ctx, env.popupUrl); env.page = await openPopup(env.ctx, env.popupUrl);
await visible(env.page, "#view-address"); await visible(env.page, "#view-address");
}); });
@@ -500,9 +507,68 @@ test("turning on Use Blockscout from AddressDetail's gear asks Blockscout for th
); );
}); });
// Unticked from AddressDetail's gear while the address holds the stub token,
// which the user has not tracked and only Blockscout reports. Ticked again at
// the end, so the rest of the run has Blockscout as before.
test("turning off Use Blockscout from AddressDetail's gear takes an untracked token off AddressDetail, Send and Home on Back (#668)", async (env) => {
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.seedTokenBalance = true;
const tokenRow = '[data-token="' + STUB_TOKEN.address + '"]';
try {
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI, expectToken: true });
await openAddressDetail(env.page);
await visible(env.page, "#address-balances " + tokenRow);
await openSettings(env.page);
await env.page.uncheck("#settings-use-blockscout");
await waitForPersisted(
env.page,
"useBlockscout",
false,
"once unticked",
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-address");
await visible(env.page, "#address-tokens-note");
assert(
(await env.page
.locator("#address-balances " + tokenRow)
.count()) === 0,
"AddressDetail still lists the untracked token",
);
await env.page.click("#btn-send");
await visible(env.page, "#view-send");
await visible(env.page, "#send-tokens-note");
const offered = await env.page.$$eval("#send-token option", (opts) =>
opts.map((opt) => opt.value.toLowerCase()),
);
assert(
!offered.includes(STUB_TOKEN.address),
"Send's dropdown still offers the untracked token",
);
await env.page.click("#btn-send-back");
await visible(env.page, "#view-address");
await goHome(env.page);
await visible(env.page, "#home-tokens-note");
assert(
(await env.page.locator("#wallet-list " + tokenRow).count()) === 0,
"Home still lists the untracked token",
);
} finally {
await openSettings(env.page);
await env.page.check("#settings-use-blockscout");
await waitForPersisted(env.page, "useBlockscout", true, "ticked again");
await env.page.click("#btn-settings-back");
env.routeOpts.ethBalanceWei = null;
env.routeOpts.seedTokenBalance = false;
}
});
// Ticked from ConfirmTx's gear, so Back lands on the screen that said no // 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 // Etherscan check was made. Left on for the rest of the run, so the later
// always made before #668. // confirmation screens are driven with the check made.
test("turning on Check recipients on Etherscan from ConfirmTx's gear has ConfirmTx ask Etherscan on Back and show its label (#668)", async (env) => { 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.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.etherscanLabel = "Fake_Phishing668"; env.routeOpts.etherscanLabel = "Fake_Phishing668";
@@ -903,7 +969,8 @@ test("a token added by its contract address is listed on the address screen (#15
await visible(env.page, "#view-address"); await visible(env.page, "#view-address");
// No wait: the confirm renders the balance list before it shows the // No wait: the confirm renders the balance list before it shows the
// screen, and nothing renders the list again while the screen is up. // screen. The refresh it starts draws the list again, with the row still
// in it.
const row = env.page.locator( const row = env.page.locator(
'#address-balances [data-token="' + STUB_TOKEN.address + '"]', '#address-balances [data-token="' + STUB_TOKEN.address + '"]',
{ hasText: STUB_TOKEN.symbol }, { hasText: STUB_TOKEN.symbol },
+114
View File
@@ -0,0 +1,114 @@
// 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 that address unless the provider has `disableCcipRead` set.
// That request would carry the name to a server other than the RPC endpoint
// (https://git.eeqj.de/sneak/AutistMask/issues/668). With the flag set, such a
// name does not resolve.
//
// The test builds its provider with getProvider() and runs it against a fake
// JSON-RPC node. The fake node answers through ethers' own request hook. The
// hook 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()
// - it gives NAME as the reverse record of any address
// - it answers resolve() with the OffchainLookup error, which 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 });
});
+7 -2
View File
@@ -35,8 +35,11 @@ global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); throw new Error("tests must not perform network requests");
}); });
// Blockscout is asked only while the stored "Use Blockscout" is on.
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub() }; global.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
};
const { AbiCoder, Interface } = require("ethers"); const { AbiCoder, Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants"); const { ERC20_ABI } = require("../src/shared/constants");
@@ -114,7 +117,6 @@ async function fetchOnto(items, trackedTokens = []) {
const balances = await fetchTokenBalances( const balances = await fetchTokenBalances(
HOLDER, HOLDER,
BLOCKSCOUT, BLOCKSCOUT,
true,
trackedTokens, trackedTokens,
); );
state.trackedTokens = trackedTokens; state.trackedTokens = trackedTokens;
@@ -166,6 +168,9 @@ beforeEach(() => {
debugFetch.mockReset(); debugFetch.mockReset();
state.trackedTokens = []; state.trackedTokens = [];
state.wallets = []; state.wallets = [];
// The approval screens read the explorer's report only while the page's
// "Use Blockscout" is on.
state.useBlockscout = true;
}); });
describe("what fetchTokenBalances stores for an absent scale", () => { describe("what fetchTokenBalances stores for an absent scale", () => {
+1
View File
@@ -48,6 +48,7 @@ async function boot(stored) {
for (const wallet of wallets) { for (const wallet of wallets) {
for (const addr of wallet.addresses) addr.balance = chainBalance; for (const addr of wallet.addresses) addr.balance = chainBalance;
} }
return [];
}); });
return { page, refreshBalances }; return { page, refreshBalances };
} }
+1
View File
@@ -88,6 +88,7 @@ describe.each([
showFlash: (msg) => flashes.push(msg), showFlash: (msg) => flashes.push(msg),
escapeHtml: (s) => s, escapeHtml: (s) => s,
goBack: () => {}, goBack: () => {},
trackToken: () => {},
})); }));
require("../src/popup/views/" + view).init({ require("../src/popup/views/" + view).init({
+10 -10
View File
@@ -21,7 +21,11 @@ jest.mock("../src/shared/log", () => ({
global.fetch = jest.fn(() => { global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); throw new Error("tests must not perform network requests");
}); });
global.chrome = { storage: { local: {} } }; // Blockscout is asked only while the stored "Use Blockscout" is on.
const { makeStorageStub } = require("./support/storageStub");
global.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
};
const { const {
LOW_HOLDER_THRESHOLD, LOW_HOLDER_THRESHOLD,
@@ -150,23 +154,19 @@ describe("the balance-list spam gate", () => {
test("a token with plenty of reported holders is listed", async () => { test("a token with plenty of reported holders is listed", async () => {
respondWith([item()]); respondWith([item()]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []); const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1); expect(balances).toHaveLength(1);
expect(balances[0].holders).toBe(50000); expect(balances[0].holders).toBe(50000);
}); });
test("a token reporting zero holders is still excluded", async () => { test("a token reporting zero holders is still excluded", async () => {
respondWith([item({ token: { holders_count: "0" } })]); respondWith([item({ token: { holders_count: "0" } })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual( expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
[],
);
}); });
test("an unknown holder count does not admit an unvouched token", async () => { test("an unknown holder count does not admit an unvouched token", async () => {
respondWith([item({ token: { holders_count: null } })]); respondWith([item({ token: { holders_count: null } })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual( expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
[],
);
}); });
// The path that reaches the send selector and the history filter: a token // The path that reaches the send selector and the history filter: a token
@@ -175,7 +175,7 @@ describe("the balance-list spam gate", () => {
// zero that would then hide it downstream. // zero that would then hide it downstream.
test("a tracked token with an unknown count is listed with holders null", async () => { test("a tracked token with an unknown count is listed with holders null", async () => {
respondWith([item({ token: { holders_count: undefined } })]); respondWith([item({ token: { holders_count: undefined } })]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [ const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [
{ address: NOVEL_TOKEN.toUpperCase() }, { address: NOVEL_TOKEN.toUpperCase() },
]); ]);
expect(balances).toHaveLength(1); expect(balances).toHaveLength(1);
@@ -192,7 +192,7 @@ describe("the balance-list spam gate", () => {
}, },
}), }),
]); ]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []); const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1); expect(balances).toHaveLength(1);
expect(balances[0].holders).toBeNull(); expect(balances[0].holders).toBeNull();
}); });
+9
View File
@@ -165,6 +165,15 @@ describe("declared icons", () => {
}); });
}); });
// Firefox gives a page in a private window locks of its own, so that page's
// save could land between the background's read and its write (README,
// "Stored state and its version").
describe("private windows", () => {
test("a page in a firefox private window cannot read or write extension storage", () => {
expect(readManifest("firefox").incognito).toBe("not_allowed");
});
});
describe("shipped Content Security Policy", () => { describe("shipped Content Security Policy", () => {
// MV3 takes an object and applies extension_pages to the popup and the // MV3 takes an object and applies extension_pages to the popup and the
// background service worker, which is where libsodium runs. // background service worker, which is where libsodium runs.
+6 -1
View File
@@ -436,8 +436,13 @@ describe.each([
}); });
test("the transaction detail screen's type line and fee", async () => { test("the transaction detail screen's type line and fee", async () => {
// The fee is Blockscout's. // The fee is Blockscout's, asked for only while the stored "Use
// Blockscout" is on.
state.useBlockscout = true; state.useBlockscout = true;
global.chrome.storage.write("autistmask", {
...global.chrome.storage.read("autistmask"),
useBlockscout: true,
});
debugFetch.mockImplementationOnce(async () => ({ debugFetch.mockImplementationOnce(async () => ({
ok: true, ok: true,
status: 200, status: 200,
+5 -1
View File
@@ -286,8 +286,12 @@ describe("a malformed fraudContracts", () => {
function stubSendDocument() { function stubSendDocument() {
const select = { innerHTML: "", children: [] }; const select = { innerHTML: "", children: [] };
select.appendChild = (child) => select.children.push(child); select.appendChild = (child) => select.children.push(child);
const elements = {
"send-token": select,
"send-tokens-note": { classList: { toggle: () => {} } },
};
globalThis.document = { globalThis.document = {
getElementById: (id) => (id === "send-token" ? select : null), getElementById: (id) => elements[id] || null,
createElement: () => ({ value: "", textContent: "" }), createElement: () => ({ value: "", textContent: "" }),
}; };
return select; return select;
+17 -5
View File
@@ -618,8 +618,6 @@ const CONTRACT = [
"hideFraudContracts", "hideFraudContracts",
"hideDustTransactions", "hideDustTransactions",
"utcTimestamps", "utcTimestamps",
"useBlockscout",
"checkRecipientsOnEtherscan",
"debugMode", "debugMode",
].map((field) => ({ ].map((field) => ({
field, field,
@@ -627,11 +625,25 @@ const CONTRACT = [
// A flag: only ever tested for truthiness, and written back verbatim. // A flag: only ever tested for truthiness, and written back verbatim.
hostile: [42, "notabool", { a: 1 }], hostile: [42, "notabool", { a: 1 }],
// Both answers to that truthiness test have to be driven, and 0 is a // Both answers to that truthiness test have to be driven, and 0 is a
// value src/ never writes for a flag. For utcTimestamps, // value src/ never writes for a flag. For utcTimestamps and debugMode
// useBlockscout, checkRecipientsOnEtherscan and debugMode the falsy // the falsy answer is also the DEFAULT_STATE default.
// answer is also the DEFAULT_STATE default.
falsy: [0], falsy: [0],
})), })),
// Anything but true is off for these three settings:
// - useBlockscout and checkRecipientsOnEtherscan let a request reach
// another service.
// - allowWeakPasswords lets AddWallet accept a short password.
...[
"useBlockscout",
"checkRecipientsOnEtherscan",
"allowWeakPasswords",
].map((field) => ({
field,
kind: KIND.SCALAR,
hostile: [42, "notabool", { a: 1 }],
floorOnly: [0, null, "true"],
holds: (v) => v === false,
})),
]; ];
function siteMapHolds(v) { function siteMapHolds(v) {
+3 -2
View File
@@ -44,8 +44,9 @@ 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 // A boot onto a screen that sends a request no mocked module answers: that
// request reaches the boot's fetch() stub, never the internet // request reaches the boot's fetch() stub, never the internet
// (https://git.eeqj.de/sneak/AutistMask/issues/559). Both requests are made // (https://git.eeqj.de/sneak/AutistMask/issues/559). TransactionDetail asks
// only while the user has the setting for them on. // Blockscout only while "Use Blockscout" is on. ConfirmTx asks Etherscan only
// while "Check recipients on Etherscan" is on. So the profile turns both on.
function restoringOnto(view, viewData) { function restoringOnto(view, viewData) {
return unversionedValidProfile({ return unversionedValidProfile({
currentView: view, currentView: view,
+1 -1
View File
@@ -87,7 +87,7 @@ test("the popup open on AddressDetail shows the new network and its balances", a
// Its next refresh runs on Sepolia, and what it fetched is stored. // Its next refresh runs on Sepolia, and what it fetched is stored.
await page.tick(); await page.tick();
const { refreshBalances } = require("../src/shared/balances"); const { refreshBalances } = require("../src/shared/balances");
expect(refreshBalances.mock.lastCall[5]).toBe("sepolia"); expect(refreshBalances.mock.lastCall[4]).toBe("sepolia");
const stored = page.storage.read("autistmask"); const stored = page.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia"); expect(stored.networkId).toBe("sepolia");
expect(stored.lastBalanceRefresh).toBeGreaterThan(0); expect(stored.lastBalanceRefresh).toBeGreaterThan(0);
-1
View File
@@ -88,7 +88,6 @@ test("the balance refresh", async () => {
wallets, wallets,
RPC_URL, RPC_URL,
"https://explorer.example.invalid/api/v2", "https://explorer.example.invalid/api/v2",
true,
[], [],
"mainnet", "mainnet",
); );
+5 -2
View File
@@ -90,7 +90,7 @@ let approvalDetails = null;
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
global.chrome = { global.chrome = {
storage: makeStorageStub(), storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
runtime: { runtime: {
connect: () => ({ connect: () => ({
postMessage() {}, postMessage() {},
@@ -220,11 +220,14 @@ async function refreshWith(balanceWei, tokenItems = []) {
state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }]; state.wallets = [{ name: "Wallet 1", addresses: [{ address: HOLDER }] }];
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 0; state.selectedAddress = 0;
// Blockscout is asked only while the stored "Use Blockscout" is on. The
// screens save this page's copy of "Use Blockscout" over the stored one. So
// the page's copy is turned on here too.
state.useBlockscout = true;
await refreshBalances( await refreshBalances(
state.wallets, state.wallets,
"https://rpc.example.invalid", "https://rpc.example.invalid",
"https://blockscout.example/api/v2", "https://blockscout.example/api/v2",
true,
state.trackedTokens, state.trackedTokens,
"mainnet", "mainnet",
); );
+8 -2
View File
@@ -100,7 +100,10 @@ global.fetch = jest.fn(() => {
}); });
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } }; global.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
runtime: { sendMessage() {} },
};
// A stub DOM: every id resolves to a recording element. // A stub DOM: every id resolves to a recording element.
const elements = new Map(); const elements = new Map();
@@ -213,11 +216,14 @@ async function refreshWith(balanceWei, tokenItems = []) {
]; ];
state.selectedWallet = 0; state.selectedWallet = 0;
state.selectedAddress = 0; state.selectedAddress = 0;
// Blockscout is asked only while the stored "Use Blockscout" is on. The
// screens save this page's copy of "Use Blockscout" over the stored one. So
// the page's copy is turned on here too.
state.useBlockscout = true;
await refreshBalances( await refreshBalances(
state.wallets, state.wallets,
"https://rpc.example.invalid", "https://rpc.example.invalid",
"https://blockscout.example/api/v2", "https://blockscout.example/api/v2",
true,
state.trackedTokens, state.trackedTokens,
"mainnet", "mainnet",
); );
+5 -1
View File
@@ -27,8 +27,12 @@ let select;
function installStubDocument() { function installStubDocument() {
select = { innerHTML: "", children: [] }; select = { innerHTML: "", children: [] };
select.appendChild = (child) => select.children.push(child); select.appendChild = (child) => select.children.push(child);
const elements = {
"send-token": select,
"send-tokens-note": { classList: { toggle: () => {} } },
};
globalThis.document = { globalThis.document = {
getElementById: (id) => (id === "send-token" ? select : null), getElementById: (id) => elements[id] || null,
createElement: () => ({ value: "", textContent: "" }), createElement: () => ({ value: "", textContent: "" }),
}; };
} }
+10 -7
View File
@@ -523,13 +523,16 @@ describe("a garbage value in a field the gate does not check", () => {
}); });
test("a malformed tokenBalances entry is dropped, and the wallet and its address survive", async () => { test("a malformed tokenBalances entry is dropped, and the wallet and its address survive", async () => {
const env = await bootPopup( // "Use Blockscout" is on. Otherwise the boot's balance refresh would
profileWithTokenBalances([ // drop the well-formed entry too, since the user has not tracked its
null, // token.
42, const profile = profileWithTokenBalances([
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" }, null,
]), 42,
); { address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
]);
profile.useBlockscout = true;
const env = await bootPopup(profile);
const stored = env.storage.read("autistmask"); const stored = env.storage.read("autistmask");
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1"); expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
+2 -3
View File
@@ -6,9 +6,8 @@
// now holds a lock from its read to its write. // now holds a lock from its read to its write.
// //
// The popup and the background run here as the real modules over one shared // The popup and the background run here as the real modules over one shared
// storage stub, and share the lock the way the background and a page outside // storage stub, and share the lock the way the background and its pages share
// a Firefox private window share the browser's // the browser's (tests/support/navigatorLocks.js).
// (tests/support/navigatorLocks.js).
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
+2 -3
View File
@@ -4,9 +4,8 @@
// the callback settles, whether it resolved or rejected. That is what the // the callback settles, whether it resolved or rejected. That is what the
// browser's own does for the one call the extension makes, // browser's own does for the one call the extension makes,
// `navigator.locks.request(name, callback)`. Every module a test file loads // `navigator.locks.request(name, callback)`. Every module a test file loads
// shares it, as the extension's background and its pages share the browser's, // shares it, as the extension's background and its pages share the browser's
// except a page in a Firefox private window (README, "Stored state and its // (README, "Stored state and its version").
// version").
// //
// Run before each test file by `setupFiles` in package.json. // Run before each test file by `setupFiles` in package.json.
+5 -2
View File
@@ -279,10 +279,13 @@ async function bootPopup(stored, options) {
getAddressValue: () => ({ usd: null, partial: false }), getAddressValue: () => ({ usd: null, partial: false }),
})); }));
// scanForAddresses() answers in the real one's shape, a scan that found // scanForAddresses() answers in the real one's shape, a scan that found
// no used address: AddWallet reads `addresses` from it. // no used address: AddWallet reads `addresses` from it. refreshBalances()
// answers as a refresh that read no tracked token's balance.
jest.doMock("../../src/shared/balances", () => ({ jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []), fetchTokenBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => {}), refreshBalances: jest.fn(async () => []),
dropUntrackedTokens: jest.requireActual("../../src/shared/balances")
.dropUntrackedTokens,
lookupTokenInfo: jest.fn(async () => null), lookupTokenInfo: jest.fn(async () => null),
getProvider: () => ({}), getProvider: () => ({}),
scanForAddresses: jest.fn(async () => ({ scanForAddresses: jest.fn(async () => ({
+22 -25
View File
@@ -52,8 +52,14 @@ jest.mock("../src/shared/log", () => ({
global.fetch = jest.fn(() => { global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); throw new Error("tests must not perform network requests");
}); });
// Blockscout is asked only while the stored "Use Blockscout" is on.
global.chrome = { global.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } }, storage: {
local: {
get: async () => ({ autistmask: { useBlockscout: true } }),
set: async () => {},
},
},
}; };
const { isSpoofedSymbol } = require("../src/shared/symbolSpoof"); const { isSpoofedSymbol } = require("../src/shared/symbolSpoof");
@@ -485,8 +491,12 @@ describe("surface 2: the Send token selector", () => {
function render(tokenBalances) { function render(tokenBalances) {
select = { innerHTML: "", children: [] }; select = { innerHTML: "", children: [] };
select.appendChild = (child) => select.children.push(child); select.appendChild = (child) => select.children.push(child);
const elements = {
"send-token": select,
"send-tokens-note": { classList: { toggle: () => {} } },
};
globalThis.document = { globalThis.document = {
getElementById: (id) => (id === "send-token" ? select : null), getElementById: (id) => elements[id] || null,
createElement: () => ({ value: "", textContent: "" }), createElement: () => ({ value: "", textContent: "" }),
}; };
renderSendTokenSelect({ renderSendTokenSelect({
@@ -582,14 +592,12 @@ describe("surface 3: the balance list", () => {
// 1,000-holder floor and was listed as a holding named ETH. // 1,000-holder floor and was listed as a holding named ETH.
test("a fake ETH token clearing the holder floor is filtered", async () => { test("a fake ETH token clearing the holder floor is filtered", async () => {
respondWith([fakeEthItem()]); respondWith([fakeEthItem()]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual( expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
[],
);
}); });
test("tracking the fake token manually does not admit it either", async () => { test("tracking the fake token manually does not admit it either", async () => {
respondWith([fakeEthItem({ holders_count: "0" })]); respondWith([fakeEthItem({ holders_count: "0" })]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [ const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [
{ address: FAKE_ETH_CONTRACT }, { address: FAKE_ETH_CONTRACT },
]); ]);
expect(balances).toEqual([]); expect(balances).toEqual([]);
@@ -599,16 +607,12 @@ describe("surface 3: the balance list", () => {
// their belief about what they own, and it renders the symbol into HTML. // their belief about what they own, and it renders the symbol into HTML.
test("a padded fake ETH token is filtered too", async () => { test("a padded fake ETH token is filtered too", async () => {
respondWith([fakeEthItem({ symbol: " ETH " })]); respondWith([fakeEthItem({ symbol: " ETH " })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual( expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
[],
);
}); });
test("a fake ETH token padded with a no-break space is filtered", async () => { test("a fake ETH token padded with a no-break space is filtered", async () => {
respondWith([fakeEthItem({ symbol: NBSP + "ETH" + NBSP })]); respondWith([fakeEthItem({ symbol: NBSP + "ETH" + NBSP })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual( expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
[],
);
}); });
// The false-positive direction on the surface that matters most: a real // The false-positive direction on the surface that matters most: a real
@@ -623,7 +627,7 @@ describe("surface 3: the balance list", () => {
decimals: "6", decimals: "6",
}), }),
]); ]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []); const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1); expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe(" USDC "); expect(balances[0].symbol).toBe(" USDC ");
}); });
@@ -637,7 +641,7 @@ describe("surface 3: the balance list", () => {
decimals: "6", decimals: "6",
}), }),
]); ]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []); const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1); expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC"); expect(balances[0].symbol).toBe("USDC");
}); });
@@ -648,9 +652,7 @@ describe("surface 3: the balance list", () => {
// before the symbol rule is consulted. // before the symbol rule is consulted.
test("a non-ERC-20 row claiming ETH never reaches the symbol rule", async () => { test("a non-ERC-20 row claiming ETH never reaches the symbol rule", async () => {
respondWith([fakeEthItem({ type: "ERC-721" })]); respondWith([fakeEthItem({ type: "ERC-721" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual( expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
[],
);
}); });
// The adjacent finding from the same review as issue #260: the type gate // The adjacent finding from the same review as issue #260: the type gate
@@ -668,20 +670,16 @@ describe("surface 3: the balance list", () => {
decimals: "6", decimals: "6",
}), }),
]); ]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []); const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1); expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC"); expect(balances[0].symbol).toBe("USDC");
}); });
test("case insensitivity does not admit another token type", async () => { test("case insensitivity does not admit another token type", async () => {
respondWith([fakeEthItem({ type: "erc-721" })]); respondWith([fakeEthItem({ type: "erc-721" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual( expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
[],
);
respondWith([fakeEthItem({ type: "ERC-20-EXTRA" })]); respondWith([fakeEthItem({ type: "ERC-20-EXTRA" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, [])).toEqual( expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
[],
);
}); });
// The money test: the user holds real ETH and has been airdropped a fake // The money test: the user holds real ETH and has been airdropped a fake
@@ -694,7 +692,6 @@ describe("surface 3: the balance list", () => {
[{ addresses: [addr] }], [{ addresses: [addr] }],
"https://rpc.example.invalid", "https://rpc.example.invalid",
BLOCKSCOUT, BLOCKSCOUT,
true,
[], [],
"mainnet", "mainnet",
); );
+7 -5
View File
@@ -38,8 +38,14 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false, isDebug: () => false,
})); }));
// Blockscout is asked only while the stored "Use Blockscout" is on.
globalThis.chrome = { globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } }, storage: {
local: {
get: async () => ({ autistmask: { useBlockscout: true } }),
set: async () => {},
},
},
}; };
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
@@ -178,7 +184,6 @@ test("a token transfer's decoded details name the token contract and the amount
const [row] = await fetchRecentTransactions( const [row] = await fetchRecentTransactions(
HOLDER, HOLDER,
EXPLORER, EXPLORER,
true,
NETWORKS.mainnet.chainId, NETWORKS.mainnet.chainId,
); );
expect(row.to).toBe(RECIPIENT); expect(row.to).toBe(RECIPIENT);
@@ -211,7 +216,6 @@ test("with mainnet active, a Sepolia transaction is read from Sepolia's Blocksco
const [row] = await fetchRecentTransactions( const [row] = await fetchRecentTransactions(
HOLDER, HOLDER,
EXPLORER, EXPLORER,
true,
NETWORKS.sepolia.chainId, NETWORKS.sepolia.chainId,
); );
@@ -240,7 +244,6 @@ test("with mainnet active and no Blockscout remembered for Sepolia, a Sepolia tr
const [row] = await fetchRecentTransactions( const [row] = await fetchRecentTransactions(
HOLDER, HOLDER,
EXPLORER, EXPLORER,
true,
NETWORKS.sepolia.chainId, NETWORKS.sepolia.chainId,
); );
mockAsked.length = 0; mockAsked.length = 0;
@@ -262,7 +265,6 @@ async function showSepoliaTransferWithMainnetActive() {
const [row] = await fetchRecentTransactions( const [row] = await fetchRecentTransactions(
HOLDER, HOLDER,
EXPLORER, EXPLORER,
true,
NETWORKS.sepolia.chainId, NETWORKS.sepolia.chainId,
); );
transactionDetail.show(row); transactionDetail.show(row);
+34 -1
View File
@@ -9,6 +9,12 @@
const { AbiCoder, FetchRequest } = require("ethers"); const { AbiCoder, FetchRequest } = require("ethers");
const { refreshBalances } = require("../src/shared/balances"); const { refreshBalances } = require("../src/shared/balances");
const { makeStorageStub } = require("./support/storageStub");
// Blockscout is asked only while the stored "Use Blockscout" is on.
globalThis.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
};
const RPC_URL = "https://rpc.example.invalid"; const RPC_URL = "https://rpc.example.invalid";
const EXPLORER_URL = "https://explorer.example.invalid/api/v2"; const EXPLORER_URL = "https://explorer.example.invalid/api/v2";
@@ -132,7 +138,6 @@ async function refresh(addresses, trackedTokens) {
[{ addresses }], [{ addresses }],
RPC_URL, RPC_URL,
EXPLORER_URL, EXPLORER_URL,
true,
trackedTokens, trackedTokens,
"mainnet", "mainnet",
new AbortController().signal, new AbortController().signal,
@@ -302,3 +307,31 @@ test("a tracked token wearing a known symbol from another contract is not listed
); );
expect(first.tokenBalances).toEqual([]); expect(first.tokenBalances).toEqual([]);
}); });
test("the refresh resolves with each tracked token whose balance it read at every address", async () => {
const flaky = "0x9999999999999999999999999999999999999999";
holdings[TOKEN] = { [FIRST]: 2000000n, [SECOND]: 0n };
// balanceOf answers at FIRST and fails at SECOND.
holdings[flaky] = { [FIRST]: 2000000n };
const read = await refreshBalances(
[
{
addresses: [
{ address: FIRST, tokenBalances: [] },
{ address: SECOND, tokenBalances: [] },
],
},
],
RPC_URL,
EXPLORER_URL,
[
TRACKED,
{ address: flaky, symbol: "FLK", name: "Flaky", decimals: 6 },
// A known symbol from another contract: its balance is not read.
{ address: OTHER, symbol: "USDC", name: "Fake", decimals: 6 },
],
"mainnet",
new AbortController().signal,
);
expect(read).toEqual([TOKEN]);
});
+4 -2
View File
@@ -159,7 +159,9 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
"sepolia", "sepolia",
expect.anything(), expect.anything(),
); );
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]); // No refresh has read the new token's balance yet.
const added = { ...SEPOLIA_TOKEN, balanceNotFetched: true };
expect(stored().trackedTokens).toEqual([added]);
await boot.click("btn-settings"); await boot.click("btn-settings");
await chooseNetwork(boot, "mainnet"); await chooseNetwork(boot, "mainnet");
@@ -171,7 +173,7 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
await chooseNetwork(boot, "sepolia"); await chooseNetwork(boot, "sepolia");
expect(stored().networkId).toBe("sepolia"); expect(stored().networkId).toBe("sepolia");
expect(stored().trackedTokens).toEqual([SEPOLIA_TOKEN]); expect(stored().trackedTokens).toEqual([added]);
const onSepolia = boot.node("settings-tracked-tokens").innerHTML; const onSepolia = boot.node("settings-tracked-tokens").innerHTML;
expect(onSepolia).toContain("TST"); expect(onSepolia).toContain("TST");
expect(onSepolia).not.toContain("DAI"); expect(onSepolia).not.toContain("DAI");
+11 -51
View File
@@ -1215,11 +1215,13 @@ function respondWith(txItems, tokenTransferItems) {
describe("fetchRecentTransactions merge and dedup", () => { describe("fetchRecentTransactions merge and dedup", () => {
beforeEach(() => { beforeEach(() => {
debugFetch.mockReset(); debugFetch.mockReset();
// Blockscout is asked only while the stored "Use Blockscout" is on.
global.chrome.storage.write("autistmask", { useBlockscout: true });
}); });
test("queries only the two Blockscout endpoints for the address", async () => { test("queries only the two Blockscout endpoints for the address", async () => {
respondWith([], []); respondWith([], []);
await fetchRecentTransactions(VICTIM, BLOCKSCOUT, true, "0x1"); await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
expect(debugFetch).toHaveBeenCalledTimes(2); expect(debugFetch).toHaveBeenCalledTimes(2);
const urls = debugFetch.mock.calls.map((c) => c[0]); const urls = debugFetch.mock.calls.map((c) => c[0]);
expect(urls).toContain( expect(urls).toContain(
@@ -1283,12 +1285,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
const merged = txs[0]; const merged = txs[0];
// The received leg (the swap output) supplies the display amount. // The received leg (the swap output) supplies the display amount.
@@ -1325,12 +1322,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC"); expect(txs[0].symbol).toBe("USDC");
expect(txs[0].value).toBe("1500.5000"); expect(txs[0].value).toBe("1500.5000");
@@ -1356,12 +1348,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
}); });
respondWith([], [leg("1000000"), leg("2000000")]); respondWith([], [leg("1000000"), leg("2000000")]);
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
// Keyed by hash plus contract, so the later leg wins. // Keyed by hash plus contract, so the later leg wins.
expect(txs[0].exactValue).toBe("2.0"); expect(txs[0].exactValue).toBe("2.0");
@@ -1401,12 +1388,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]); expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
}); });
@@ -1447,12 +1429,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC"); expect(txs[0].symbol).toBe("USDC");
expect(txs[0].exactValue).toBe("1.0"); expect(txs[0].exactValue).toBe("1.0");
@@ -1478,13 +1455,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
}); });
respondWith([item(6), item(8), item(7)], []); respondWith([item(6), item(8), item(7)], []);
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1", 2);
VICTIM,
BLOCKSCOUT,
true,
"0x1",
2,
);
expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]); expect(txs.map((t) => t.blockNumber)).toEqual([21000008, 21000007]);
}); });
@@ -1510,7 +1481,6 @@ describe("fetchRecentTransactions merge and dedup", () => {
const sepolia = await fetchRecentTransactions( const sepolia = await fetchRecentTransactions(
VICTIM, VICTIM,
BLOCKSCOUT, BLOCKSCOUT,
true,
"0xaa36a7", "0xaa36a7",
); );
expect(sepolia[0].symbol).toBe("SepoliaETH"); expect(sepolia[0].symbol).toBe("SepoliaETH");
@@ -1519,7 +1489,6 @@ describe("fetchRecentTransactions merge and dedup", () => {
const mainnet = await fetchRecentTransactions( const mainnet = await fetchRecentTransactions(
VICTIM, VICTIM,
BLOCKSCOUT, BLOCKSCOUT,
true,
"0x1", "0x1",
); );
expect(mainnet[0].symbol).toBe("ETH"); expect(mainnet[0].symbol).toBe("ETH");
@@ -1546,12 +1515,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
], ],
); );
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1");
VICTIM,
BLOCKSCOUT,
true,
"0x1",
);
expect(txs).toHaveLength(1); expect(txs).toHaveLength(1);
expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT); expect(txs[0].contractAddress).toBe(FAKE_ETH_CONTRACT);
expect(txs[0].holders).toBe(0); expect(txs[0].holders).toBe(0);
@@ -1593,7 +1557,6 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(
VICTIM, VICTIM,
BLOCKSCOUT, BLOCKSCOUT,
true,
"0x1", "0x1",
); );
expect(txs[0].holders).toBeNull(); expect(txs[0].holders).toBeNull();
@@ -1604,7 +1567,6 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(
VICTIM, VICTIM,
BLOCKSCOUT, BLOCKSCOUT,
true,
"0x1", "0x1",
); );
expect(txs[0].holders).toBeNull(); expect(txs[0].holders).toBeNull();
@@ -1615,7 +1577,6 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(
VICTIM, VICTIM,
BLOCKSCOUT, BLOCKSCOUT,
true,
"0x1", "0x1",
); );
expect(filterTransactions(txs, filters()).transactions).toEqual( expect(filterTransactions(txs, filters()).transactions).toEqual(
@@ -1632,7 +1593,6 @@ describe("fetchRecentTransactions merge and dedup", () => {
const txs = await fetchRecentTransactions( const txs = await fetchRecentTransactions(
VICTIM, VICTIM,
BLOCKSCOUT, BLOCKSCOUT,
true,
"0x1", "0x1",
); );
expect(txs[0].holders).toBe(0); expect(txs[0].holders).toBe(0);
@@ -1650,7 +1610,7 @@ describe("fetchRecentTransactions merge and dedup", () => {
}, },
})); }));
await expect( await expect(
fetchRecentTransactions(VICTIM, BLOCKSCOUT, true, "0x1"), fetchRecentTransactions(VICTIM, BLOCKSCOUT, "0x1"),
).resolves.toEqual([]); ).resolves.toEqual([]);
}); });
+7 -1
View File
@@ -42,8 +42,14 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false, isDebug: () => false,
})); }));
// Blockscout is asked only while the stored "Use Blockscout" is on.
globalThis.chrome = { globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } }, storage: {
local: {
get: async () => ({ autistmask: { useBlockscout: true } }),
set: async () => {},
},
},
}; };
const uniswap = require("../src/shared/uniswap"); const uniswap = require("../src/shared/uniswap");
+7 -2
View File
@@ -34,8 +34,14 @@ jest.mock("../src/shared/log", () => ({
isDebug: () => false, isDebug: () => false,
})); }));
// Blockscout is asked only while the stored "Use Blockscout" is on.
globalThis.chrome = { globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } }, storage: {
local: {
get: async () => ({ autistmask: { useBlockscout: true } }),
set: async () => {},
},
},
}; };
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
@@ -288,7 +294,6 @@ async function historyRow(method, sentTo, chainId) {
const rows = await fetchRecentTransactions( const rows = await fetchRecentTransactions(
RECIPIENT, RECIPIENT,
"https://explorer.example/api/v2", "https://explorer.example/api/v2",
true,
chainId, chainId,
); );
return rows[0]; return rows[0];
+3
View File
@@ -90,6 +90,9 @@ function swapDetail(data, label) {
beforeEach(() => { beforeEach(() => {
state.trackedTokens = []; state.trackedTokens = [];
state.wallets = []; state.wallets = [];
// The approval screen reads the explorer's report only while "Use
// Blockscout" is on.
state.useBlockscout = true;
}); });
describe("a swap of a token outside the bundled list", () => { describe("a swap of a token outside the bundled list", () => {
+11 -4
View File
@@ -72,8 +72,12 @@ global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests"); throw new Error("tests must not perform network requests");
}); });
// Blockscout is asked only while the stored "Use Blockscout" is on.
const { makeStorageStub } = require("./support/storageStub"); const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } }; global.chrome = {
storage: makeStorageStub({ autistmask: { useBlockscout: true } }),
runtime: { sendMessage() {} },
};
// A stub DOM. Every id in index.html that these two views touch resolves to a // A stub DOM. Every id in index.html that these two views touch resolves to a
// fresh recording element; nothing here depends on layout, only on what the // fresh recording element; nothing here depends on layout, only on what the
@@ -172,7 +176,7 @@ async function fetchOnto(items) {
statusText: "OK", statusText: "OK",
json: async () => items, json: async () => items,
})); }));
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []); const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
state.wallets = [ state.wallets = [
{ {
name: "Wallet 1", name: "Wallet 1",
@@ -197,14 +201,14 @@ async function fetchOntoBoth(itemsA, itemsB) {
statusText: "OK", statusText: "OK",
json: async () => itemsA, json: async () => itemsA,
})); }));
const a = await fetchTokenBalances(HOLDER, BLOCKSCOUT, true, []); const a = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
debugFetch.mockImplementation(async () => ({ debugFetch.mockImplementation(async () => ({
ok: true, ok: true,
status: 200, status: 200,
statusText: "OK", statusText: "OK",
json: async () => itemsB, json: async () => itemsB,
})); }));
const b = await fetchTokenBalances(SECOND_HOLDER, BLOCKSCOUT, true, []); const b = await fetchTokenBalances(SECOND_HOLDER, BLOCKSCOUT, []);
state.wallets = [ state.wallets = [
{ {
name: "Wallet 1", name: "Wallet 1",
@@ -268,6 +272,9 @@ beforeEach(() => {
state.fraudContracts = []; state.fraudContracts = [];
state.hideLowHolderTokens = false; state.hideLowHolderTokens = false;
state.currentView = null; state.currentView = null;
// The screens save this page's copy of "Use Blockscout" over the stored
// one. So the page's copy is turned on here too.
state.useBlockscout = true;
}); });
describe("the Send screen resolves the scale rather than reading the stored one", () => { describe("the Send screen resolves the scale rather than reading the stored one", () => {