Compare commits
2 Commits
d0202fde58
...
9b7e18063c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b7e18063c | ||
| 12acf4dc8c |
36
README.md
36
README.md
@@ -720,6 +720,7 @@ screen, including ExportPrivKey, falls back to Home.
|
||||
- Blockscout API: endpoint URL input + "Save" button (validated against
|
||||
`/stats` before being saved)
|
||||
- Token Spam Protection:
|
||||
- "Hide fake tokens impersonating a known symbol" checkbox
|
||||
- "Hide tokens with fewer than 1,000 holders" checkbox
|
||||
- "Hide transactions from detected fraud contracts" checkbox
|
||||
- "Hide dust transactions below N gwei" checkbox + threshold input
|
||||
@@ -1081,7 +1082,19 @@ indexes it as a real token transfer.
|
||||
a spoof and filtered from display. The fake "Ethereum" token in the attack
|
||||
above used symbol "ETH" from contract
|
||||
`0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not match the known
|
||||
WETH contract — so it would be caught by this check.
|
||||
WETH contract — so it would be caught by this check. Detecting a spoof is also
|
||||
what adds a contract to the fraud contract blocklist below; that is the only
|
||||
thing that populates it. In the transaction history the check is the "Hide
|
||||
fake tokens impersonating a known symbol" setting, on by default; with it off,
|
||||
spoofed transfers are shown and no new blocklist entries are learned from
|
||||
them. The send-screen token selector applies the same check unconditionally,
|
||||
because it decides which tokens the user can act on rather than what the
|
||||
history displays. The balance list applies it unconditionally too, but not
|
||||
identically: it exempts symbols that `KNOWN_SYMBOLS` maps to `null`, and
|
||||
`"ETH"` is the only one. So the fake "Ethereum" token above is filtered from
|
||||
the transaction history and from the send selector, but a fake-`ETH` ERC-20
|
||||
that clears the balance list's own 1,000-holder floor — or that the user
|
||||
tracked manually — is still shown in the balance list.
|
||||
|
||||
- **Low-holder token filtering**: Token transfers from ERC-20 contracts with
|
||||
fewer than 1,000 holders are hidden from transaction history by default.
|
||||
@@ -1108,13 +1121,22 @@ indexes it as a real token transfer.
|
||||
it. AutistMask hides transactions below a configurable dust threshold
|
||||
(default: 100,000 gwei / 0.0001 ETH). This is high enough to filter poisoning
|
||||
dust while low enough to preserve any transfer a user would plausibly care
|
||||
about. The threshold is user-configurable in Settings.
|
||||
about. The threshold is user-configurable in Settings; a threshold of `0`
|
||||
hides nothing, exactly as clearing the checkbox does.
|
||||
|
||||
- **User-configurable**: All of the above filters (known symbol verification,
|
||||
low-holder threshold, fraud contract blocklist, dust threshold) are settings
|
||||
that default to on but can be individually disabled by the user. AutistMask is
|
||||
designed as a sharp tool — users who understand the risks can configure the
|
||||
wallet to show everything unfiltered, unix-style.
|
||||
- **User-configurable**: All four filters (known symbol verification, low-holder
|
||||
threshold, fraud contract blocklist, dust threshold) are settings that default
|
||||
to on but can be individually disabled by the user. AutistMask is designed as
|
||||
a sharp tool — users who understand the risks can configure the wallet to show
|
||||
everything unfiltered, unix-style. All four settings govern the transaction
|
||||
history; what else each one reaches varies. The known-symbol check also runs
|
||||
unconditionally on the send-screen token selector, and on the balance list
|
||||
except for symbols mapped to `null` (`"ETH"` alone), which the balance list
|
||||
does not filter. The fraud contract blocklist is applied unconditionally on
|
||||
that selector and is not consulted by the balance list at all. The low-holder
|
||||
setting also gates the send selector, while the balance list's own
|
||||
1,000-holder floor is unconditional (see Data Model). The dust threshold
|
||||
applies to the transaction history alone.
|
||||
|
||||
#### Phishing Domain Protection
|
||||
|
||||
|
||||
9
TODO.md
9
TODO.md
@@ -44,6 +44,15 @@ undefined identifiers, which is how
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-11: Known-symbol spoof verification became a Settings toggle
|
||||
(`hideSpoofedSymbols`), on by default, governing the transaction-history
|
||||
filter and the fraud-contract learning it feeds
|
||||
([#176](https://git.eeqj.de/sneak/AutistMask/issues/176)).
|
||||
- 2026-08-11: A dust threshold of `0` now means "hide nothing" instead of
|
||||
falling back to the 100,000 gwei default, and every address comparison in
|
||||
`src/shared/transactions.js` goes through one case-normalising helper so a
|
||||
checksummed genuine contract is no longer read as a spoof
|
||||
([#179](https://git.eeqj.de/sneak/AutistMask/issues/179)).
|
||||
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
|
||||
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
|
||||
the Makefile-only targets documented in the README
|
||||
|
||||
@@ -323,7 +323,14 @@ by default:
|
||||
**Known token symbol verification.** AutistMask ships a list of roughly 500
|
||||
legitimate ERC-20 tokens with their contract addresses. If a 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.
|
||||
an unrecognized contract, it is identified as a spoof and hidden. In your
|
||||
transaction history this is the "Hide fake tokens impersonating a known symbol"
|
||||
setting, which you can switch off; doing so also stops new entries being added
|
||||
to the fraud contract blocklist below, since detecting a spoof is what fills it.
|
||||
The send token list always applies the check. Your balances apply it too, with
|
||||
one 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
|
||||
from transaction history and the send token list, and are left out of your
|
||||
|
||||
@@ -948,6 +948,15 @@
|
||||
transfers and prevent interaction with suspicious
|
||||
tokens.
|
||||
</p>
|
||||
<label
|
||||
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="settings-hide-spoofed-symbols"
|
||||
/>
|
||||
Hide fake tokens impersonating a known symbol
|
||||
</label>
|
||||
<label
|
||||
class="text-xs flex items-center gap-1 cursor-pointer mb-2"
|
||||
>
|
||||
|
||||
@@ -148,6 +148,7 @@ async function loadTransactions(address) {
|
||||
state.blockscoutUrl,
|
||||
);
|
||||
const result = filterTransactions(rawTxs, {
|
||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||
hideLowHolderTokens: state.hideLowHolderTokens,
|
||||
hideFraudContracts: state.hideFraudContracts,
|
||||
hideDustTransactions: state.hideDustTransactions,
|
||||
|
||||
@@ -222,6 +222,7 @@ async function loadTransactions(address, tokenId) {
|
||||
state.blockscoutUrl,
|
||||
);
|
||||
const result = filterTransactions(rawTxs, {
|
||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||
hideLowHolderTokens: state.hideLowHolderTokens,
|
||||
hideFraudContracts: state.hideFraudContracts,
|
||||
hideDustTransactions: state.hideDustTransactions,
|
||||
|
||||
@@ -163,6 +163,7 @@ async function loadHomeTxs(ctx) {
|
||||
if (allAddresses.length === 0) return;
|
||||
|
||||
const filters = {
|
||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||
hideLowHolderTokens: state.hideLowHolderTokens,
|
||||
hideFraudContracts: state.hideFraudContracts,
|
||||
hideDustTransactions: state.hideDustTransactions,
|
||||
|
||||
@@ -284,6 +284,12 @@ function init(ctx) {
|
||||
applyTheme(state.theme);
|
||||
});
|
||||
|
||||
$("settings-hide-spoofed-symbols").checked = state.hideSpoofedSymbols;
|
||||
$("settings-hide-spoofed-symbols").addEventListener("change", async () => {
|
||||
state.hideSpoofedSymbols = $("settings-hide-spoofed-symbols").checked;
|
||||
await saveState();
|
||||
});
|
||||
|
||||
$("settings-hide-low-holders").checked = state.hideLowHolderTokens;
|
||||
$("settings-hide-low-holders").addEventListener("change", async () => {
|
||||
state.hideLowHolderTokens = $("settings-hide-low-holders").checked;
|
||||
@@ -304,11 +310,17 @@ function init(ctx) {
|
||||
|
||||
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
||||
$("settings-dust-threshold").addEventListener("change", async () => {
|
||||
const val = parseInt($("settings-dust-threshold").value, 10);
|
||||
if (!isNaN(val) && val >= 0) {
|
||||
const raw = $("settings-dust-threshold").value.trim();
|
||||
const val = Number(raw);
|
||||
// 0 is accepted and means "hide nothing". Empty, negative,
|
||||
// fractional and non-numeric input is rejected outright rather than
|
||||
// coerced, and the field is put back to the stored threshold so it
|
||||
// never shows a value the wallet is not using.
|
||||
if (raw !== "" && Number.isInteger(val) && val >= 0) {
|
||||
state.dustThresholdGwei = val;
|
||||
await saveState();
|
||||
}
|
||||
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
||||
});
|
||||
|
||||
$("settings-utc-timestamps").checked = state.utcTimestamps;
|
||||
|
||||
@@ -21,6 +21,7 @@ const DEFAULT_STATE = {
|
||||
deniedSites: {},
|
||||
rememberSiteChoice: true,
|
||||
showZeroBalanceTokens: true,
|
||||
hideSpoofedSymbols: true,
|
||||
hideLowHolderTokens: true,
|
||||
hideFraudContracts: true,
|
||||
hideDustTransactions: true,
|
||||
@@ -61,6 +62,7 @@ async function saveState() {
|
||||
deniedSites: state.deniedSites,
|
||||
rememberSiteChoice: state.rememberSiteChoice,
|
||||
showZeroBalanceTokens: state.showZeroBalanceTokens,
|
||||
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
||||
hideLowHolderTokens: state.hideLowHolderTokens,
|
||||
hideFraudContracts: state.hideFraudContracts,
|
||||
hideDustTransactions: state.hideDustTransactions,
|
||||
@@ -112,6 +114,12 @@ async function loadState() {
|
||||
saved.showZeroBalanceTokens !== undefined
|
||||
? saved.showZeroBalanceTokens
|
||||
: true;
|
||||
// A profile written before this setting existed has no key for it.
|
||||
// It is a safety filter, so absent must load as on, not as undefined.
|
||||
state.hideSpoofedSymbols =
|
||||
saved.hideSpoofedSymbols !== undefined
|
||||
? saved.hideSpoofedSymbols
|
||||
: true;
|
||||
state.hideLowHolderTokens =
|
||||
saved.hideLowHolderTokens !== undefined
|
||||
? saved.hideLowHolderTokens
|
||||
|
||||
@@ -10,6 +10,14 @@ const { formatEther, formatUnits } = require("ethers");
|
||||
const { log, debugFetch } = require("./log");
|
||||
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||
|
||||
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
||||
// over the address, not part of its identity. Every address comparison in
|
||||
// this file goes through this helper, so an address arriving in checksummed
|
||||
// or upper-case form can never be read as a different address.
|
||||
function normalizeAddress(addr) {
|
||||
return (addr || "").toLowerCase();
|
||||
}
|
||||
|
||||
function formatTxValue(val) {
|
||||
const parts = val.split(".");
|
||||
if (parts.length === 1) return val + ".0000";
|
||||
@@ -30,10 +38,10 @@ function parseTx(tx, addrLower) {
|
||||
let exactValue = formatEther(rawWei);
|
||||
let rawAmount = rawWei;
|
||||
let rawUnit = "wei";
|
||||
let direction = from.toLowerCase() === addrLower ? "sent" : "received";
|
||||
let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
|
||||
let directionLabel = direction === "sent" ? "Sent" : "Received";
|
||||
if (toIsContract && method && method !== "transfer") {
|
||||
const token = TOKEN_BY_ADDRESS.get(to.toLowerCase());
|
||||
const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
|
||||
if (token) {
|
||||
symbol = token.symbol;
|
||||
}
|
||||
@@ -87,7 +95,8 @@ function parseTokenTransfer(tt, addrLower) {
|
||||
const to = tt.to?.hash || "";
|
||||
const decimals = parseInt(tt.total?.decimals || "18", 10);
|
||||
const rawVal = tt.total?.value || "0";
|
||||
const direction = from.toLowerCase() === addrLower ? "sent" : "received";
|
||||
const direction =
|
||||
normalizeAddress(from) === addrLower ? "sent" : "received";
|
||||
const sym = tt.token?.symbol || "?";
|
||||
return {
|
||||
hash: tt.transaction_hash,
|
||||
@@ -104,11 +113,9 @@ function parseTokenTransfer(tt, addrLower) {
|
||||
direction: direction,
|
||||
directionLabel: direction === "sent" ? "Sent" : "Received",
|
||||
isError: false,
|
||||
contractAddress: (
|
||||
tt.token?.address_hash ||
|
||||
tt.token?.address ||
|
||||
""
|
||||
).toLowerCase(),
|
||||
contractAddress: normalizeAddress(
|
||||
tt.token?.address_hash || tt.token?.address || "",
|
||||
),
|
||||
holders: parseInt(tt.token?.holders_count || "0", 10),
|
||||
};
|
||||
}
|
||||
@@ -194,7 +201,7 @@ function mergeTransactions(txs, tokenTransfers) {
|
||||
|
||||
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||
log.debugf("fetchRecentTransactions", address);
|
||||
const addrLower = address.toLowerCase();
|
||||
const addrLower = normalizeAddress(address);
|
||||
|
||||
const [txResp, ttResp] = await Promise.all([
|
||||
debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
|
||||
@@ -243,34 +250,45 @@ function isSpoofedSymbol(tx) {
|
||||
if (!KNOWN_SYMBOLS.has(symbol)) return false;
|
||||
const legit = KNOWN_SYMBOLS.get(symbol);
|
||||
if (legit === null) return true; // "ETH" as ERC-20 is always fake
|
||||
return tx.contractAddress !== legit;
|
||||
return normalizeAddress(tx.contractAddress) !== normalizeAddress(legit);
|
||||
}
|
||||
|
||||
// Pure filter function. Takes raw transactions and filter settings,
|
||||
// returns { transactions, newFraudContracts }.
|
||||
function filterTransactions(txs, filters = {}) {
|
||||
const fraudSet = new Set(
|
||||
(filters.fraudContracts || []).map((a) => a.toLowerCase()),
|
||||
(filters.fraudContracts || []).map(normalizeAddress),
|
||||
);
|
||||
// The dust threshold defaults only when it is unset (nullish): a
|
||||
// threshold of 0 is a real value meaning "hide nothing", since no
|
||||
// transaction has a value below 0 gwei. It is therefore equivalent to
|
||||
// clearing the hide-dust checkbox, and the two controls cannot override
|
||||
// each other in either direction.
|
||||
const dustThresholdGwei = filters.dustThresholdGwei ?? 100000;
|
||||
const newFraud = [];
|
||||
const filtered = [];
|
||||
// Fail-safe, unlike the three flags below: this one is off only when the
|
||||
// caller says so explicitly, so a caller that omits the key keeps the
|
||||
// check rather than silently losing it. The setting also governs the
|
||||
// blocklist learning below, which exists only to serve this check —
|
||||
// leaving learning on while the check is off would re-hide the very rows
|
||||
// the user asked to see, through the fraud-contract rule.
|
||||
const hideSpoofed = filters.hideSpoofedSymbols !== false;
|
||||
|
||||
for (const tx of txs) {
|
||||
// Always filter spoofed known symbols and record the fraud contract
|
||||
if (isSpoofedSymbol(tx)) {
|
||||
if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) {
|
||||
fraudSet.add(tx.contractAddress);
|
||||
newFraud.push(tx.contractAddress);
|
||||
const contract = normalizeAddress(tx.contractAddress);
|
||||
|
||||
// Filter spoofed known symbols and record the fraud contract
|
||||
if (hideSpoofed && isSpoofedSymbol(tx)) {
|
||||
if (contract && !fraudSet.has(contract)) {
|
||||
fraudSet.add(contract);
|
||||
newFraud.push(contract);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter fraud contracts if setting is on
|
||||
if (
|
||||
filters.hideFraudContracts &&
|
||||
tx.contractAddress &&
|
||||
fraudSet.has(tx.contractAddress)
|
||||
) {
|
||||
if (filters.hideFraudContracts && contract && fraudSet.has(contract)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -291,7 +309,7 @@ function filterTransactions(txs, filters = {}) {
|
||||
filters.hideDustTransactions &&
|
||||
!tx.isContractCall &&
|
||||
tx.valueGwei !== null &&
|
||||
tx.valueGwei < (filters.dustThresholdGwei || 100000)
|
||||
tx.valueGwei < dustThresholdGwei
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -102,3 +102,60 @@ describe("loadState hasWallet reconciliation", () => {
|
||||
expect(mod.state.activeAddress).toBe(ADDRESS);
|
||||
});
|
||||
});
|
||||
|
||||
// The known-symbol spoof filter is a safety filter, so an existing profile
|
||||
// stored before the setting existed must load with it on rather than with
|
||||
// undefined, which would read as off.
|
||||
describe("hideSpoofedSymbols persistence", () => {
|
||||
test("defaults to on with empty storage", async () => {
|
||||
const { mod } = loadModuleWith(null);
|
||||
await mod.loadState();
|
||||
expect(mod.state.hideSpoofedSymbols).toBe(true);
|
||||
});
|
||||
|
||||
test("a profile stored without the key loads with it on", async () => {
|
||||
const { mod } = loadModuleWith({ wallets: oneWallet() });
|
||||
await mod.loadState();
|
||||
expect(mod.state.hideSpoofedSymbols).toBe(true);
|
||||
});
|
||||
|
||||
test("an explicit false survives the load", async () => {
|
||||
const { mod } = loadModuleWith({
|
||||
wallets: oneWallet(),
|
||||
hideSpoofedSymbols: false,
|
||||
});
|
||||
await mod.loadState();
|
||||
expect(mod.state.hideSpoofedSymbols).toBe(false);
|
||||
});
|
||||
|
||||
test("saveState persists the flag", async () => {
|
||||
const { mod, set } = loadModuleWith(null);
|
||||
mod.state.hideSpoofedSymbols = false;
|
||||
await mod.saveState();
|
||||
expect(set).toHaveBeenCalledWith({
|
||||
autistmask: expect.objectContaining({ hideSpoofedSymbols: false }),
|
||||
});
|
||||
});
|
||||
|
||||
test("the flag round-trips off through save and load", async () => {
|
||||
const first = loadModuleWith(null);
|
||||
first.mod.state.hideSpoofedSymbols = false;
|
||||
await first.mod.saveState();
|
||||
const persisted = first.set.mock.calls[0][0].autistmask;
|
||||
|
||||
const second = loadModuleWith(persisted);
|
||||
await second.mod.loadState();
|
||||
expect(second.mod.state.hideSpoofedSymbols).toBe(false);
|
||||
});
|
||||
|
||||
test("the flag round-trips back on through save and load", async () => {
|
||||
const first = loadModuleWith(null);
|
||||
first.mod.state.hideSpoofedSymbols = true;
|
||||
await first.mod.saveState();
|
||||
const persisted = first.set.mock.calls[0][0].autistmask;
|
||||
|
||||
const second = loadModuleWith(persisted);
|
||||
await second.mod.loadState();
|
||||
expect(second.mod.state.hideSpoofedSymbols).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +78,7 @@ const ORDINARY_PEER = "0x5aa0f9f1e0a1d0e0e5c1e7ce3b7dbbe9c19f0a11";
|
||||
|
||||
// The documented default settings (README.md:810-814, state.js:24-27).
|
||||
const DEFAULT_FILTERS = {
|
||||
hideSpoofedSymbols: true,
|
||||
hideLowHolderTokens: true,
|
||||
hideFraudContracts: true,
|
||||
hideDustTransactions: true,
|
||||
@@ -329,44 +330,150 @@ describe("known-symbol spoof verification", () => {
|
||||
expect(result.newFraudContracts).toEqual([]);
|
||||
});
|
||||
|
||||
// Documents current behaviour, not desired behaviour: the spoof check
|
||||
// compares tx.contractAddress against a lowercased known address with
|
||||
// ===, so a caller passing a checksummed address for a genuine token has
|
||||
// it treated as a spoof. In the app this cannot happen because
|
||||
// parseTokenTransfer lowercases, but the exported function is not
|
||||
// defensive about it the way the blocklist check is.
|
||||
test("current behaviour: a checksummed genuine contract is treated as a spoof", () => {
|
||||
const genuineButChecksummed = tokenTx({
|
||||
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
||||
});
|
||||
const result = filterTransactions([genuineButChecksummed], filters());
|
||||
expect(result.transactions).toEqual([]);
|
||||
// Regression guard (#179): EIP-55 mixed case is a checksum over the
|
||||
// address, not part of its identity, so the contract comparison must be
|
||||
// case-insensitive in both directions — a genuine token in any casing is
|
||||
// genuine, and a spoof cannot escape detection by changing its casing.
|
||||
test("a genuine contract in all-lowercase form is not a spoof", () => {
|
||||
const tx = tokenTx({ contractAddress: USDC_CONTRACT });
|
||||
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
||||
});
|
||||
|
||||
// Documents current behaviour: README.md:810-814 says all four filters
|
||||
// "default to on but can be individually disabled". There is no setting
|
||||
// for known-symbol verification, and filterTransactions applies it
|
||||
// unconditionally, so it cannot be turned off.
|
||||
test("current behaviour: spoof filtering cannot be disabled by any setting", () => {
|
||||
const allFiltersOff = {
|
||||
test("a genuine contract in EIP-55 checksummed form is not a spoof", () => {
|
||||
const tx = tokenTx({
|
||||
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
||||
});
|
||||
const result = filterTransactions([tx], filters());
|
||||
expect(result.transactions).toEqual([tx]);
|
||||
expect(result.newFraudContracts).toEqual([]);
|
||||
});
|
||||
|
||||
test("a genuine contract in all-uppercase form is not a spoof", () => {
|
||||
const tx = tokenTx({
|
||||
contractAddress: "0X" + USDC_CONTRACT.slice(2).toUpperCase(),
|
||||
});
|
||||
const result = filterTransactions([tx], filters());
|
||||
expect(result.transactions).toEqual([tx]);
|
||||
expect(result.newFraudContracts).toEqual([]);
|
||||
});
|
||||
|
||||
test("a genuinely different contract claiming USDC is still a spoof in any casing", () => {
|
||||
const tx = tokenTx({
|
||||
contractAddress: "0xD05339F9EA5AB9D9F03B9D57F671D2ABD1F55C82",
|
||||
});
|
||||
const result = filterTransactions([tx], filters());
|
||||
expect(result.transactions).toEqual([]);
|
||||
// The recorded fraud contract is normalised, so the persisted
|
||||
// blocklist matches later transfers whatever casing they arrive in.
|
||||
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
||||
});
|
||||
|
||||
// Turning the other three filters off must not turn this one off: each
|
||||
// filter is independent, and this is the one the README calls out as the
|
||||
// defense against the fake "ETH" attack.
|
||||
test("the check still runs when the other three filters are off", () => {
|
||||
const result = filterTransactions(
|
||||
[fakeEthTokenTransfer()],
|
||||
filters({
|
||||
hideLowHolderTokens: false,
|
||||
hideFraudContracts: false,
|
||||
hideDustTransactions: false,
|
||||
dustThresholdGwei: 1,
|
||||
fraudContracts: [],
|
||||
};
|
||||
const result = filterTransactions(
|
||||
[fakeEthTokenTransfer()],
|
||||
allFiltersOff,
|
||||
}),
|
||||
);
|
||||
expect(result.transactions).toEqual([]);
|
||||
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
||||
});
|
||||
|
||||
test("current behaviour: spoof filtering also applies with no filters argument", () => {
|
||||
test("spoof filtering also applies with no filters argument", () => {
|
||||
const result = filterTransactions([fakeEthTokenTransfer()]);
|
||||
expect(result.transactions).toEqual([]);
|
||||
});
|
||||
|
||||
// Fail-safe: unlike the other three flags, an absent hideSpoofedSymbols
|
||||
// leaves the check ON. A caller that forgets the key keeps the wallet's
|
||||
// headline protection; only a user who deliberately switched the setting
|
||||
// off sends an explicit false.
|
||||
test("an absent hideSpoofedSymbols leaves the check on", () => {
|
||||
const result = filterTransactions([fakeEthTokenTransfer()], {
|
||||
fraudContracts: [],
|
||||
});
|
||||
expect(result.transactions).toEqual([]);
|
||||
});
|
||||
|
||||
test("a truthy-but-not-true hideSpoofedSymbols leaves the check on", () => {
|
||||
const result = filterTransactions(
|
||||
[fakeEthTokenTransfer()],
|
||||
filters({ hideSpoofedSymbols: undefined }),
|
||||
);
|
||||
expect(result.transactions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disabling known-symbol spoof verification", () => {
|
||||
test("the spoofed transfer is shown when hideSpoofedSymbols is false", () => {
|
||||
const attack = fakeEthTokenTransfer();
|
||||
const result = filterTransactions(
|
||||
[attack],
|
||||
filters({
|
||||
hideSpoofedSymbols: false,
|
||||
// The blocklist rule would otherwise hide the same row via a
|
||||
// contract this pass had already learned.
|
||||
hideFraudContracts: false,
|
||||
hideLowHolderTokens: false,
|
||||
}),
|
||||
);
|
||||
expect(result.transactions).toEqual([attack]);
|
||||
});
|
||||
|
||||
// The blocklist is populated only by this check, so switching the check
|
||||
// off stops the learning too. Leaving learning on would make the setting
|
||||
// a no-op: the contract it recorded would immediately hide the same row
|
||||
// through the fraud-contract rule, which is on by default.
|
||||
test("no fraud contract is learned when hideSpoofedSymbols is false", () => {
|
||||
const result = filterTransactions(
|
||||
[fakeEthTokenTransfer()],
|
||||
filters({ hideSpoofedSymbols: false }),
|
||||
);
|
||||
expect(result.newFraudContracts).toEqual([]);
|
||||
});
|
||||
|
||||
test("the setting off does not stop the other three rules", () => {
|
||||
const dust = nativeDustTransfer();
|
||||
const lowHolder = tokenTx({
|
||||
symbol: NOVEL_SPAM_SYMBOL,
|
||||
contractAddress: NOVEL_SPAM_CONTRACT,
|
||||
holders: 0,
|
||||
});
|
||||
const result = filterTransactions(
|
||||
[dust, lowHolder],
|
||||
filters({ hideSpoofedSymbols: false }),
|
||||
);
|
||||
expect(result.transactions).toEqual([]);
|
||||
});
|
||||
|
||||
// An already-persisted fraud contract keeps being filtered: the blocklist
|
||||
// rule is a separate setting and is unaffected by this one.
|
||||
test("an already-blocklisted contract is still hidden with the check off", () => {
|
||||
const result = filterTransactions(
|
||||
[fakeEthTokenTransfer()],
|
||||
filters({
|
||||
hideSpoofedSymbols: false,
|
||||
fraudContracts: [FAKE_ETH_CONTRACT],
|
||||
}),
|
||||
);
|
||||
expect(result.transactions).toEqual([]);
|
||||
expect(result.newFraudContracts).toEqual([]);
|
||||
});
|
||||
|
||||
test("a genuine transfer is unaffected by the setting either way", () => {
|
||||
const tx = tokenTx();
|
||||
expect(
|
||||
filterTransactions([tx], filters({ hideSpoofedSymbols: false }))
|
||||
.transactions,
|
||||
).toEqual([tx]);
|
||||
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("low-holder token filtering (the 1,000-holder rule)", () => {
|
||||
@@ -412,6 +519,21 @@ describe("low-holder token filtering (the 1,000-holder rule)", () => {
|
||||
expect(tx.holders).toBeNull();
|
||||
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
||||
});
|
||||
|
||||
// Regression guard (#179): an unknown holder count on a real token — the
|
||||
// explorer rate-limited the call, or a self-hosted instance omits the
|
||||
// field — must not be read as zero holders. Reading it that way hides a
|
||||
// legitimate transfer from the user's history, the same over-filtering
|
||||
// harm as the zero-threshold bug. This pins the `tx.holders !== null`
|
||||
// guard, which no fixture previously reached.
|
||||
test("a token whose holder count is unknown is not filtered", () => {
|
||||
const tx = tokenTx({
|
||||
symbol: NOVEL_SPAM_SYMBOL,
|
||||
contractAddress: NOVEL_SPAM_CONTRACT,
|
||||
holders: null,
|
||||
});
|
||||
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fraud contract blocklist", () => {
|
||||
@@ -575,21 +697,56 @@ describe("dust threshold filtering", () => {
|
||||
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
||||
});
|
||||
|
||||
// Documents current behaviour: the threshold is read as
|
||||
// `filters.dustThresholdGwei || 100000`, so a user who sets the threshold
|
||||
// to 0 (the natural way to ask for no dust filtering while leaving the
|
||||
// toggle on) silently gets the 100,000 gwei default instead.
|
||||
test("current behaviour: a threshold of 0 falls back to the 100,000 gwei default", () => {
|
||||
const result = filterTransactions(
|
||||
[dustOf(50)],
|
||||
// Regression guard (#179): 0 is a real threshold meaning "hide nothing",
|
||||
// not an absent one. It used to be swallowed by `|| 100000`, so the one
|
||||
// value a user would pick to see everything was the one that did not
|
||||
// work.
|
||||
test("a threshold of 0 hides nothing, leaving the toggle on", () => {
|
||||
const dust = dustOf(50);
|
||||
const zero = dustOf(0);
|
||||
const opts = filters({ dustThresholdGwei: 0 });
|
||||
expect(filterTransactions([dust], opts).transactions).toEqual([dust]);
|
||||
expect(filterTransactions([zero], opts).transactions).toEqual([zero]);
|
||||
});
|
||||
|
||||
test("a threshold of 0 agrees with clearing the hide-dust checkbox", () => {
|
||||
const tx = nativeDustTransfer();
|
||||
const thresholdZero = filterTransactions(
|
||||
[tx],
|
||||
filters({ dustThresholdGwei: 0 }),
|
||||
);
|
||||
expect(result.transactions).toEqual([]);
|
||||
const toggleOff = filterTransactions(
|
||||
[tx],
|
||||
filters({ hideDustTransactions: false }),
|
||||
);
|
||||
expect(thresholdZero.transactions).toEqual([tx]);
|
||||
expect(toggleOff.transactions).toEqual([tx]);
|
||||
});
|
||||
|
||||
test("0, unset and a set threshold are three distinct behaviours", () => {
|
||||
const tx = dustOf(50);
|
||||
expect(
|
||||
filterTransactions([tx], filters({ dustThresholdGwei: 0 }))
|
||||
.transactions,
|
||||
).toEqual([tx]);
|
||||
expect(
|
||||
filterTransactions([tx], filters({ dustThresholdGwei: undefined }))
|
||||
.transactions,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
filterTransactions([tx], filters({ dustThresholdGwei: 40 }))
|
||||
.transactions,
|
||||
).toEqual([tx]);
|
||||
expect(
|
||||
filterTransactions([tx], filters({ dustThresholdGwei: 60 }))
|
||||
.transactions,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filter defaults promised by the README and Settings", () => {
|
||||
test("all three toggles default to on and the threshold to 100,000 gwei", () => {
|
||||
test("all four toggles default to on and the threshold to 100,000 gwei", () => {
|
||||
expect(state.hideSpoofedSymbols).toBe(true);
|
||||
expect(state.hideLowHolderTokens).toBe(true);
|
||||
expect(state.hideFraudContracts).toBe(true);
|
||||
expect(state.hideDustTransactions).toBe(true);
|
||||
@@ -600,10 +757,10 @@ describe("filter defaults promised by the README and Settings", () => {
|
||||
expect(state.fraudContracts).toEqual([]);
|
||||
});
|
||||
|
||||
// Documents current behaviour: filterTransactions itself defaults every
|
||||
// optional filter to off. The "default to on" promise is satisfied by
|
||||
// the state defaults above, which every caller passes in; the pure
|
||||
// function makes no assumption of its own.
|
||||
// Documents current behaviour: filterTransactions defaults the other three
|
||||
// optional filters to off. Their "default to on" promise is satisfied by
|
||||
// the state defaults above, which every caller passes in. Spoof
|
||||
// verification is the exception and stays on unless explicitly disabled.
|
||||
test("current behaviour: with no filters argument only spoof filtering runs", () => {
|
||||
const dust = nativeDustTransfer();
|
||||
const lowHolder = tokenTx({
|
||||
|
||||
Reference in New Issue
Block a user