Files
AutistMask/src/shared/transactions.js
user 34c23bdc01
All checks were successful
check / check (push) Successful in 9s
feat: warn when sending to address with zero tx history (#82)
On the confirm-tx screen, asynchronously check the recipient address
via Blockscout API. If the address has never sent or received any
transactions, display a prominent red warning banner.

Closes #82
2026-02-28 14:55:00 -08:00

287 lines
9.8 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 { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
function formatTxValue(val) {
const parts = val.split(".");
if (parts.length === 1) return val + ".0000";
const dec = (parts[1] + "0000").slice(0, 4);
return parts[0] + "." + dec;
}
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 = from.toLowerCase() === addrLower ? "sent" : "received";
let directionLabel = direction === "sent" ? "Sent" : "Received";
if (toIsContract && method && method !== "transfer") {
const token = TOKEN_BY_ADDRESS.get(to.toLowerCase());
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 || "";
const decimals = parseInt(tt.total?.decimals || "18", 10);
const rawVal = tt.total?.value || "0";
const direction = from.toLowerCase() === addrLower ? "sent" : "received";
const sym = tt.token?.symbol || "?";
return {
hash: tt.transaction_hash,
blockNumber: tt.block_number,
timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000),
from: from,
to: to,
value: formatTxValue(formatUnits(rawVal, decimals)),
exactValue: formatUnits(rawVal, decimals),
rawAmount: rawVal,
rawUnit: sym + " base units (10^-" + decimals + ")",
valueGwei: null,
symbol: sym,
direction: direction,
directionLabel: direction === "sent" ? "Sent" : "Received",
isError: false,
contractAddress: (
tt.token?.address_hash ||
tt.token?.address ||
""
).toLowerCase(),
holders: parseInt(tt.token?.holders_count || "0", 10),
};
}
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
log.debugf("fetchRecentTransactions", address);
const addrLower = address.toLowerCase();
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 txsByHash = new Map();
for (const tx of txJson.items || []) {
txsByHash.set(tx.hash, parseTx(tx, addrLower));
}
// When a token transfer shares a hash with a normal tx, the normal tx
// is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. A single transaction (e.g. a swap) can produce
// multiple token transfers (one per token involved), so we key token
// transfers by hash + contract address to keep all of them. We also
// preserve contract-call metadata (direction, label, method) from the
// matching normal tx so swaps display correctly.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") {
parsed.direction = "contract";
parsed.directionLabel = existing.directionLabel;
parsed.isContractCall = true;
parsed.method = existing.method;
// Remove the bare-hash normal tx so it doesn't appear as a
// duplicate with empty value; token transfers replace it.
txsByHash.delete(parsed.hash);
}
// Use composite key so multiple token transfers per tx are kept.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed);
}
const txs = [...txsByHash.values()];
txs.sort((a, b) => b.blockNumber - a.blockNumber);
const result = txs.slice(0, count);
log.debugf("fetchRecentTransactions done, count:", result.length);
return result;
}
// Check if a token transfer is spoofing a known symbol.
// Returns true if the symbol matches a known token but the contract
// address doesn't match the legitimate one.
function isSpoofedSymbol(tx) {
if (!tx.contractAddress) return false;
const symbol = (tx.symbol || "").toUpperCase();
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;
}
// 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()),
);
const newFraud = [];
const filtered = [];
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);
}
continue;
}
// Filter fraud contracts if setting is on
if (
filters.hideFraudContracts &&
tx.contractAddress &&
fraudSet.has(tx.contractAddress)
) {
continue;
}
// Filter low-holder tokens (<1000) if setting is on
if (
filters.hideLowHolderTokens &&
tx.contractAddress &&
tx.holders !== null &&
tx.holders < 1000
) {
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 < (filters.dustThresholdGwei || 100000)
) {
continue;
}
filtered.push(tx);
}
return { transactions: filtered, newFraudContracts: newFraud };
}
async function hasTransactionHistory(address, blockscoutUrl) {
try {
const resp = await debugFetch(blockscoutUrl + "/addresses/" + address);
if (!resp.ok) {
// If Blockscout returns 404, the address has never been seen on-chain.
if (resp.status === 404) return false;
log.errorf(
"blockscout address check:",
resp.status,
resp.statusText,
);
return null; // unknown
}
const data = await resp.json();
// Blockscout v2 address endpoint returns tx counts.
// An address with no history may still exist (e.g. received ETH once
// but shows 0 outgoing). We check both transactions_count and
// token_transfers_count to be thorough.
const txCount =
(parseInt(data.transactions_count, 10) || 0) +
(parseInt(data.token_transfers_count, 10) || 0);
return txCount > 0;
} catch (e) {
log.errorf("hasTransactionHistory error:", e.message);
return null; // unknown, don't block the user
}
}
module.exports = {
fetchRecentTransactions,
filterTransactions,
hasTransactionHistory,
};