diff --git a/README.md b/README.md index f4e8ae7..b0c96f0 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,13 +885,16 @@ 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 prices.js — ETH/USD and token/USD via CoinDesk API scamlist.js — known fraud contract addresses state.js — persisted state (extension storage) + storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan", + read from storage by the requests they gate symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces tokenList.js — top ERC-20 tokens by market cap (hardcoded) transactions.js — tx history fetching + anti-poisoning filters @@ -1388,23 +1431,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. + +Blockscout may answer just before the user turns the setting off. So if the +stored setting is off when the refresh saves, in the popup or the background, +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 +1636,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 as a +reopened popup draws it, so Home takes the place of a screen such as Send. + 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 +1688,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 +1717,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 +1726,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 +1799,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 +1873,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 +1896,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 @@ -1825,13 +1918,14 @@ view would leave a wallet one click from deletion. - **Transitions**: - "Review" (valid inputs, ENS resolved) → **ConfirmTx** - "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 - zero, written as digits with at most one decimal point, with nothing but - zeros after the token's last decimal place. ETH has 18 places, and a token - with more, or with an unknown number, is held to 18. Zeros past those - places leave the amount unchanged, 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 + message, no screen change. A name with no address, or one resolved + off-chain, gets "That ENS name has no on-chain address." An amount is + valid when it is a number above zero, written as digits with at most one + decimal point, with nothing but zeros after the token's last decimal + place. ETH has 18 places, and a token with more, or with an unknown + number, is held to 18. Zeros past those places leave the amount unchanged, + 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) #### ConfirmTx (`confirm-tx`) @@ -1875,7 +1969,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 +2140,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 +2227,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 +2265,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 +2287,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 +2445,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 +2788,35 @@ 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. + +A request that either setting gates reads the setting from storage before it +asks, and again when the answer lands. While the setting is off, nothing is +asked, and an answer that lands is dropped. + +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 +2828,42 @@ 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 + - **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 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**: whether or not the setting is on, pressing "Save" + beside the URL in Settings asks that URL for `/stats`. This request + carries no user data. + - **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"): While the + setting is on, ConfirmTx looks up the recipient's address on Etherscan each + time it is shown. It checks for a phishing or scam label, best-effort. 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 +2872,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 +3056,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..d208483 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 @@ -84,10 +90,10 @@ this in Settings to any Ethereum JSON-RPC endpoint, including your own local node. When it is contacted: on every balance refresh (every 10 seconds while the popup -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 -is polled for its receipt, and for the reverse ENS lookups used to label -addresses (cached for 12 hours). +is open, every 60 seconds in the background), 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, and for the reverse ENS lookups used to label addresses +(cached for 12 hours). What gets sent: standard Ethereum JSON-RPC requests (balance queries, transaction broadcasts, gas estimates, ENS lookups, contract-code checks). Your @@ -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 sends it your data +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 + +Whether or not the setting is on, pressing "Save" beside the Blockscout URL in +Settings asks that URL for `/stats` to check it. That request 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`) @@ -122,16 +143,21 @@ address. **Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia) -When you review a send, AutistMask fetches the recipient's public Etherscan -address page and looks for a "Fake_Phishing"/"Phish/Hack" label or a scam -warning, and shows a red warning if it finds one. This is a plain page fetch -with no API key, made by your browser. It is best-effort: if it fails, it is -silently ignored. This endpoint is not user-configurable. +While "Check recipients on Etherscan" is on, AutistMask fetches the recipient's +public Etherscan address page when you review a send. 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 setting is off in a new install. While it 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 +211,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 +249,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". @@ -239,7 +272,8 @@ screen. Tokens can also be added from Settings, under "Tracked Tokens". 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 that survives the spam filters). -3. Enter the recipient address or ENS name (e.g. `vitalik.eth`). +3. Enter the recipient address or ENS name (e.g. `vitalik.eth`). AutistMask does + not look up a name resolved off-chain, so enter its address instead. 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 fee. @@ -389,8 +423,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..3e46b3c 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. +// +// Blockscout may have answered before the user turned "Use Blockscout" off. 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(); @@ -1423,6 +1431,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 +454,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..d645d42 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( @@ -401,8 +403,11 @@ function init(_ctx) { try { const provider = getProvider(state.rpcUrl, state.networkId); 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) { - showFlash("That ENS name has no address."); + showFlash("That ENS name has no on-chain address."); return; } resolvedTo = resolved; @@ -426,7 +431,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..6f25449 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,35 @@ 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 +433,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 +573,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..2edb60e 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -26,6 +26,7 @@ const { state } = require("../../shared/state"); const { nativeCurrencyByChainId } = require("../../shared/networks"); const { formatEther, formatUnits } = require("ethers"); const { log, debugFetch } = require("../../shared/log"); +const { storedSettingIsOn } = require("../../shared/storedSettings"); const { decodeCalldata } = require("./approval"); /** @@ -191,6 +192,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 +309,12 @@ function populateOnChainDetails(txData, chainId) { } } +// Reads the block, network details and calldata from Blockscout, and decodes +// the calldata. While the stored "Use Blockscout" is off, Blockscout is not +// asked, and an answer that lands after it went off is dropped +// (src/shared/storedSettings.js). Either way none of them is shown. async function loadFullTxDetails(txHash, chainId) { + if (!(await storedSettingIsOn("useBlockscout"))) return; const section = $("tx-detail-calldata-section"); const actionEl = $("tx-detail-calldata-action"); const detailsEl = $("tx-detail-calldata-details"); @@ -319,6 +330,7 @@ async function loadFullTxDetails(txHash, chainId) { ); if (!resp.ok) return; const txData = await resp.json(); + if (!(await storedSettingIsOn("useBlockscout"))) return; // Populate on-chain detail fields (block, nonce, gas, fee) populateOnChainDetails(txData, chainId); 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..0125aeb 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, @@ -18,6 +22,7 @@ const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders"); const { isSpoofedSymbol } = require("./symbolSpoof"); const { toDecimals } = require("./transferAmount"); const { resolveTokenDecimals } = require("./approvalAmount"); +const { storedSettingIsOn } = require("./storedSettings"); // Use a static network to skip auto-detection (which can fail and cause // "could not coalesce error" on some RPC endpoints like Cloudflare). @@ -37,9 +42,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 +95,12 @@ function rawUnits(value) { // Fetch token balances for a single address from Blockscout. // Returns [{ address, name, symbol, decimals, balance, holders }]. +// +// While the stored "Use Blockscout" is off, Blockscout is not asked, and an +// answer that lands after it went off is dropped (src/shared/storedSettings.js). +// Either way 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 @@ -99,6 +119,7 @@ async function fetchTokenBalances( trackedTokens, signal, ) { + if (!(await storedSettingIsOn("useBlockscout"))) return []; try { const resp = await debugFetch( blockscoutUrl + "/addresses/" + address + "/token-balances", @@ -108,6 +129,7 @@ async function fetchTokenBalances( return null; } const items = await resp.json(); + if (!(await storedSettingIsOn("useBlockscout"))) return []; if (!Array.isArray(items)) return null; const trackedSet = new Set( @@ -362,7 +384,8 @@ 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 || []; @@ -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..da9b0a7 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 { storedSettingIsOn } = require("./storedSettings"); // Patterns in the page title that indicate a flagged address. // Title format: "Fake_Phishing184810 | Address: 0x... | Etherscan" @@ -78,12 +79,19 @@ function parseEtherscanPage(html) { * when it is omitted: etherscan.io on mainnet, sepolia.etherscan.io on * Sepolia. * + * While the stored "Check recipients on Etherscan" is off, Etherscan is not + * asked, and an answer that lands after it went off is dropped + * (src/shared/storedSettings.js). Either way 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 { + if (!(await storedSettingIsOn("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"); @@ -94,6 +102,9 @@ async function checkEtherscanLabel(address, chainId) { }); if (!resp.ok) return null; const html = await resp.text(); + if (!(await storedSettingIsOn("checkRecipientsOnEtherscan"))) { + return null; + } const result = parseEtherscanPage(html); if (result.isPhishing) { return { 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/storedSettings.js b/src/shared/storedSettings.js new file mode 100644 index 0000000..5984467 --- /dev/null +++ b/src/shared/storedSettings.js @@ -0,0 +1,24 @@ +// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored +// profile. +// +// A request that either setting gates reads it here twice: before it asks, and +// again when the answer lands. While the setting is off, nothing is asked, and +// an answer that lands 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 `name`, "useBlockscout" or "checkRecipientsOnEtherscan", is on in the +// stored profile. 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 }; diff --git a/src/shared/transactions.js b/src/shared/transactions.js index a72f429..4ff634d 100644 --- a/src/shared/transactions.js +++ b/src/shared/transactions.js @@ -21,6 +21,7 @@ const { toDecimals } = require("./transferAmount"); // detail view is the authoritative record and already shows exact precision. const { truncateAmount: formatTxValue } = require("./amountDisplay"); const { routerRelease } = require("./uniswap"); +const { storedSettingIsOn } = require("./storedSettings"); // Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum // over the address, not part of its identity. Every address comparison in @@ -235,12 +236,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`). +// +// While the stored "Use Blockscout" is off, Blockscout is not asked, and an +// answer that lands after it went off is dropped (src/shared/storedSettings.js). +// Either way the answer is an empty list. async function fetchRecentTransactions( address, blockscoutUrl, chainId, count = 25, ) { + if (!(await storedSettingIsOn("useBlockscout"))) return []; log.debugf("fetchRecentTransactions", address); const addrLower = normalizeAddress(address); @@ -271,6 +277,7 @@ async function fetchRecentTransactions( const txJson = txResp.ok ? await txResp.json() : {}; const ttJson = ttResp.ok ? await ttResp.json() : {}; + if (!(await storedSettingIsOn("useBlockscout"))) return []; const txs = mergeTransactions( (txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)), 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..327bcda 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, @@ -443,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([]); + }); +}); diff --git a/tests/balanceRefreshCancelled.test.js b/tests/balanceRefreshCancelled.test.js index 9686fa8..7c12447 100644 --- a/tests/balanceRefreshCancelled.test.js +++ b/tests/balanceRefreshCancelled.test.js @@ -24,6 +24,12 @@ const TRACKED = { 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; let logged; diff --git a/tests/blockscoutEtherscanSettings.test.js b/tests/blockscoutEtherscanSettings.test.js new file mode 100644 index 0000000..c160923 --- /dev/null +++ b/tests/blockscoutEtherscanSettings.test.js @@ -0,0 +1,841 @@ +// 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. +// +// A request that either setting gates reads the stored setting, not the page's +// copy, before it asks and again when the answer lands. So a change another +// page saved applies to it 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 { checkEtherscanLabel } = require("../src/shared/etherscanLabels"); +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", + }, +}; + +// 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 = + "