parseInt(decimals || "18") ran before writing stored tokenBalances[].decimals, so an explorer reporting no decimals produced a fabricated 18 indistinguishable from a real one at read time. That defeated the resolve-or-refuse guarantees of #306 and #340: their refusal paths were intact but never fired, because the guess was laundered upstream of them. An absent scale is now stored as unknown, and a holding whose scale nothing knows carries a null balance -- unknown, never zero -- with six reader sites saying so rather than printing 0.0000. The Send screen resolves the display scale rather than reading the stored one, so a bundled token whose explorer row omits decimals still sends; when the scale cannot be resolved the stored quantity is withdrawn too, so the user is told the balance is unknown rather than only that the fee failed. Existing fabricated 18s cannot be told apart retroactively and are replaced wholesale on the next balance refresh. An explorer-sourced scale stays trusted -- only fabrication is removed; the reasoning is recorded on the issue.
339 lines
13 KiB
JavaScript
339 lines
13 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");
|
|
// 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) {
|
|
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 = "ETH";
|
|
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,
|
|
};
|
|
}
|
|
|
|
function parseTokenTransfer(tt, addrLower) {
|
|
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 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),
|
|
};
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
async function fetchRecentTransactions(address, blockscoutUrl, 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)),
|
|
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
|
|
);
|
|
|
|
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,
|
|
};
|