Author SHA1 Message Date
sneak 4f46515d87 fix: a transaction history that lands after a network switch is dropped (closes #657)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Home, AddressDetail and AddressToken remember which network is active
before they ask Blockscout for the transaction history. Each screen drops
the answer if another network is active on the page or in storage when the
answer lands. AddressDetail and AddressToken also drop an answer that lands
once the screen shows another address. AddressToken also drops an answer
that lands once the screen shows another token.

A dropped answer draws no rows and saves no fraud contracts. A failed
request goes through the same checks. A dropped failure shows no message.
Holding the lock, the save adds new fraud contracts only if the page and
the stored record still name the request's network.

storedNetworkIs() reads the stored network, in src/shared/storedSettings.js.

Model: opus-5-5
2026-10-27 11:57:54 +00:00
34 changed files with 791 additions and 1403 deletions
+41 -44
View File
@@ -905,8 +905,8 @@ src/
prices.js — ETH/USD and token/USD from Chainlink price feeds prices.js — ETH/USD and token/USD from Chainlink price feeds
scamlist.js — known fraud contract addresses scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage) state.js — persisted state (extension storage)
storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan", storedSettings.js — "Use Blockscout", "Check recipients on Etherscan" and
read from storage for the requests those settings gate the active network, read from storage
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
tokenList.js — top ERC-20 tokens by market cap, and their Chainlink tokenList.js — top ERC-20 tokens by market cap, and their Chainlink
price feeds (hardcoded) price feeds (hardcoded)
@@ -1188,12 +1188,11 @@ The wallet takes the `decimals` from these sources, in this order
"Use Blockscout" is on (`tokenSourcesByChainId()` in "Use Blockscout" is on (`tokenSourcesByChainId()` in
`src/popup/views/helpers.js`). `src/popup/views/helpers.js`).
The token's name comes from the same sources. The bundled token list is used The token's name comes from the same sources. While "Use Blockscout" is off, a
only for a token on Ethereum Mainnet. While "Use Blockscout" is off, a token token gets a line under it when the bundled list and the tracked tokens lack its
gets a line under it when the bundled list and the tracked tokens lack its name name or decimals. The line says "Blockscout's report of your balances is not
or decimals. The line says "Blockscout's report of your balances is not used to used to name this token, because "Use Blockscout" is off in Settings." These
name this token, because "Use Blockscout" is off in Settings." These screens screens show the line:
show the line:
- TxApproval and SignApproval - TxApproval and SignApproval
- WaitTx, SuccessTx and ErrorTx, which follow TxApproval - WaitTx, SuccessTx and ErrorTx, which follow TxApproval
@@ -1474,20 +1473,20 @@ tracked.
A tracked token is shown when its balance is nonzero, and any other ERC-20 when A tracked token is shown when its balance is nonzero, and any other ERC-20 when
its balance is nonzero and it is in the bundled known-token list or has 1,000 or its balance is nonzero and it is in the bundled known-token list or has 1,000 or
more holders. On Ethereum Mainnet, a token that claims a bundled token's symbol more holders. A token claiming a symbol from the bundled list from any other
from another contract is always dropped, tracked or not. On every network, a contract address is always dropped, tracked or not, and so is any token claiming
token claiming a label of the native asset is always dropped. Those labels are a symbol that belongs to the native asset and therefore has no legitimate
`"ETH"` and each network's `nativeCurrency`, such as `"SepoliaETH"`. The balance contract at all (`"ETH"`, and every network's `nativeCurrency`, such as
list applies all three rules whatever the settings say. The "Hide tokens with `"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
fewer than 1,000 holders" setting governs only the transaction history and the tokens with fewer than 1,000 holders" setting governs the transaction history
send-screen token selector. A token's holder count is unknown when the explorer and the send-screen token selector, not this list. A token's holder count is
reports none, or reports anything other than a whole number written in digits unknown when the explorer reports none, or reports anything other than a whole
alone, such as `1,000` or `1e3` (`parseHoldersCount()` in number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()`
`src/shared/holders.js`). This list does not take an unknown count as 1,000 or in `src/shared/holders.js`). This list does not take an unknown count as 1,000
more, so such a token is shown only when it is on the bundled list or tracked. or more, so such a token is shown only when it is on the bundled list or
Every nonzero holding of a token shown is stored exactly, however small, but a tracked. Every nonzero holding of a token shown is stored exactly, however
holding below 0.000001 is left out of the balance lists, the send-screen token small, but a holding below 0.000001 is left out of the balance lists, the
selector, the address total and the remove-address warning send-screen token selector, the address total and the remove-address warning
(`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and (`isBelowOneMillionth()` in `src/shared/amountDisplay.js`). The Send and
confirmation screens show it when its token is the one being sent. Tracked confirmation screens show it when its token is the one being sent. Tracked
tokens with a zero balance are listed as well while "Show tracked tokens with tokens with a zero balance are listed as well while "Show tracked tokens with
@@ -2280,9 +2279,6 @@ view would leave a wallet one click from deletion.
no user data and is made whether or not "Use Blockscout" is ticked. no user data and is made whether or not "Use Blockscout" is ticked.
- If the network changes before either "Save" stores its URL, the URL is not - If the network changes before either "Save" stores its URL, the URL is not
saved and a flash message says so saved and a flash message says so
- Either "Save" stores its URL only if the input still shows that URL when
the endpoint answers. Otherwise a flash message says that the URL was not
saved
- Etherscan: - Etherscan:
- "Check recipients on Etherscan" checkbox, unticked until the user - "Check recipients on Etherscan" checkbox, unticked until the user
ticks it ticks it
@@ -3199,25 +3195,26 @@ indexes it as a real token transfer.
Ethereum mainnet ERC-20s taken from the CoinGecko API, with decimals verified Ethereum mainnet ERC-20s taken from the CoinGecko API, with decimals verified
on-chain and addresses EIP-55 checksummed; `TOKENS` in on-chain and addresses EIP-55 checksummed; `TOKENS` in
`src/shared/tokenList.js` is the authoritative set. It is bundled at build `src/shared/tokenList.js` is the authoritative set. It is bundled at build
time and only changes when that file is regenerated. On Ethereum Mainnet, a time and only changes when that file is regenerated. Any token transfer
token that claims a symbol from this list, such as "USDT" or "USDC", is a claiming a symbol from this list (e.g. "ETH", "USDT", "USDC") but originating
spoof unless its contract is the listed one. The wallet hides it. The fake from an unrecognized contract address is identified as a spoof and filtered
"Ethereum" token in the attack above used symbol "ETH" from contract from display. The fake "Ethereum" token in the attack above used symbol "ETH"
`0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`. No contract may bear "ETH", so from contract `0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not
that token is a spoof. Detecting a spoof is also what adds a contract to the match the known WETH contract — so it would be caught by this check. Detecting
fraud contract blocklist below; that is the only thing that populates it. In a spoof is also what adds a contract to the fraud contract blocklist below;
the transaction history the check is the "Hide fake tokens impersonating a that is the only thing that populates it. In the transaction history the check
known symbol" setting, on by default; with it off, spoofed transfers are shown is the "Hide fake tokens impersonating a known symbol" setting, on by default;
and no new blocklist entries are learned from them. The send-screen token with it off, spoofed transfers are shown and no new blocklist entries are
selector and the balance list apply the same check unconditionally, because learned from them. The send-screen token selector and the balance list apply
they decide which tokens the user can act on and what the user believes they the same check unconditionally, because they decide which tokens the user can
own rather than what the history displays. All three surfaces read the rule act on and what the user believes they own rather than what the history
from `src/shared/symbolSpoof.js`, so they cannot answer the question displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`,
differently. A symbol the list maps to no contract at all — the native asset's so they cannot answer the question differently. A symbol the list maps to no
labels: `"ETH"` and every network's `nativeCurrency`, such as `"SepoliaETH"`, contract at all — the native asset's labels: `"ETH"` and every network's
on every network — may be borne by no contract, so every ERC-20 claiming it is `nativeCurrency`, such as `"SepoliaETH"`, on every network — may be borne by
a spoof on all three. The user's real ETH balance is not an ERC-20 and is read no contract, so every ERC-20 claiming it is a spoof on all three. The user's
over RPC, so the rule never sees it. real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees
it.
- **Low-holder token filtering**: Token transfers from ERC-20 contracts with - **Low-holder token filtering**: Token transfers from ERC-20 contracts with
fewer than 1,000 holders are hidden from transaction history by default. fewer than 1,000 holders are hidden from transaction history by default.
+7 -9
View File
@@ -377,15 +377,13 @@ point-in-time snapshot of the highest-market-cap Ethereum mainnet ERC-20s, fixed
at build time and updated only when a new release ships a newer snapshot. If a at build time and updated only when a new release ships a newer snapshot. If a
transaction or balance claims to involve a known symbol (like "ETH" or "USDT") transaction or balance claims to involve a known symbol (like "ETH" or "USDT")
but comes from an unrecognized contract, it is identified as a spoof and hidden. but comes from an unrecognized contract, it is identified as a spoof and hidden.
On Sepolia, only "ETH" and "SepoliaETH" count as known symbols, because the In your transaction history this is the "Hide fake tokens impersonating a known
bundled list holds Ethereum Mainnet tokens. In your transaction history the symbol" setting, which you can switch off; doing so also stops new entries being
check is the "Hide fake tokens impersonating a known symbol" setting, which you added to the fraud contract blocklist below, since detecting a spoof is what
can switch off; doing so also stops new entries being added to the fraud fills it. The send token list always applies the check. Your balances apply it
contract blocklist below, since detecting a spoof is what fills it. The send too, with one exception: a token claiming the symbol "ETH" is not filtered
token list always applies the check. Your balances apply it too, with one there, so a fake "ETH" token can still show up in your balance list even though
exception: a token claiming the symbol "ETH" is not filtered there, so a fake it is hidden from your transaction history and from the send token list.
"ETH" token can still show up in your balance list even though it is hidden from
your transaction history and from the send token list.
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden **Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden
from transaction history and the send token list, and are left out of your from transaction history and the send token list, and are left out of your
+10 -27
View File
@@ -65,14 +65,7 @@ function renderWalletList() {
const pageClosed = new AbortController(); const pageClosed = new AbortController();
window.addEventListener("pagehide", () => pageClosed.abort()); window.addEventListener("pagehide", () => pageClosed.abort());
// While a refresh runs, a request through ctx is not dropped. The request sets
// refreshAgain, and one more refresh runs once the running one ends. Several
// requests in that time lead to one more refresh, not several. An import asks
// for a refresh after its address scan. A refresh already running does not
// have the addresses the scan found
// (https://git.eeqj.de/sneak/AutistMask/issues/581).
let refreshInFlight = false; let refreshInFlight = false;
let refreshAgain = false;
// The ten-second refresh startRefresh() starts, stopped when the popup moves to // The ten-second refresh startRefresh() starts, stopped when the popup moves to
// the recovery screen: there is no profile left to refresh. // the recovery screen: there is no profile left to refresh.
@@ -96,10 +89,7 @@ let refreshTimer = null;
// user turned "Use Blockscout" off. So the save drops every token the user has // user turned "Use Blockscout" off. So the save drops every token the user has
// not tracked if the stored setting is off by then. // not tracked if the stored setting is off by then.
async function doRefreshAndRender() { async function doRefreshAndRender() {
if (refreshInFlight) { if (refreshInFlight) return;
refreshAgain = true;
return;
}
refreshInFlight = true; refreshInFlight = true;
try { try {
await followStoredSettings(); await followStoredSettings();
@@ -142,35 +132,28 @@ async function doRefreshAndRender() {
}); });
renderWalletList(); renderWalletList();
} catch (e) { } catch (e) {
// No caller waits for this function, so it must never reject. An // Every call site fires this and walks away — the boot below, the ten
// unhandled rejection reports nothing. The save inside it reports its // second interval, and eight views through ctx — so it must never
// own failure through onSaveFailure() (see src/shared/state.js); what // reject: an unhandled rejection is not a report of anything. The save
// is left here is a failed network round trip, which the next tick // inside it reports its own failure through onSaveFailure() (see
// retries. // src/shared/state.js); what is left here is a failed network round
// trip, which the next tick retries.
log.errorf("popup: background refresh failed:", e); log.errorf("popup: background refresh failed:", e);
} finally { } finally {
refreshInFlight = false; refreshInFlight = false;
} }
if (refreshAgain) {
refreshAgain = false;
doRefreshAndRender();
}
} }
// Refreshes now and every ten seconds from then on. Called once a wallet // Refreshes now and every ten seconds from then on. Called once a wallet
// exists: by init() when the popup opens with one, and by AddWallet when it // exists: by init() when the popup opens with one, and by AddWallet when it
// adds one, which in a popup that opened on Welcome is the first // adds one, which in a popup that opened on Welcome is the first
// (https://git.eeqj.de/sneak/AutistMask/issues/530). With the ten-second // (https://git.eeqj.de/sneak/AutistMask/issues/530). With the refresh already
// refresh already started, it starts no second one. On the recovery screen it // running it starts no second one, and on the recovery screen it does nothing.
// does nothing.
function startRefresh() { function startRefresh() {
if (!$("view-state-recovery").classList.contains("hidden")) return; if (!$("view-state-recovery").classList.contains("hidden")) return;
doRefreshAndRender(); doRefreshAndRender();
if (refreshTimer === null) { if (refreshTimer === null) {
// A tick that finds a refresh running is dropped. refreshTimer = setInterval(doRefreshAndRender, 10000);
refreshTimer = setInterval(() => {
if (!refreshInFlight) return doRefreshAndRender();
}, 10000);
} }
} }
+47 -14
View File
@@ -22,6 +22,7 @@ const {
filterTransactions, filterTransactions,
} = require("../../shared/transactions"); } = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens"); const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const { const {
updateSendBalance, updateSendBalance,
renderSendTokenSelect, renderSendTokenSelect,
@@ -98,6 +99,7 @@ let loadedTxs = [];
let ensNameMap = new Map(); let ensNameMap = new Map();
async function loadTransactions(address) { async function loadTransactions(address) {
const networkId = state.networkId;
try { try {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
address, address,
@@ -114,43 +116,74 @@ async function loadTransactions(address) {
}); });
const txs = result.transactions; const txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
loadedTxs = txs;
// Collect ALL unique addresses (from + to) for ENS resolution so // Collect ALL unique addresses (from + to) for ENS resolution so
// that reverse lookups work for every displayed address, not just // that reverse lookups work for every displayed address, not just
// the ones that were originally entered as ENS names. // the ones that were originally entered as ENS names.
const counterparties = [ const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))), ...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
]; ];
let names = new Map();
if (counterparties.length > 0) { if (counterparties.length > 0) {
try { try {
ensNameMap = await resolveEnsNames( names = await resolveEnsNames(
counterparties, counterparties,
state.rpcUrl, state.rpcUrl,
state.networkId, state.networkId,
ctx.pageClosed, ctx.pageClosed,
); );
} catch { } catch {
ensNameMap = new Map(); // The rows then show no ENS names.
} }
} }
// If the network changed during the lookups above, or the screen now
// shows another address, nothing is saved or drawn. See
// src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-line").dataset.full !== address
) {
return;
}
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (result.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
loadedTxs = txs;
ensNameMap = names;
renderTransactions(txs); renderTransactions(txs);
} catch (e) { } catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in // Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js. // src/popup/index.js.
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message); log.errorf("loadTransactions failed:", e.message);
// If the network changed, or the screen now shows another address,
// the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-line").dataset.full !== address
) {
return;
}
$("tx-list").innerHTML = $("tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>'; '<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
} }
+51 -18
View File
@@ -22,13 +22,14 @@ const {
timeAgo, timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { bundledToken, resolveSymbol } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
filterTransactions, filterTransactions,
} = require("../../shared/transactions"); } = require("../../shared/transactions");
const { resolveEnsNames } = require("../../shared/ens"); const { resolveEnsNames } = require("../../shared/ens");
const { storedNetworkIs } = require("../../shared/storedSettings");
const { const {
updateSendBalance, updateSendBalance,
renderSendTokenSelect, renderSendTokenSelect,
@@ -52,8 +53,7 @@ function show() {
// Determine token symbol and balance // Determine token symbol and balance
let symbol, amount, price; let symbol, amount, price;
const chainId = currentNetwork().chainId; const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
const knownToken = bundledToken(tokenId, chainId);
if (tokenId === "ETH") { if (tokenId === "ETH") {
symbol = nativeCurrency(); symbol = nativeCurrency();
amount = parseFloat(addr.balance || "0"); amount = parseFloat(addr.balance || "0");
@@ -66,7 +66,6 @@ function show() {
tokenId, tokenId,
addr.tokenBalances, addr.tokenBalances,
state.trackedTokens, state.trackedTokens,
chainId,
); );
// null when the scale is unknown: no quantity to show, and none to // null when the scale is unknown: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000. // price. balanceLine() states that rather than printing 0.0000.
@@ -172,6 +171,7 @@ function show() {
} }
async function loadTransactions(address, tokenId) { async function loadTransactions(address, tokenId) {
const networkId = state.networkId;
try { try {
const rawTxs = await fetchRecentTransactions( const rawTxs = await fetchRecentTransactions(
address, address,
@@ -188,16 +188,6 @@ async function loadTransactions(address, tokenId) {
}); });
let txs = result.transactions; let txs = result.transactions;
// Persist any newly discovered fraud contracts
if (result.newFraudContracts.length > 0) {
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
await saveState();
}
// Filter to this token only // Filter to this token only
if (tokenId === "ETH") { if (tokenId === "ETH") {
txs = txs.filter((tx) => tx.contractAddress === null); txs = txs.filter((tx) => tx.contractAddress === null);
@@ -209,32 +199,75 @@ async function loadTransactions(address, tokenId) {
); );
} }
loadedTxs = txs;
// Collect ALL unique addresses for ENS resolution so reverse // Collect ALL unique addresses for ENS resolution so reverse
// lookups work for every displayed address. // lookups work for every displayed address.
const counterparties = [ const counterparties = [
...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))), ...new Set(txs.flatMap((tx) => [tx.from, tx.to].filter(Boolean))),
]; ];
let names = new Map();
if (counterparties.length > 0) { if (counterparties.length > 0) {
try { try {
ensNameMap = await resolveEnsNames( names = await resolveEnsNames(
counterparties, counterparties,
state.rpcUrl, state.rpcUrl,
state.networkId, state.networkId,
ctx.pageClosed, ctx.pageClosed,
); );
} catch { } catch {
ensNameMap = new Map(); // The rows then show no ENS names.
} }
} }
// If the network changed during the lookups above, or the screen now
// shows another address or token, nothing is saved or drawn. See
// src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
return;
}
// Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (result.newFraudContracts.length > 0) {
let saved = false;
await saveState((stored) => {
if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of result.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
}
saved = true;
});
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
}
loadedTxs = txs;
ensNameMap = names;
renderTransactions(txs); renderTransactions(txs);
} catch (e) { } catch (e) {
// Cancelled by the popup closing, not failed: see pageClosed in // Cancelled by the popup closing, not failed: see pageClosed in
// src/popup/index.js. // src/popup/index.js.
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
log.errorf("loadTransactions failed:", e.message); log.errorf("loadTransactions failed:", e.message);
// If the network changed, or the screen now shows another address or
// token, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId)) ||
$("address-token-line").dataset.full !== address ||
state.selectedToken !== tokenId
) {
return;
}
$("address-token-tx-list").innerHTML = $("address-token-tx-list").innerHTML =
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>'; '<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
} }
+2 -5
View File
@@ -313,17 +313,14 @@ function nativeCurrency() {
// The wallets' balances hold what Blockscout reported for each token. So the // The wallets' balances hold what Blockscout reported for each token. So the
// wallets are returned only while "Use Blockscout" is on. // wallets are returned only while "Use Blockscout" is on.
function tokenSourcesByChainId(chainId) { function tokenSourcesByChainId(chainId) {
const chainIdOrActive = chainId || currentNetwork().chainId; const net = chainId ? networkByChainId(chainId) : currentNetwork();
const net = networkByChainId(chainIdOrActive);
if (net && net.id === state.networkId) { if (net && net.id === state.networkId) {
return { return {
chainId: chainIdOrActive,
trackedTokens: state.trackedTokens, trackedTokens: state.trackedTokens,
wallets: state.useBlockscout ? state.wallets : [], wallets: state.useBlockscout ? state.wallets : [],
}; };
} }
return { return {
chainId: chainIdOrActive,
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [], trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
wallets: [], wallets: [],
}; };
@@ -354,7 +351,7 @@ function blockscoutOffTokenNoteHtml(token, chainId) {
// The symbol shown for a token line, from these sources in order: // The symbol shown for a token line, from these sources in order:
// //
// - the bundled list, for a token on Ethereum Mainnet // - the bundled list
// - the tokens the user tracks // - the tokens the user tracks
// - the explorer's report, while "Use Blockscout" is on // - the explorer's report, while "Use Blockscout" is on
// //
+34 -6
View File
@@ -44,6 +44,7 @@ const {
fetchRecentTransactions, fetchRecentTransactions,
filterTransactions, filterTransactions,
} = require("../../shared/transactions"); } = require("../../shared/transactions");
const { storedNetworkIs } = require("../../shared/storedSettings");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
function findActiveAddr() { function findActiveAddr() {
@@ -183,6 +184,7 @@ async function loadHomeTxs(ctx) {
} }
if (allAddresses.length === 0) return; if (allAddresses.length === 0) return;
const networkId = state.networkId;
const filters = { const filters = {
hideSpoofedSymbols: state.hideSpoofedSymbols, hideSpoofedSymbols: state.hideSpoofedSymbols,
hideLowHolderTokens: state.hideLowHolderTokens, hideLowHolderTokens: state.hideLowHolderTokens,
@@ -202,6 +204,15 @@ async function loadHomeTxs(ctx) {
); );
const results = await Promise.all(fetches); const results = await Promise.all(fetches);
// If the network changed during the lookups, nothing is saved or
// drawn. See src/shared/storedSettings.js.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
) {
return;
}
// Merge, deduplicate by hash, filter, sort, take 25 // Merge, deduplicate by hash, filter, sort, take 25
const seen = new Set(); const seen = new Set();
let merged = []; let merged = [];
@@ -215,14 +226,24 @@ async function loadHomeTxs(ctx) {
const filtered = filterTransactions(merged, filters); const filtered = filterTransactions(merged, filters);
// Persist any newly discovered fraud contracts // Persist any newly discovered fraud contracts, inside the save and
// holding the lock. Nothing is added unless this page and the stored
// record both still name the request's network.
if (filtered.newFraudContracts.length > 0) { if (filtered.newFraudContracts.length > 0) {
for (const addr of filtered.newFraudContracts) { let saved = false;
if (!state.fraudContracts.includes(addr)) { await saveState((stored) => {
state.fraudContracts.push(addr); if (state.networkId !== networkId) return false;
if (stored.networkId !== networkId) return false;
for (const addr of filtered.newFraudContracts) {
if (!state.fraudContracts.includes(addr)) {
state.fraudContracts.push(addr);
}
} }
} saved = true;
await saveState(); });
// If the network changed while the save waited for the lock,
// nothing is drawn.
if (!saved) return;
} }
merged = filtered.transactions; merged = filtered.transactions;
@@ -234,6 +255,13 @@ async function loadHomeTxs(ctx) {
// src/popup/index.js. // src/popup/index.js.
if (ctx.pageClosed.aborted) return; if (ctx.pageClosed.aborted) return;
log.errorf("loadHomeTxs failed:", e.message); log.errorf("loadHomeTxs failed:", e.message);
// If the network changed, the failure is not shown.
if (
state.networkId !== networkId ||
!(await storedNetworkIs(networkId))
) {
return;
}
const list = $("home-tx-list"); const list = $("home-tx-list");
if (list) { if (list) {
list.innerHTML = list.innerHTML =
+2 -6
View File
@@ -12,7 +12,7 @@ const {
goBack, goBack,
tokenSourcesByChainId, tokenSourcesByChainId,
} = require("./helpers"); } = require("./helpers");
const { state, currentAddress, currentNetwork } = require("../../shared/state"); const { state, currentAddress } = require("../../shared/state");
let ctx; let ctx;
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { resolveTokenDecimals } = require("../../shared/approvalAmount"); const { resolveTokenDecimals } = require("../../shared/approvalAmount");
@@ -154,9 +154,7 @@ function renderSendTokenSelect(addr) {
// out. Its token's own screen can still send it: there // out. Its token's own screen can still send it: there
// state.selectedToken picks the token, not this list. // state.selectedToken picks the token, not this list.
if (isBelowOneMillionth(t.balance)) continue; if (isBelowOneMillionth(t.balance)) continue;
if (isSpoofedSymbol(t.symbol, t.address, currentNetwork().chainId)) { if (isSpoofedSymbol(t.symbol, t.address)) continue;
continue;
}
if (fraudSet.has(t.address.toLowerCase())) continue; if (fraudSet.has(t.address.toLowerCase())) continue;
// An unknown holder count does not withhold a token the user holds: // An unknown holder count does not withhold a token the user holds:
// only a count the explorer actually reported as below the threshold // only a count the explorer actually reported as below the threshold
@@ -232,7 +230,6 @@ function updateSendBalance() {
token, token,
addr.tokenBalances, addr.tokenBalances,
state.trackedTokens, state.trackedTokens,
currentNetwork().chainId,
); );
// A null balance is a holding whose scale is unknown. Saying a figure // A null balance is a holding whose scale is unknown. Saying a figure
// for it would be a claim about the amount, so it reads as the // for it would be a claim about the amount, so it reads as the
@@ -388,7 +385,6 @@ function init(_ctx) {
token, token,
addr.tokenBalances, addr.tokenBalances,
state.trackedTokens, state.trackedTokens,
currentNetwork().chainId,
); );
({ tokenDecimals } = tokenBalanceAndDecimals(addr, token)); ({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
} }
+4 -18
View File
@@ -339,31 +339,17 @@ function showNewSettings() {
// Another page may have stored a different URL since then. So `url` is set in // Another page may have stored a different URL since then. So `url` is set in
// the stored record too. "Saved." then means that the stored record holds // the stored record too. "Saved." then means that the stored record holds
// `url`. // `url`.
// async function saveEndpoint(field, url, networkId) {
// `input` is the id of the input that the user typed `url` in. When another
// page changes the network, "Use Blockscout" or "Check recipients on
// Etherscan", this page draws Settings again. A redraw during the endpoint
// check puts the URL from `state` back in the input. So if the input no longer
// shows `url`, nothing is saved. The flash message then says that the URL was
// changed during the check. The save compares the input and sets `url` in
// `state` in one step. So a redraw after the comparison shows `url`.
async function saveEndpoint(field, input, url, networkId) {
let refused = false; let refused = false;
let inputChanged = false;
await saveState((stored) => { await saveState((stored) => {
refused = refused =
state.networkId !== networkId || stored.networkId !== networkId; state.networkId !== networkId || stored.networkId !== networkId;
inputChanged = $(input).value.trim() !== url; if (refused) return false;
if (refused || inputChanged) return false;
state[field] = url; state[field] = url;
stored[field] = url; stored[field] = url;
}); });
if (refused) { if (refused) {
showFlash("The URL was not saved because the network changed."); showFlash("The URL was not saved because the network changed.");
} else if (inputChanged) {
showFlash(
"The URL was not saved because it was changed during the check.",
);
} else { } else {
showFlash("Saved."); showFlash("Saved.");
} }
@@ -415,7 +401,7 @@ function init(_ctx) {
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
await saveEndpoint("rpcUrl", "settings-rpc", url, net.id); await saveEndpoint("rpcUrl", url, net.id);
}); });
$("btn-save-blockscout").addEventListener("click", async () => { $("btn-save-blockscout").addEventListener("click", async () => {
@@ -439,7 +425,7 @@ function init(_ctx) {
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
await saveEndpoint("blockscoutUrl", "settings-blockscout", url, net.id); await saveEndpoint("blockscoutUrl", url, net.id);
}); });
// Each change is made inside the save, holding the lock. So // Each change is made inside the save, holding the lock. So
-12
View File
@@ -311,21 +311,10 @@ function populateOnChainDetails(txData, chainId) {
} }
} }
// Whether TransactionDetail still holds the transaction with `txHash` on
// `chainId`. Opening another transaction replaces the one TransactionDetail
// holds. A visit to Settings does not.
function holdsTransaction(txHash, chainId) {
const tx = state.viewData.tx;
return tx?.hash === txHash && tx?.chainId === chainId;
}
// Reads the block, network details and calldata from Blockscout, and decodes // Reads the block, network details and calldata from Blockscout, and decodes
// the calldata. While the stored "Use Blockscout" is off, Blockscout is not // the calldata. While the stored "Use Blockscout" is off, Blockscout is not
// asked. An answer that lands after the setting went off is dropped // asked. An answer that lands after the setting went off is dropped
// (src/shared/storedSettings.js). Either way none of them is shown. // (src/shared/storedSettings.js). Either way none of them is shown.
//
// Blockscout can answer after the user has opened another transaction. That
// answer is dropped.
async function loadFullTxDetails(txHash, chainId) { async function loadFullTxDetails(txHash, chainId) {
if (!(await storedSettingIsOn("useBlockscout"))) return; if (!(await storedSettingIsOn("useBlockscout"))) return;
const section = $("tx-detail-calldata-section"); const section = $("tx-detail-calldata-section");
@@ -344,7 +333,6 @@ async function loadFullTxDetails(txHash, chainId) {
if (!resp.ok) return; if (!resp.ok) return;
const txData = await resp.json(); const txData = await resp.json();
if (!(await storedSettingIsOn("useBlockscout"))) return; if (!(await storedSettingIsOn("useBlockscout"))) return;
if (!holdsTransaction(txHash, chainId)) return;
// Populate on-chain detail fields (block, nonce, gas, fee) // Populate on-chain detail fields (block, nonce, gas, fee)
populateOnChainDetails(txData, chainId); populateOnChainDetails(txData, chainId);
+10 -13
View File
@@ -29,7 +29,7 @@
// string, and a token the user added by hand carries whatever lookupTokenInfo() // string, and a token the user added by hand carries whatever lookupTokenInfo()
// got back, so the accepted types are enumerated rather than coerced. // got back, so the accepted types are enumerated rather than coerced.
const { toDecimals } = require("./transferAmount"); const { toDecimals } = require("./transferAmount");
const { bundledToken } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { isSpoofedSymbol } = require("./symbolSpoof"); const { isSpoofedSymbol } = require("./symbolSpoof");
// Every decimals the explorer reported for this contract, across all the // Every decimals the explorer reported for this contract, across all the
@@ -53,14 +53,12 @@ function explorerDecimals(lower, wallets) {
} }
// The decimals to render a token amount with, or null when nothing knows. // The decimals to render a token amount with, or null when nothing knows.
// `sources` is { chainId, trackedTokens, wallets }. `chainId` is the chain id // `sources` is { trackedTokens, wallets }, both shaped as they are on `state`.
// of the token's network. The bundled list is read only when that network is
// Ethereum Mainnet. `trackedTokens` and `wallets` are shaped as on `state`.
function resolveTokenDecimals(tokenAddress, sources) { function resolveTokenDecimals(tokenAddress, sources) {
const lower = (tokenAddress || "").toLowerCase(); const lower = (tokenAddress || "").toLowerCase();
if (!lower) return null; if (!lower) return null;
const bundled = bundledToken(lower, sources && sources.chainId); const bundled = TOKEN_BY_ADDRESS.get(lower);
if (bundled) { if (bundled) {
const d = toDecimals(bundled.decimals); const d = toDecimals(bundled.decimals);
if (d !== null) return d; if (d !== null) return d;
@@ -102,19 +100,18 @@ function explorerSymbol(lower, wallets) {
// explorer reported: the same sources and the same precedence // explorer reported: the same sources and the same precedence
// resolveTokenDecimals() uses, so a token's name and its scale are drawn from // resolveTokenDecimals() uses, so a token's name and its scale are drawn from
// the same place and the two can no longer disagree about which sources they // the same place and the two can no longer disagree about which sources they
// trust. `sources` is as for resolveTokenDecimals(). // trust. `sources` is { trackedTokens, wallets }, shaped as on `state`.
// //
// A tracked or explorer-reported symbol is attacker-influenced text, so it is // A tracked or explorer-reported symbol is attacker-influenced text, so it is
// held to the spoof rule (symbolSpoof.js): a candidate that the rule calls a // held to the spoof rule (symbolSpoof.js): a candidate that wears a bundled or
// spoof is refused and the next source tried, so resolving a symbol never // native ticker from a contract not entitled to it is refused and the next
// becomes a new way to claim a known ticker. The bundled list is the wallet's // source tried, so resolving a symbol never becomes a new way to claim a known
// own data and is trusted as it is. // ticker. The bundled list is the wallet's own data and is trusted as it is.
function resolveTokenSymbol(tokenAddress, sources) { function resolveTokenSymbol(tokenAddress, sources) {
const lower = (tokenAddress || "").toLowerCase(); const lower = (tokenAddress || "").toLowerCase();
if (!lower) return null; if (!lower) return null;
const chainId = sources && sources.chainId; const bundled = TOKEN_BY_ADDRESS.get(lower);
const bundled = bundledToken(lower, chainId);
if (bundled && bundled.symbol) return bundled.symbol; if (bundled && bundled.symbol) return bundled.symbol;
const tracked = ((sources && sources.trackedTokens) || []).find( const tracked = ((sources && sources.trackedTokens) || []).find(
@@ -126,7 +123,7 @@ function resolveTokenSymbol(tokenAddress, sources) {
if (reported) candidates.push(reported); if (reported) candidates.push(reported);
for (const symbol of candidates) { for (const symbol of candidates) {
if (!isSpoofedSymbol(symbol, tokenAddress, chainId)) return symbol; if (!isSpoofedSymbol(symbol, tokenAddress)) return symbol;
} }
return null; return null;
} }
+7 -20
View File
@@ -17,7 +17,7 @@ const { ERC20_ABI } = require("./constants");
const { NETWORKS } = require("./networks"); const { NETWORKS } = require("./networks");
const { log, debugFetch, urlOrigin } = require("./log"); const { log, debugFetch, urlOrigin } = require("./log");
const { deriveAddressFromXpub } = require("./wallet"); const { deriveAddressFromXpub } = require("./wallet");
const { bundledToken } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders"); const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof"); const { isSpoofedSymbol } = require("./symbolSpoof");
const { toDecimals } = require("./transferAmount"); const { toDecimals } = require("./transferAmount");
@@ -106,9 +106,6 @@ function rawUnits(value) {
// balance is read through the RPC endpoint instead, and its row here gives that // balance is read through the RPC endpoint instead, and its row here gives that
// entry its holder count and `decimals` (fetchTrackedTokenBalances()). // entry its holder count and `decimals` (fetchTrackedTokenBalances()).
// //
// `chainId` is the chain id of the network `blockscoutUrl` serves. The known
// token list is used only when that network is Ethereum Mainnet.
//
// `decimals` and `balance` are each null when the answer is unknown, the same // `decimals` and `balance` are each null when the answer is unknown, the same
// way `holders` already is. Absence is never filled in here: this is the // way `holders` already is. Absence is never filled in here: this is the
// upstream of every screen that displays a token amount, so a value invented // upstream of every screen that displays a token amount, so a value invented
@@ -120,7 +117,6 @@ async function fetchTokenBalances(
address, address,
blockscoutUrl, blockscoutUrl,
trackedTokens, trackedTokens,
chainId,
signal, signal,
) { ) {
if (!(await storedSettingIsOn("useBlockscout"))) return []; if (!(await storedSettingIsOn("useBlockscout"))) return [];
@@ -179,10 +175,7 @@ async function fetchTokenBalances(
// either way: copying another source into it would make // either way: copying another source into it would make
// explorerDecimals()'s disagreement check compare something other // explorerDecimals()'s disagreement check compare something other
// than explorer values. // than explorer values.
const known = resolveTokenDecimals(tokenAddr, { const known = resolveTokenDecimals(tokenAddr, { trackedTokens });
chainId,
trackedTokens,
});
const scale = known !== null ? known : decimals; const scale = known !== null ? known : decimals;
// null is a holding of an amount that cannot be stated, which is // null is a holding of an amount that cannot be stated, which is
// not the same as a holding of zero, and must never render as one. // not the same as a holding of zero, and must never render as one.
@@ -198,7 +191,7 @@ async function fetchTokenBalances(
// through to the views, where the two low-holder filters treat // through to the views, where the two low-holder filters treat
// an unknown count as "do not judge" rather than as zero. // an unknown count as "do not judge" rather than as zero.
const holders = parseHoldersCount(item.token.holders_count); const holders = parseHoldersCount(item.token.holders_count);
const isKnown = bundledToken(tokenAddr, chainId) !== null; const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
const isTracked = trackedSet.has(tokenAddr); const isTracked = trackedSet.has(tokenAddr);
const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD; const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD;
@@ -209,9 +202,7 @@ async function fetchTokenBalances(
// Every row here is an ERC-20 the explorer reported, so it has a // Every row here is an ERC-20 the explorer reported, so it has a
// contract address; the native ETH balance is fetched over RPC in // contract address; the native ETH balance is fetched over RPC in
// refreshBalances and never passes through this loop. // refreshBalances and never passes through this loop.
if (isSpoofedSymbol(item.token.symbol, tokenAddr, chainId)) { if (isSpoofedSymbol(item.token.symbol, tokenAddr)) continue;
continue;
}
balances.push({ balances.push({
address: item.token.address_hash, address: item.token.address_hash,
@@ -258,18 +249,17 @@ async function fetchTokenBalances(
// zero from the tracked list. A token whose symbol the spoof rule refuses gets // zero from the tracked list. A token whose symbol the spoof rule refuses gets
// no entry either, as in fetchTokenBalances(). // no entry either, as in fetchTokenBalances().
// //
// `chainId` and `signal` are as for fetchTokenBalances(). // `signal` is as for fetchTokenBalances().
async function fetchTrackedTokenBalances( async function fetchTrackedTokenBalances(
provider, provider,
address, address,
trackedTokens, trackedTokens,
previous, previous,
explorerRequest, explorerRequest,
chainId,
signal, signal,
) { ) {
const listed = trackedTokens.filter( const listed = trackedTokens.filter(
(token) => !isSpoofedSymbol(token.symbol, token.address, chainId), (token) => !isSpoofedSymbol(token.symbol, token.address),
); );
// Each token's balanceOf answer, or null where it failed. // Each token's balanceOf answer, or null where it failed.
const balances = await Promise.all( const balances = await Promise.all(
@@ -311,7 +301,7 @@ async function fetchTrackedTokenBalances(
if (raw === 0n) return; if (raw === 0n) return;
const entry = entryIn(reported); const entry = entryIn(reported);
const decimals = entry ? entry.decimals : null; const decimals = entry ? entry.decimals : null;
const known = resolveTokenDecimals(lower, { chainId, trackedTokens }); const known = resolveTokenDecimals(lower, { trackedTokens });
const scale = known !== null ? known : decimals; const scale = known !== null ? known : decimals;
rows.push({ rows.push({
address: token.address, address: token.address,
@@ -340,7 +330,6 @@ async function refreshBalances(
) { ) {
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl)); log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
const provider = getProvider(rpcUrl, networkId); const provider = getProvider(rpcUrl, networkId);
const { chainId } = requireNetworkId(networkId);
const tracked = trackedTokens || []; const tracked = trackedTokens || [];
const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase())); const trackedSet = new Set(tracked.map((t) => t.address.toLowerCase()));
const updates = []; const updates = [];
@@ -404,7 +393,6 @@ async function refreshBalances(
addr.address, addr.address,
blockscoutUrl, blockscoutUrl,
tracked, tracked,
chainId,
signal, signal,
); );
updates.push( updates.push(
@@ -415,7 +403,6 @@ async function refreshBalances(
tracked, tracked,
previous, previous,
explorerRequest, explorerRequest,
chainId,
signal, signal,
), ),
explorerRequest, explorerRequest,
+5 -18
View File
@@ -53,31 +53,18 @@ function clearPopupCaches() {
// change. So the switch also resets these fields in the stored record: // change. So the switch also resets these fields in the stored record:
// //
// - every address's balance and token balances; // - every address's balance and token balances;
// - the time of the last balance refresh; // - the time of the last balance refresh.
// - the fraud contracts.
// //
// A balance or a fraud contract that the background or another page stored // A balance that the background or another page stored since this page's last
// since this page's last save is then not kept under the new network // save is then not kept under the new network
// (https://git.eeqj.de/sneak/AutistMask/issues/653, // (https://git.eeqj.de/sneak/AutistMask/issues/653).
// https://git.eeqj.de/sneak/AutistMask/issues/692).
//
// Before the switch, `state` takes the tracked tokens that another page stored
// since this page's last save (takeStoredTrackedTokens() in state.js). The
// switch then puts them away under the previous network, the network they were
// added on. When the stored record is on another network, a switch saved
// elsewhere has not reached this page yet. The stored record's tokens are then
// that network's, and none are taken.
async function onChainSwitch(newNetworkId) { async function onChainSwitch(newNetworkId) {
const { state, saveState, takeStoredTrackedTokens } = require("./state"); const { state, saveState } = require("./state");
let net = null; let net = null;
await saveState((stored) => { await saveState((stored) => {
if (state.networkId === newNetworkId) return false; if (state.networkId === newNetworkId) return false;
if (stored.networkId === state.networkId) {
takeStoredTrackedTokens(stored);
}
net = applyChainSwitchFields(state, newNetworkId); net = applyChainSwitchFields(state, newNetworkId);
stored.lastBalanceRefresh = 0; stored.lastBalanceRefresh = 0;
stored.fraudContracts = [];
for (const wallet of stored.wallets) { for (const wallet of stored.wallets) {
for (const addr of wallet.addresses) { for (const addr of wallet.addresses) {
addr.balance = "0"; addr.balance = "0";
+7 -33
View File
@@ -125,12 +125,11 @@ function currentNetwork() {
return networkById(state.networkId); return networkById(state.networkId);
} }
// `baseline` holds the persisted fields as this page held them when its last // The persisted fields as this page held them when its last loadState()
// loadState() finished, or when its last successful saveState() began. // finished, or when its last successful saveState() began, with any network
// takeStoredNetwork() and takeStoredTrackedTokens() also write into it what // switch saved elsewhere that this page has taken since (takeStoredNetwork()).
// this page has taken from the stored record since then. saveState() diffs the // saveState() diffs the live state against this to find only the fields THIS
// live state against `baseline` to find only the fields THIS page changed // page changed since.
// since.
// //
// Deep-cloned, not a reference: callers mutate persisted objects and arrays // Deep-cloned, not a reference: callers mutate persisted objects and arrays
// in place (state.wallets.push(...)), and a reference baseline would mutate // in place (state.wallets.push(...)), and a reference baseline would mutate
@@ -443,10 +442,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// milder than the other two. // milder than the other two.
// //
// Every other persisted field stays a whole-field diff: // Every other persisted field stays a whole-field diff:
// `fraudContracts`/`viewStack` are arrays of scalars with no per-element // `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
// identity to merge by; a save diffs `trackedTokens` whole, and a switch in // per-element identity to merge by; `networkTrackedTokens` is diffed whole like
// Settings first merges that list by contract address
// (takeStoredTrackedTokens()); `networkTrackedTokens` is diffed whole like
// `trackedTokens` and `networkId`, the two fields a network switch moves // `trackedTokens` and `networkId`, the two fields a network switch moves
// together with it; `tokenHolderCache` is a map shaped like the ones above, // together with it; `tokenHolderCache` is a map shaped like the ones above,
// but nothing in src/ ever writes an entry into it — it is only ever reset // but nothing in src/ ever writes an entry into it — it is only ever reset
@@ -618,28 +615,6 @@ function takeStoredNetwork(stored) {
copyChainSwitchFields(baseline, structuredClone(stored)); copyChainSwitchFields(baseline, structuredClone(stored));
} }
// Give this page the tracked tokens of `stored`, a record on this page's
// network that a save of this page has just read (onChainSwitch() in
// src/shared/chainSwitch.js calls this from `beforeWrite`):
//
// - A token that another page tracked or removed since this page's last save
// is tracked or removed in `state` too.
// - A token that this page tracked or removed since then stays as this page
// has it.
//
// `baseline` takes the stored list, so the save stores the list in `state`
// whenever the two differ.
function takeStoredTrackedTokens(stored) {
rawState.trackedTokens = mergeListByIdentity(
baseline.trackedTokens,
rawState.trackedTokens,
stored.trackedTokens,
(token) => token.address.toLowerCase(),
(base, ours) => ours,
);
baseline.trackedTokens = structuredClone(stored.trackedTokens);
}
// Rejects with StateUnusableError for a stored record this build cannot make // Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a // sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first // caller that ignores the rejection gets StateNotLoadedError on the first
@@ -686,7 +661,6 @@ module.exports = {
saveState, saveState,
onSaveFailure, onSaveFailure,
takeStoredNetwork, takeStoredNetwork,
takeStoredTrackedTokens,
loadState, loadState,
currentAddress, currentAddress,
currentNetwork, currentNetwork,
+19 -3
View File
@@ -1,5 +1,5 @@
// Reads "Use Blockscout" or "Check recipients on Etherscan" from the stored // Reads "Use Blockscout", "Check recipients on Etherscan" or the active network
// profile. // from the stored profile.
// //
// Before each request that sends the user's data to Blockscout or Etherscan, the // 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 // wallet reads that service's setting from storage. The wallet reads the setting
@@ -7,6 +7,11 @@
// made, and an answer that lands after the setting went off is dropped. A page's // 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 // own copy of the setting is not used, because another page may have just
// changed it. // changed it.
//
// Before asking Blockscout for the transaction history, a screen remembers
// which network is active. If another network is active on the page or in
// storage when the answer lands, the screen drops the answer. A switch saved
// elsewhere reaches storage first and the page later.
const { storageGet } = require("./browserApi"); const { storageGet } = require("./browserApi");
const { normalizePersisted } = require("./persistedState"); const { normalizePersisted } = require("./persistedState");
@@ -24,4 +29,15 @@ async function storedSettingIsOn(name) {
} }
} }
module.exports = { storedSettingIsOn }; // Whether `networkId` is the active network in the stored profile. A failed
// read counts as no.
async function storedNetworkIs(networkId) {
try {
const { autistmask } = await storageGet("autistmask");
return normalizePersisted(autistmask).networkId === networkId;
} catch {
return false;
}
}
module.exports = { storedSettingIsOn, storedNetworkIs };
+3 -11
View File
@@ -19,8 +19,7 @@
// them hid the other one's holders' money (issue #276). Membership, not // them hid the other one's holders' money (issue #276). Membership, not
// equality, is therefore the question — but it is the same question, asked of // equality, is therefore the question — but it is the same question, asked of
// a table that can now state the truth. Every address in a set is one the // a table that can now state the truth. Every address in a set is one the
// wallet ships as a real token on Ethereum Mainnet. On mainnet, a contract // wallet ships as a real token; a contract outside the set is still a spoof.
// outside the set is still a spoof.
// //
// The symbol is attacker-controlled — it is whatever the ERC-20 contract // The symbol is attacker-controlled — it is whatever the ERC-20 contract
// returns — so the lookup is done on a normalized form (issue #260): the // returns — so the lookup is done on a normalized form (issue #260): the
@@ -28,7 +27,6 @@
// since that is what the user acts on. // since that is what the user acts on.
const { KNOWN_SYMBOLS } = require("./tokenList"); const { KNOWN_SYMBOLS } = require("./tokenList");
const { NETWORKS } = require("./networks");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum // Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. // over the address, not part of its identity.
@@ -95,24 +93,18 @@ function normalizeSymbol(symbol) {
} }
// True when a token bearing `symbol` from contract `contractAddress` is // True when a token bearing `symbol` from contract `contractAddress` is
// impersonating a known symbol. `chainId` is the chain id of the token's // impersonating a known symbol.
// network.
//
// The native asset's labels are known symbols on every network. The bundled
// list's symbols are known symbols only on Ethereum Mainnet, because the list
// holds mainnet contracts.
// //
// An empty contract address is the native asset, which is never a spoof: // An empty contract address is the native asset, which is never a spoof:
// this is what keeps the user's real ETH out of the rule, and it holds for // this is what keeps the user's real ETH out of the rule, and it holds for
// any symbol that becomes null-mapped later, not just for ETH. // any symbol that becomes null-mapped later, not just for ETH.
function isSpoofedSymbol(symbol, contractAddress, chainId) { function isSpoofedSymbol(symbol, contractAddress) {
const contract = normalizeAddress(contractAddress); const contract = normalizeAddress(contractAddress);
if (!contract) return false; if (!contract) return false;
const sym = normalizeSymbol(symbol); const sym = normalizeSymbol(symbol);
if (!KNOWN_SYMBOLS.has(sym)) return false; if (!KNOWN_SYMBOLS.has(sym)) return false;
const legit = KNOWN_SYMBOLS.get(sym); const legit = KNOWN_SYMBOLS.get(sym);
if (legit === null) return true; if (legit === null) return true;
if (chainId !== NETWORKS.mainnet.chainId) return false;
return !legit.has(contract); return !legit.has(contract);
} }
+4 -16
View File
@@ -3953,15 +3953,6 @@ for (const t of TOKENS) {
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t); TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
} }
// The bundled list's entry for the contract at `address` on the network with
// chain id `chainId`, or null. The list holds Ethereum Mainnet contracts only.
// A contract at the same address on another network is another token, so any
// other chain id gets null.
function bundledToken(address, chainId) {
if (chainId !== NETWORKS.mainnet.chainId) return null;
return TOKEN_BY_ADDRESS.get((address || "").toLowerCase()) || null;
}
// Build a map of symbol (uppercased) -> the set of contract addresses // Build a map of symbol (uppercased) -> the set of contract addresses
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection. // (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
// "ETH" maps to null: the native asset has no contract, so no contract may // "ETH" maps to null: the native asset has no contract, so no contract may
@@ -3978,8 +3969,7 @@ function bundledToken(address, chainId) {
// hides from the balance list, the history and the send selector at its own // hides from the balance list, the history and the send selector at its own
// address, so the user cannot spend it (issue #276). Naming every address // address, so the user cannot spend it (issue #276). Naming every address
// that bears the symbol is the only shape that says what is true; it does not // that bears the symbol is the only shape that says what is true; it does not
// loosen the rule. On Ethereum Mainnet, a contract outside the set is still a // loosen the rule, because a contract outside the set is still a spoof.
// spoof.
const KNOWN_SYMBOLS = new Map(); const KNOWN_SYMBOLS = new Map();
KNOWN_SYMBOLS.set("ETH", null); KNOWN_SYMBOLS.set("ETH", null);
for (const network of Object.values(NETWORKS)) { for (const network of Object.values(NETWORKS)) {
@@ -3998,15 +3988,14 @@ for (const t of TOKENS) {
} }
} }
// Resolve a token symbol from multiple sources, never returning "?". The token // Resolve a token symbol from multiple sources, never returning "?".
// is a contract on the network with chain id `chainId`. function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens, chainId) {
const lower = (tokenAddress || "").toLowerCase(); const lower = (tokenAddress || "").toLowerCase();
const tb = (tokenBalances || []).find( const tb = (tokenBalances || []).find(
(t) => t.address.toLowerCase() === lower, (t) => t.address.toLowerCase() === lower,
); );
if (tb && tb.symbol) return tb.symbol; if (tb && tb.symbol) return tb.symbol;
const known = bundledToken(lower, chainId); const known = TOKEN_BY_ADDRESS.get(lower);
if (known && known.symbol) return known.symbol; if (known && known.symbol) return known.symbol;
const tracked = (trackedTokens || []).find( const tracked = (trackedTokens || []).find(
(t) => t.address.toLowerCase() === lower, (t) => t.address.toLowerCase() === lower,
@@ -4023,6 +4012,5 @@ module.exports = {
USD_PRICE_FEEDS, USD_PRICE_FEEDS,
PRICE_FEED_BY_ADDRESS, PRICE_FEED_BY_ADDRESS,
getTopTokens, getTopTokens,
bundledToken,
resolveSymbol, resolveSymbol,
}; };
+3 -6
View File
@@ -8,7 +8,7 @@
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log"); const { log, debugFetch } = require("./log");
const { bundledToken } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders"); const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof"); const { isSpoofedSymbol } = require("./symbolSpoof");
const { nativeCurrencyByChainId } = require("./networks"); const { nativeCurrencyByChainId } = require("./networks");
@@ -47,7 +47,7 @@ function parseTx(tx, addrLower, chainId) {
let direction = normalizeAddress(from) === addrLower ? "sent" : "received"; let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
let directionLabel = direction === "sent" ? "Sent" : "Received"; let directionLabel = direction === "sent" ? "Sent" : "Received";
if (toIsContract && method && method !== "transfer") { if (toIsContract && method && method !== "transfer") {
const token = bundledToken(to, chainId); const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
if (token) { if (token) {
symbol = token.symbol; symbol = token.symbol;
} }
@@ -317,10 +317,7 @@ function filterTransactions(txs, filters = {}) {
const contract = normalizeAddress(tx.contractAddress); const contract = normalizeAddress(tx.contractAddress);
// Filter spoofed known symbols and record the fraud contract // Filter spoofed known symbols and record the fraud contract
if ( if (hideSpoofed && isSpoofedSymbol(tx.symbol, tx.contractAddress)) {
hideSpoofed &&
isSpoofedSymbol(tx.symbol, tx.contractAddress, tx.chainId)
) {
if (contract && !fraudSet.has(contract)) { if (contract && !fraudSet.has(contract)) {
fraudSet.add(contract); fraudSet.add(contract);
newFraud.push(contract); newFraud.push(contract);
+11 -20
View File
@@ -452,8 +452,8 @@ const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
// `decimals` is null when nothing knows this token's scale. It is not // `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the // defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as // ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
// 0.000000000001. `sources` is as for resolveTokenDecimals(), which reads the // 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are
// bundled list on Ethereum Mainnet, then the tracked tokens and the wallets. // on `state`; resolveTokenDecimals() reads the bundled list, then those.
// //
// A null `address` means UNDETERMINED — the calldata named no currency for // A null `address` means UNDETERMINED — the calldata named no currency for
// that side — and is refused rather than named. It is not native ETH: Uniswap // that side — and is refused rather than named. It is not native ETH: Uniswap
@@ -1240,9 +1240,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const release = routerRelease(toAddress, chainId); const release = routerRelease(toAddress, chainId);
if (release === null) return null; if (release === null) return null;
const { commandTypeMask, commands, allowRevert } = release; const { commandTypeMask, commands, allowRevert } = release;
// The swap's tokens are contracts on the network with `chainId`, so they
// are named and scaled for that network.
const tokenSources = { ...sources, chainId };
try { try {
const parsed = ROUTER_IFACE.parseTransaction({ data }); const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null; if (!parsed) return null;
@@ -2211,11 +2208,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// Any other output keeps its own token and figure: a swap that pays // Any other output keeps its own token and figure: a swap that pays
// its WETH to the user and then unwraps receives WETH, and one that // its WETH to the user and then unwraps receives WETH, and one that
// buys USDC and then unwraps the WETH it did not spend receives USDC. // buys USDC and then unwraps the WETH it did not spend receives USDC.
const inInfo = tokenInfo(inputToken, tokenSources); const inInfo = tokenInfo(inputToken, sources);
const outInfo = const outInfo =
outputUnwrapped || (hasUnwrapWeth && !outputEstablished) outputUnwrapped || (hasUnwrapWeth && !outputEstablished)
? { symbol: "ETH", decimals: 18, address: null } ? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, tokenSources); : tokenInfo(outputToken, sources);
const inSymbol = inInfo.symbol; const inSymbol = inInfo.symbol;
const outSymbol = outInfo.symbol; const outSymbol = outInfo.symbol;
@@ -2340,19 +2337,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
details.push({ details.push({
label: "Moved from the sender", label: "Moved from the sender",
value: present(s.amount) value: present(s.amount)
? spentText( ? spentText(s.amount, tokenInfo(s.token, sources), s.atMost)
s.amount, .display
tokenInfo(s.token, tokenSources),
s.atMost,
).display
: UNDECODED_CURRENCY, : UNDECODED_CURRENCY,
}); });
if (present(s.token)) { if (present(s.token)) {
details.push( details.push(
tokenMovedLine( tokenMovedLine("Token moved", tokenInfo(s.token, sources)),
"Token moved",
tokenInfo(s.token, tokenSources),
),
); );
details.push({ details.push({
label: "Moved to", label: "Moved to",
@@ -2364,7 +2355,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and // What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own. // where it goes, an address on a line of its own.
for (const m of movedFromSender) { for (const m of movedFromSender) {
const info = permit2TokenInfo(m.token, tokenSources); const info = permit2TokenInfo(m.token, sources);
details.push({ details.push({
label: "Moved from the sender", label: "Moved from the sender",
value: amountText(m.amount, info).display, value: amountText(m.amount, info).display,
@@ -2399,7 +2390,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}); });
continue; continue;
} }
const info = permit2TokenInfo(p.token, tokenSources); const info = permit2TokenInfo(p.token, sources);
details.push({ details.push({
label: "Permit2 allowance", label: "Permit2 allowance",
value: value:
@@ -2465,7 +2456,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE }); paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
} }
for (const m of movedIntoRouter) { for (const m of movedIntoRouter) {
const info = permit2TokenInfo(m.token, tokenSources); const info = permit2TokenInfo(m.token, sources);
paidTo.push({ paidTo.push({
label: "Paid to", label: "Paid to",
value: value:
@@ -2490,7 +2481,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
paidTo.push( paidTo.push(
tokenMovedLine( tokenMovedLine(
"Token moved into the router", "Token moved into the router",
tokenInfo(getAddress(token), tokenSources), tokenInfo(getAddress(token), sources),
), ),
); );
} }
+2 -5
View File
@@ -90,11 +90,9 @@ beforeEach(() => {
}); });
describe("resolveTokenDecimals", () => { describe("resolveTokenDecimals", () => {
// The bundled list is read only for a token on Ethereum Mainnet.
test("prefers the bundled list", () => { test("prefers the bundled list", () => {
state.trackedTokens = [{ address: USDC, symbol: "USDC", decimals: 2 }]; state.trackedTokens = [{ address: USDC, symbol: "USDC", decimals: 2 }];
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens }; expect(resolveTokenDecimals(USDC, state)).toBe(6);
expect(resolveTokenDecimals(USDC, sources)).toBe(6);
}); });
test("reads a token the user tracks", () => { test("reads a token the user tracks", () => {
@@ -126,8 +124,7 @@ describe("resolveTokenDecimals", () => {
// answers 0 is used rather than fallen past like the unusable entry above. // answers 0 is used rather than fallen past like the unusable entry above.
test("uses a bundled scale of zero", () => { test("uses a bundled scale of zero", () => {
state.trackedTokens = [{ address: SLP, symbol: "SLP", decimals: 18 }]; state.trackedTokens = [{ address: SLP, symbol: "SLP", decimals: 18 }];
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens }; expect(resolveTokenDecimals(SLP, state)).toBe(0);
expect(resolveTokenDecimals(SLP, sources)).toBe(0);
}); });
test("uses a tracked scale of zero", () => { test("uses a tracked scale of zero", () => {
-169
View File
@@ -1,169 +0,0 @@
// The approval screen for a site's Sepolia transfer of the contract at USDC's
// mainnet address, while Sepolia is active.
//
// The bundled token list holds Ethereum Mainnet contracts. The contract at the
// same address on Sepolia is another token, but the screen named it USDC and
// scaled its amount at USDC's decimals
// (https://git.eeqj.de/sneak/AutistMask/issues/632). The bundled list now names
// a token only on Ethereum Mainnet. The same screen for a mainnet transfer of
// USDC, while Sepolia is active, still names USDC.
//
// Driven through the real approval screen, against the minimal DOM stub
// tests/approvalTokenNetwork.test.js uses.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const approval = require("../src/popup/views/approval");
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// USDC's address on Ethereum Mainnet, where the bundled list names it.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// FROM sends 1234500000 base units to RECIPIENT.
const TRANSFER_DATA = new Interface([
"function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// One line of the decoded details: its label and what is drawn under it.
function decodedLine(label) {
return node("approve-tx-decoded-details")
.innerHTML.split('<div class="mb-2">')
.find((line) =>
line.startsWith(`<div class="text-muted">${label}</div>`),
);
}
// Open the approval screen, with Sepolia active, for the site's transaction
// sending TRANSFER_DATA to the contract at USDC's address on the network with
// chain id `chainId`.
async function showTransfer(chainId) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId,
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: USDC,
value: "0x0",
data: TRANSFER_DATA,
accessList: [],
},
});
},
};
state.networkId = "sepolia";
state.trackedTokens = [];
state.networkTrackedTokens = {};
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: FROM, index: 0, balance: "0", tokenBalances: [] },
],
},
];
state.currentView = null;
approval.init({});
await approval.show(1);
}
test("names no token, and states the amount in base units", async () => {
await showTransfer(NETWORKS.sepolia.chainId);
expect(state.currentView).toBe("approve-tx");
expect(node("approve-tx-to").innerHTML).not.toContain("USDC");
expect(decodedLine("Token")).toContain(
'<div class="font-bold">Unknown token</div>',
);
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
);
});
// A transaction is named for its own network, not the active one.
test("a mainnet transfer of USDC is still named and scaled as USDC", async () => {
await showTransfer(NETWORKS.mainnet.chainId);
expect(state.currentView).toBe("approve-tx");
expect(decodedLine("Token")).toContain('<div class="font-bold">USDC</div>');
expect(decodedLine("Amount")).toContain(
'<div class="font-bold">1234.5000 USDC</div>',
);
});
-81
View File
@@ -1,81 +0,0 @@
// The token screen, the Send screen and the confirmation screen for the
// contract at USDC's mainnet address. No address holds it and the user does
// not track it, so only the bundled token list can name it.
//
// The bundled token list holds Ethereum Mainnet contracts, so these screens
// name the token from it on Ethereum Mainnet only
// (https://git.eeqj.de/sneak/AutistMask/issues/632). Driven through the real
// popup over a stored profile.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
} = require("./support/popupBoot");
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
afterEach(() => {
cleanupPopup();
});
// The popup reopened onto AddressToken for USDC's address, on `networkId`.
function bootOnToken(networkId) {
return bootPopup(
unversionedValidProfile({
networkId,
currentView: "address-token",
viewStack: ["main", "address"],
selectedWallet: 0,
selectedAddress: 0,
selectedToken: USDC,
}),
);
}
// AddressToken's Send, then Review for 1 token to RECIPIENT.
async function reviewSend(boot) {
await boot.click("btn-address-token-send");
expect(boot.visibleViews()).toEqual(["send"]);
boot.node("send-to").value = RECIPIENT;
boot.node("send-amount").value = "1";
await boot.click("btn-send-review");
expect(boot.visibleViews()).toEqual(["confirm-tx"]);
}
describe("on Ethereum Mainnet, the bundled list names the token", () => {
test("on the token screen", async () => {
const boot = await bootOnToken("mainnet");
expect(boot.visibleViews()).toEqual(["address-token"]);
expect(boot.text("address-token-title")).toContain("USDC");
const info = boot.node("address-token-contract-info").innerHTML;
expect(info).toContain(
'<span class="text-muted">Decimals:</span> 6</div>',
);
});
test("on the Send screen and the confirmation screen", async () => {
const boot = await bootOnToken("mainnet");
await reviewSend(boot);
expect(boot.text("send-balance")).toContain("USDC");
expect(boot.text("confirm-amount")).toContain("USDC");
});
});
describe("with Sepolia active, the bundled list does not name the token", () => {
test("on the token screen", async () => {
const boot = await bootOnToken("sepolia");
expect(boot.visibleViews()).toEqual(["address-token"]);
expect(boot.text("address-token-title")).not.toContain("USDC");
const info = boot.node("address-token-contract-info").innerHTML;
expect(info).not.toContain("Decimals:");
});
test("on the Send screen and the confirmation screen", async () => {
const boot = await bootOnToken("sepolia");
await reviewSend(boot);
expect(boot.text("send-balance")).not.toContain("USDC");
expect(boot.text("confirm-amount")).not.toContain("USDC");
});
});
+1 -3
View File
@@ -111,15 +111,13 @@ function respondWith(items) {
} }
// Fetch and place the result exactly where refreshBalances() places it, so the // Fetch and place the result exactly where refreshBalances() places it, so the
// approval screens read what a real refresh on Ethereum Mainnet would have // approval screens read what a real refresh would have left on state.
// left on state.
async function fetchOnto(items, trackedTokens = []) { async function fetchOnto(items, trackedTokens = []) {
respondWith(items); respondWith(items);
const balances = await fetchTokenBalances( const balances = await fetchTokenBalances(
HOLDER, HOLDER,
BLOCKSCOUT, BLOCKSCOUT,
trackedTokens, trackedTokens,
"0x1",
); );
state.trackedTokens = trackedTokens; state.trackedTokens = trackedTokens;
state.wallets = [ state.wallets = [
+493
View File
@@ -0,0 +1,493 @@
// Home, AddressDetail and AddressToken drop a transaction history answer that
// lands after a network switch. A dropped answer draws no rows and saves no
// fraud contracts (https://git.eeqj.de/sneak/AutistMask/issues/657).
//
// Most tests hold the history request open, switch the network or what the
// screen shows, and then let the answer land. The views run against a minimal
// DOM stub, as in tests/transactionListCancelled.test.js, and over
// tests/support/storageStub.js.
const { makeStorageStub } = require("./support/storageStub");
const { STATE_SCHEMA_VERSION } = require("../src/shared/stateSchema");
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: jest.fn(async () => []),
}));
jest.mock("../src/shared/ens", () => ({
resolveEnsNames: jest.fn(async () => new Map()),
}));
const { fetchRecentTransactions } = require("../src/shared/transactions");
const { resolveEnsNames } = require("../src/shared/ens");
const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
const { applyChainSwitchFields } = require("../src/shared/chainSwitchFields");
const { storedNetworkIs } = require("../src/shared/storedSettings");
const home = require("../src/popup/views/home");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const ADDRESS = "0x1111111111111111111111111111111111111111";
const OTHER_ADDRESS = "0x2222222222222222222222222222222222222222";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// A contract that sends a token claiming USDT's symbol. Spotting the spoof
// adds this contract to the fraud contracts.
const SPOOF_CONTRACT = "0x3333333333333333333333333333333333333333";
const TOKEN = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
// A profile on Ethereum Mainnet with two addresses, "Use Blockscout" on and no
// fraud contracts.
function profile() {
return {
schemaVersion: STATE_SCHEMA_VERSION,
hasWallet: true,
wallets: [
{
type: "hd",
name: "Main",
xpub: "xpub-main",
encryptedSecret: "encrypted-secret",
nextIndex: 2,
addresses: [
{ address: ADDRESS, balance: "1.0", tokenBalances: [] },
{
address: OTHER_ADDRESS,
balance: "0.0",
tokenBalances: [],
},
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
useBlockscout: true,
fraudContracts: [],
};
}
// What Blockscout answers for ADDRESS on Ethereum Mainnet:
//
// - a transfer of ETH from ADDRESS;
// - a transfer of the fake USDT from SPOOF_CONTRACT.
function history() {
return [
{
hash: "0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84",
blockNumber: 2,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
rawAmount: "1000000000000000000",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
},
{
hash: "0x0d6a6e0a0d1b7d4c3b2a19087f6e5d4c3b2a19087f6e5d4c3b2a19087f6e5d4c",
blockNumber: 1,
from: SPOOF_CONTRACT,
to: ADDRESS,
value: "100.0000",
exactValue: "100.0",
rawAmount: "100000000",
rawUnit: "base units",
symbol: "USDT",
timestamp: 1790000000,
isError: false,
directionLabel: "Received",
direction: "received",
contractAddress: SPOOF_CONTRACT,
},
];
}
// The next history request waits until the returned function is called, and
// then answers with `answer`.
function holdHistory(answer = history()) {
let land;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve) => {
land = () => resolve(answer);
}),
);
return () => land();
}
// The next history request waits until the returned function is called, and
// then fails.
function holdFailure() {
let fail;
fetchRecentTransactions.mockImplementationOnce(
() =>
new Promise((resolve, reject) => {
fail = () => reject(new Error("Blockscout is down"));
}),
);
return () => fail();
}
// Another write holds the lock until the returned function is called. Every
// save waits until then.
function holdLock() {
let release;
const held = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
release = resolve;
}),
);
return async () => {
release();
await held;
};
}
function makeElement(id) {
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: () => {},
remove: () => {},
contains: () => false,
toggle: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Lets every promise that is not held open settle.
function settle() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
let storage;
beforeEach(async () => {
storage = makeStorageStub({ autistmask: profile() });
globalThis.chrome = {
storage: { local: storage.local, onChanged: storage.onChanged },
};
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
await loadState();
state.viewStack = [];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
const ctx = { pageClosed: new AbortController().signal };
addressDetail.init(ctx);
addressToken.init(ctx);
});
afterEach(() => {
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
});
const HOME = {
name: "Home",
listId: "home-tx-list",
open: () => home.render({ pageClosed: new AbortController().signal }),
};
const ADDRESS_DETAIL = {
name: "AddressDetail",
listId: "tx-list",
open: () => addressDetail.show(),
};
const ADDRESS_TOKEN = {
name: "AddressToken",
listId: "address-token-tx-list",
open: () => addressToken.show(),
};
// Three ways a test switches the network:
//
// - made in Settings: the page and storage have both switched;
// - saved elsewhere: storage has switched and the page has not;
// - not yet stored: the page has switched and storage has not.
const SWITCHES = [
{
how: "made in Settings",
to: (networkId) => onChainSwitch(networkId),
},
{
// Another page or the background stored the switch, and this page has
// not followed it yet.
how: "saved elsewhere",
to: async (networkId) => {
const record = storage.read("autistmask");
applyChainSwitchFields(record, networkId);
storage.write("autistmask", record);
},
},
{
// The switch's own save has changed the page and has not yet written
// storage.
how: "not yet stored",
to: async (networkId) => {
applyChainSwitchFields(state, networkId);
},
},
];
// The list still shows `before`, and neither the page nor storage holds a
// fraud contract.
function expectNothingAdded(screen, before) {
expect(node(screen.listId).innerHTML).toBe(before);
expect(state.fraudContracts).toEqual([]);
expect(storage.read("autistmask").fraudContracts).toEqual([]);
}
describe.each([HOME, ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands on the same network is drawn and saved", async () => {
const land = holdHistory();
screen.open();
land();
await settle();
expect(node(screen.listId).innerHTML).toContain("tx-row");
expect(state.fraudContracts).toEqual([SPOOF_CONTRACT]);
expect(storage.read("autistmask").fraudContracts).toEqual([
SPOOF_CONTRACT,
]);
});
test.each(SWITCHES)(
"an answer that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
// The answer is the transfer of ETH alone, which adds no fraud contract.
// Nothing is saved, so only the check before the save can drop the answer.
test.each(SWITCHES)(
"an answer without a fraud contract that lands after a switch $how adds nothing",
async ({ to }) => {
const land = holdHistory(history().slice(0, 1));
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
land();
await settle();
expectNothingAdded(screen, before);
},
);
test.each(SWITCHES)(
"a request that fails after a switch $how shows no message",
async ({ to }) => {
const fail = holdFailure();
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await to("sepolia");
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
},
);
// The user picks Sepolia in Settings while another write holds the lock.
// The answer lands, and its save waits behind the switch.
test("an answer whose save waits behind a switch made in Settings adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
const switching = onChainSwitch("sepolia");
land();
await settle();
await releaseLock();
await switching;
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. Another page or the
// background then stores a switch.
test("an answer whose save waits while a switch is saved elsewhere adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
const record = storage.read("autistmask");
applyChainSwitchFields(record, "sepolia");
storage.write("autistmask", record);
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
// The answer lands and its save waits for the lock. The page then switches,
// and storage has not.
test("an answer whose save waits while the page switches adds nothing", async () => {
const land = holdHistory();
screen.open();
const before = node(screen.listId).innerHTML;
const releaseLock = holdLock();
land();
await settle();
applyChainSwitchFields(state, "sepolia");
await releaseLock();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails after a switch leaves the new network's rows", async () => {
const fail = holdFailure();
screen.open();
await onChainSwitch("sepolia");
fetchRecentTransactions.mockImplementationOnce(async () => history());
screen.open();
await settle();
const drawn = node(screen.listId).innerHTML;
fail();
await settle();
expect(drawn).toContain("tx-row");
expect(node(screen.listId).innerHTML).toBe(drawn);
});
});
describe.each([ADDRESS_DETAIL, ADDRESS_TOKEN])("$name", (screen) => {
test("an answer that lands once the screen shows another address adds nothing", async () => {
const land = holdHistory();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
land();
await settle();
expectNothingAdded(screen, before);
});
test("a request that fails once the screen shows another address shows no message", async () => {
const fail = holdFailure();
screen.open();
state.selectedAddress = 1;
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
fail();
await settle();
expect(node(screen.listId).innerHTML).toBe(before);
});
test("an answer whose name lookup lands after a switch adds nothing", async () => {
let landNames;
resolveEnsNames.mockImplementationOnce(
() =>
new Promise((resolve) => {
landNames = () => resolve(new Map());
}),
);
fetchRecentTransactions.mockImplementationOnce(async () => history());
screen.open();
await settle();
const before = node(screen.listId).innerHTML;
await onChainSwitch("sepolia");
landNames();
await settle();
expectNothingAdded(screen, before);
});
});
test("AddressToken: an answer that lands once the screen shows another token adds nothing", async () => {
const land = holdHistory();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
land();
await settle();
expectNothingAdded(ADDRESS_TOKEN, before);
});
test("AddressToken: a request that fails once the screen shows another token shows no message", async () => {
const fail = holdFailure();
addressToken.show();
state.selectedToken = TOKEN;
addressToken.show();
await settle();
const before = node(ADDRESS_TOKEN.listId).innerHTML;
fail();
await settle();
expect(node(ADDRESS_TOKEN.listId).innerHTML).toBe(before);
});
test("storedNetworkIs(): a failed read counts as no", async () => {
globalThis.chrome.storage.local = {
get: async () => {
throw new Error("storage is down");
},
};
await expect(storedNetworkIs("mainnet")).resolves.toBe(false);
});
+1 -7
View File
@@ -192,13 +192,7 @@ describe("the balance-list spam gate", () => {
}, },
}), }),
]); ]);
// On Ethereum Mainnet, the only network the known-token list covers. const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
[],
"0x1",
);
expect(balances).toHaveLength(1); expect(balances).toHaveLength(1);
expect(balances[0].holders).toBeNull(); expect(balances[0].holders).toBeNull();
}); });
-157
View File
@@ -1,157 +0,0 @@
// While a refresh runs, a request through ctx is not dropped. One more refresh
// runs once the running one ends, however many were asked for in that time
// (https://git.eeqj.de/sneak/AutistMask/issues/581). A tick of the ten-second
// timer that finds a refresh running is dropped. Each test boots the real popup
// over a stored profile and holds the ten-second refresh open. `readChain()`
// sets each address's balance from `CHAIN`, as the real `refreshBalances()`
// sets it from the node.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const {
hdWalletFromMnemonic,
deriveAddressFromXpub,
} = require("../src/shared/wallet");
const PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const PASSWORD = "correct horse battery staple";
// The first two addresses of PHRASE, both used on the chain.
const { xpub, firstAddress } = hdWalletFromMnemonic(PHRASE);
const SECOND_ADDRESS = deriveAddressFromXpub(xpub, 1);
// The ETH balance the chain holds for each address, in ether.
const CHAIN = {
[ADDRESS]: "1.5",
[firstAddress]: "2.0",
[SECOND_ADDRESS]: "3.0",
};
afterEach(() => {
cleanupPopup();
});
// The balance stand-in for a refresh that returns.
async function readChain(wallets) {
for (const wallet of wallets) {
for (const addr of wallet.addresses) {
addr.balance = CHAIN[addr.address];
}
}
}
// Boots the popup over a profile with one wallet. From then on,
// `refreshBalances()` never returns unless the test answers it. So an unwanted
// refresh is counted once and then waits, instead of starting more.
async function boot() {
const page = await bootPopup(unversionedValidProfile());
expect(page.pageErrors).toEqual([]);
const { refreshBalances } = require("../src/shared/balances");
refreshBalances.mockImplementation(() => new Promise(() => {}));
return { page, refreshBalances };
}
// Starts the ten-second refresh and holds it open until finish() is called.
// The held refresh then writes the chain's balances. finish() resolves once
// the held refresh has saved them and drawn Home. The one refresh after the
// held one also returns.
async function holdRefresh(page) {
const { refreshBalances } = require("../src/shared/balances");
let answer;
refreshBalances
.mockImplementationOnce(async (wallets) => {
await new Promise((resolve) => {
answer = resolve;
});
await readChain(wallets);
})
.mockImplementationOnce(readChain);
const ticking = page.tick();
await page.settle();
return {
finish: async () => {
answer();
await ticking;
},
};
}
// Imports PHRASE on AddWallet. Its address scan finds the phrase's first two
// addresses.
async function importPhrase(page) {
const { scanForAddresses } = require("../src/shared/balances");
scanForAddresses.mockImplementationOnce(async () => ({
addresses: [
{ address: firstAddress, index: 0 },
{ address: SECOND_ADDRESS, index: 1 },
],
nextIndex: 2,
}));
await page.click("btn-add-wallet-bottom");
await page.click("tab-mnemonic");
page.node("wallet-mnemonic").value = PHRASE;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
// Imports the private key `key` on AddWallet.
async function importKey(page, key) {
await page.click("btn-add-wallet-bottom");
await page.click("tab-privkey");
page.node("import-private-key").value = key;
page.node("add-wallet-password").value = PASSWORD;
page.node("add-wallet-password-confirm").value = PASSWORD;
await page.click("btn-add-wallet-confirm");
}
test("addresses that an import finds during a refresh show their balances once one more refresh returns", async () => {
const { page } = await boot();
const refresh = await holdRefresh(page);
await importPhrase(page);
expect(page.visibleViews()).toEqual(["main"]);
expect(page.text("flash-msg")).toBe("Found 2 addresses.");
await refresh.finish();
await page.settle();
const list = page.node("wallet-list").innerHTML;
expect(list).toContain("<span>2.0000</span>");
expect(list).toContain("<span>3.0000</span>");
});
test("several requests during a refresh lead to exactly one more refresh", async () => {
const { page, refreshBalances } = await boot();
const refresh = await holdRefresh(page);
const calls = refreshBalances.mock.calls.length;
await importKey(page, "0x" + "11".repeat(32));
await importKey(page, "0x" + "22".repeat(32));
await importPhrase(page);
expect(refreshBalances).toHaveBeenCalledTimes(calls);
await refresh.finish();
await page.settle();
expect(refreshBalances).toHaveBeenCalledTimes(calls + 1);
});
test("a tick of the ten-second timer during a refresh starts no other refresh", async () => {
const { page, refreshBalances } = await boot();
const refresh = await holdRefresh(page);
const calls = refreshBalances.mock.calls.length;
await page.tick();
await refresh.finish();
await page.settle();
expect(refreshBalances).toHaveBeenCalledTimes(calls);
});
+3 -17
View File
@@ -67,15 +67,13 @@ beforeEach(() => {
}); });
describe("resolveTokenSymbol", () => { describe("resolveTokenSymbol", () => {
// The bundled list is read only for a token on Ethereum Mainnet.
test("reads the bundled list", () => { test("reads the bundled list", () => {
expect(resolveTokenSymbol(USDC, { chainId: "0x1" })).toBe("USDC"); expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
}); });
test("prefers the bundled list over a tracked entry", () => { test("prefers the bundled list over a tracked entry", () => {
state.trackedTokens = [{ address: USDC, symbol: "NOTUSDC" }]; state.trackedTokens = [{ address: USDC, symbol: "NOTUSDC" }];
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens }; expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
expect(resolveTokenSymbol(USDC, sources)).toBe("USDC");
}); });
test("reads a token the user tracks", () => { test("reads a token the user tracks", () => {
@@ -106,19 +104,7 @@ describe("resolveTokenSymbol", () => {
test("rejects a tracked entry claiming a bundled ticker it is not", () => { test("rejects a tracked entry claiming a bundled ticker it is not", () => {
// NOVEL_TOKEN is not the real USDC contract, so it may not wear USDC. // NOVEL_TOKEN is not the real USDC contract, so it may not wear USDC.
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }]; state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }];
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens }; expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
expect(resolveTokenSymbol(NOVEL_TOKEN, sources)).toBeNull();
});
// The bundled list holds mainnet contracts, so on Sepolia any contract may
// carry one of its symbols.
test("on Sepolia, reads a tracked entry carrying a bundled ticker", () => {
state.trackedTokens = [{ address: NOVEL_TOKEN, symbol: "USDC" }];
const sources = {
chainId: "0xaa36a7",
trackedTokens: state.trackedTokens,
};
expect(resolveTokenSymbol(NOVEL_TOKEN, sources)).toBe("USDC");
}); });
test("rejects an explorer entry claiming the native ETH ticker", () => { test("rejects an explorer entry claiming the native ETH ticker", () => {
-120
View File
@@ -47,123 +47,3 @@ test.each([
expect(page.text("flash-msg")).toBe("Saved."); expect(page.text("flash-msg")).toBe("Saved.");
}, },
); );
// The user types a URL in Settings and taps "Save". While the check waits for
// the endpoint's answer, another page turns "Use Blockscout" on. This page
// follows that change and draws Settings again, with the stored URL in the
// input. The check then passes. The typed URL is not stored. The flash message
// says that the URL was changed during the check
// (https://git.eeqj.de/sneak/AutistMask/issues/695).
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing when Settings is drawn again during the check",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
page.node(input).value = "https://typed.example.invalid";
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
// The browser announces this write to this page.
await page.storage.local.set({
autistmask: {
...page.storage.read("autistmask"),
useBlockscout: true,
},
});
await page.settle();
expect(page.node("settings-use-blockscout").checked).toBe(true);
// This answer passes both endpoint checks on Ethereum Mainnet.
answer({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await Promise.all(saving);
await page.settle();
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.value(input)).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because it was changed during the check.",
);
},
);
// As above, but another write holds the lock. So this page's follow of the
// change to "Use Blockscout" waits for the lock. The endpoint answers while the
// input still shows the typed URL. The save then asks for the lock, after the
// follow did. So the follow draws Settings again before the save compares the
// input.
test.each([
{ button: "btn-save-rpc", input: "settings-rpc", field: "rpcUrl" },
{
button: "btn-save-blockscout",
input: "settings-blockscout",
field: "blockscoutUrl",
},
])(
"$button stores nothing when Settings is drawn again while the save waits for the lock",
async ({ button, input, field }) => {
const page = await bootPopup(unversionedValidProfile());
await page.click("btn-settings");
const storedUrl = page.value(input);
let answer;
globalThis.fetch.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
const typedUrl = "https://typed.example.invalid";
page.node(input).value = typedUrl;
const saving = page.node(button).listeners.click.map((fn) => fn());
await page.settle();
let releaseLock;
const lockHeld = navigator.locks.request(
"autistmask",
() =>
new Promise((resolve) => {
releaseLock = resolve;
}),
);
// The browser announces this write to this page.
await page.storage.local.set({
autistmask: {
...page.storage.read("autistmask"),
useBlockscout: true,
},
});
await page.settle();
// This answer passes both endpoint checks on Ethereum Mainnet.
answer({
ok: true,
json: async () => ({ result: NETWORKS.mainnet.chainId }),
});
await page.settle();
expect(page.value(input)).toBe(typedUrl);
releaseLock();
await lockHeld;
await Promise.all(saving);
await page.settle();
expect(page.node("settings-use-blockscout").checked).toBe(true);
expect(page.storage.read("autistmask")[field]).toBe(storedUrl);
expect(page.value(input)).toBe(storedUrl);
expect(page.text("flash-msg")).toBe(
"The URL was not saved because it was changed during the check.",
);
},
);
+16 -110
View File
@@ -72,11 +72,6 @@ const {
const { renderSendTokenSelect } = require("../src/popup/views/send"); const { renderSendTokenSelect } = require("../src/popup/views/send");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { debugFetch } = require("../src/shared/log"); const { debugFetch } = require("../src/shared/log");
const { NETWORKS } = require("../src/shared/networks");
// The bundled list's symbols are known symbols only on Ethereum Mainnet.
const MAINNET = NETWORKS.mainnet.chainId;
const SEPOLIA = NETWORKS.sepolia.chainId;
// The fake "Ethereum" token with symbol "ETH" from the attack documented in // The fake "Ethereum" token with symbol "ETH" from the attack documented in
// README.md, given a holder count high enough to clear every other filter so // README.md, given a holder count high enough to clear every other filter so
@@ -121,29 +116,14 @@ describe("the shared rule", () => {
}); });
test("a known symbol from its own contract is not a spoof", () => { test("a known symbol from its own contract is not a spoof", () => {
expect(isSpoofedSymbol("USDC", USDC_CONTRACT, MAINNET)).toBe(false); expect(isSpoofedSymbol("USDC", USDC_CONTRACT)).toBe(false);
expect( expect(isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase())).toBe(
isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase(), MAINNET), false,
).toBe(false); );
}); });
test("a known symbol from another contract is a spoof", () => { test("a known symbol from another contract is a spoof", () => {
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT, MAINNET)).toBe(true); expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT)).toBe(true);
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so on Sepolia any contract may carry one of its
// symbols.
test("on Sepolia, a bundled symbol from any contract is not a spoof", () => {
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT, SEPOLIA)).toBe(false);
expect(isSpoofedSymbol("USDC", USDC_CONTRACT, SEPOLIA)).toBe(false);
});
test("on Sepolia, the native asset's labels are still reserved", () => {
expect(isSpoofedSymbol("ETH", FAKE_ETH_CONTRACT, SEPOLIA)).toBe(true);
expect(isSpoofedSymbol("SepoliaETH", FAKE_ETH_CONTRACT, SEPOLIA)).toBe(
true,
);
}); });
test("a symbol that is not in the table is not judged here", () => { test("a symbol that is not in the table is not judged here", () => {
@@ -184,9 +164,7 @@ describe("the shared rule: symbols that render as a known symbol", () => {
test("ASCII padding does not buy a pass", () => { test("ASCII padding does not buy a pass", () => {
expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true); expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("\tETH\n", FAKE_ETH_CONTRACT)).toBe(true); expect(isSpoofedSymbol("\tETH\n", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT, MAINNET)).toBe( expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT)).toBe(true);
true,
);
}); });
test("non-breaking and other Unicode spaces do not either", () => { test("non-breaking and other Unicode spaces do not either", () => {
@@ -265,9 +243,7 @@ describe("the shared rule: symbols that render as a known symbol", () => {
test("compatibility forms fold onto the symbol they imitate", () => { test("compatibility forms fold onto the symbol they imitate", () => {
expect(isSpoofedSymbol(FULLWIDTH_ETH, FAKE_ETH_CONTRACT)).toBe(true); expect(isSpoofedSymbol(FULLWIDTH_ETH, FAKE_ETH_CONTRACT)).toBe(true);
expect( expect(isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT)).toBe(true);
isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT, MAINNET),
).toBe(true);
}); });
// The two knowingly open classes, asserted here so that the boundary is // The two knowingly open classes, asserted here so that the boundary is
@@ -295,10 +271,8 @@ describe("the shared rule: symbols that render as a known symbol", () => {
}); });
test("a genuine contract still bears its own padded symbol", () => { test("a genuine contract still bears its own padded symbol", () => {
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT, MAINNET)).toBe(false); expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT)).toBe(false);
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT, MAINNET)).toBe( expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT)).toBe(false);
false,
);
}); });
// Normalization must not invent a match. Interior ASCII whitespace is // Normalization must not invent a match. Interior ASCII whitespace is
@@ -322,7 +296,7 @@ describe("the shared rule: symbols that render as a known symbol", () => {
expect(symbol).toMatch(/^[!-~]+$/); expect(symbol).toMatch(/^[!-~]+$/);
if (addresses === null) continue; if (addresses === null) continue;
for (const address of addresses) { for (const address of addresses) {
expect(isSpoofedSymbol(symbol, address, MAINNET)).toBe(false); expect(isSpoofedSymbol(symbol, address)).toBe(false);
} }
} }
}); });
@@ -343,7 +317,7 @@ describe("the shared rule: symbols that render as a known symbol", () => {
describe("the shipped token list", () => { describe("the shipped token list", () => {
test("no bundled token is filtered at its own address", () => { test("no bundled token is filtered at its own address", () => {
const filtered = TOKENS.filter((t) => const filtered = TOKENS.filter((t) =>
isSpoofedSymbol(t.symbol, t.address, MAINNET), isSpoofedSymbol(t.symbol, t.address),
).map((t) => t.symbol + " @ " + t.address); ).map((t) => t.symbol + " @ " + t.address);
expect(filtered).toEqual([]); expect(filtered).toEqual([]);
}); });
@@ -386,9 +360,7 @@ describe("the shipped token list", () => {
expect(shared.length).toBeGreaterThan(0); expect(shared.length).toBeGreaterThan(0);
for (const [, list] of shared) { for (const [, list] of shared) {
for (const t of list) { for (const t of list) {
expect(isSpoofedSymbol(t.symbol, t.address, MAINNET)).toBe( expect(isSpoofedSymbol(t.symbol, t.address)).toBe(false);
false,
);
} }
} }
}); });
@@ -432,7 +404,7 @@ describe("the shipped token list", () => {
[...addresses].sort(), [...addresses].sort(),
); );
for (const address of addresses) { for (const address of addresses) {
expect(isSpoofedSymbol(symbol, address, MAINNET)).toBe(false); expect(isSpoofedSymbol(symbol, address)).toBe(false);
} }
} }
}); });
@@ -449,9 +421,7 @@ describe("the shipped token list", () => {
} }
for (const [symbol, list] of bySymbol) { for (const [symbol, list] of bySymbol) {
if (list.length < 2) continue; if (list.length < 2) continue;
expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT, MAINNET)).toBe( expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT)).toBe(true);
true,
);
} }
}); });
}); });
@@ -536,40 +506,10 @@ describe("surface 2: the Send token selector", () => {
} }
beforeEach(() => { beforeEach(() => {
state.networkId = "mainnet";
state.fraudContracts = []; state.fraudContracts = [];
state.hideLowHolderTokens = true; state.hideLowHolderTokens = true;
}); });
afterAll(() => {
state.networkId = "mainnet";
});
// A token reporting USDC's symbol from another contract.
function otherUsdc() {
return {
address: FAKE_ETH_CONTRACT,
symbol: "USDC",
decimals: 6,
balance: "12.5",
holders: 900000,
};
}
test("a bundled symbol from another contract is not selectable", () => {
render([otherUsdc()]);
expect(select.children).toEqual([]);
});
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so on Sepolia any contract may carry its symbols.
test("with Sepolia active, a bundled symbol from another contract is selectable", () => {
state.networkId = "sepolia";
render([otherUsdc()]);
expect(select.children).toHaveLength(1);
expect(select.children[0].value).toBe(FAKE_ETH_CONTRACT);
});
test("a fake ETH token is not selectable", () => { test("a fake ETH token is not selectable", () => {
render([ render([
{ {
@@ -687,12 +627,7 @@ describe("surface 3: the balance list", () => {
decimals: "6", decimals: "6",
}), }),
]); ]);
const balances = await fetchTokenBalances( const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
HOLDER,
BLOCKSCOUT,
[],
MAINNET,
);
expect(balances).toHaveLength(1); expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe(" USDC "); expect(balances[0].symbol).toBe(" USDC ");
}); });
@@ -706,40 +641,11 @@ describe("surface 3: the balance list", () => {
decimals: "6", decimals: "6",
}), }),
]); ]);
const balances = await fetchTokenBalances( const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
HOLDER,
BLOCKSCOUT,
[],
MAINNET,
);
expect(balances).toHaveLength(1); expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC"); expect(balances[0].symbol).toBe("USDC");
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so on Sepolia any contract may carry its symbols.
test("a bundled symbol from another contract is listed on Sepolia only", async () => {
respondWith([fakeEthItem({ symbol: "USDC", decimals: "6" })]);
expect(
await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], MAINNET),
).toEqual([]);
const sepolia = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
[],
SEPOLIA,
);
expect(sepolia).toHaveLength(1);
expect(sepolia[0].symbol).toBe("USDC");
});
test("a fake ETH token is filtered on Sepolia too", async () => {
respondWith([fakeEthItem()]);
expect(
await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], SEPOLIA),
).toEqual([]);
});
// The trap in this change: the user's real ETH balance is not an ERC-20 // The trap in this change: the user's real ETH balance is not an ERC-20
// and is fetched over RPC in refreshBalances, so it never passes through // and is fetched over RPC in refreshBalances, so it never passes through
// this loop at all. An explorer row that is not an ERC-20 is dropped // this loop at all. An explorer row that is not an ERC-20 is dropped
+2 -75
View File
@@ -133,13 +133,13 @@ function trackedRow(balance, decimals = null, holders = null) {
}; };
} }
async function refresh(addresses, trackedTokens, networkId = "mainnet") { async function refresh(addresses, trackedTokens) {
await refreshBalances( await refreshBalances(
[{ addresses }], [{ addresses }],
RPC_URL, RPC_URL,
EXPLORER_URL, EXPLORER_URL,
trackedTokens, trackedTokens,
networkId, "mainnet",
new AbortController().signal, new AbortController().signal,
); );
return addresses; return addresses;
@@ -307,76 +307,3 @@ test("a tracked token wearing a known symbol from another contract is not listed
); );
expect(first.tokenBalances).toEqual([]); expect(first.tokenBalances).toEqual([]);
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// Ethereum Mainnet contracts. A token outside the tracked tokens with an
// unknown holder count is listed only when the bundled list names it.
test("an untracked bundled token with an unknown holder count is listed on Ethereum Mainnet only", async () => {
explorerRows = [explorerRow(USDC, "USDC", 5000000, null)];
const [mainnet] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[],
);
expect(mainnet.tokenBalances).toEqual([
{
address: USDC,
name: "USDC",
symbol: "USDC",
decimals: 6,
balance: "5.0",
holders: null,
},
]);
const [sepolia] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[],
"sepolia",
);
expect(sepolia.tokenBalances).toEqual([]);
});
test("a tracked token at a bundled token's address takes the bundled scale on Ethereum Mainnet only", async () => {
holdings[USDC] = { [FIRST]: 5000000n };
// The tracked entry records no decimals, and Blockscout fails.
const tracked = [
{ address: USDC, symbol: "USDC", name: "USD Coin", decimals: null },
];
const row = (balance) => ({
address: USDC,
name: "USD Coin",
symbol: "USDC",
decimals: null,
balance: balance,
holders: null,
});
const [mainnet] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
tracked,
);
expect(mainnet.tokenBalances).toEqual([row("5.0")]);
const [sepolia] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
tracked,
"sepolia",
);
expect(sepolia.tokenBalances).toEqual([row(null)]);
});
test("with Sepolia active, a tracked token wearing a bundled symbol from another contract is listed", async () => {
holdings[OTHER] = { [FIRST]: 5000000n };
const [first] = await refresh(
[{ address: FIRST, tokenBalances: [] }],
[{ address: OTHER, symbol: "USDC", name: "Other", decimals: 6 }],
"sepolia",
);
expect(first.tokenBalances).toEqual([
{
address: OTHER,
name: "Other",
symbol: "USDC",
decimals: null,
balance: "5.0",
holders: null,
},
]);
});
+4 -93
View File
@@ -5,9 +5,6 @@
// a contract lookup that answers after one. Driven through the real popup over // a contract lookup that answers after one. Driven through the real popup over
// a stored profile; the same switch made by the background is in // a stored profile; the same switch made by the background is in
// tests/coldWorkerChainSwitch.test.js. // tests/coldWorkerChainSwitch.test.js.
//
// The tests of what another page stored before a switch in Settings cover a
// fraud contract too (https://git.eeqj.de/sneak/AutistMask/issues/692).
const { const {
bootPopup, bootPopup,
@@ -181,8 +178,9 @@ test("a token added by contract address on Sepolia is tracked on Sepolia only",
}); });
// Another popup page tracks DAI on Ethereum Mainnet after this page's last // Another popup page tracks DAI on Ethereum Mainnet after this page's last
// save. The switch to Sepolia puts DAI away under Ethereum Mainnet. // save. The switch's save writes only the token lists that this page changed.
test("a token another page tracked before a switch in Settings stays tracked on Ethereum Mainnet", async () => { // So DAI stays in `trackedTokens`, the active network's list.
test("a token another page tracked before a switch in Settings stays in trackedTokens", async () => {
const boot = await bootPopup(unversionedValidProfile()); const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]); expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", { boot.storage.write("autistmask", {
@@ -195,97 +193,10 @@ test("a token another page tracked before a switch in Settings stays tracked on
const stored = boot.storage.read("autistmask"); const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia"); expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]); expect(stored.trackedTokens).toEqual([MAINNET_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// Another popup page stops tracking DAI on Ethereum Mainnet after this page's
// last save. This page still holds DAI. The switch to Sepolia does not store
// DAI under Ethereum Mainnet again.
test("a token another page removed before a switch in Settings stays removed", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]); expect(stored.networkTrackedTokens.mainnet).toEqual([]);
}); });
// Another popup page removes DAI after this page's last save. That page then
// adds DAI again, typing the contract address in lower case. The two pages'
// entries for DAI match, because contract addresses are compared ignoring
// letter case. The switch to Sepolia keeps this page's entry under Ethereum
// Mainnet.
test("a token another page added again with a lower-case address keeps this page's entry", async () => {
const boot = await bootPopup(
unversionedValidProfile({ trackedTokens: [MAINNET_TOKEN] }),
);
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
trackedTokens: [
{ ...MAINNET_TOKEN, address: MAINNET_TOKEN.address.toLowerCase() },
],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.networkTrackedTokens.mainnet).toEqual([MAINNET_TOKEN]);
});
// Another popup page saves a switch to Sepolia after this page's last save.
// TST is tracked on Sepolia. This page has not taken that switch yet. A switch
// to Sepolia in this page's Settings keeps TST off Ethereum Mainnet's list.
test("a token tracked on Sepolia when another page switched there is not tracked on Ethereum Mainnet", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
await boot.click("btn-settings");
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
networkId: "sepolia",
trackedTokens: [SEPOLIA_TOKEN],
});
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.trackedTokens).toEqual([SEPOLIA_TOKEN]);
expect(stored.networkTrackedTokens.mainnet).toEqual([]);
});
// Another popup page records a fraud contract on Ethereum Mainnet after this
// page's last save. The switch to Sepolia drops that fraud contract, as the
// switch drops this page's own.
test("a fraud contract another page recorded before a switch in Settings is not kept on Sepolia", async () => {
const boot = await bootPopup(unversionedValidProfile());
expect(boot.pageErrors).toEqual([]);
boot.storage.write("autistmask", {
...boot.storage.read("autistmask"),
fraudContracts: [NEW_TOKEN_ADDRESS],
});
await boot.click("btn-settings");
await chooseNetwork(boot, "sepolia");
const stored = boot.storage.read("autistmask");
expect(stored.networkId).toBe("sepolia");
expect(stored.fraudContracts).toEqual([]);
});
// The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the // The contract lookup on SettingsAddToken answers on Ethereum Mainnet while the
// switch to Sepolia waits for the lock. The page adds the token to Ethereum // switch to Sepolia waits for the lock. The page adds the token to Ethereum
// Mainnet's list. The switch then keeps that list for Ethereum Mainnet. // Mainnet's list. The switch then keeps that list for Ethereum Mainnet.
-202
View File
@@ -1,202 +0,0 @@
// TransactionDetail shows only details that Blockscout gave for the
// transaction on screen (https://git.eeqj.de/sneak/AutistMask/issues/675).
//
// The screen asks Blockscout for its transaction and does not wait for the
// answer. So the user can press Back and open another transaction before
// Blockscout answers for the first. The first answer must then fill nothing on
// the second transaction's screen. An answer that lands while Settings is up
// must still fill TransactionDetail.
//
// Driven through the real popup over a stored profile
// (tests/support/popupBoot.js). Each transaction is opened as a history screen
// opens it: the current screen is pushed, then transactionDetail.show() draws
// the transaction.
"use strict";
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
} = require("./support/popupBoot");
const MAINNET_BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
const SEPOLIA_BLOCKSCOUT = "https://eth-sepolia.blockscout.com/api/v2";
const RECIPIENT = "0x2222222222222222222222222222222222222222";
// A history entry for a send of 1 `symbol` from ADDRESS to RECIPIENT, on the
// network with `chainId`.
function historyEntry(hash, chainId, symbol) {
return {
hash,
from: ADDRESS,
to: RECIPIENT,
value: "1.0000",
exactValue: "1.0",
symbol,
timestamp: 1790000000,
isError: false,
direction: "sent",
directionLabel: "Sent",
contractAddress: null,
chainId,
};
}
const FIRST = historyEntry("0x" + "aa".repeat(32), "0x1", "ETH");
const SECOND = historyEntry("0x" + "bb".repeat(32), "0x1", "ETH");
// A transaction on Sepolia with the same hash as FIRST.
const FIRST_ON_SEPOLIA = historyEntry(FIRST.hash, "0xaa36a7", "SepoliaETH");
// The URL that asks Blockscout for `entry`, on the network it was sent on.
function requestUrl(entry) {
const blockscout =
entry.chainId === "0x1" ? MAINNET_BLOCKSCOUT : SEPOLIA_BLOCKSCOUT;
return blockscout + "/transactions/" + entry.hash;
}
// What Blockscout reports for each transaction, by the URL that asks for it.
const ANSWERS = {
[requestUrl(FIRST)]: {
block_number: 21000001,
nonce: 4441,
fee: { value: "21000000000000" },
raw_input: "0x11111111",
method: "firstMethod",
},
[requestUrl(SECOND)]: {
block_number: 21000002,
nonce: 4442,
fee: { value: "42000000000000" },
raw_input: "0x22222222",
method: "secondMethod",
},
[requestUrl(FIRST_ON_SEPOLIA)]: {
block_number: 21000003,
nonce: 4443,
fee: { value: "63000000000000" },
raw_input: "0x33333333",
method: "sepoliaMethod",
},
};
// The text each answer puts on screen: its block, nonce, fee, method name and
// raw data. No text from one answer appears in another.
const FIRST_SHOWN = [
"21000001",
"4441",
"0.000021 ETH",
"firstMethod",
"0x11111111",
];
const SECOND_SHOWN = [
"21000002",
"4442",
"0.000042 ETH",
"secondMethod",
"0x22222222",
];
const FIRST_ON_SEPOLIA_SHOWN = [
"21000003",
"4443",
"0.000063 SepoliaETH",
"sepoliaMethod",
"0x33333333",
];
// Everything drawn in the fields that Blockscout's answer fills.
function drawnDetails(page) {
return [
"tx-detail-block",
"tx-detail-nonce",
"tx-detail-fee",
"tx-detail-calldata-action",
"tx-detail-rawdata",
]
.map((id) => page.node(id).innerHTML + page.node(id).textContent)
.join("\n");
}
// Opens `entry` on TransactionDetail, as a history screen does.
function open(entry) {
require("../src/popup/views/helpers").pushCurrentView();
require("../src/popup/views/transactionDetail").show(entry);
}
// The URLs of every request to Blockscout so far, in order.
function requestedUrls() {
return globalThis.fetch.mock.calls.map(([url]) => url);
}
// Boots the popup on Home with "Use Blockscout" on. Blockscout answers every
// request at once from ANSWERS, except the request for `held`. Returns the
// page and answer(), which lets the answer for `held` land.
async function bootWithHeldRequest(held) {
const page = await bootPopup(
unversionedValidProfile({ useBlockscout: true }),
);
let answer;
const heldAnswer = new Promise((resolve) => {
answer = resolve;
});
globalThis.fetch = jest.fn(async (url) => {
if (url === requestUrl(held)) await heldAnswer;
return { ok: true, status: 200, json: async () => ANSWERS[url] };
});
return { page, answer: () => answer() };
}
// The user opens `first`, whose request to Blockscout is held. Then the user
// presses Back and opens `second`. Then the answer for `first` lands. Returns
// the page.
async function firstAnswerLandsOnSecond(first, second) {
const { page, answer } = await bootWithHeldRequest(first);
open(first);
await page.settle();
await page.click("btn-tx-back");
expect(page.visibleViews()).toEqual(["main"]);
open(second);
await page.settle();
expect(requestedUrls()).toEqual([requestUrl(first), requestUrl(second)]);
answer();
await page.settle();
expect(page.visibleViews()).toEqual(["transaction"]);
return page;
}
afterEach(() => {
cleanupPopup();
});
test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => {
const page = await firstAnswerLandsOnSecond(FIRST, SECOND);
const drawn = drawnDetails(page);
for (const text of SECOND_SHOWN) expect(drawn).toContain(text);
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
});
test("an answer for a hash on mainnet that lands on the same hash's screen on Sepolia fills nothing", async () => {
const page = await firstAnswerLandsOnSecond(FIRST, FIRST_ON_SEPOLIA);
const drawn = drawnDetails(page);
for (const text of FIRST_ON_SEPOLIA_SHOWN) expect(drawn).toContain(text);
for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text);
});
test("an answer that lands while Settings is up still fills TransactionDetail", async () => {
const { page, answer } = await bootWithHeldRequest(FIRST);
open(FIRST);
await page.settle();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["settings"]);
answer();
await page.settle();
await page.click("btn-settings");
expect(page.visibleViews()).toEqual(["transaction"]);
expect(requestedUrls()).toEqual([requestUrl(FIRST)]);
const drawn = drawnDetails(page);
for (const text of FIRST_SHOWN) expect(drawn).toContain(text);
});
-62
View File
@@ -144,7 +144,6 @@ function tokenTx(overrides = {}) {
isError: false, isError: false,
contractAddress: USDC_CONTRACT, contractAddress: USDC_CONTRACT,
holders: 3500000, holders: 3500000,
chainId: "0x1",
...overrides, ...overrides,
}; };
} }
@@ -260,32 +259,6 @@ describe("known-symbol spoof verification", () => {
expect(result.newFraudContracts).toEqual([NOVEL_SPAM_CONTRACT]); expect(result.newFraudContracts).toEqual([NOVEL_SPAM_CONTRACT]);
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled list holds
// mainnet contracts, so its symbols are not known symbols on Sepolia.
test("on Sepolia, a bundled symbol from another contract is kept", () => {
const sepoliaToken = tokenTx({
symbol: "USDC",
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 5000000,
chainId: "0xaa36a7",
});
const result = filterTransactions([sepoliaToken], filters());
expect(result.transactions).toEqual([sepoliaToken]);
expect(result.newFraudContracts).toEqual([]);
});
test('on Sepolia, "ETH" as an ERC-20 is still spoofed', () => {
const fakeEth = tokenTx({
symbol: "ETH",
contractAddress: NOVEL_SPAM_CONTRACT,
holders: 5000000,
chainId: "0xaa36a7",
});
expect(filterTransactions([fakeEth], filters()).transactions).toEqual(
[],
);
});
test("the genuine contract for that symbol is not spoofed", () => { test("the genuine contract for that symbol is not spoofed", () => {
const genuine = tokenTx(); const genuine = tokenTx();
const result = filterTransactions([genuine], filters()); const result = filterTransactions([genuine], filters());
@@ -1522,41 +1495,6 @@ describe("fetchRecentTransactions merge and dedup", () => {
expect(mainnet[0].chainId).toBe("0x1"); expect(mainnet[0].chainId).toBe("0x1");
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/632: the bundled token list
// names a contract only on Ethereum Mainnet. On Sepolia, a call to the
// contract at USDC's mainnet address is labelled with the network's native
// currency. A call to any contract the wallet does not name gets that label.
test("a contract call on Sepolia is not named by the bundled list", async () => {
respondWith(
[
{
hash: "0x" + "b".repeat(64),
block_number: 21000095,
timestamp: TS,
from: { hash: VICTIM },
to: { hash: USDC_CONTRACT, is_contract: true },
value: "0",
method: "approve",
status: "ok",
},
],
[],
);
const sepolia = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
"0xaa36a7",
);
expect(sepolia[0].directionLabel).toBe("Approve");
expect(sepolia[0].symbol).toBe("SepoliaETH");
const mainnet = await fetchRecentTransactions(
VICTIM,
BLOCKSCOUT,
"0x1",
);
expect(mainnet[0].symbol).toBe("USDC");
});
test("the fake token transfer survives fetching and is then filtered", async () => { test("the fake token transfer survives fetching and is then filtered", async () => {
respondWith( respondWith(
[], [],
+2 -3
View File
@@ -167,8 +167,7 @@ function row(token = {}, value = FIVE_WETH) {
} }
// Fetch the explorer's rows through the real fetcher and put them exactly where // Fetch the explorer's rows through the real fetcher and put them exactly where
// refreshBalances() puts them on Ethereum Mainnet. The node reports the holding // refreshBalances() puts them. The node reports the holding the explorer does.
// the explorer does.
async function fetchOnto(items) { async function fetchOnto(items) {
mockProvider.tokenUnits = BigInt(items[0].value); mockProvider.tokenUnits = BigInt(items[0].value);
debugFetch.mockImplementation(async () => ({ debugFetch.mockImplementation(async () => ({
@@ -177,7 +176,7 @@ async function fetchOnto(items) {
statusText: "OK", statusText: "OK",
json: async () => items, json: async () => items,
})); }));
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], "0x1"); const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
state.wallets = [ state.wallets = [
{ {
name: "Wallet 1", name: "Wallet 1",