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

Model: opus-5-5
2026-10-21 16:11:02 +00:00
8 changed files with 260 additions and 87 deletions
+45 -34
View File
@@ -18,13 +18,14 @@ don't implement any crypto, and don't send user-specific data anywhere but a
unless the user turns on one of the two lookups below. Out of the box the
extension contacts the configured RPC node for blockchain interactions and a
public CoinDesk API (no API key) for realtime price information, which is sent
token symbols and nothing else. Two more services are asked nothing until the
user turns them on, on the Welcome screen or in Settings: a Blockscout
block-explorer API for transaction history, the tokens the user has not tracked
and transaction details ("Use Blockscout"), and best-effort Etherscan address
label lookups during transaction confirmation ("Check recipients on Etherscan").
A community-maintained phishing domain blocklist is built into the extension at
build time and checked locally; nothing is fetched for it at runtime.
token symbols and nothing else. Two more services are sent none of the user's
data until the user turns them on, on the Welcome screen or in Settings: a
Blockscout block-explorer API for transaction history, the tokens the user has
not tracked and transaction details ("Use Blockscout"), and best-effort
Etherscan address label lookups during transaction confirmation ("Check
recipients on Etherscan"). A community-maintained phishing domain blocklist is
built into the extension at build time and checked locally; nothing is fetched
for it at runtime.
In the extension is a hardcoded list of the top ERC20 contract addresses. You
can add any ERC20 contract by contract address if you wish, but the hardcoded
@@ -428,16 +429,20 @@ mainnet must take it down, both while Settings is still on screen.
It also covers what a new install sends to Blockscout and Etherscan
([#668](https://git.eeqj.de/sneak/AutistMask/issues/668)). The route handler
records every request the extension makes, from its pages and its background
worker, from the moment the browser starts. Welcome must offer "Use Blockscout"
and "Check recipients on Etherscan" unticked. With neither changed, Home,
AddressDetail, AddressToken and ConfirmTx must show the lines saying what is
off; the popup is then closed until the background has run a balance refresh of
its own, and not one recorded request may have gone to Blockscout or Etherscan.
Each is then ticked in Settings, after which Blockscout must be asked for the
history and the token balances, and ConfirmTx must ask Etherscan about the
recipient and show the label the stub page gives it. Both stay on for the rest
of the run. TransactionDetail with "Use Blockscout" off is not driven here,
since no history row leads to it then; the unit tests cover it.
worker, from when the harness installs it, before the first popup page opens; a
request the background worker makes while it starts up, before then, is not
recorded. Welcome must offer "Use Blockscout" and "Check recipients on
Etherscan" unticked. With neither changed, Home, AddressDetail, AddressToken and
ConfirmTx must show the lines saying what is off; the popup is then closed until
the background has run a balance refresh of its own, and not one recorded
request may have gone to Blockscout or Etherscan. "Use Blockscout" is then
ticked from AddressDetail's gear: on "Back", AddressDetail must ask Blockscout
for its history and no longer say it is off, and Blockscout must then be asked
for the token balances. "Check recipients on Etherscan" is ticked from
ConfirmTx's gear: on "Back", ConfirmTx must ask Etherscan about the recipient
and show the label the stub page gives it. Both stay on for the rest of the run.
TransactionDetail with "Use Blockscout" off is not driven here, since no history
row leads to it then; the unit tests cover it.
It also covers the confirmation screen, for both a native ETH send and an ERC-20
send: Send disabled while the fee estimate is in flight, enabled once it lands,
@@ -1406,13 +1411,13 @@ 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. Blockscout is asked only while the user has "Use
Blockscout" on. While it is off, as it is until the user turns it on, a tracked
token's entry has no holder count and no `decimals` of Blockscout's, so its
balance is stated at the scale the bundled list or the tracked token records,
and no other token is listed, those from earlier refreshes included. A tracked
token is shown when its balance is nonzero, and any other ERC-20 when its
balance is nonzero and it is in the bundled known-token list or has 1,000 or
figures while Blockscout fails. The refresh asks Blockscout only while the user
has "Use Blockscout" on. While it is off, as it is until the user turns it on, a
tracked token's entry has no holder count and no `decimals` of Blockscout's, so
its balance is stated at the scale the bundled list or the tracked token
records, and no other token is listed, those from earlier refreshes included. A
tracked token is shown when its balance is nonzero, and any other ERC-20 when
its balance is nonzero and it is in the bundled known-token list or has 1,000 or
more holders. A token claiming a symbol from the bundled list from any other
contract address is always dropped, tracked or not, and so is any token claiming
a symbol that belongs to the native asset and therefore has no legitimate
@@ -1599,7 +1604,10 @@ renders as it goes, and `viewRouter.js` records every screen that reaches
more — rendering it a second time would re-fetch and overwrite what it holds,
such as an edit typed into Settings and not yet saved. Home is the one screen
"Back" always re-renders, so the wallet list reflects anything that changed
while the user was away from it.
while the user was away from it. Changing "Use Blockscout" or "Check recipients
on Etherscan" in Settings makes every screen but Settings count as not yet
rendered, so "Back" renders the screen it lands on under the new setting; the
stack is left as it is.
A network switch 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
@@ -2089,7 +2097,7 @@ view would leave a wallet one click from deletion.
and raw data are read from that network's Blockscout, the one Settings has for
it, and its etherscan links open that network's explorer. They are read only
while "Use Blockscout" is on. The screen can still be shown with it off, when
the popup reopens onto it after the user turned the setting off; then
the user opens Settings from it, turns the setting off and presses Back; then
Blockscout is not asked, and a line in their place says "No block, decoded
details, network details or raw data are shown, because "Use Blockscout" is
off in Settings."
@@ -2732,10 +2740,10 @@ view would leave a wallet one click from deletion.
AutistMask is not a fully self-contained offline tool. By default it sends the
user's data to one service only, the Ethereum RPC endpoint, and asks one more,
the CoinDesk price API, for prices with nothing but token symbols. Two more
services are asked nothing until the user turns on the setting for each, on the
Welcome screen or in Settings: Blockscout ("Use Blockscout") and Etherscan
("Check recipients on Etherscan"). Both settings are off in a new profile, and
each is one setting for every network.
services are sent none of the user's data until the user turns on the setting
for each, on the Welcome screen or in Settings: Blockscout ("Use Blockscout")
and Etherscan ("Check recipients on Etherscan"). Both settings are off in a new
profile, and each is one setting for every network.
Contacted by default:
@@ -2744,9 +2752,12 @@ Contacted by default:
them the balance of every tracked token at every address (`balanceOf`),
estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
broadcast transactions (`eth_sendRawTransaction`), and check transaction
receipts. The default endpoint is a public RPC (configurable by the user to
any endpoint they prefer, including a local node). By default the extension
talks to `https://ethereum-rpc.publicnode.com`.
receipts. ENS lookups, of a name typed into Send and of the names shown for
the user's addresses, are made through it alone, so a name whose resolver says
it is resolved off-chain does not resolve. The default endpoint is a public
RPC (configurable by the user to any endpoint they prefer, including a local
node). By default the extension talks to
`https://ethereum-rpc.publicnode.com`.
- **Data sent**: Ethereum addresses, transaction data, contract call
parameters. The RPC endpoint can see all on-chain queries and submitted
transactions.
@@ -2758,7 +2769,7 @@ Contacted by default:
- **Data sent**: Token symbol strings only (e.g. "ETH", "USDC"). No
addresses or user-specific data.
Contacted only while the user has turned the setting on:
Sent the user's data only while the user has turned the setting on:
- **Blockscout block-explorer API** ("Use Blockscout"): Used to fetch
transaction history (normal transactions and ERC-20 token transfers) for Home,
+9 -9
View File
@@ -72,9 +72,9 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
AutistMask is a browser extension that runs entirely in your browser. It does
not have a backend server. Out of the box it sends your data only to the
Ethereum RPC endpoint, and asks the CoinDesk price API for prices with nothing
but token symbols. Blockscout and Etherscan are asked nothing until you turn on
"Use Blockscout" or "Check recipients on Etherscan", on the Welcome screen or
later in Settings. Both are off in a new install.
but token symbols. Blockscout and Etherscan are sent none of your data until you
turn on "Use Blockscout" or "Check recipients on Etherscan", on the Welcome
screen or later in Settings. Both are off in a new install.
### External Services
@@ -109,14 +109,14 @@ and calldata of a transaction you open. You can change this in Settings to a
self-hosted Blockscout instance. While it is off, the screens that would show
history say so, and only the tokens you track are listed.
When it is contacted: only while "Use Blockscout" is on, on every balance
refresh, whenever a screen showing transaction history is opened, and when you
open a transaction.
When it is contacted: while "Use Blockscout" is on, on every balance refresh,
whenever a screen showing transaction history is opened, and when you open a
transaction. The one exception, whether or not the setting is on, is pressing
"Save" beside the Blockscout URL in Settings, which checks that URL by asking it
for `/stats` and sends none of your data.
What gets sent: your Ethereum addresses (to look up balances and transactions)
and the hash of each transaction you open. Pressing "Save" beside the Blockscout
URL in Settings checks that URL by asking it for `/stats`, which sends none of
your data, whether or not the setting is on.
and the hash of each transaction you open.
**CoinDesk CADLI price API** (`data-api.coindesk.com`)
+5 -3
View File
@@ -40,9 +40,11 @@ function markViewRendered(view) {
// Begin a fresh page-load scope. The popup gets one by being loaded, and
// asks for one after a network switch, because every screen then on the page
// shows the previous network (showNewNetwork() in views/settings.js); the
// unit tests, which simulate several page loads against one module
// instance, ask for one too.
// shows the previous network (showNewNetwork() in views/settings.js), and
// after "Use Blockscout" or "Check recipients on Etherscan" changes in
// Settings, because every screen then on the page was drawn under the old
// setting; the unit tests, which simulate several page loads against one
// module instance, ask for one too.
function resetRenderedViews() {
renderedViews.clear();
}
+9 -2
View File
@@ -367,11 +367,16 @@ function init(_ctx) {
showFlash("Saved.");
});
// Welcome offers the same two settings, and Back from here to Welcome only
// unhides it, so its boxes are set here too.
// The screens under Settings were drawn under the old setting, so each
// change makes every screen but Settings count as not yet drawn, as a
// network switch does, and Back draws the one it lands on again. Welcome
// offers the same two settings and is only unhidden on Back, so its boxes
// are set here too.
$("settings-use-blockscout").addEventListener("change", async () => {
state.useBlockscout = $("settings-use-blockscout").checked;
$("welcome-use-blockscout").checked = state.useBlockscout;
resetRenderedViews();
markViewRendered("settings");
await saveState();
});
@@ -383,6 +388,8 @@ function init(_ctx) {
).checked;
$("welcome-check-recipients-on-etherscan").checked =
state.checkRecipientsOnEtherscan;
resetRenderedViews();
markViewRendered("settings");
await saveState();
},
);
+10 -1
View File
@@ -38,9 +38,18 @@ const { resolveTokenDecimals } = require("./approvalAmount");
// ethers knows chains this wallet does not, so an id that is not one of ours
// is a caller bug and must not resolve to a working provider for some other
// chain.
//
// `disableCcipRead` keeps every ENS lookup on the RPC endpoint. Left on, as
// ethers has it, a resolver that says a name is resolved off-chain makes ethers
// fetch the web address the resolver gives, straight from the browser, with the
// name being looked up; for the reverse lookup of the user's addresses that is
// the user's own name (https://git.eeqj.de/sneak/AutistMask/issues/668). Such a
// name does not resolve.
function getProvider(rpcUrl, networkId) {
const net = Network.from(requireNetworkId(networkId).id);
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
const provider = new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
provider.disableCcipRead = true;
return provider;
}
function requireNetworkId(networkId) {
+9 -6
View File
@@ -1,9 +1,12 @@
// Blockscout and Etherscan are asked nothing until the user turns them on
// (https://git.eeqj.de/sneak/AutistMask/issues/668). A new profile has "Use
// Blockscout" and "Check recipients on Etherscan" both off. Each request to
// either service is skipped where it is made while its setting is off, and
// made while it is on; the popup and the background hand the stored setting
// to the code that makes the request.
// Blockscout and Etherscan are sent none of the user's data until the user
// turns them on (https://git.eeqj.de/sneak/AutistMask/issues/668). A new
// profile has "Use Blockscout" and "Check recipients on Etherscan" both off.
// Each request to either service is skipped where it is made while its setting
// is off, and made while it is on; the popup and the background hand the
// stored setting to the code that makes the request. The one exception is the
// endpoint check Settings makes when the user presses "Save" beside the
// Blockscout URL. It asks only for `/stats` and carries no user data; it is
// made whether or not the setting is on.
//
// The functions that make the requests are driven directly, against a fetch()
// that records every URL it is asked for. The screens are driven through the
+62 -32
View File
@@ -269,15 +269,22 @@ test("the exact confirmation phrase erases the record and reloads into Welcome (
}
});
// ------------------ Blockscout and Etherscan are asked nothing (#668)
// ------------------ Blockscout and Etherscan are sent no user data (#668)
//
// A fresh install asks neither Blockscout nor Etherscan anything until the
// user turns on "Use Blockscout" or "Check recipients on Etherscan". The route
// handler in tests/e2e/network.js records every request the extension makes,
// from its pages and its background worker, from the moment the browser
// starts, in env.routeOpts.requests. These tests drive every screen that
// would ask either service, and the background's own balance refresh, before
// either setting is changed, then turn each on and watch its requests start.
// A fresh install sends Blockscout and Etherscan none of the user's data until
// the user turns on "Use Blockscout" or "Check recipients on Etherscan". The
// endpoint check Settings makes when the user presses "Save" beside the
// Blockscout URL asks only for `/stats` and carries no user data; it is made
// whether or not the setting is on. The route handler in tests/e2e/network.js
// records every request the extension makes, from its pages and its background
// worker, in env.routeOpts.requests. Recording starts when launch() in
// tests/e2e/harness.js installs that handler, before the first popup page
// opens; a request the background worker makes while it starts up, before
// then, is not recorded. These tests drive Home, AddressDetail, AddressToken
// and ConfirmTx, and the background's own balance refresh, before either
// setting is changed, then turn each on and watch its requests start.
// TransactionDetail, which also asks Blockscout, is left to the unit tests in
// tests/blockscoutEtherscanSettings.test.js.
// What the three transaction lists say while Use Blockscout is off. Written
// out here rather than imported, so the assertion does not compare the popup
@@ -444,23 +451,41 @@ test("with neither setting changed, nothing has reached Blockscout or Etherscan,
await visible(env.page, "#view-address");
});
test("turning on Use Blockscout in Settings asks Blockscout for the history and the tokens (#668)", async (env) => {
// Ticked from AddressDetail's gear, so Back lands on the screen that said the
// setting is off.
test("turning on Use Blockscout from AddressDetail's gear asks Blockscout for that screen's history on Back, then for the tokens (#668)", async (env) => {
await openAddressDetail(env.page);
await waitForHistoryOff(env.page, "tx-list");
await openSettings(env.page);
await env.page.check("#settings-use-blockscout");
await waitForPersisted(env.page, "useBlockscout", true, "once ticked");
const asked = Promise.all([
env.page.waitForRequest(
(r) =>
r.url().includes("blockscout.com") &&
r.url().endsWith("/transactions"),
),
env.page.waitForRequest((r) => r.url().endsWith("/token-balances"), {
timeout: 30000,
}),
]);
const historyAsked = env.page.waitForRequest(
(r) =>
r.url().includes("blockscout.com") &&
r.url().endsWith("/transactions"),
);
const tokensAsked = env.page.waitForRequest(
(r) => r.url().endsWith("/token-balances"),
{ timeout: 30000 },
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-address");
await historyAsked;
await env.page.waitForFunction(
() =>
document
.getElementById("tx-list")
.textContent.includes("No transactions found."),
null,
{ timeout: 15000 },
);
assert(
!(await env.page.isVisible("#address-tokens-note")),
"AddressDetail still says only tracked tokens are listed",
);
await goHome(env.page);
await asked;
await tokensAsked;
await env.page.waitForFunction(
() =>
document
@@ -475,11 +500,21 @@ test("turning on Use Blockscout in Settings asks Blockscout for the history and
);
});
// Left on for the rest of the run, as the check was always made before #668.
test("turning on Check recipients on Etherscan in Settings has ConfirmTx ask Etherscan and show its label (#668)", async (env) => {
// Ticked from ConfirmTx's gear, so Back lands on the screen that said no
// Etherscan check was made. Left on for the rest of the run, as the check was
// always made before #668.
test("turning on Check recipients on Etherscan from ConfirmTx's gear has ConfirmTx ask Etherscan on Back and show its label (#668)", async (env) => {
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.etherscanLabel = "Fake_Phishing668";
try {
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI });
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await visible(env.page, "#confirm-etherscan-note");
await openSettings(env.page);
await env.page.check("#settings-check-recipients-on-etherscan");
await waitForPersisted(
@@ -488,15 +523,9 @@ test("turning on Check recipients on Etherscan in Settings has ConfirmTx ask Eth
true,
"once ticked",
);
await env.page.click("#btn-settings-back");
await settleOnMain(env, { ethWei: FUNDED_ETH_WEI });
const before = env.routeOpts.requests.length;
await goToConfirm(env.page, {
token: "ETH",
balance: FUNDED_ETH_TEXT + " ETH",
amount: COMFORTABLE_AMOUNT,
});
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-confirm-tx");
await visible(env.page, "#confirm-etherscan-warning", 30000);
assert(
!(await env.page.isVisible("#confirm-etherscan-note")),
@@ -5488,8 +5517,9 @@ async function main() {
// order. The dApp transaction round trip asserts against these bytes
// rather than against anything the extension reported about them.
broadcastTransactions: [],
// Every request the extension makes, from the browser's start, in
// order (#668).
// Every request the extension makes, in order, from when launch()
// installs the route handler, before the first popup page opens
// (#668).
requests: [],
};
+111
View File
@@ -0,0 +1,111 @@
// ENS lookups ask nothing but the RPC endpoint. A resolver can answer that a
// name is resolved off-chain, naming a web address to fetch the answer from;
// ethers fetches it unless the provider has `disableCcipRead` set, and that
// request would carry the name to a server other than the RPC endpoint
// (https://git.eeqj.de/sneak/AutistMask/issues/668). With it set, such a name
// does not resolve.
//
// The provider getProvider() builds runs against a fake JSON-RPC node,
// answered through ethers' own request hook, which also records the address of
// every request ethers makes, the off-chain fetch included.
const { FetchRequest, Interface } = require("ethers");
const { getProvider } = require("../src/shared/balances");
const RPC_URL = "https://rpc.example.invalid";
const RESOLVER = "0x5555555555555555555555555555555555555555";
const USER = "0x1111111111111111111111111111111111111111";
const NAME = "offchain.eth";
// The ENS registry's resolver(), and the resolver's functions ethers calls: the
// registry names RESOLVER for every name, the resolver says it resolves names
// through resolve(), gives NAME as the reverse record of any address, and
// answers resolve() with the OffchainLookup error that says the name is
// resolved off-chain.
const ens = new Interface([
"function resolver(bytes32) view returns (address)",
"function supportsInterface(bytes4) view returns (bool)",
"function name(bytes32) view returns (string)",
"function resolve(bytes, bytes) view returns (bytes)",
"error OffchainLookup(address sender, string[] urls, bytes callData, bytes4 callbackFunction, bytes extraData)",
]);
let asked;
// One JSON-RPC answer. Anything but a call to one of the functions above is
// refused.
function reply(req) {
const envelope = { jsonrpc: "2.0", id: req.id };
const call =
req.method === "eth_call" ? ens.parseTransaction(req.params[0]) : null;
if (call === null) {
return {
...envelope,
error: { code: -32601, message: "method not found" },
};
}
if (call.name === "resolve") {
const offchain = ens.encodeErrorResult("OffchainLookup", [
RESOLVER,
["https://gateway.example.invalid/{sender}/{data}.json"],
"0x1234",
"0x12345678",
"0x",
]);
return {
...envelope,
error: { code: 3, message: "execution reverted", data: offchain },
};
}
const answers = {
resolver: [RESOLVER],
supportsInterface: [true],
name: [NAME],
};
return {
...envelope,
result: ens.encodeFunctionResult(call.name, answers[call.name]),
};
}
beforeEach(() => {
asked = [];
FetchRequest.registerGetUrl(async (req) => {
asked.push(req.url);
// Any other address is the off-chain fetch, answered as not found.
if (req.url !== RPC_URL) {
return {
statusCode: 404,
statusMessage: "Not Found",
headers: {},
body: null,
};
}
const body = JSON.parse(Buffer.from(req.body).toString("utf8"));
const answer = Array.isArray(body) ? body.map(reply) : reply(body);
return {
statusCode: 200,
statusMessage: "OK",
headers: { "content-type": "application/json" },
body: Buffer.from(JSON.stringify(answer)),
};
});
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
});
test("a name typed into Send that is resolved off-chain does not resolve, and only the RPC endpoint is asked", async () => {
const provider = getProvider(RPC_URL, "mainnet");
const [outcome] = await Promise.allSettled([provider.resolveName(NAME)]);
expect(asked.filter((url) => url !== RPC_URL)).toEqual([]);
expect(outcome).toEqual({ status: "fulfilled", value: null });
});
test("the reverse lookup of a user's address whose name is resolved off-chain finds no name, and only the RPC endpoint is asked", async () => {
const provider = getProvider(RPC_URL, "mainnet");
const [outcome] = await Promise.allSettled([provider.lookupAddress(USER)]);
expect(asked.filter((url) => url !== RPC_URL)).toEqual([]);
expect(outcome).toEqual({ status: "fulfilled", value: null });
});