fix: Blockscout and Etherscan lookups are off until the user turns them on (closes #668)
Out of the box the wallet sent the user's addresses to Blockscout and each recipient to Etherscan. Two settings, "Use Blockscout" and "Check recipients on Etherscan", now gate those requests. Both are off in a new install and offered unticked on Welcome and in Settings. While one is off, the screens that relied on it say so in a plain line. Each gated request reads its setting from storage before it asks and when the answer lands, and drops a late answer. An open popup takes another page's change and draws its screens again. While "Use Blockscout" is off, no address keeps an untracked token, and no screen names a token from Blockscout's report. ENS lookups ask only the RPC endpoint. Model: opus-5-5 (implementation); fable-5-1 (prose)
This commit is contained in:
+43
-5
@@ -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
|
||||
// the setting 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, the name sent
|
||||
// is the user's own (https://git.eeqj.de/sneak/AutistMask/issues/668). With the
|
||||
// flag set, such a name does not resolve.
|
||||
function getProvider(rpcUrl, networkId) {
|
||||
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. An answer
|
||||
// that lands after the setting went off is dropped
|
||||
// (src/shared/storedSettings.js). Either way this returns an empty list, so
|
||||
// only tracked tokens are listed.
|
||||
//
|
||||
// Filters out spam: only shows tokens that are in the known token list,
|
||||
// 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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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. An answer that lands after the setting went off is dropped
|
||||
* (src/shared/storedSettings.js). Either way the answer is null.
|
||||
*
|
||||
* @param {string} address - Ethereum address to check.
|
||||
* @param {string} [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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+6
-4
@@ -469,9 +469,11 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
||||
// exists to remove, just delayed and confined to one page instead of two
|
||||
// (caught by tests/txStatus.test.js). A page's live picture of a field it
|
||||
// does not own goes on being whatever its last loadState() saw, same as
|
||||
// before this fix; only the persisted record is guaranteed current. The
|
||||
// network is the exception: the popup takes a switch another context saved
|
||||
// through `beforeWrite` (followNetworkSwitch() in src/popup/index.js).
|
||||
// before this fix; only the persisted record is guaranteed current. Three
|
||||
// fields are the exceptions: the network, "Use Blockscout" and "Check
|
||||
// recipients on Etherscan". Through `beforeWrite`, the popup takes a change to
|
||||
// any of them that another context saved (followStoredSettings() in
|
||||
// src/popup/index.js).
|
||||
async function saveStateOnce(beforeWrite) {
|
||||
const result = await storageGet("autistmask");
|
||||
// The record in storage right now is about to be merged into and written
|
||||
@@ -603,7 +605,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
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored
|
||||
// profile.
|
||||
//
|
||||
// Before each request that sends the user's data to Blockscout or Etherscan, the
|
||||
// wallet reads that service's setting from storage. The wallet reads the setting
|
||||
// again when the answer lands. While the setting is off, no such request is
|
||||
// made, and an answer that lands after the setting went off is dropped. A page's
|
||||
// own copy of the setting is not used, because another page may have just
|
||||
// changed it.
|
||||
|
||||
const { storageGet } = require("./browserApi");
|
||||
const { normalizePersisted } = require("./persistedState");
|
||||
|
||||
// Whether the setting `name` is on in the stored profile. `name` is
|
||||
// "useBlockscout" or "checkRecipientsOnEtherscan". The profile is read through
|
||||
// normalizePersisted(), as a load reads it, so only a stored true is on. A
|
||||
// failed read counts as off.
|
||||
async function storedSettingIsOn(name) {
|
||||
try {
|
||||
const { autistmask } = await storageGet("autistmask");
|
||||
return normalizePersisted(autistmask)[name];
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { storedSettingIsOn };
|
||||
@@ -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. An answer
|
||||
// that lands after the setting 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)),
|
||||
|
||||
Reference in New Issue
Block a user