310 lines
11 KiB
JavaScript
310 lines
11 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),
|
|
};
|
|
}
|
|
|
|
// 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 = 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 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;
|
|
}
|
|
|
|
// 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,
|
|
mergeTransactions,
|
|
};
|