fix: honour a dust threshold of 0 and compare addresses case-insensitively (closes #179)
Some checks failed
check / check (push) Has been cancelled
Some checks failed
check / check (push) Has been cancelled
This commit was merged in pull request #228.
This commit is contained in:
@@ -1108,7 +1108,8 @@ indexes it as a real token transfer.
|
|||||||
it. AutistMask hides transactions below a configurable dust threshold
|
it. AutistMask hides transactions below a configurable dust threshold
|
||||||
(default: 100,000 gwei / 0.0001 ETH). This is high enough to filter poisoning
|
(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
|
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,
|
- **User-configurable**: All of the above filters (known symbol verification,
|
||||||
low-holder threshold, fraud contract blocklist, dust threshold) are settings
|
low-holder threshold, fraud contract blocklist, dust threshold) are settings
|
||||||
|
|||||||
5
TODO.md
5
TODO.md
@@ -44,6 +44,11 @@ undefined identifiers, which is how
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 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
|
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
|
||||||
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
|
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
|
||||||
the Makefile-only targets documented in the README
|
the Makefile-only targets documented in the README
|
||||||
|
|||||||
@@ -304,11 +304,17 @@ function init(ctx) {
|
|||||||
|
|
||||||
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
||||||
$("settings-dust-threshold").addEventListener("change", async () => {
|
$("settings-dust-threshold").addEventListener("change", async () => {
|
||||||
const val = parseInt($("settings-dust-threshold").value, 10);
|
const raw = $("settings-dust-threshold").value.trim();
|
||||||
if (!isNaN(val) && val >= 0) {
|
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;
|
state.dustThresholdGwei = val;
|
||||||
await saveState();
|
await saveState();
|
||||||
}
|
}
|
||||||
|
$("settings-dust-threshold").value = state.dustThresholdGwei;
|
||||||
});
|
});
|
||||||
|
|
||||||
$("settings-utc-timestamps").checked = state.utcTimestamps;
|
$("settings-utc-timestamps").checked = state.utcTimestamps;
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ const { formatEther, formatUnits } = require("ethers");
|
|||||||
const { log, debugFetch } = require("./log");
|
const { log, debugFetch } = require("./log");
|
||||||
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
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) {
|
function formatTxValue(val) {
|
||||||
const parts = val.split(".");
|
const parts = val.split(".");
|
||||||
if (parts.length === 1) return val + ".0000";
|
if (parts.length === 1) return val + ".0000";
|
||||||
@@ -30,10 +38,10 @@ function parseTx(tx, addrLower) {
|
|||||||
let exactValue = formatEther(rawWei);
|
let exactValue = formatEther(rawWei);
|
||||||
let rawAmount = rawWei;
|
let rawAmount = rawWei;
|
||||||
let rawUnit = "wei";
|
let rawUnit = "wei";
|
||||||
let direction = from.toLowerCase() === 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(to.toLowerCase());
|
const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
|
||||||
if (token) {
|
if (token) {
|
||||||
symbol = token.symbol;
|
symbol = token.symbol;
|
||||||
}
|
}
|
||||||
@@ -87,7 +95,8 @@ function parseTokenTransfer(tt, addrLower) {
|
|||||||
const to = tt.to?.hash || "";
|
const to = tt.to?.hash || "";
|
||||||
const decimals = parseInt(tt.total?.decimals || "18", 10);
|
const decimals = parseInt(tt.total?.decimals || "18", 10);
|
||||||
const rawVal = tt.total?.value || "0";
|
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 || "?";
|
const sym = tt.token?.symbol || "?";
|
||||||
return {
|
return {
|
||||||
hash: tt.transaction_hash,
|
hash: tt.transaction_hash,
|
||||||
@@ -104,11 +113,9 @@ function parseTokenTransfer(tt, addrLower) {
|
|||||||
direction: direction,
|
direction: direction,
|
||||||
directionLabel: direction === "sent" ? "Sent" : "Received",
|
directionLabel: direction === "sent" ? "Sent" : "Received",
|
||||||
isError: false,
|
isError: false,
|
||||||
contractAddress: (
|
contractAddress: normalizeAddress(
|
||||||
tt.token?.address_hash ||
|
tt.token?.address_hash || tt.token?.address || "",
|
||||||
tt.token?.address ||
|
),
|
||||||
""
|
|
||||||
).toLowerCase(),
|
|
||||||
holders: parseInt(tt.token?.holders_count || "0", 10),
|
holders: parseInt(tt.token?.holders_count || "0", 10),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -194,7 +201,7 @@ function mergeTransactions(txs, tokenTransfers) {
|
|||||||
|
|
||||||
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
|
||||||
log.debugf("fetchRecentTransactions", address);
|
log.debugf("fetchRecentTransactions", address);
|
||||||
const addrLower = address.toLowerCase();
|
const addrLower = normalizeAddress(address);
|
||||||
|
|
||||||
const [txResp, ttResp] = await Promise.all([
|
const [txResp, ttResp] = await Promise.all([
|
||||||
debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
|
debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
|
||||||
@@ -243,34 +250,38 @@ function isSpoofedSymbol(tx) {
|
|||||||
if (!KNOWN_SYMBOLS.has(symbol)) return false;
|
if (!KNOWN_SYMBOLS.has(symbol)) return false;
|
||||||
const legit = KNOWN_SYMBOLS.get(symbol);
|
const legit = KNOWN_SYMBOLS.get(symbol);
|
||||||
if (legit === null) return true; // "ETH" as ERC-20 is always fake
|
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,
|
// Pure filter function. Takes raw transactions and filter settings,
|
||||||
// returns { transactions, newFraudContracts }.
|
// returns { transactions, newFraudContracts }.
|
||||||
function filterTransactions(txs, filters = {}) {
|
function filterTransactions(txs, filters = {}) {
|
||||||
const fraudSet = new Set(
|
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 newFraud = [];
|
||||||
const filtered = [];
|
const filtered = [];
|
||||||
|
|
||||||
for (const tx of txs) {
|
for (const tx of txs) {
|
||||||
|
const contract = normalizeAddress(tx.contractAddress);
|
||||||
|
|
||||||
// Always filter spoofed known symbols and record the fraud contract
|
// Always filter spoofed known symbols and record the fraud contract
|
||||||
if (isSpoofedSymbol(tx)) {
|
if (isSpoofedSymbol(tx)) {
|
||||||
if (tx.contractAddress && !fraudSet.has(tx.contractAddress)) {
|
if (contract && !fraudSet.has(contract)) {
|
||||||
fraudSet.add(tx.contractAddress);
|
fraudSet.add(contract);
|
||||||
newFraud.push(tx.contractAddress);
|
newFraud.push(contract);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter fraud contracts if setting is on
|
// Filter fraud contracts if setting is on
|
||||||
if (
|
if (filters.hideFraudContracts && contract && fraudSet.has(contract)) {
|
||||||
filters.hideFraudContracts &&
|
|
||||||
tx.contractAddress &&
|
|
||||||
fraudSet.has(tx.contractAddress)
|
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +302,7 @@ function filterTransactions(txs, filters = {}) {
|
|||||||
filters.hideDustTransactions &&
|
filters.hideDustTransactions &&
|
||||||
!tx.isContractCall &&
|
!tx.isContractCall &&
|
||||||
tx.valueGwei !== null &&
|
tx.valueGwei !== null &&
|
||||||
tx.valueGwei < (filters.dustThresholdGwei || 100000)
|
tx.valueGwei < dustThresholdGwei
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,18 +329,42 @@ describe("known-symbol spoof verification", () => {
|
|||||||
expect(result.newFraudContracts).toEqual([]);
|
expect(result.newFraudContracts).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Documents current behaviour, not desired behaviour: the spoof check
|
// Regression guard (#179): EIP-55 mixed case is a checksum over the
|
||||||
// compares tx.contractAddress against a lowercased known address with
|
// address, not part of its identity, so the contract comparison must be
|
||||||
// ===, so a caller passing a checksummed address for a genuine token has
|
// case-insensitive in both directions — a genuine token in any casing is
|
||||||
// it treated as a spoof. In the app this cannot happen because
|
// genuine, and a spoof cannot escape detection by changing its casing.
|
||||||
// parseTokenTransfer lowercases, but the exported function is not
|
test("a genuine contract in all-lowercase form is not a spoof", () => {
|
||||||
// defensive about it the way the blocklist check is.
|
const tx = tokenTx({ contractAddress: USDC_CONTRACT });
|
||||||
test("current behaviour: a checksummed genuine contract is treated as a spoof", () => {
|
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
||||||
const genuineButChecksummed = tokenTx({
|
});
|
||||||
|
|
||||||
|
test("a genuine contract in EIP-55 checksummed form is not a spoof", () => {
|
||||||
|
const tx = tokenTx({
|
||||||
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
||||||
});
|
});
|
||||||
const result = filterTransactions([genuineButChecksummed], filters());
|
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([]);
|
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]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Documents current behaviour: README.md:810-814 says all four filters
|
// Documents current behaviour: README.md:810-814 says all four filters
|
||||||
@@ -412,6 +436,21 @@ describe("low-holder token filtering (the 1,000-holder rule)", () => {
|
|||||||
expect(tx.holders).toBeNull();
|
expect(tx.holders).toBeNull();
|
||||||
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
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", () => {
|
describe("fraud contract blocklist", () => {
|
||||||
@@ -575,16 +614,50 @@ describe("dust threshold filtering", () => {
|
|||||||
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
expect(filterTransactions([tx], filters()).transactions).toEqual([tx]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Documents current behaviour: the threshold is read as
|
// Regression guard (#179): 0 is a real threshold meaning "hide nothing",
|
||||||
// `filters.dustThresholdGwei || 100000`, so a user who sets the threshold
|
// not an absent one. It used to be swallowed by `|| 100000`, so the one
|
||||||
// to 0 (the natural way to ask for no dust filtering while leaving the
|
// value a user would pick to see everything was the one that did not
|
||||||
// toggle on) silently gets the 100,000 gwei default instead.
|
// work.
|
||||||
test("current behaviour: a threshold of 0 falls back to the 100,000 gwei default", () => {
|
test("a threshold of 0 hides nothing, leaving the toggle on", () => {
|
||||||
const result = filterTransactions(
|
const dust = dustOf(50);
|
||||||
[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 }),
|
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([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user