Files
AutistMask/src/shared/transactions.js
T
clawbot 6c885a0c05
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
harden: a holders_count that is not plain digits is unknown, not read in part (closes #251)
parseHoldersCount used parseInt, which reads "1,000" as 1, "0x10" as 0 and
"1e3" as 1: a reported low count, which hides the token in the transaction
history and the send-screen token selector. It now accepts only a whole
number of zero or more, or a string of digits alone, no larger than
Number.MAX_SAFE_INTEGER, and returns null for anything else. The balance
list's holders !== null check did nothing, since null >= 1000 is already
false, and is dropped. README.md and docs/README.md say how each filter
treats an unknown count and that the token screen then leaves out its
Holders row; README.md lists src/shared/holders.js.

Model: opus-5-5
2026-10-05 02:59:15 +02:00

353 lines
14 KiB
JavaScript

// Transaction history fetching via Blockscout v2 API.
// Fetches normal transactions and ERC-20 token transfers,
// merges them, and returns the most recent entries.
//
// Filtering is separated from fetching: fetchRecentTransactions returns
// raw parsed data including token metadata, and filterTransactions is
// a pure function that applies anti-poisoning heuristics.
const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
const { nativeCurrencyByChainId } = require("./networks");
// The uint8 test every scale in this wallet goes through. Shared, not copied:
// a scale is either reported or it is unknown, and "unknown" must mean the
// same thing here as it does on the screens that refuse to format one.
const { toDecimals } = require("./transferAmount");
// The plain 4-decimal rule. The history and balance lists deliberately keep
// truncation without the approval screens' nonzero floor: the transaction
// detail view is the authoritative record and already shows exact precision.
const { truncateAmount: formatTxValue } = require("./amountDisplay");
// 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 parseTx(tx, addrLower, chainId) {
const from = tx.from?.hash || "";
const to = tx.to?.hash || "";
const rawWei = tx.value || "0";
const toIsContract = tx.to?.is_contract || false;
const method = tx.method || null;
// For contract calls, produce a meaningful label instead of "0.0000 ETH"
let symbol = nativeCurrencyByChainId(chainId);
let value = formatTxValue(formatEther(rawWei));
let exactValue = formatEther(rawWei);
let rawAmount = rawWei;
let rawUnit = "wei";
let direction = normalizeAddress(from) === addrLower ? "sent" : "received";
let directionLabel = direction === "sent" ? "Sent" : "Received";
if (toIsContract && method && method !== "transfer") {
const token = TOKEN_BY_ADDRESS.get(normalizeAddress(to));
if (token) {
symbol = token.symbol;
}
// Map known DEX methods to "Swap" for cleaner display
const SWAP_METHODS = new Set([
"execute",
"swap",
"swapExactTokensForTokens",
"swapTokensForExactTokens",
"swapExactETHForTokens",
"swapTokensForExactETH",
"swapExactTokensForETH",
"swapETHForExactTokens",
"multicall",
]);
const label = SWAP_METHODS.has(method)
? "Swap"
: method.charAt(0).toUpperCase() + method.slice(1);
direction = "contract";
directionLabel = label;
value = "";
exactValue = "";
rawAmount = "";
rawUnit = "";
}
return {
hash: tx.hash,
blockNumber: tx.block_number,
timestamp: Math.floor(new Date(tx.timestamp).getTime() / 1000),
from: from,
to: to,
value: value,
exactValue: exactValue,
rawAmount: rawAmount,
rawUnit: rawUnit,
valueGwei: Math.floor(Number(BigInt(rawWei) / BigInt(1000000000))),
symbol: symbol,
direction: direction,
directionLabel: directionLabel,
isError: tx.status !== "ok",
contractAddress: null,
holders: null,
isContractCall: toIsContract,
method: method,
chainId: chainId,
};
}
function parseTokenTransfer(tt, addrLower, chainId) {
const from = tt.from?.hash || "";
const to = tt.to?.hash || "";
// The explorer's own answer, or null. Never a default: a transfer of
// 5000000000 units formatted at a guessed 18 reads as 0.000000005, and
// nothing downstream can tell that from a real 18-decimal transfer of
// that size. `parseInt(x || "18", 10)` also collapsed a genuine scale of
// ZERO into 18 (https://git.eeqj.de/sneak/AutistMask/issues/246).
const decimals = toDecimals(tt.total?.decimals);
const rawVal = tt.total?.value || "0";
const direction =
normalizeAddress(from) === addrLower ? "sent" : "received";
const sym = tt.token?.symbol || "?";
// Without a scale there is no token quantity, so none is stated: the list
// row falls back to the symbol alone and the detail screen to its
// direction label, exactly as the contract-call rows above already do.
// The exact figure is not lost — it is the base-unit line below, which is
// the one number that needs no scale to be true.
const formatted =
decimals === null ? "" : formatTxValue(formatUnits(rawVal, decimals));
const exact = decimals === null ? "" : formatUnits(rawVal, decimals);
return {
hash: tt.transaction_hash,
blockNumber: tt.block_number,
timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000),
from: from,
to: to,
value: formatted,
exactValue: exact,
rawAmount: rawVal,
rawUnit:
decimals === null
? sym + " base units (decimals unknown)"
: sym + " base units (10^-" + decimals + ")",
valueGwei: null,
symbol: sym,
direction: direction,
directionLabel: direction === "sent" ? "Sent" : "Received",
isError: false,
contractAddress: normalizeAddress(
tt.token?.address_hash || tt.token?.address || "",
),
// null when the explorer reported no readable count: unknown, not
// zero. The low-holder filter declines to judge a null, so a
// legitimate token is not hidden because a field went missing
// upstream.
holders: parseHoldersCount(tt.token?.holders_count),
chainId: chainId,
};
}
// True when a parsed native entry moved no ETH. Contract-call entries have
// their amount fields blanked by parseTx, so they are never judged here.
function movedNoEther(tx) {
if (tx.direction === "contract") return false;
return BigInt(tx.rawAmount || "0") === BigInt(0);
}
// Merge parsed normal transactions with parsed ERC-20 token transfers into
// one row per distinct value movement. Pure: it reads only its arguments
// and returns a new list sorted newest block first.
//
// The merge key is the transaction hash for the native entry and
// hash + token contract for each token transfer, so:
//
// - A display-level contract call (a swap and friends, direction
// "contract") absorbs every token leg of its hash into the single
// native entry, because the legs are hops of one operation rather
// than separate movements the user made.
// - Otherwise each distinct token contract in the transaction keeps its
// own row, so a hash carrying several genuine transfers stays several
// rows.
// - The native entry of such a transaction is dropped when it moved no
// ETH and at least one token transfer shares its hash: that entry is
// the ERC-20 call itself, already represented by the token row. A
// native entry that moved ETH survives alongside the token rows, since
// the ETH and the tokens are two real movements, and a zero-value
// native transaction with no token transfer on its hash survives too.
function mergeTransactions(txs, tokenTransfers) {
const byKey = new Map();
// Entries are copied so consolidation never writes through to the
// caller's objects.
for (const tx of txs) {
byKey.set(tx.hash, { ...tx });
}
const absorbedHashes = new Set();
for (const parsed of tokenTransfers) {
const existing = byKey.get(parsed.hash);
if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
if (isReceived || needsAmount) {
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
}
// Keep the original tx's from/to (the user's address and the
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
}
if (existing && movedNoEther(existing)) {
absorbedHashes.add(parsed.hash);
}
// Every other token transfer gets its own entry.
byKey.set(parsed.hash + ":" + (parsed.contractAddress || ""), {
...parsed,
});
}
for (const hash of absorbedHashes) {
byKey.delete(hash);
}
const merged = [...byKey.values()];
merged.sort((a, b) => b.blockNumber - a.blockNumber);
return merged;
}
// `chainId` is the chain id of the network `blockscoutUrl` serves. Every entry
// carries it, and a native entry is labelled with that network's
// `nativeCurrency` from networks.js (`ETH`, `SepoliaETH`).
async function fetchRecentTransactions(
address,
blockscoutUrl,
chainId,
count = 25,
) {
log.debugf("fetchRecentTransactions", address);
const addrLower = normalizeAddress(address);
const [txResp, ttResp] = await Promise.all([
debugFetch(blockscoutUrl + "/addresses/" + address + "/transactions"),
debugFetch(
blockscoutUrl +
"/addresses/" +
address +
"/token-transfers?type=ERC-20",
),
]);
if (!txResp.ok) {
log.errorf(
"blockscout transactions:",
txResp.status,
txResp.statusText,
);
}
if (!ttResp.ok) {
log.errorf(
"blockscout token-transfers:",
ttResp.status,
ttResp.statusText,
);
}
const txJson = txResp.ok ? await txResp.json() : {};
const ttJson = ttResp.ok ? await ttResp.json() : {};
const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower, chainId)),
(ttJson.items || []).map((tt) =>
parseTokenTransfer(tt, addrLower, chainId),
),
);
const result = txs.slice(0, count);
log.debugf("fetchRecentTransactions done, count:", result.length);
return result;
}
// Pure filter function. Takes raw transactions and filter settings,
// returns { transactions, newFraudContracts }.
function filterTransactions(txs, filters = {}) {
const fraudSet = new Set(
(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) {
const contract = normalizeAddress(tx.contractAddress);
// Filter spoofed known symbols and record the fraud contract
if (hideSpoofed && isSpoofedSymbol(tx.symbol, tx.contractAddress)) {
if (contract && !fraudSet.has(contract)) {
fraudSet.add(contract);
newFraud.push(contract);
}
continue;
}
// Filter fraud contracts if setting is on
if (filters.hideFraudContracts && contract && fraudSet.has(contract)) {
continue;
}
// Filter low-holder tokens (<1000) if setting is on. A token whose
// holder count the explorer did not report is kept: only a reported
// count below the threshold is "low".
if (
filters.hideLowHolderTokens &&
tx.contractAddress &&
isLowHolderCount(tx.holders)
) {
continue;
}
// Filter dust transactions (below gwei threshold) if setting is on.
// Contract calls (approve, transfer, etc.) often have 0 ETH value
// and should never be filtered as dust.
if (
filters.hideDustTransactions &&
!tx.isContractCall &&
tx.valueGwei !== null &&
tx.valueGwei < dustThresholdGwei
) {
continue;
}
filtered.push(tx);
}
return { transactions: filtered, newFraudContracts: newFraud };
}
module.exports = {
fetchRecentTransactions,
filterTransactions,
mergeTransactions,
};