fix: honour a dust threshold of 0 and compare addresses case-insensitively (closes #179)
All checks were successful
check / check (push) Successful in 41s

Two defects in the anti-poisoning filters, both silent over-filtering: the
wallet hid transactions the user had asked to see.

A dust threshold of 0 was read as `filters.dustThresholdGwei || 100000`, so
the one value a user would pick to mean "show everything" was swallowed and
replaced by the default. It is now `??`, making 0 a real threshold that hides
nothing and agrees exactly with clearing the hide-dust checkbox. The Settings
input rejects empty, negative, fractional and non-numeric entries outright
instead of coercing them, and resyncs the field to the stored value so it
never displays a threshold the wallet is not using.

isSpoofedSymbol compared the contract address with `===` against a lowercased
known address. EIP-55 mixed case is a checksum, not identity, so a genuine
token arriving checksummed was classified as a spoof and hidden. All address
comparisons in the module now go through one normalizeAddress helper, which
also normalises the fraud contracts recorded from a detected spoof.

Tests cover threshold 0 versus unset versus a set value, and the contract
comparison in lowercase, uppercase and EIP-55 form as well as against a
genuinely different address. The two `current behaviour:` tests pinning the
old behaviour are inverted into regression guards, and a fixture pairing a
real contract address with a null holder count pins the `tx.holders !== null`
guard that no fixture previously reached.
This commit is contained in:
clawbot
2026-08-11 13:07:17 +00:00
parent f455b0ae7f
commit ea1fcd476d
5 changed files with 136 additions and 40 deletions

View File

@@ -304,11 +304,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;

View File

@@ -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,38 @@ 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 = [];
for (const tx of txs) {
const contract = normalizeAddress(tx.contractAddress);
// 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);
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 +302,7 @@ function filterTransactions(txs, filters = {}) {
filters.hideDustTransactions &&
!tx.isContractCall &&
tx.valueGwei !== null &&
tx.valueGwei < (filters.dustThresholdGwei || 100000)
tx.valueGwei < dustThresholdGwei
) {
continue;
}