All checks were successful
check / check (push) Successful in 22s
- Preserve contract call metadata (direction, label, method) when token transfers merge with normal txs in fetchRecentTransactions - Handle 'contract' direction in counterparty display for home and address detail list views - Add decoded calldata display to transaction detail view, fetching raw input from Blockscout and using decodeCalldata from approval.js - Show 'Unknown contract call' with raw hex for unrecognized calldata - Export decodeCalldata from approval.js for reuse
234 lines
7.7 KiB
JavaScript
234 lines
7.7 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;
|
|
}
|
|
const label = 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. Replace the normal tx with the token transfer,
|
|
// but preserve contract call metadata (direction, label, method) so
|
|
// swaps and other contract interactions 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;
|
|
}
|
|
txsByHash.set(parsed.hash, 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 };
|
|
}
|
|
|
|
module.exports = { fetchRecentTransactions, filterTransactions };
|