fix: the bundled token list names tokens on Ethereum Mainnet only (closes #632)
bundledToken(address, chainId) in tokenList.js answers only for Ethereum Mainnet's chain id. Every reader of TOKEN_BY_ADDRESS now goes through it, with the chain id of the transaction, balance or history entry it draws. The token sources the screens build carry that chain id. The Uniswap decoder sets the transaction's chain id on them. fetchTokenBalances() takes the chain id of the network it asks about. isSpoofedSymbol() takes the chain id of the token's network. It checks the bundled list's symbols only on Ethereum Mainnet. The native asset's labels stay reserved on every network. Eighteen existing tests and three shared test helpers read the bundled list without naming a network. Each now names Ethereum Mainnet. Model: opus-5-5
This commit is contained in:
@@ -1188,11 +1188,12 @@ 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. While "Use Blockscout" is off, a
|
The token's name comes from the same sources. The bundled token list is used
|
||||||
token gets a line under it when the bundled list and the tracked tokens lack its
|
only for a token on Ethereum Mainnet. While "Use Blockscout" is off, a token
|
||||||
name or decimals. The line says "Blockscout's report of your balances is not
|
gets a line under it when the bundled list and the tracked tokens lack its name
|
||||||
used to name this token, because "Use Blockscout" is off in Settings." These
|
or decimals. The line says "Blockscout's report of your balances is not used to
|
||||||
screens show the line:
|
name this token, because "Use Blockscout" is off in Settings." These screens
|
||||||
|
show the line:
|
||||||
|
|
||||||
- TxApproval and SignApproval
|
- TxApproval and SignApproval
|
||||||
- WaitTx, SuccessTx and ErrorTx, which follow TxApproval
|
- WaitTx, SuccessTx and ErrorTx, which follow TxApproval
|
||||||
@@ -1473,20 +1474,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. A token claiming a symbol from the bundled list from any other
|
more holders. On Ethereum Mainnet, a token that claims a bundled token's symbol
|
||||||
contract address is always dropped, tracked or not, and so is any token claiming
|
from another contract is always dropped, tracked or not. On every network, a
|
||||||
a symbol that belongs to the native asset and therefore has no legitimate
|
token claiming a label of the native asset is always dropped. Those labels are
|
||||||
contract at all (`"ETH"`, and every network's `nativeCurrency`, such as
|
`"ETH"` and each network's `nativeCurrency`, such as `"SepoliaETH"`. The balance
|
||||||
`"SepoliaETH"`, on every network). That filter is unconditional — the "Hide
|
list applies all three rules whatever the settings say. The "Hide tokens with
|
||||||
tokens with fewer than 1,000 holders" setting governs the transaction history
|
fewer than 1,000 holders" setting governs only the transaction history and the
|
||||||
and the send-screen token selector, not this list. A token's holder count is
|
send-screen token selector. A token's holder count is unknown when the explorer
|
||||||
unknown when the explorer reports none, or reports anything other than a whole
|
reports none, or reports anything other than a whole number written in digits
|
||||||
number written in digits alone, such as `1,000` or `1e3` (`parseHoldersCount()`
|
alone, such as `1,000` or `1e3` (`parseHoldersCount()` in
|
||||||
in `src/shared/holders.js`). This list does not take an unknown count as 1,000
|
`src/shared/holders.js`). This list does not take an unknown count as 1,000 or
|
||||||
or more, so such a token is shown only when it is on the bundled list or
|
more, so such a token is shown only when it is on the bundled list or tracked.
|
||||||
tracked. Every nonzero holding of a token shown is stored exactly, however
|
Every nonzero holding of a token shown is stored exactly, however small, but a
|
||||||
small, but a holding below 0.000001 is left out of the balance lists, the
|
holding below 0.000001 is left out of the balance lists, the send-screen token
|
||||||
send-screen token selector, the address total and the remove-address warning
|
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
|
||||||
@@ -3198,26 +3199,25 @@ 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. Any token transfer
|
time and only changes when that file is regenerated. On Ethereum Mainnet, a
|
||||||
claiming a symbol from this list (e.g. "ETH", "USDT", "USDC") but originating
|
token that claims a symbol from this list, such as "USDT" or "USDC", is a
|
||||||
from an unrecognized contract address is identified as a spoof and filtered
|
spoof unless its contract is the listed one. The wallet hides it. The fake
|
||||||
from display. The fake "Ethereum" token in the attack above used symbol "ETH"
|
"Ethereum" token in the attack above used symbol "ETH" from contract
|
||||||
from contract `0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not
|
`0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`. No contract may bear "ETH", so
|
||||||
match the known WETH contract — so it would be caught by this check. Detecting
|
that token is a spoof. Detecting a spoof is also what adds a contract to the
|
||||||
a spoof is also what adds a contract to the fraud contract blocklist below;
|
fraud contract blocklist below; that is the only thing that populates it. In
|
||||||
that is the only thing that populates it. In the transaction history the check
|
the transaction history the check is the "Hide fake tokens impersonating a
|
||||||
is the "Hide fake tokens impersonating a known symbol" setting, on by default;
|
known symbol" setting, on by default; with it off, spoofed transfers are shown
|
||||||
with it off, spoofed transfers are shown and no new blocklist entries are
|
and no new blocklist entries are learned from them. The send-screen token
|
||||||
learned from them. The send-screen token selector and the balance list apply
|
selector and the balance list apply the same check unconditionally, because
|
||||||
the same check unconditionally, because they decide which tokens the user can
|
they decide which tokens the user can act on and what the user believes they
|
||||||
act on and what the user believes they own rather than what the history
|
own rather than what the history displays. All three surfaces read the rule
|
||||||
displays. All three surfaces read the rule from `src/shared/symbolSpoof.js`,
|
from `src/shared/symbolSpoof.js`, so they cannot answer the question
|
||||||
so they cannot answer the question differently. A symbol the list maps to no
|
differently. A symbol the list maps to no contract at all — the native asset's
|
||||||
contract at all — the native asset's labels: `"ETH"` and every network's
|
labels: `"ETH"` and every network's `nativeCurrency`, such as `"SepoliaETH"`,
|
||||||
`nativeCurrency`, such as `"SepoliaETH"`, on every network — may be borne by
|
on every network — may be borne by no contract, so every ERC-20 claiming it is
|
||||||
no contract, so every ERC-20 claiming it is a spoof on all three. The user's
|
a spoof on all three. The user's real ETH balance is not an ERC-20 and is read
|
||||||
real ETH balance is not an ERC-20 and is read over RPC, so the rule never sees
|
over RPC, so the rule never sees it.
|
||||||
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.
|
||||||
|
|||||||
+9
-7
@@ -377,13 +377,15 @@ 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.
|
||||||
In your transaction history this is the "Hide fake tokens impersonating a known
|
On Sepolia, only "ETH" and "SepoliaETH" count as known symbols, because the
|
||||||
symbol" setting, which you can switch off; doing so also stops new entries being
|
bundled list holds Ethereum Mainnet tokens. In your transaction history the
|
||||||
added to the fraud contract blocklist below, since detecting a spoof is what
|
check is the "Hide fake tokens impersonating a known symbol" setting, which you
|
||||||
fills it. The send token list always applies the check. Your balances apply it
|
can switch off; doing so also stops new entries being added to the fraud
|
||||||
too, with one exception: a token claiming the symbol "ETH" is not filtered
|
contract blocklist below, since detecting a spoof is what fills it. The send
|
||||||
there, so a fake "ETH" token can still show up in your balance list even though
|
token list always applies the check. Your balances apply it too, with one
|
||||||
it is hidden from your transaction history and from the send token list.
|
exception: a token claiming the symbol "ETH" is not filtered there, so a fake
|
||||||
|
"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
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const {
|
|||||||
timeAgo,
|
timeAgo,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
|
const { bundledToken, resolveSymbol } = require("../../shared/tokenList");
|
||||||
const { formatUsd, getPrice } = require("../../shared/prices");
|
const { formatUsd, getPrice } = require("../../shared/prices");
|
||||||
const {
|
const {
|
||||||
fetchRecentTransactions,
|
fetchRecentTransactions,
|
||||||
@@ -52,7 +52,8 @@ function show() {
|
|||||||
|
|
||||||
// Determine token symbol and balance
|
// Determine token symbol and balance
|
||||||
let symbol, amount, price;
|
let symbol, amount, price;
|
||||||
const knownToken = TOKEN_BY_ADDRESS.get(tokenId.toLowerCase());
|
const chainId = currentNetwork().chainId;
|
||||||
|
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");
|
||||||
@@ -65,6 +66,7 @@ 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.
|
||||||
|
|||||||
@@ -313,14 +313,17 @@ 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 net = chainId ? networkByChainId(chainId) : currentNetwork();
|
const chainIdOrActive = chainId || currentNetwork().chainId;
|
||||||
|
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: [],
|
||||||
};
|
};
|
||||||
@@ -351,7 +354,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
|
// - the bundled list, for a token on Ethereum Mainnet
|
||||||
// - 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
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const {
|
|||||||
goBack,
|
goBack,
|
||||||
tokenSourcesByChainId,
|
tokenSourcesByChainId,
|
||||||
} = require("./helpers");
|
} = require("./helpers");
|
||||||
const { state, currentAddress } = require("../../shared/state");
|
const { state, currentAddress, currentNetwork } = 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,7 +154,9 @@ 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)) continue;
|
if (isSpoofedSymbol(t.symbol, t.address, currentNetwork().chainId)) {
|
||||||
|
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
|
||||||
@@ -230,6 +232,7 @@ 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
|
||||||
@@ -385,6 +388,7 @@ function init(_ctx) {
|
|||||||
token,
|
token,
|
||||||
addr.tokenBalances,
|
addr.tokenBalances,
|
||||||
state.trackedTokens,
|
state.trackedTokens,
|
||||||
|
currentNetwork().chainId,
|
||||||
);
|
);
|
||||||
({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
|
({ tokenDecimals } = tokenBalanceAndDecimals(addr, token));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { bundledToken } = 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,12 +53,14 @@ 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 { trackedTokens, wallets }, both shaped as they are on `state`.
|
// `sources` is { chainId, trackedTokens, wallets }. `chainId` is the chain id
|
||||||
|
// 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 = TOKEN_BY_ADDRESS.get(lower);
|
const bundled = bundledToken(lower, sources && sources.chainId);
|
||||||
if (bundled) {
|
if (bundled) {
|
||||||
const d = toDecimals(bundled.decimals);
|
const d = toDecimals(bundled.decimals);
|
||||||
if (d !== null) return d;
|
if (d !== null) return d;
|
||||||
@@ -100,18 +102,19 @@ 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 { trackedTokens, wallets }, shaped as on `state`.
|
// trust. `sources` is as for resolveTokenDecimals().
|
||||||
//
|
//
|
||||||
// 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 wears a bundled or
|
// held to the spoof rule (symbolSpoof.js): a candidate that the rule calls a
|
||||||
// native ticker from a contract not entitled to it is refused and the next
|
// spoof is refused and the next source tried, so resolving a symbol never
|
||||||
// source tried, so resolving a symbol never becomes a new way to claim a known
|
// becomes a new way to claim a known ticker. The bundled list is the wallet's
|
||||||
// ticker. The bundled list is the wallet's own data and is trusted as it is.
|
// 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 bundled = TOKEN_BY_ADDRESS.get(lower);
|
const chainId = sources && sources.chainId;
|
||||||
|
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(
|
||||||
@@ -123,7 +126,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)) return symbol;
|
if (!isSpoofedSymbol(symbol, tokenAddress, chainId)) return symbol;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-7
@@ -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 { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { bundledToken } = 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,6 +106,9 @@ 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
|
||||||
@@ -117,6 +120,7 @@ async function fetchTokenBalances(
|
|||||||
address,
|
address,
|
||||||
blockscoutUrl,
|
blockscoutUrl,
|
||||||
trackedTokens,
|
trackedTokens,
|
||||||
|
chainId,
|
||||||
signal,
|
signal,
|
||||||
) {
|
) {
|
||||||
if (!(await storedSettingIsOn("useBlockscout"))) return [];
|
if (!(await storedSettingIsOn("useBlockscout"))) return [];
|
||||||
@@ -175,7 +179,10 @@ 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, { trackedTokens });
|
const known = resolveTokenDecimals(tokenAddr, {
|
||||||
|
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.
|
||||||
@@ -191,7 +198,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 = TOKEN_BY_ADDRESS.has(tokenAddr);
|
const isKnown = bundledToken(tokenAddr, chainId) !== null;
|
||||||
const isTracked = trackedSet.has(tokenAddr);
|
const isTracked = trackedSet.has(tokenAddr);
|
||||||
const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD;
|
const hasEnoughHolders = holders >= LOW_HOLDER_THRESHOLD;
|
||||||
|
|
||||||
@@ -202,7 +209,9 @@ 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)) continue;
|
if (isSpoofedSymbol(item.token.symbol, tokenAddr, chainId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
balances.push({
|
balances.push({
|
||||||
address: item.token.address_hash,
|
address: item.token.address_hash,
|
||||||
@@ -249,17 +258,18 @@ 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().
|
||||||
//
|
//
|
||||||
// `signal` is as for fetchTokenBalances().
|
// `chainId` and `signal` are 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),
|
(token) => !isSpoofedSymbol(token.symbol, token.address, chainId),
|
||||||
);
|
);
|
||||||
// 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(
|
||||||
@@ -301,7 +311,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, { trackedTokens });
|
const known = resolveTokenDecimals(lower, { chainId, trackedTokens });
|
||||||
const scale = known !== null ? known : decimals;
|
const scale = known !== null ? known : decimals;
|
||||||
rows.push({
|
rows.push({
|
||||||
address: token.address,
|
address: token.address,
|
||||||
@@ -330,6 +340,7 @@ 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 = [];
|
||||||
@@ -393,6 +404,7 @@ async function refreshBalances(
|
|||||||
addr.address,
|
addr.address,
|
||||||
blockscoutUrl,
|
blockscoutUrl,
|
||||||
tracked,
|
tracked,
|
||||||
|
chainId,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
updates.push(
|
updates.push(
|
||||||
@@ -403,6 +415,7 @@ async function refreshBalances(
|
|||||||
tracked,
|
tracked,
|
||||||
previous,
|
previous,
|
||||||
explorerRequest,
|
explorerRequest,
|
||||||
|
chainId,
|
||||||
signal,
|
signal,
|
||||||
),
|
),
|
||||||
explorerRequest,
|
explorerRequest,
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
// 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; a contract outside the set is still a spoof.
|
// wallet ships as a real token on Ethereum Mainnet. On mainnet, a contract
|
||||||
|
// 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
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
// 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.
|
||||||
@@ -93,18 +95,24 @@ 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.
|
// impersonating a known symbol. `chainId` is the chain id of the token's
|
||||||
|
// 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) {
|
function isSpoofedSymbol(symbol, contractAddress, chainId) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-4
@@ -3953,6 +3953,15 @@ 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
|
||||||
@@ -3969,7 +3978,8 @@ for (const t of TOKENS) {
|
|||||||
// 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, because a contract outside the set is still a spoof.
|
// loosen the rule. On Ethereum Mainnet, a contract outside the set is still a
|
||||||
|
// 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)) {
|
||||||
@@ -3988,14 +3998,15 @@ for (const t of TOKENS) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve a token symbol from multiple sources, never returning "?".
|
// Resolve a token symbol from multiple sources, never returning "?". The token
|
||||||
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
|
// is a contract on the network with chain id `chainId`.
|
||||||
|
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 = TOKEN_BY_ADDRESS.get(lower);
|
const known = bundledToken(lower, chainId);
|
||||||
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,
|
||||||
@@ -4012,5 +4023,6 @@ module.exports = {
|
|||||||
USD_PRICE_FEEDS,
|
USD_PRICE_FEEDS,
|
||||||
PRICE_FEED_BY_ADDRESS,
|
PRICE_FEED_BY_ADDRESS,
|
||||||
getTopTokens,
|
getTopTokens,
|
||||||
|
bundledToken,
|
||||||
resolveSymbol,
|
resolveSymbol,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { bundledToken } = 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 = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
|
const token = bundledToken(to, chainId);
|
||||||
if (token) {
|
if (token) {
|
||||||
symbol = token.symbol;
|
symbol = token.symbol;
|
||||||
}
|
}
|
||||||
@@ -317,7 +317,10 @@ 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 (hideSpoofed && isSpoofedSymbol(tx.symbol, tx.contractAddress)) {
|
if (
|
||||||
|
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);
|
||||||
|
|||||||
+20
-11
@@ -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 { trackedTokens, wallets }, shaped as they are
|
// 0.000000000001. `sources` is as for resolveTokenDecimals(), which reads the
|
||||||
// on `state`; resolveTokenDecimals() reads the bundled list, then those.
|
// bundled list on Ethereum Mainnet, then the tracked tokens and the wallets.
|
||||||
//
|
//
|
||||||
// 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,6 +1240,9 @@ 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;
|
||||||
@@ -2208,11 +2211,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, sources);
|
const inInfo = tokenInfo(inputToken, tokenSources);
|
||||||
const outInfo =
|
const outInfo =
|
||||||
outputUnwrapped || (hasUnwrapWeth && !outputEstablished)
|
outputUnwrapped || (hasUnwrapWeth && !outputEstablished)
|
||||||
? { symbol: "ETH", decimals: 18, address: null }
|
? { symbol: "ETH", decimals: 18, address: null }
|
||||||
: tokenInfo(outputToken, sources);
|
: tokenInfo(outputToken, tokenSources);
|
||||||
|
|
||||||
const inSymbol = inInfo.symbol;
|
const inSymbol = inInfo.symbol;
|
||||||
const outSymbol = outInfo.symbol;
|
const outSymbol = outInfo.symbol;
|
||||||
@@ -2337,13 +2340,19 @@ 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(s.amount, tokenInfo(s.token, sources), s.atMost)
|
? spentText(
|
||||||
.display
|
s.amount,
|
||||||
|
tokenInfo(s.token, tokenSources),
|
||||||
|
s.atMost,
|
||||||
|
).display
|
||||||
: UNDECODED_CURRENCY,
|
: UNDECODED_CURRENCY,
|
||||||
});
|
});
|
||||||
if (present(s.token)) {
|
if (present(s.token)) {
|
||||||
details.push(
|
details.push(
|
||||||
tokenMovedLine("Token moved", tokenInfo(s.token, sources)),
|
tokenMovedLine(
|
||||||
|
"Token moved",
|
||||||
|
tokenInfo(s.token, tokenSources),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
details.push({
|
details.push({
|
||||||
label: "Moved to",
|
label: "Moved to",
|
||||||
@@ -2355,7 +2364,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, sources);
|
const info = permit2TokenInfo(m.token, tokenSources);
|
||||||
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,
|
||||||
@@ -2390,7 +2399,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const info = permit2TokenInfo(p.token, sources);
|
const info = permit2TokenInfo(p.token, tokenSources);
|
||||||
details.push({
|
details.push({
|
||||||
label: "Permit2 allowance",
|
label: "Permit2 allowance",
|
||||||
value:
|
value:
|
||||||
@@ -2456,7 +2465,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, sources);
|
const info = permit2TokenInfo(m.token, tokenSources);
|
||||||
paidTo.push({
|
paidTo.push({
|
||||||
label: "Paid to",
|
label: "Paid to",
|
||||||
value:
|
value:
|
||||||
@@ -2481,7 +2490,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), sources),
|
tokenInfo(getAddress(token), tokenSources),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,9 +90,11 @@ 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 }];
|
||||||
expect(resolveTokenDecimals(USDC, state)).toBe(6);
|
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
|
||||||
|
expect(resolveTokenDecimals(USDC, sources)).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reads a token the user tracks", () => {
|
test("reads a token the user tracks", () => {
|
||||||
@@ -124,7 +126,8 @@ 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 }];
|
||||||
expect(resolveTokenDecimals(SLP, state)).toBe(0);
|
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
|
||||||
|
expect(resolveTokenDecimals(SLP, sources)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses a tracked scale of zero", () => {
|
test("uses a tracked scale of zero", () => {
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// 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>',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -111,13 +111,15 @@ 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 would have left on state.
|
// approval screens read what a real refresh on Ethereum Mainnet would have
|
||||||
|
// 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 = [
|
||||||
|
|||||||
@@ -192,7 +192,13 @@ describe("the balance-list spam gate", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
// On Ethereum Mainnet, the only network the known-token list covers.
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,13 +67,15 @@ 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, state)).toBe("USDC");
|
expect(resolveTokenSymbol(USDC, { chainId: "0x1" })).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" }];
|
||||||
expect(resolveTokenSymbol(USDC, state)).toBe("USDC");
|
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
|
||||||
|
expect(resolveTokenSymbol(USDC, sources)).toBe("USDC");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reads a token the user tracks", () => {
|
test("reads a token the user tracks", () => {
|
||||||
@@ -104,7 +106,19 @@ 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" }];
|
||||||
expect(resolveTokenSymbol(NOVEL_TOKEN, state)).toBeNull();
|
const sources = { chainId: "0x1", trackedTokens: state.trackedTokens };
|
||||||
|
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", () => {
|
||||||
|
|||||||
+110
-16
@@ -72,6 +72,11 @@ 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
|
||||||
@@ -116,14 +121,29 @@ 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)).toBe(false);
|
expect(isSpoofedSymbol("USDC", USDC_CONTRACT, MAINNET)).toBe(false);
|
||||||
expect(isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase())).toBe(
|
expect(
|
||||||
false,
|
isSpoofedSymbol("usdc", USDC_CONTRACT.toUpperCase(), MAINNET),
|
||||||
);
|
).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)).toBe(true);
|
expect(isSpoofedSymbol("USDC", FAKE_ETH_CONTRACT, MAINNET)).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", () => {
|
||||||
@@ -164,7 +184,9 @@ 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)).toBe(true);
|
expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT, MAINNET)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("non-breaking and other Unicode spaces do not either", () => {
|
test("non-breaking and other Unicode spaces do not either", () => {
|
||||||
@@ -243,7 +265,9 @@ 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(isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT)).toBe(true);
|
expect(
|
||||||
|
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
|
||||||
@@ -271,8 +295,10 @@ 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)).toBe(false);
|
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT, MAINNET)).toBe(false);
|
||||||
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT)).toBe(false);
|
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT, MAINNET)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Normalization must not invent a match. Interior ASCII whitespace is
|
// Normalization must not invent a match. Interior ASCII whitespace is
|
||||||
@@ -296,7 +322,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)).toBe(false);
|
expect(isSpoofedSymbol(symbol, address, MAINNET)).toBe(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -317,7 +343,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),
|
isSpoofedSymbol(t.symbol, t.address, MAINNET),
|
||||||
).map((t) => t.symbol + " @ " + t.address);
|
).map((t) => t.symbol + " @ " + t.address);
|
||||||
expect(filtered).toEqual([]);
|
expect(filtered).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -360,7 +386,9 @@ 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)).toBe(false);
|
expect(isSpoofedSymbol(t.symbol, t.address, MAINNET)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -404,7 +432,7 @@ describe("the shipped token list", () => {
|
|||||||
[...addresses].sort(),
|
[...addresses].sort(),
|
||||||
);
|
);
|
||||||
for (const address of addresses) {
|
for (const address of addresses) {
|
||||||
expect(isSpoofedSymbol(symbol, address)).toBe(false);
|
expect(isSpoofedSymbol(symbol, address, MAINNET)).toBe(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -421,7 +449,9 @@ 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)).toBe(true);
|
expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT, MAINNET)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -506,10 +536,40 @@ 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([
|
||||||
{
|
{
|
||||||
@@ -627,7 +687,12 @@ describe("surface 3: the balance list", () => {
|
|||||||
decimals: "6",
|
decimals: "6",
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
const balances = await fetchTokenBalances(
|
||||||
|
HOLDER,
|
||||||
|
BLOCKSCOUT,
|
||||||
|
[],
|
||||||
|
MAINNET,
|
||||||
|
);
|
||||||
expect(balances).toHaveLength(1);
|
expect(balances).toHaveLength(1);
|
||||||
expect(balances[0].symbol).toBe(" USDC ");
|
expect(balances[0].symbol).toBe(" USDC ");
|
||||||
});
|
});
|
||||||
@@ -641,11 +706,40 @@ describe("surface 3: the balance list", () => {
|
|||||||
decimals: "6",
|
decimals: "6",
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
const balances = await fetchTokenBalances(
|
||||||
|
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
|
||||||
|
|||||||
@@ -133,13 +133,13 @@ function trackedRow(balance, decimals = null, holders = null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh(addresses, trackedTokens) {
|
async function refresh(addresses, trackedTokens, networkId = "mainnet") {
|
||||||
await refreshBalances(
|
await refreshBalances(
|
||||||
[{ addresses }],
|
[{ addresses }],
|
||||||
RPC_URL,
|
RPC_URL,
|
||||||
EXPLORER_URL,
|
EXPLORER_URL,
|
||||||
trackedTokens,
|
trackedTokens,
|
||||||
"mainnet",
|
networkId,
|
||||||
new AbortController().signal,
|
new AbortController().signal,
|
||||||
);
|
);
|
||||||
return addresses;
|
return addresses;
|
||||||
@@ -307,3 +307,76 @@ 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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ function tokenTx(overrides = {}) {
|
|||||||
isError: false,
|
isError: false,
|
||||||
contractAddress: USDC_CONTRACT,
|
contractAddress: USDC_CONTRACT,
|
||||||
holders: 3500000,
|
holders: 3500000,
|
||||||
|
chainId: "0x1",
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -259,6 +260,32 @@ 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());
|
||||||
@@ -1495,6 +1522,41 @@ 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(
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ 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. The node reports the holding the explorer does.
|
// refreshBalances() puts them on Ethereum Mainnet. The node reports the holding
|
||||||
|
// 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 () => ({
|
||||||
@@ -176,7 +177,7 @@ async function fetchOnto(items) {
|
|||||||
statusText: "OK",
|
statusText: "OK",
|
||||||
json: async () => items,
|
json: async () => items,
|
||||||
}));
|
}));
|
||||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [], "0x1");
|
||||||
state.wallets = [
|
state.wallets = [
|
||||||
{
|
{
|
||||||
name: "Wallet 1",
|
name: "Wallet 1",
|
||||||
|
|||||||
Reference in New Issue
Block a user