diff --git a/README.md b/README.md index f4e8ae7..2329c41 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,27 @@ intercepts tx status links to their own site instead of going to Etherscan, etc. None of the common alternatives work on Firefox. Hence, a minimally viable ERC20 browser wallet/signer that works cross-platform. -Everything you need, nothing you don't. We import as few libraries as possible, -don't implement any crypto, and don't send user-specific data anywhere but a -(user-configurable) Ethereum RPC endpoint (which defaults to a public node). The -extension contacts three user-configurable services: the configured RPC node for -blockchain interactions, a public CoinDesk API (no API key) for realtime price -information, and a Blockscout block-explorer API for transaction history and -token balances. It also performs best-effort Etherscan address label lookups -during transaction confirmation. A community-maintained phishing domain -blocklist is built into the extension at build time and checked locally; nothing -is fetched for it at runtime. +Everything you need, nothing you don't. We import as few libraries as possible +and don't implement any crypto. By default the wallet sends user-specific data +only to the Ethereum RPC endpoint. The user can change that endpoint in +Settings. It defaults to a public node. + +Out of the box the extension contacts two services: + +- the configured RPC node, for blockchain interactions +- a public CoinDesk API (no API key), for realtime prices. It is sent token + symbols and nothing else. + +Two more services are sent none of the user's data until the user turns them on. +Each has a checkbox on the Welcome screen and in Settings: + +- "Use Blockscout": a Blockscout block-explorer API, for transaction history, + the tokens the user has not tracked, and 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 can add any ERC20 contract by contract address if you wish, but the hardcoded @@ -422,6 +433,35 @@ It also covers the red banner on the Settings screen: switching the network there to Sepolia must put up the `[TESTNET]` banner, and switching back to mainnet must take it down, both while Settings is still on screen. +It also covers what a new install sends to Blockscout and Etherscan +([#668](https://git.eeqj.de/sneak/AutistMask/issues/668)). The harness installs +a route handler before the first popup page opens. From then on, the handler +records every request the extension makes, from its pages and from its +background worker. A request the background worker makes while it starts up +comes earlier and is not recorded. The tests take these steps in order: + +- Welcome must offer "Use Blockscout" and "Check recipients on Etherscan" + unticked. +- With neither ticked, Home, AddressDetail, AddressToken and ConfirmTx must each + show the line saying what is off. +- The popup is then closed until the background has run a balance refresh of its + own. No recorded request may have gone to Blockscout or Etherscan. +- "Use Blockscout" is ticked from AddressDetail's gear. On "Back", AddressDetail + must ask Blockscout for its history and no longer say the setting is off. + Blockscout must then be asked for the token balances. +- 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 it. + +Both settings stay on for the rest of the run. TransactionDetail with "Use +Blockscout" off is not driven here, because no history row leads to it then. The +unit tests cover it. + It also covers the confirmation screen, for both a native ETH send and an ERC-20 send: Send disabled while the fee estimate is in flight, enabled once it lands, the fee block quoting the expected cost and the reserve separately, the distinct @@ -845,7 +885,8 @@ src/ views/ — one JS module per screen (home, send, approval, etc.) shared/ — modules used by both popup and background alarms.js — recurring background jobs (extension alarms API) - balances.js — ETH and tracked token balances via RPC, others via Blockscout + balances.js — ETH and tracked token balances via RPC, others via + Blockscout while "Use Blockscout" is on constants.js — chain IDs, default RPC endpoint, ERC-20 ABI ens.js — ENS forward/reverse resolution (popup only) holders.js — holder-count parsing and the low-holder rule @@ -1388,23 +1429,38 @@ that token's previous entry, as a failed ETH balance keeps its figure, or takes Blockscout's entry from the same refresh when there is none. Every other token comes from the Blockscout `token-balances` response (`fetchTokenBalances()`), so tokens do appear without the user adding them, and they keep their previous -figures while Blockscout fails. A tracked token is shown when its balance is -nonzero, and any other ERC-20 when its balance is nonzero and it is in the -bundled known-token list or has 1,000 or more holders. A token claiming a symbol -from the bundled list from any other contract address is always dropped, tracked -or not, and so is any token claiming a symbol that belongs to the native asset -and therefore has no legitimate contract at all (`"ETH"`, and every network's -`nativeCurrency`, such as `"SepoliaETH"`, on every network). That filter is -unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs -the transaction history and the send-screen token selector, not this list. A -token's holder count is unknown when the explorer reports none, or reports -anything other than a whole number written in digits alone, such as `1,000` or -`1e3` (`parseHoldersCount()` in `src/shared/holders.js`). This list does not -take an unknown count as 1,000 or more, so such a token is shown only when it is -on the bundled list or tracked. Every nonzero holding of a token shown is stored -exactly, however small, but a holding below 0.000001 is left out of the balance -lists, the send-screen token selector, the address total and the remove-address -warning (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and +figures while Blockscout fails. + +The refresh asks Blockscout only while the user has "Use Blockscout" on. The +setting is off until the user turns it on. While it is off: + +- 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. + +Each refresh, in the popup or the background, reads the stored setting when it +starts. If the setting is off when the refresh saves, 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 +more holders. A token claiming a symbol from the bundled list from any other +contract address is always dropped, tracked or not, and so is any token claiming +a symbol that belongs to the native asset and therefore has no legitimate +contract at all (`"ETH"`, and every network's `nativeCurrency`, such as +`"SepoliaETH"`, on every network). That filter is unconditional — the "Hide +tokens with fewer than 1,000 holders" setting governs the transaction history +and the send-screen token selector, not this list. A token's holder count is +unknown when the explorer reports none, or reports anything other than a whole +number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()` +in `src/shared/holders.js`). This list does not take an unknown count as 1,000 +or more, so such a token is shown only when it is on the bundled list or +tracked. Every nonzero holding of a token shown is stored exactly, however +small, but a holding below 0.000001 is left out of the balance lists, the +send-screen token selector, the address total and the remove-address warning +(`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and confirmation screens show it when its token is the one being sent. Tracked tokens with a zero balance are listed as well while "Show tracked tokens with zero balance" is on. @@ -1578,6 +1634,17 @@ such as an edit typed into Settings and not yet saved. Home is the one screen "Back" always re-renders, so the wallet list reflects anything that changed while the user was away from it. +Changing "Use Blockscout" or "Check recipients on Etherscan" in Settings makes +every screen but Settings count as not yet rendered. "Back" then renders again +any screen 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 popup open meanwhile takes either change from the stored record, as it +takes a network switch (below). It then draws its screen on top again under the +new setting. + A network switch leaves every screen on the page showing the previous network. The user makes it in Settings, or approves a site's request and the background saves it. Any other popup open meanwhile takes the switch from the stored @@ -1619,8 +1686,15 @@ view would leave a wallet one click from deletion. screen in that case. - **Elements**: - "Welcome! To get started, add a wallet." text + - A well with a line saying that the wallet sends the user's data only to + the Ethereum RPC endpoint unless one of the two checkboxes below is ticked + - In the well, the "Use Blockscout" and "Check recipients on Etherscan" + checkboxes, the same two as in Settings. Each has a sentence saying what + it sends and where. Both are unticked until the user ticks them - "Add wallet" button - **Transitions**: + - Ticking or unticking either checkbox → stores the setting, as Settings + does (no screen change) - "Add wallet" → **AddWallet** #### Home (`main`) @@ -1641,6 +1715,8 @@ view would leave a wallet one click from deletion. holding more than one address); the ENS name, if resolved, is below it; then the full address on a row of its own, followed by the address USD total and a balance line for ETH and for each token shown for that address + - While "Use Blockscout" is off, a line under the wallet list: "Only tracked + tokens are listed, because "Use Blockscout" is off in Settings." - "Recent Transactions": up to 25 transactions merged across every address of every wallet, deduplicated by hash and filtered. Each row is three lines: age and direction, then the counterparty's colour dot (with our own @@ -1648,7 +1724,11 @@ view would leave a wallet one click from deletion. counterparty's full address on a row of its own. A contract creation has no counterparty: its second line is the amount alone and its third line says "This transaction creates a new contract. It has no recipient." The - transaction lists on AddressDetail and AddressToken draw the same rows + transaction lists on AddressDetail and AddressToken draw the same rows. + The history comes from Blockscout. While "Use Blockscout" is off, + Blockscout is not asked. This list then shows only the line "No + transaction history is shown, because "Use Blockscout" is off in + Settings." - "Add additional wallet..." link at bottom - **Transitions**: - Tap address row → sets the active address and broadcasts @@ -1717,9 +1797,13 @@ view would leave a wallet one click from deletion. - USD total for address (see [Partial USD totals](#partial-usd-totals)) - Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal places, USD inline). Each balance row is clickable → **AddressToken** + - While "Use Blockscout" is off, a line under the balance list: "Only + tracked tokens are listed, because "Use Blockscout" is off in Settings." - Send / Receive / + Token buttons and a "···" menu button - "···" dropdown containing a single "Export Private Key" entry - - Transaction list (with ENS resolution for counterparties) + - Transaction list (with ENS resolution for counterparties). While "Use + Blockscout" is off, it shows only the line "No transaction history is + shown, because "Use Blockscout" is off in Settings." - **Transitions**: - Tap balance row → **AddressToken** (for that token) - "Send" → **Send** (refuses with a flash message on a zero balance) @@ -1787,9 +1871,13 @@ view would leave a wallet one click from deletion. - Token contract well (ERC-20 only): full contract address (tap to copy, etherscan link) plus name, symbol, decimals, holder count and project website where known. The "Holders:" row is left out, not shown as 0, when - the token's balance-list entry has no holder count: the explorer did not - report a readable one, or the token is not in the balance list - - Token-filtered transaction list (only this token's transfers) + the token's balance-list entry has no holder count. That happens when: + - the explorer did not report a readable one + - "Use Blockscout" is off + - the token is not in the balance list + - 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**: - "Send" → **Send** (token locked: the dropdown is replaced by a static symbol and contract address) @@ -1806,6 +1894,9 @@ view would leave a wallet one click from deletion. - From: address with color dot + etherscan link - What to send: token dropdown (or static display with contract address when 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 - Amount input with current balance display, which reads `Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as @@ -1875,7 +1966,12 @@ view would leave a wallet one click from deletion. recipient that has never sent a transaction and holds no ETH, recipient is a contract, burn address, and an Etherscan phishing/scam label. An address that has received ETH but never sent a transaction still holds that ETH, - so it gets no warning + so it gets no warning. The Etherscan label check is made only while "Check + recipients on Etherscan" is on. While it is off, the Etherscan box is left + out. A line in its place says "This address was not checked on Etherscan, + because "Check recipients on Etherscan" is off in Settings." The box or + the line is chosen when the screen is drawn, so neither moves anything + later - Errors (an invalid amount), plus five reserved error boxes, so the balance and the fee arriving later move nothing — the amount exceeds the balance (for a token whose scale is unknown it says the token's balance is @@ -2041,7 +2137,11 @@ view would leave a wallet one click from deletion. AddressToken. The screen describes the transaction on the network it was sent on, whichever network is active: its block, network details, decoded details and raw data are read from that network's Blockscout, the one Settings has for - it, and its etherscan links open that network's explorer. + it, and its etherscan links open that network's explorer. The block, network + details, decoded details and raw data are read only while "Use Blockscout" is + on. While it is off, a line in their place says "No block, decoded details, + network details or raw data are shown, because "Use Blockscout" is off in + Settings." - **Elements** (grouped into logical blocks using light well containers; field labels are self-explanatory so groups have no headings): - "Transaction" heading, "Back" button @@ -2124,8 +2224,21 @@ view would leave a wallet one click from deletion. already active changes nothing and sends nothing - Ethereum RPC: endpoint URL input + "Save" button (validated against `eth_chainId` before being saved) - - Blockscout API: endpoint URL input + "Save" button (validated against - `/stats` before being saved) + - Blockscout API: + - "Use Blockscout" checkbox, unticked until the user ticks it + - A sentence saying what it shows and what it sends. It sends the + Blockscout API the user's addresses and the hash of each transaction + opened + - Endpoint URL input + "Save" button. "Save" checks the URL by asking it + for `/stats` before saving it. That request carries no user data. It + is made whether or not "Use Blockscout" is ticked. + - Etherscan: + - "Check recipients on Etherscan" checkbox, unticked until the user + ticks it + - A sentence saying it sends each recipient's address to Etherscan to + check it for a phishing or scam label + - Each of the two settings applies to every network. Welcome offers the same + two checkboxes - Token Spam Protection: - "Hide fake tokens impersonating a known symbol" checkbox - "Hide tokens with fewer than 1,000 holders" checkbox @@ -2149,6 +2262,10 @@ view would leave a wallet one click from deletion. - Debug: hidden until revealed, then an "Enable debug mode" checkbox that turns on the red banner and verbose logging - **Transitions**: + - Ticking or unticking "Use Blockscout" or "Check recipients on Etherscan" → + stores the setting (no screen change). The next balance refresh and every + screen drawn after that follow it. Unticking "Use Blockscout" also drops + every token the user has not tracked from every address - "+ Add wallet" → **AddWallet** - "+ Add token" → **SettingsAddToken** - `[recovery phrase]` on an HD wallet → **ShowRecoveryPhrase** @@ -2167,7 +2284,8 @@ view would leave a wallet one click from deletion. nothing and tells the background nothing - Ten clicks on the version → reveals the Debug well (no screen change) - "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`) @@ -2324,6 +2442,8 @@ view would leave a wallet one click from deletion. money. The predicate is `addressHoldsFunds()` in `src/popup/views/helpers.js`, unrounded and token-aware. A balance is a 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 removing the last one means deleting the wallet from Settings - Error line @@ -2665,17 +2785,31 @@ view would leave a wallet one click from deletion. ### External Services -AutistMask is not a fully self-contained offline tool. It necessarily -communicates with three external services to function as a wallet: +AutistMask is not a fully self-contained offline tool. By default it sends the +user's data only to the Ethereum RPC endpoint. It also asks the CoinDesk price +API for prices, sending token symbols and nothing else. + +Two more services are sent none of the user's data until the user turns on their +settings: + +- Blockscout, with "Use Blockscout" +- Etherscan, with "Check recipients on Etherscan" + +Both settings are on the Welcome screen and in Settings. Both are off in a new +install. Each setting applies to every network. + +Contacted by default: - **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`), among them the balance of every tracked token at every address (`balanceOf`), estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`), broadcast transactions (`eth_sendRawTransaction`), and check transaction - receipts. The default endpoint is a public RPC (configurable by the user to - any endpoint they prefer, including a local node). By default the extension - talks to `https://ethereum-rpc.publicnode.com`. + receipts. Every ENS lookup is made through it alone. A name whose resolver + says it is resolved off-chain therefore does not resolve. The default endpoint + is a public RPC (configurable by the user to any endpoint they prefer, + including a local node). By default the extension talks to + `https://ethereum-rpc.publicnode.com`. - **Data sent**: Ethereum addresses, transaction data, contract call parameters. The RPC endpoint can see all on-chain queries and submitted transactions. @@ -2687,13 +2821,38 @@ communicates with three external services to function as a wallet: - **Data sent**: Token symbol strings only (e.g. "ETH", "USDC"). No addresses or user-specific data. -- **Blockscout block-explorer API**: Used to fetch transaction history (normal - transactions and ERC-20 token transfers), ERC-20 token balances, and token - holder counts (for spam filtering). The default endpoint is +Sent the user's data only while the user has turned the setting on: + +- **Blockscout block-explorer API** ("Use Blockscout"): The default endpoint is `https://eth.blockscout.com/api/v2` (configurable by the user in Settings). - - **Data sent**: Ethereum addresses. Blockscout receives the user's - addresses to query their transaction history and token balances. No - private keys, passwords, or signing operations are sent. + - **Used for**: + - transaction history (normal transactions and ERC-20 token transfers), + for Home, AddressDetail and AddressToken + - the balances of the ERC-20 tokens the user has not tracked, for Home, + AddressDetail, Send and DeleteAddress + - token holder counts, for spam filtering + - a transaction's block, network details and calldata, for + TransactionDetail + - **While the setting is off**: each of those screens shows a line saying + what it leaves out and that "Use Blockscout" is off. Only tracked tokens + are listed. + - **Saving the URL**: pressing "Save" beside the URL in Settings asks that + URL for `/stats`. This request carries no user data. It is made whether or + not the setting is on. + - **Data sent**: Ethereum addresses, and the hash of each transaction the + user opens. Blockscout receives the user's addresses to query their + transaction history and token balances. No private keys, passwords, or + signing operations are sent. + +- **Etherscan address labels** ("Check recipients on Etherscan"): When + confirming a transaction, the extension performs a best-effort lookup of the + recipient address on Etherscan to check for phishing/scam labels. This is a + direct page fetch with no API key; the user's browser makes the request. While + the setting is off, ConfirmTx says that no Etherscan check was made. + - **Data sent**: the recipient's address and nothing else. + +The etherscan links on the screens open a page in the browser when the user +clicks one. They are not requests the wallet makes. What the extension does NOT do: @@ -2702,16 +2861,9 @@ What the extension does NOT do: - No Infura/Alchemy dependency (any JSON-RPC endpoint works) - No backend servers operated by the developer -In addition to the three user-configurable services above (RPC endpoint, -CoinDesk price API, and Blockscout API), AutistMask also contacts: - -- **Etherscan address labels**: When confirming a transaction, the extension - performs a best-effort lookup of the recipient address on Etherscan to check - for phishing/scam labels. This is a direct page fetch with no API key; the - user's browser makes the request. - -Users who want maximum privacy can point the RPC and Blockscout URLs at their -own self-hosted instances (price fetching can be disabled in a future version). +Users who want maximum privacy can point the RPC URL at their own node. With +"Use Blockscout" on, they can point the Blockscout URL at their own instance +too. ### Dependencies @@ -2893,8 +3045,11 @@ logged. - Add multiple addresses within an HD wallet - Manage multiple wallets simultaneously - View ETH balance per address -- View ERC-20 token balances (tokens on the bundled known-token list, tokens - with 1,000 or more holders, and tokens the user adds by contract address) +- View ERC-20 token balances: + - tokens the user adds by contract address + - while "Use Blockscout" is on, tokens on the bundled known-token list and + tokens with 1,000 or more holders +- View transaction history and transaction details, while "Use Blockscout" is on - Send ETH to an address - Send ERC-20 tokens to an address - Receive ETH/tokens (display address, copy to clipboard, QR code) diff --git a/docs/README.md b/docs/README.md index 424d7a2..d63905d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,11 +57,12 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that. - **No NFT galleries or portfolio views.** This is a wallet, not a dashboard. -- **No third-party token list APIs.** Token balances come from the same block - explorer you configure for transaction history, and the extension ships its - own hardcoded list of top ERC-20 contract addresses for symbol-spoofing - detection. Any token you want tracked across all your addresses, you add - yourself by contract address. +- **No third-party token list APIs.** The balances of the tokens you track are + read from your RPC endpoint. While "Use Blockscout" is on, the other tokens + come from the same block explorer you configure for transaction history. The + extension ships its own hardcoded list of top ERC-20 contract addresses for + symbol-spoofing detection. Any token you want tracked across all your + addresses, you add yourself by contract address. - **No backend servers operated by the developer.** Nothing is sent to any server run by AutistMask. Every network destination is listed below. @@ -69,8 +70,13 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that. ## How It Works AutistMask is a browser extension that runs entirely in your browser. It does -not have a backend server. It communicates with five external destinations: -three you configure yourself, and two fixed ones used for scam detection. +not have a backend server. Out of the box it sends your data only to the +Ethereum RPC endpoint. It also asks the CoinDesk price API for prices, sending +token symbols and nothing else. + +Blockscout and Etherscan are sent none of your data until you turn them on. +Their settings are "Use Blockscout" and "Check recipients on Etherscan". Both +are on the Welcome screen and in Settings. Both are off in a new install. ### External Services @@ -96,16 +102,31 @@ addresses are necessarily visible to the RPC provider when querying balances. **Blockscout API** (default: `eth.blockscout.com/api/v2`; `eth-sepolia.blockscout.com/api/v2` on Sepolia) -Used to fetch token balances and transaction history. Blockscout is an -open-source blockchain explorer. AutistMask queries it for your ERC-20 token -balances (including the holder counts used for spam filtering) and your recent -transactions and token transfers. You can change this in Settings to a -self-hosted Blockscout instance. +Blockscout is an open-source blockchain explorer. AutistMask uses it only while +"Use Blockscout" is on. It then asks Blockscout for: -When it is contacted: on every balance refresh, and whenever a screen showing -transaction history is opened. +- your recent transactions and token transfers +- the balances of the tokens you have not tracked, with the holder counts used + for spam filtering +- the block, fees and calldata of a transaction you open -What gets sent: your Ethereum addresses (to look up balances and transactions). +You can change this in Settings to a self-hosted Blockscout instance. While the +setting is off, each screen that would show Blockscout's data says so instead. +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 also contacts it, even +with the setting off. That request checks the URL by asking it for `/stats`. It +sends none of your data. + +What gets sent: your Ethereum addresses (to look up balances and transactions) +and the hash of each transaction you open. **CoinDesk CADLI price API** (`data-api.coindesk.com`) @@ -123,15 +144,19 @@ address. **Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia) When you review a send, AutistMask fetches the recipient's public Etherscan -address page and looks for a "Fake_Phishing"/"Phish/Hack" label or a scam +address page. It looks for a "Fake_Phishing"/"Phish/Hack" label or a scam warning, and shows a red warning if it finds one. This is a plain page fetch with no API key, made by your browser. It is best-effort: if it fails, it is silently ignored. This endpoint is not user-configurable. -When it is contacted: each time you reach the send confirmation screen. +The check is off until you turn on "Check recipients on Etherscan". While the +setting is off, the confirmation screen says that no Etherscan check was made. -What gets sent: the recipient address you are about to send to, and your IP -address. Your own addresses are not sent. +When it is contacted: only while "Check recipients on Etherscan" is on, each +time you reach the send confirmation screen. + +What gets sent: the recipient's address and nothing else. As with any request, +Etherscan sees your IP address. Etherscan links shown elsewhere in the UI (on addresses, transactions, and token contracts) are ordinary links. They contact nothing until you click them. @@ -185,6 +210,9 @@ password. 1. Click the AutistMask icon in your browser toolbar. 2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet". + On first use, the screen with "Add wallet" also offers "Use Blockscout" and + "Check recipients on Etherscan", both off. Tick either if you want what it + does (see External Services). You can change both later in Settings. 3. On the "From Phrase" tab, click the die button to generate a random 12-word recovery phrase. 4. **Write down the recovery phrase and store it safely.** Anyone with these @@ -220,10 +248,14 @@ always produce the same sequence of addresses. ### Adding ERC-20 Tokens -Tokens you hold show up automatically only if they are in the extension's -bundled list of well-known tokens or have at least 1,000 holders; everything -else is treated as spam and hidden. To track a token explicitly (which also -shows it at zero balance), add it by contract address: +Tokens you hold show up automatically only while "Use Blockscout" is on. Even +then, a token shows up only if it is in the extension's bundled list of +well-known tokens or has at least 1,000 holders. Everything else is treated as +spam and hidden. + +A token you track is shown whether or not "Use Blockscout" is on. By default it +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). 2. Click "+ Token". @@ -389,8 +421,9 @@ Click the gear icon on the home screen to access settings: that network's defaults if you have not used it before. - **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC. You can use your own node for maximum privacy. -- **Blockscout API**: Change the Blockscout instance used for token balances and - transaction history. You can use a self-hosted instance. +- **Blockscout API**: the "Use Blockscout" checkbox, off until you tick it, and + the URL of the Blockscout instance. You can use a self-hosted instance. +- **Etherscan**: "Check recipients on Etherscan", off until you tick it. - **Token Spam Protection**: Toggle individual scam filters and set the dust transaction threshold. - **Allowed Sites / Denied Sites**: View and manage web3 site permissions. diff --git a/src/background/index.js b/src/background/index.js index f9d6b9d..68c3688 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -17,7 +17,11 @@ const { applyChainSwitchFields } = require("../shared/chainSwitchFields"); // the same prohibition reported early, not the guarantee. const { getState, updateState } = require("./state"); 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 { 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). // lastBalanceRefresh is left as the switch set it, so the next tick refreshes // the new network. +// +// The refresh asks Blockscout only if "Use Blockscout" is on when it starts. +// The user may turn it off while the refresh runs. So when "Use Blockscout" +// stored NOW is off, the save drops every token the user has not tracked. async function backgroundRefresh() { const s = await getState(); const now = Date.now(); @@ -1397,6 +1405,7 @@ async function backgroundRefresh() { wallets, s.rpcUrl, s.blockscoutUrl, + s.useBlockscout, s.trackedTokens, s.networkId, ); @@ -1423,6 +1432,9 @@ async function backgroundRefresh() { } } } + if (!fresh.useBlockscout) { + dropUntrackedTokens(fresh.wallets, fresh.trackedTokens); + } fresh.lastBalanceRefresh = now; }); } diff --git a/src/popup/index.html b/src/popup/index.html index b41cf9e..001c7e8 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -43,6 +43,38 @@
+ Shows transaction history, tokens you have not tracked + and transaction details. Sends your addresses, and the + hash of each transaction you open, to the Blockscout API + below. +
- Used to fetch token balances and transaction history. Change this if you run your own Blockscout instance.
+ Sends each recipient's address to Etherscan to check it + for a phishing or scam label. +
+@@ -1291,6 +1380,13 @@ >
A wallet always keeps at least one address. To remove the last one, delete the whole wallet from Settings instead. @@ -1504,6 +1600,16 @@ + +
+No wallets yet. Add one to get started.
'; @@ -447,7 +455,7 @@ function init(ctx) { } $("send-to").value = ""; $("send-amount").value = ""; - $("send-token").classList.remove("hidden"); + $("send-token-dropdown").classList.remove("hidden"); $("send-token-static").classList.add("hidden"); renderSendTokenSelect(addr); updateSendBalance(); diff --git a/src/popup/views/send.js b/src/popup/views/send.js index 6556c9f..123ca62 100644 --- a/src/popup/views/send.js +++ b/src/popup/views/send.js @@ -141,6 +141,8 @@ function updateToValidation() { } function renderSendTokenSelect(addr) { + // Tokens the user has not tracked are Blockscout's to report. + $("send-tokens-note").classList.toggle("hidden", !!state.useBlockscout); const sel = $("send-token"); sel.innerHTML = ``; const fraudSet = new Set( @@ -426,7 +428,7 @@ function init(_ctx) { }); $("btn-send-back").addEventListener("click", () => { - $("send-token").classList.remove("hidden"); + $("send-token-dropdown").classList.remove("hidden"); $("send-token-static").classList.add("hidden"); goBack(); }); diff --git a/src/popup/views/settings.js b/src/popup/views/settings.js index 4d0d53a..7b6d9fb 100644 --- a/src/popup/views/settings.js +++ b/src/popup/views/settings.js @@ -17,6 +17,7 @@ const { const { state, saveState, currentNetwork } = require("../../shared/state"); const { onChainSwitch } = require("../../shared/chainSwitch"); const { restorableStack } = require("../../shared/persistedState"); +const { dropUntrackedTokens } = require("../../shared/balances"); const { resetRenderedViews, markViewRendered } = require("../viewRouter"); const { log, @@ -201,6 +202,10 @@ function show() { $("settings-rpc").value = state.rpcUrl; $("settings-blockscout").value = state.blockscoutUrl; $("settings-network").value = state.networkId; + // Set on every show, not only at init: Welcome can change these two. + $("settings-use-blockscout").checked = state.useBlockscout; + $("settings-check-recipients-on-etherscan").checked = + state.checkRecipientsOnEtherscan; renderTrackedTokens(); renderSiteLists(); renderWalletListSettings(); @@ -257,6 +262,8 @@ async function renderSiteLists() { // native token: any other token there was picked on the previous network, so // it is no longer selected either. Without a wallet the stack starts at // Welcome, which that cut would drop, and Welcome shows nothing from a network. +// A change to "Use Blockscout" cuts the stack the same way (setUseBlockscout() +// below). function cutViewStack() { if (!state.hasWallet) return; if (state.selectedToken !== "ETH") { @@ -269,18 +276,39 @@ function cutViewStack() { state.viewStack = restorableStack(state.viewStack, state.currentView); } -// Every screen counts as not yet rendered, so "Back" renders the one it lands -// on for the new network. Settings on top is brought up to date in place; -// any other screen is drawn again as a reopened popup draws it, which is Home -// for one that cannot be rendered from saved state. Without a wallet any other -// screen is Welcome or AddWallet, which show nothing from a network. -function showNewNetwork() { +// Sets "Use Blockscout" to `on` on this page. It is called inside a save, +// holding the lock, for the user's change here or another page's +// (followStoredSettings() in src/popup/index.js). +// +// Send's token dropdown was filled under the old setting, and Back cannot draw +// 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 the one it lands on under the new +// settings. 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(); + $("welcome-use-blockscout").checked = state.useBlockscout; + $("welcome-check-recipients-on-etherscan").checked = + state.checkRecipientsOnEtherscan; if (state.currentView === "settings") { markViewRendered("settings"); $("settings-network").value = state.networkId; $("settings-rpc").value = state.rpcUrl; $("settings-blockscout").value = state.blockscoutUrl; + $("settings-use-blockscout").checked = state.useBlockscout; + $("settings-check-recipients-on-etherscan").checked = + state.checkRecipientsOnEtherscan; renderTrackedTokens(); } else if (state.hasWallet) { ctx.restoreView(); @@ -363,6 +391,34 @@ function init(_ctx) { showFlash("Saved."); }); + // Each change is made inside the save, holding the lock. Then + // followStoredSettings() in src/popup/index.js never takes this page's + // unsaved change for another page's. The screens under Settings were drawn + // under the old setting. So each change makes every screen but Settings count as not yet + // drawn, as a network switch does. Back then draws again any screen a + // reopened popup can draw. Back only unhides Welcome, so its boxes are set + // here too. + $("settings-use-blockscout").addEventListener("change", async () => { + const on = $("settings-use-blockscout").checked; + await saveState(() => setUseBlockscout(on)); + $("welcome-use-blockscout").checked = on; + resetRenderedViews(); + markViewRendered("settings"); + }); + + $("settings-check-recipients-on-etherscan").addEventListener( + "change", + async () => { + const on = $("settings-check-recipients-on-etherscan").checked; + await saveState(() => { + state.checkRecipientsOnEtherscan = on; + }); + $("welcome-check-recipients-on-etherscan").checked = on; + resetRenderedViews(); + markViewRendered("settings"); + }, + ); + const networkSelect = $("settings-network"); networkSelect.addEventListener("change", async () => { // Whether `state` already has the network picked is asked only once @@ -376,7 +432,7 @@ function init(_ctx) { // Open pages are told by the background, as after a site's approved // switch request. notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId }); - showNewNetwork(); + showNewSettings(); showFlash("Switched to " + net.name + "."); }); @@ -516,5 +572,6 @@ module.exports = { show, renderSiteLists, cutViewStack, - showNewNetwork, + setUseBlockscout, + showNewSettings, }; diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index 832211f..03ade08 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -191,6 +191,11 @@ function render() { if (el) el.classList.add("hidden"); } + // Those details are Blockscout's; this line says why there are none. + $("tx-detail-blockscout-note").classList.toggle( + "hidden", + !!state.useBlockscout, + ); loadFullTxDetails(tx.hash, tx.chainId); const isoStr = isoDate(tx.timestamp); @@ -303,7 +308,11 @@ function populateOnChainDetails(txData, chainId) { } } +// Reads the block, network details and calldata from Blockscout, and decodes +// the calldata. While "Use Blockscout" is off, Blockscout is not asked and none +// of them is shown. async function loadFullTxDetails(txHash, chainId) { + if (!state.useBlockscout) return; const section = $("tx-detail-calldata-section"); const actionEl = $("tx-detail-calldata-action"); const detailsEl = $("tx-detail-calldata-details"); diff --git a/src/popup/views/welcome.js b/src/popup/views/welcome.js index 46d27f3..0afa6c8 100644 --- a/src/popup/views/welcome.js +++ b/src/popup/views/welcome.js @@ -1,7 +1,37 @@ -const { $ } = require("./helpers"); +// The first screen, shown while there is no wallet. It offers "Use Blockscout" +// and "Check recipients on Etherscan", which Settings offers too. Both are off +// until the user ticks them. -function init(ctx) { - $("btn-welcome-add").addEventListener("click", ctx.showAddWalletView); +const { $, showView } = require("./helpers"); +const { state, saveState } = require("../../shared/state"); + +function show() { + $("welcome-use-blockscout").checked = state.useBlockscout; + $("welcome-check-recipients-on-etherscan").checked = + state.checkRecipientsOnEtherscan; + showView("welcome"); } -module.exports = { init }; +// Each change is made inside the save, holding the lock, as Settings makes it. +function init(ctx) { + $("btn-welcome-add").addEventListener("click", ctx.showAddWalletView); + + $("welcome-use-blockscout").addEventListener("change", async () => { + const on = $("welcome-use-blockscout").checked; + await saveState(() => { + state.useBlockscout = on; + }); + }); + + $("welcome-check-recipients-on-etherscan").addEventListener( + "change", + async () => { + const on = $("welcome-check-recipients-on-etherscan").checked; + await saveState(() => { + state.checkRecipientsOnEtherscan = on; + }); + }, + ); +} + +module.exports = { init, show }; diff --git a/src/shared/balances.js b/src/shared/balances.js index c459f85..dac86ee 100644 --- a/src/shared/balances.js +++ b/src/shared/balances.js @@ -1,6 +1,10 @@ -// Balance fetching: ETH balances and the balances of the tokens the user -// tracks via RPC, the other ERC-20 token balances and the holder counts via -// Blockscout, ENS reverse lookup via RPC. +// Balance fetching: +// +// - ETH balances, and the balances of the tokens the user tracks, via RPC +// - the other ERC-20 token balances and the holder counts via Blockscout, +// while the user has "Use Blockscout" on. Those balances are dropped when +// it is off. +// - ENS reverse lookup via RPC const { JsonRpcProvider, @@ -37,9 +41,18 @@ const { resolveTokenDecimals } = require("./approvalAmount"); // ethers knows chains this wallet does not, so an id that is not one of ours // is a caller bug and must not resolve to a working provider for some other // chain. +// +// `disableCcipRead` keeps every ENS lookup on the RPC endpoint. 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, that is the +// user's own name (https://git.eeqj.de/sneak/AutistMask/issues/668). With the +// flag set, such a name does not resolve. function getProvider(rpcUrl, networkId) { const net = Network.from(requireNetworkId(networkId).id); - return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net }); + const provider = new JsonRpcProvider(rpcUrl, net, { staticNetwork: net }); + provider.disableCcipRead = true; + return provider; } function requireNetworkId(networkId) { @@ -81,6 +94,11 @@ function rawUnits(value) { // Fetch token balances for a single address from Blockscout. // Returns [{ address, name, symbol, decimals, balance, holders }]. +// +// `useBlockscout` is the user's "Use Blockscout" setting. While it is off, +// Blockscout is not asked and the answer is an empty list. So no token the user +// has not tracked is listed. +// // Filters out spam: only shows tokens that are in the known token list, // explicitly tracked by the user, or have >= 1000 holders. A tracked token's // balance is read through the RPC endpoint instead, and its row here gives that @@ -96,9 +114,11 @@ function rawUnits(value) { async function fetchTokenBalances( address, blockscoutUrl, + useBlockscout, trackedTokens, signal, ) { + if (!useBlockscout) return []; try { const resp = await debugFetch( blockscoutUrl + "/addresses/" + address + "/token-balances", @@ -296,12 +316,13 @@ async function fetchTrackedTokenBalances( } // Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses. -// `trackedTokens` are the tokens tracked on the active network. `signal` is as -// for fetchTokenBalances(). +// `trackedTokens` are the tokens tracked on the active network. +// `useBlockscout` and `signal` are as for fetchTokenBalances(). async function refreshBalances( wallets, rpcUrl, blockscoutUrl, + useBlockscout, trackedTokens, networkId, signal, @@ -362,13 +383,15 @@ async function refreshBalances( // ERC-20 token balances: the tracked tokens' through the RPC // endpoint, and from Blockscout the tokens the user has not // tracked. When Blockscout fails, the untracked rows from before - // this refresh stay. The Blockscout request is started first and + // this refresh stay. While "Use Blockscout" is off, there are no + // untracked rows. The Blockscout request is started first and // handed to fetchTrackedTokenBalances() too, which waits for it // after sending its balanceOf reads, so the two run side by side. const previous = addr.tokenBalances || []; const explorerRequest = fetchTokenBalances( addr.address, blockscoutUrl, + useBlockscout, tracked, signal, ); @@ -404,6 +427,20 @@ async function refreshBalances( log.debugf("refreshBalances done"); } +// 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. // Calls symbol() and decimals() to verify it implements ERC-20. // `signal` is as for fetchTokenBalances(). @@ -522,6 +559,7 @@ async function scanForAddresses(xpub, rpcUrl, networkId, signal, gapLimit = 5) { module.exports = { fetchTokenBalances, refreshBalances, + dropUntrackedTokens, lookupTokenInfo, getProvider, scanForAddresses, diff --git a/src/shared/chainSwitch.js b/src/shared/chainSwitch.js index 9c54ca2..5c86f84 100644 --- a/src/shared/chainSwitch.js +++ b/src/shared/chainSwitch.js @@ -4,7 +4,7 @@ // // - onChainSwitch() below makes the switch the user picks in Settings, with // applyChainSwitchFields() (chainSwitchFields.js). -// - followNetworkSwitch() in src/popup/index.js takes a switch the background +// - followStoredSettings() in src/popup/index.js takes a switch the background // or another popup page saved, from the stored record, with // takeStoredNetwork() (state.js), which copies it with // copyChainSwitchFields() (chainSwitchFields.js). @@ -45,7 +45,7 @@ function clearPopupCaches() { // // `state` is switched inside the save that stores the switch, holding the // lock, so it is never switched while a follow of a switch saved elsewhere -// waits for the lock (followNetworkSwitch() in src/popup/index.js): that +// waits for the lock (followStoredSettings() in src/popup/index.js): that // follow runs first, and this switch is made after it. async function onChainSwitch(newNetworkId) { const { state, saveState } = require("./state"); diff --git a/src/shared/etherscanLabels.js b/src/shared/etherscanLabels.js index 9f19345..ed8cc2f 100644 --- a/src/shared/etherscanLabels.js +++ b/src/shared/etherscanLabels.js @@ -3,6 +3,7 @@ // This is a best-effort enrichment: network failures return null silently. const { networkByChainId } = require("./networks"); +const { storageGet } = require("./browserApi"); // Patterns in the page title that indicate a flagged address. // Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan" @@ -78,12 +79,18 @@ function parseEtherscanPage(html) { * when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on * Sepolia. * + * Etherscan is asked only while "Check recipients on Etherscan" is on in the + * stored profile. The page's own copy of the setting is not used, because + * another page may have just changed it. While it is off, the answer is null. + * * @param {string} address - Ethereum address to check. * @param {string} [chainId] - Chain id of the network to look it up on. * @returns {Promise<{type: string, message: string, severity: string}|null>} */ async function checkEtherscanLabel(address, chainId) { try { + const { autistmask } = await storageGet("autistmask"); + if (!autistmask || !autistmask.checkRecipientsOnEtherscan) return null; // Lazy require to avoid pulling in chrome.storage at module scope // (which breaks unit tests that only exercise parseEtherscanPage). const { currentNetwork } = require("./state"); @@ -105,6 +112,7 @@ async function checkEtherscanLabel(address, chainId) { return null; } catch { // Network errors are expected — Etherscan may rate-limit or block. + // A failed storage read lands here too, before Etherscan is asked. return null; } } diff --git a/src/shared/persistedState.js b/src/shared/persistedState.js index 4613045..f6ced9f 100644 --- a/src/shared/persistedState.js +++ b/src/shared/persistedState.js @@ -48,6 +48,14 @@ const DEFAULT_STATE = { hideDustTransactions: true, dustThresholdGwei: 100000, utcTimestamps: false, + // "Use Blockscout" and "Check recipients on Etherscan" in Settings. Each + // setting applies to every network. Both are off until the user turns them + // on. With "Use Blockscout" on, Blockscout is sent the user's addresses and + // the hash of each transaction opened. With "Check recipients on + // Etherscan" on, Etherscan is sent the recipient's address and nothing + // else. + useBlockscout: false, + checkRecipientsOnEtherscan: false, fraudContracts: [], tokenHolderCache: {}, theme: "system", @@ -406,6 +414,10 @@ function normalizePersisted(saved) { : 100000; out.utcTimestamps = saved.utcTimestamps !== undefined ? saved.utcTimestamps : false; + // Anything but true is off: each setting lets a request reach another + // service. + out.useBlockscout = saved.useBlockscout === true; + out.checkRecipientsOnEtherscan = saved.checkRecipientsOnEtherscan === true; // A list of contract addresses, floored the same way: send.js builds its // fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())` // and filterTransactions() maps the same list through normalizeAddress(), diff --git a/src/shared/state.js b/src/shared/state.js index 74b630a..cdb8cb0 100644 --- a/src/shared/state.js +++ b/src/shared/state.js @@ -470,8 +470,9 @@ function mergeNetworkEndpoints(base, ours, theirs) { // (caught by tests/txStatus.test.js). A page's live picture of a field it // does not own goes on being whatever its last loadState() saw, same as // before this fix; only the persisted record is guaranteed current. The -// network is the exception: the popup takes a switch another context saved -// through `beforeWrite` (followNetworkSwitch() in src/popup/index.js). +// network, "Use Blockscout" and "Check recipients on Etherscan" are the +// exceptions: the popup takes a change another context saved through +// `beforeWrite` (followStoredSettings() in src/popup/index.js). async function saveStateOnce(beforeWrite) { const result = await storageGet("autistmask"); // The record in storage right now is about to be merged into and written @@ -603,7 +604,7 @@ function saveState(beforeWrite) { } // 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 // 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 diff --git a/src/shared/transactions.js b/src/shared/transactions.js index a72f429..12c22a7 100644 --- a/src/shared/transactions.js +++ b/src/shared/transactions.js @@ -235,12 +235,17 @@ function mergeTransactions(txs, tokenTransfers) { // `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry // carries it, and a native entry is labelled with that network's // `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`). +// +// `useBlockscout` is the user's "Use Blockscout" setting. While it is off, +// Blockscout is not asked and the answer is an empty list. async function fetchRecentTransactions( address, blockscoutUrl, + useBlockscout, chainId, count = 25, ) { + if (!useBlockscout) return []; log.debugf("fetchRecentTransactions", address); const addrLower = normalizeAddress(address); diff --git a/tests/alarms.test.js b/tests/alarms.test.js index dfdc61f..b0d3194 100644 --- a/tests/alarms.test.js +++ b/tests/alarms.test.js @@ -41,6 +41,8 @@ jest.mock("../src/shared/balances", () => ({ refreshBalances: jest.fn(async () => { mockBalanceRefreshAt.push(Date.now()); }), + dropUntrackedTokens: jest.requireActual("../src/shared/balances") + .dropUntrackedTokens, getProvider: jest.fn(() => ({})), })); diff --git a/tests/backgroundStateIsolation.test.js b/tests/backgroundStateIsolation.test.js index ee24cc7..c29edc7 100644 --- a/tests/backgroundStateIsolation.test.js +++ b/tests/backgroundStateIsolation.test.js @@ -142,6 +142,8 @@ function loadWorker(networkId, opts) { }, refreshBalances: options.refreshBalances || jest.fn(async () => undefined), + dropUntrackedTokens: jest.requireActual("../src/shared/balances") + .dropUntrackedTokens, })); jest.doMock("../src/shared/phishingDomains", () => ({ isPhishingDomain: () => false, @@ -410,7 +412,14 @@ describe("a balance refresh under a network switch", () => { const ranOn = []; const bg = loadWorker("sepolia", { - refreshBalances: async (wallets, rpcUrl, explorer, tokens, id) => { + refreshBalances: async ( + wallets, + rpcUrl, + explorer, + useBlockscout, + tokens, + id, + ) => { ranOn.push(id); reachedNetwork.resolve(); await roundTrip.promise; @@ -443,3 +452,61 @@ 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). A refresh that started while it was on + // 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 handed = []; + + const bg = loadWorker("mainnet", { + refreshBalances: async ( + wallets, + rpcUrl, + explorer, + useBlockscout, + ) => { + handed.push(useBlockscout); + 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; + + expect(handed).toEqual([true]); + const after = bg.persisted(); + expect(after.wallets[0].addresses[0].balance).toBe("7.0"); + expect(after.wallets[0].addresses[0].tokenBalances).toEqual([]); + }); +}); diff --git a/tests/balanceRefreshCancelled.test.js b/tests/balanceRefreshCancelled.test.js index 9686fa8..ed60705 100644 --- a/tests/balanceRefreshCancelled.test.js +++ b/tests/balanceRefreshCancelled.test.js @@ -54,6 +54,7 @@ function refresh(signal) { wallets, RPC_URL, EXPLORER_URL, + true, [TRACKED], "mainnet", signal, diff --git a/tests/balanceRefreshNetworkSwitch.test.js b/tests/balanceRefreshNetworkSwitch.test.js index 94b0a4a..3f94f2a 100644 --- a/tests/balanceRefreshNetworkSwitch.test.js +++ b/tests/balanceRefreshNetworkSwitch.test.js @@ -91,7 +91,7 @@ test("a refresh that returns after a switch in Settings writes nothing, and the await page.tick(); - expect(refreshBalances.mock.lastCall[4]).toBe("sepolia"); + expect(refreshBalances.mock.lastCall[5]).toBe("sepolia"); expect(page.storage.read("autistmask").lastBalanceRefresh).toBeGreaterThan( 0, ); diff --git a/tests/blockscoutEtherscanSettings.test.js b/tests/blockscoutEtherscanSettings.test.js new file mode 100644 index 0000000..92f23b0 --- /dev/null +++ b/tests/blockscoutEtherscanSettings.test.js @@ -0,0 +1,783 @@ +// Blockscout and Etherscan are sent none of the user's data until the user +// turns them on (https://git.eeqj.de/sneak/AutistMask/issues/668). A new +// profile has "Use Blockscout" and "Check recipients on Etherscan" both off. +// While a setting is off, the code that would ask its service asks nothing. +// +// The one request made with the setting off is the check behind "Save" beside +// the Blockscout URL in Settings. It asks that URL only for `/stats` and sends +// none of the user's data. +// +// The balance refresh and the Etherscan check follow the stored setting, not +// the page's copy of it. So a change another page saved applies to them 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() +// that records every URL it is asked for. The screens are driven through the +// real popup over a stored profile (tests/support/popupBoot.js). Its fetch() +// stub records the same way. + +"use strict"; + +const { FetchRequest } = require("ethers"); +const { + bootPopup, + cleanupPopup, + unversionedValidProfile, + ADDRESS, + TOKEN_ADDRESS, +} = require("./support/popupBoot"); +const { makeStorageStub } = require("./support/storageStub"); +const { + DEFAULT_STATE, + normalizePersisted, +} = require("../src/shared/persistedState"); +const { + fetchTokenBalances, + refreshBalances, +} = require("../src/shared/balances"); +const { fetchRecentTransactions } = require("../src/shared/transactions"); +const { getFullWarnings } = require("../src/shared/addressWarnings"); +const { HISTORY_OFF_TEXT, escapeHtml } = require("../src/popup/views/helpers"); + +const RPC_URL = "https://rpc.example.invalid"; +const BLOCKSCOUT = "https://eth.blockscout.com/api/v2"; +const RECIPIENT = "0x2222222222222222222222222222222222222222"; +const TX_HASH = "0x" + "ab".repeat(32); +const ZERO_WORD = "0x" + "0".repeat(64); + +// USDC, from the bundled list, as Blockscout reports a holding of it. +const USDC_ROW = { + value: "5000000", + token: { + type: "ERC-20", + address_hash: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + symbol: "USDC", + name: "USD Coin", + decimals: "6", + holders_count: "50000", + }, +}; + +const realFetch = globalThis.fetch; + +afterEach(() => { + cleanupPopup(); + jest.restoreAllMocks(); +}); + +describe("a new profile", () => { + test("has both settings off", () => { + expect(DEFAULT_STATE.useBlockscout).toBe(false); + expect(DEFAULT_STATE.checkRecipientsOnEtherscan).toBe(false); + const loaded = normalizePersisted(undefined); + expect(loaded.useBlockscout).toBe(false); + expect(loaded.checkRecipientsOnEtherscan).toBe(false); + }); + + test("keeps either setting the user turned on", () => { + const loaded = normalizePersisted({ + useBlockscout: true, + checkRecipientsOnEtherscan: true, + }); + expect(loaded.useBlockscout).toBe(true); + expect(loaded.checkRecipientsOnEtherscan).toBe(true); + }); +}); + +describe("where each request is made", () => { + // Every URL fetch() was asked for, in order. + let asked; + + beforeEach(() => { + asked = []; + globalThis.fetch = jest.fn(async (url) => { + asked.push(String(url)); + return { + ok: true, + status: 200, + json: async () => + String(url).endsWith("/token-balances") + ? [USDC_ROW] + : { items: [] }, + text: async () => "", + }; + }); + }); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + test("Blockscout's token balances are asked for only while Use Blockscout is on", async () => { + expect( + await fetchTokenBalances(ADDRESS, BLOCKSCOUT, false, []), + ).toEqual([]); + expect(asked).toEqual([]); + + const rows = await fetchTokenBalances(ADDRESS, BLOCKSCOUT, true, []); + expect(rows.map((row) => row.symbol)).toEqual(["USDC"]); + expect(asked).toEqual([ + BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-balances", + ]); + }); + + test("the transaction history is asked for only while Use Blockscout is on", async () => { + expect( + await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, false, "0x1"), + ).toEqual([]); + expect(asked).toEqual([]); + + await fetchRecentTransactions(ADDRESS, BLOCKSCOUT, true, "0x1"); + expect(asked.sort()).toEqual([ + BLOCKSCOUT + + "/addresses/" + + ADDRESS + + "/token-transfers?type=ERC-20", + BLOCKSCOUT + "/addresses/" + ADDRESS + "/transactions", + ]); + }); + + test("the Etherscan label check is made only while the stored profile has Check recipients on Etherscan on", async () => { + const provider = { + getCode: async () => "0x", + getTransactionCount: async () => 1, + getBalance: async () => 0n, + }; + const checkWith = async (stored) => { + globalThis.chrome = { storage: makeStorageStub(stored) }; + try { + await getFullWarnings(RECIPIENT, provider, { chainId: "0x1" }); + } finally { + delete globalThis.chrome; + } + }; + + await checkWith({}); + await checkWith({ autistmask: { checkRecipientsOnEtherscan: false } }); + expect(asked).toEqual([]); + + await checkWith({ autistmask: { checkRecipientsOnEtherscan: true } }); + expect(asked).toEqual(["https://etherscan.io/address/" + RECIPIENT]); + }); + + describe("a balance refresh", () => { + // The RPC endpoint answers every balance with zero, and every other + // call, the ENS reverse lookup's, with no resolver. + beforeEach(() => { + FetchRequest.registerGetUrl(async (req) => { + const body = JSON.parse(Buffer.from(req.body).toString("utf8")); + const reply = (r) => ({ + jsonrpc: "2.0", + id: r.id, + result: r.method === "eth_getBalance" ? "0x0" : ZERO_WORD, + }); + const answer = Array.isArray(body) + ? body.map(reply) + : reply(body); + return { + statusCode: 200, + statusMessage: "OK", + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify(answer)), + }; + }); + }); + + afterEach(() => { + FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc()); + }); + + // An address that was refreshed while Use Blockscout was on, and holds + // the USDC Blockscout reported then. + function addressWithUsdc() { + return { + address: ADDRESS, + tokenBalances: [ + { + address: USDC_ROW.token.address_hash, + name: "USD Coin", + symbol: "USDC", + decimals: 6, + balance: "5.0", + holders: 50000, + }, + ], + }; + } + + async function refresh(addr, useBlockscout) { + await refreshBalances( + [{ addresses: [addr] }], + RPC_URL, + BLOCKSCOUT, + useBlockscout, + [], + "mainnet", + ); + } + + test("asks Blockscout nothing while Use Blockscout is off, and then lists no token the user has not tracked", async () => { + const addr = addressWithUsdc(); + await refresh(addr, false); + expect(asked).toEqual([]); + expect(addr.tokenBalances).toEqual([]); + }); + + test("asks Blockscout for the token balances while Use Blockscout is on", async () => { + const addr = addressWithUsdc(); + await refresh(addr, true); + expect(asked).toEqual([ + BLOCKSCOUT + "/addresses/" + ADDRESS + "/token-balances", + ]); + expect(addr.tokenBalances.map((row) => row.symbol)).toEqual([ + "USDC", + ]); + }); + }); +}); + +describe("the popup", () => { + // Every URL the booted page handed to fetch(). + function fetched() { + return globalThis.fetch.mock.calls.map((call) => String(call[0])); + } + + function blockscoutOrEtherscan(url) { + return url.includes("blockscout") || url.includes("etherscan"); + } + + beforeEach(() => { + // The screens below log their failed RPC reads; the stub node in + // popupBoot.js answers none. + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + test("hands its balance refresh and Home's history the stored Use Blockscout setting", async () => { + await bootPopup(unversionedValidProfile()); + const { + refreshBalances: popupRefresh, + } = require("../src/shared/balances"); + const { + fetchRecentTransactions: popupHistory, + } = require("../src/shared/transactions"); + expect(popupRefresh.mock.calls[0][3]).toBe(false); + expect(popupHistory.mock.calls[0][2]).toBe(false); + cleanupPopup(); + + await bootPopup(unversionedValidProfile({ useBlockscout: true })); + const { + refreshBalances: onRefresh, + } = require("../src/shared/balances"); + const { + fetchRecentTransactions: onHistory, + } = require("../src/shared/transactions"); + expect(onRefresh.mock.calls[0][3]).toBe(true); + expect(onHistory.mock.calls[0][2]).toBe(true); + }); + + test("Home says why there is no history and only tracked tokens while Use Blockscout is off", async () => { + const page = await bootPopup(unversionedValidProfile()); + expect(page.node("home-tx-list").innerHTML).toContain( + escapeHtml(HISTORY_OFF_TEXT), + ); + expect(page.hidden("home-tokens-note")).toBe(false); + cleanupPopup(); + + const on = await bootPopup( + unversionedValidProfile({ useBlockscout: true }), + ); + expect(on.node("home-tx-list").innerHTML).toContain( + "No transactions found.", + ); + expect(on.hidden("home-tokens-note")).toBe(true); + }); + + // The transaction screen, as a reopened popup draws it. + function onTransactionScreen(extra) { + return unversionedValidProfile({ + currentView: "transaction", + viewStack: ["main"], + selectedWallet: 0, + selectedAddress: 0, + viewData: { + tx: { + hash: TX_HASH, + from: ADDRESS, + to: RECIPIENT, + value: "1.0000", + exactValue: "1.0", + symbol: "ETH", + timestamp: 1790000000, + isError: false, + direction: "sent", + directionLabel: "Sent", + contractAddress: null, + chainId: "0x1", + }, + }, + ...extra, + }); + } + + test("TransactionDetail asks Blockscout for the transaction only while Use Blockscout is on", async () => { + const page = await bootPopup(onTransactionScreen()); + expect(page.visibleViews()).toEqual(["transaction"]); + expect(fetched().filter(blockscoutOrEtherscan)).toEqual([]); + expect(page.hidden("tx-detail-blockscout-note")).toBe(false); + cleanupPopup(); + + const on = await bootPopup( + onTransactionScreen({ useBlockscout: true }), + ); + expect(fetched().filter(blockscoutOrEtherscan)).toEqual([ + BLOCKSCOUT + "/transactions/" + TX_HASH, + ]); + expect(on.hidden("tx-detail-blockscout-note")).toBe(true); + }); + + // The confirmation screen for a send of 0.1 ETH to RECIPIENT, as a + // reopened popup draws it. + function onConfirmScreen(extra) { + return unversionedValidProfile({ + currentView: "confirm-tx", + viewStack: ["main"], + selectedWallet: 0, + selectedAddress: 0, + viewData: { + pendingTx: { + token: "ETH", + from: ADDRESS, + to: RECIPIENT, + amount: "0.1", + chainId: "0x1", + }, + }, + ...extra, + }); + } + + test("ConfirmTx asks Etherscan about the recipient only while Check recipients on Etherscan is on", async () => { + const page = await bootPopup(onConfirmScreen()); + expect(page.visibleViews()).toEqual(["confirm-tx"]); + expect(fetched().filter(blockscoutOrEtherscan)).toEqual([]); + expect(page.hidden("confirm-etherscan-note")).toBe(false); + expect(page.hidden("confirm-etherscan-warning")).toBe(true); + cleanupPopup(); + + const on = await bootPopup( + onConfirmScreen({ checkRecipientsOnEtherscan: true }), + ); + expect(fetched().filter(blockscoutOrEtherscan)).toEqual([ + "https://etherscan.io/address/" + RECIPIENT, + ]); + expect(on.hidden("confirm-etherscan-note")).toBe(true); + expect(on.hidden("confirm-etherscan-warning")).toBe(false); + }); + + // Ticks or unticks a checkbox on the page, as the user does. + async function setBox(page, id, checked) { + const box = page.node(id); + box.checked = checked; + for (const fn of box.listeners.change) await fn(); + await page.settle(); + } + + test("Welcome offers both settings unticked, and ticking one there stores it", async () => { + const page = await bootPopup(undefined); + expect(page.visibleViews()).toEqual(["welcome"]); + expect(page.node("welcome-use-blockscout").checked).toBe(false); + expect(page.node("welcome-check-recipients-on-etherscan").checked).toBe( + false, + ); + + await setBox(page, "welcome-use-blockscout", true); + expect(page.storage.read("autistmask").useBlockscout).toBe(true); + await setBox(page, "welcome-check-recipients-on-etherscan", true); + expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe( + true, + ); + }); + + test("Settings shows both settings unticked, and ticking one there stores it", async () => { + const page = await bootPopup(unversionedValidProfile()); + await page.click("btn-settings"); + expect(page.node("settings-use-blockscout").checked).toBe(false); + expect( + page.node("settings-check-recipients-on-etherscan").checked, + ).toBe(false); + + await setBox(page, "settings-use-blockscout", true); + expect(page.storage.read("autistmask").useBlockscout).toBe(true); + await setBox(page, "settings-check-recipients-on-etherscan", true); + expect(page.storage.read("autistmask").checkRecipientsOnEtherscan).toBe( + true, + ); + }); + + // A token the user tracks, and one Blockscout reported that the user has + // not tracked. Both are written in lower case, as the balance rows' and + // the dropdown's token ids are compared below. + const TRACKED = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const UNTRACKED = TOKEN_ADDRESS.toLowerCase(); + const SECOND_ADDRESS = "0x3333333333333333333333333333333333333333"; + + function tokenRow(address, symbol) { + return { + address, + name: symbol, + symbol, + decimals: 18, + balance: "5.0", + holders: 50000, + }; + } + + // AddressDetail of the first of two addresses, as a reopened popup draws + // it. Both addresses hold the tracked token. While "Use Blockscout" is on, + // 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 is where the options Send's + // dropdown was filled with are read. + 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 it 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"), + ]; + }); + const ticking = page.tick(); + await page.settle(); + + await page.click("btn-settings"); + await setBox(page, "settings-use-blockscout", false); + answer(); + await ticking; + + expect( + page.storage.read("autistmask").wallets[0].addresses[0].balance, + ).toBe("7.0"); + expect(storedTokens(page)).toEqual([TRACKED]); + await page.click("btn-settings-back"); + 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's next refresh follows the stored setting and is saved", + async (_how, before, after) => { + const page = await bootPopup(onAddressDetail(before)); + const { refreshBalances: held } = require("../src/shared/balances"); + held.mockImplementationOnce( + async (wallets, rpcUrl, blockscoutUrl, useBlockscout) => { + const addr = wallets[0].addresses[0]; + addr.balance = "7.0"; + addr.tokenBalances = useBlockscout + ? [tokenRow(TRACKED, "TRK"), tokenRow(UNTRACKED, "TST")] + : [tokenRow(TRACKED, "TRK")]; + }, + ); + + anotherPageSaves(page, { useBlockscout: after }); + await page.tick(); + + expect(held.mock.calls.at(-1)[3]).toBe(after); + 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 each URL asked for is counted once. + 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); + }); +}); + +describe("the background", () => { + afterEach(() => { + delete globalThis.chrome; + jest.dontMock("../src/shared/balances"); + }); + + // Starts the background over `profile`, fires its balance alarm, and + // answers with the Use Blockscout setting its refresh was handed. + async function settingTheRefreshIsHanded(profile) { + jest.resetModules(); + let handed; + const called = new Promise((resolve) => { + handed = resolve; + }); + jest.doMock("../src/shared/balances", () => ({ + getProvider: () => ({}), + refreshBalances: async (...args) => handed(args[3]), + dropUntrackedTokens: () => {}, + })); + let onAlarm = null; + globalThis.chrome = { + storage: makeStorageStub({ autistmask: profile }), + alarms: { + create: () => {}, + get: async () => undefined, + clear: async () => false, + onAlarm: { + addListener: (fn) => { + onAlarm = fn; + }, + }, + }, + runtime: { + onMessage: { addListener: () => {} }, + onConnect: { addListener: () => {} }, + onInstalled: { addListener: () => {} }, + onStartup: { addListener: () => {} }, + getURL: (p) => "chrome-extension://test/" + p, + lastError: null, + }, + windows: { onRemoved: { addListener: () => {} } }, + tabs: { query: () => {}, sendMessage: () => {} }, + action: { setPopup: () => {} }, + }; + require("../src/background/index"); + const { BALANCE_REFRESH_ALARM } = require("../src/shared/alarms"); + onAlarm({ name: BALANCE_REFRESH_ALARM }); + return called; + } + + test("hands its balance refresh the stored Use Blockscout setting", async () => { + const profile = { ...unversionedValidProfile(), lastBalanceRefresh: 0 }; + expect(await settingTheRefreshIsHanded(profile)).toBe(false); + expect( + await settingTheRefreshIsHanded({ + ...profile, + useBlockscout: true, + }), + ).toBe(true); + }); +}); diff --git a/tests/coldWorkerChainId.test.js b/tests/coldWorkerChainId.test.js index 77331fb..7c64b09 100644 --- a/tests/coldWorkerChainId.test.js +++ b/tests/coldWorkerChainId.test.js @@ -73,6 +73,8 @@ function loadColdWorker(networkId, opts) { jest.doMock("../src/shared/balances", () => ({ getProvider: () => ({}), refreshBalances: options.refreshBalances || jest.fn(async () => {}), + dropUntrackedTokens: jest.requireActual("../src/shared/balances") + .dropUntrackedTokens, })); jest.doMock("../src/shared/phishingDomains", () => ({ isPhishingDomain: () => false, diff --git a/tests/confirmTxAmount.test.js b/tests/confirmTxAmount.test.js index 0760cf6..684144a 100644 --- a/tests/confirmTxAmount.test.js +++ b/tests/confirmTxAmount.test.js @@ -247,6 +247,7 @@ async function openSend(token, tokenUnits = null) { state.wallets, "https://rpc.example.invalid", "https://blockscout.example/api/v2", + true, state.trackedTokens, "mainnet", ); diff --git a/tests/confirmTxLateResult.test.js b/tests/confirmTxLateResult.test.js index 780912b..697296b 100644 --- a/tests/confirmTxLateResult.test.js +++ b/tests/confirmTxLateResult.test.js @@ -50,8 +50,14 @@ function explorerPage(title) { // The explorer labels no address. global.fetch = jest.fn(async () => explorerPage("Etherscan")); +// The Etherscan label check is made, so its box can be shown. const { makeStorageStub } = require("./support/storageStub"); -global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } }; +global.chrome = { + storage: makeStorageStub({ + autistmask: { checkRecipientsOnEtherscan: true }, + }), + runtime: { sendMessage() {} }, +}; // A stub DOM: every id resolves to a recording element. const elements = new Map(); diff --git a/tests/confirmTxNetworkSwitch.test.js b/tests/confirmTxNetworkSwitch.test.js index 1a9a10b..116ccf2 100644 --- a/tests/confirmTxNetworkSwitch.test.js +++ b/tests/confirmTxNetworkSwitch.test.js @@ -142,13 +142,19 @@ jest.mock("../src/popup/views/txStatus", () => ({ })); // The Etherscan label check is its only caller here: the explorer pages it -// asks for are recorded, and nothing is fetched. +// asks for are recorded, and nothing is fetched. The stored profile has the +// check turned on. global.fetch = jest.fn(() => { throw new Error("tests must not perform network requests"); }); const { makeStorageStub } = require("./support/storageStub"); -global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } }; +global.chrome = { + storage: makeStorageStub({ + autistmask: { checkRecipientsOnEtherscan: true }, + }), + runtime: { sendMessage() {} }, +}; // A stub DOM: every id resolves to a recording element. const elements = new Map(); diff --git a/tests/contractCreation.test.js b/tests/contractCreation.test.js index 3d96d6c..ec82fa1 100644 --- a/tests/contractCreation.test.js +++ b/tests/contractCreation.test.js @@ -297,6 +297,8 @@ describe.each([ state.selectedWallet = 0; state.selectedAddress = 0; state.selectedToken = "ETH"; + // The history is Blockscout's. + state.useBlockscout = true; }); test("a contract creation's row says so, with no colour dot and no address line", async () => { diff --git a/tests/e2e/network.js b/tests/e2e/network.js index 9ba2ea4..898b5e5 100644 --- a/tests/e2e/network.js +++ b/tests/e2e/network.js @@ -653,6 +653,12 @@ function traceEnabled(raw) { * confirmed receipt instead of null, so a wait screen resolves. * @param {boolean} [opts.failReceiptLookup] answer eth_getTransactionReceipt * with an error, so every receipt lookup fails; read at request time. + * @param {string} [opts.etherscanLabel] the label Etherscan's page gives the + * address asked about, such as "Fake_Phishing1"; none when unset. Read at + * request time. + * @param {object[]} [opts.requests] every request this handler sees, from the + * popup's pages and the background worker alike, appended in order as + * { method, url, fromWorker }. * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * Promise