Limits token name to 64 chars and symbol to 12 chars to prevent storage of excessively long values from malicious contracts.
268 lines
9.1 KiB
JavaScript
268 lines
9.1 KiB
JavaScript
// Balance fetching: ETH balances via RPC, ERC-20 token balances via
|
|
// Blockscout, ENS reverse lookup via RPC.
|
|
|
|
const {
|
|
JsonRpcProvider,
|
|
Network,
|
|
Contract,
|
|
formatEther,
|
|
formatUnits,
|
|
} = require("ethers");
|
|
const { ERC20_ABI } = require("./constants");
|
|
const { log, debugFetch } = require("./log");
|
|
const { deriveAddressFromXpub } = require("./wallet");
|
|
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
|
|
|
// Use a static network to skip auto-detection (which can fail and cause
|
|
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
|
const mainnet = Network.from("mainnet");
|
|
|
|
function getProvider(rpcUrl) {
|
|
return new JsonRpcProvider(rpcUrl, mainnet, { staticNetwork: mainnet });
|
|
}
|
|
|
|
function formatBalance(wei) {
|
|
const eth = formatEther(wei);
|
|
const parts = eth.split(".");
|
|
if (parts.length === 1) return eth + ".0";
|
|
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
|
|
return parts[0] + "." + dec;
|
|
}
|
|
|
|
function formatTokenBalance(raw, decimals) {
|
|
const val = formatUnits(raw, decimals);
|
|
const parts = val.split(".");
|
|
if (parts.length === 1) return val + ".0";
|
|
const dec = parts[1].slice(0, 6).replace(/0+$/, "") || "0";
|
|
return parts[0] + "." + dec;
|
|
}
|
|
|
|
// Fetch token balances for a single address from Blockscout.
|
|
// Returns [{ address, symbol, decimals, balance }].
|
|
// Filters out spam: only shows tokens that are in the known token list,
|
|
// explicitly tracked by the user, or have >= 1000 holders.
|
|
async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
|
try {
|
|
const resp = await debugFetch(
|
|
blockscoutUrl + "/addresses/" + address + "/token-balances",
|
|
);
|
|
if (!resp.ok) {
|
|
log.errorf("blockscout token-balances:", resp.status);
|
|
return null;
|
|
}
|
|
const items = await resp.json();
|
|
if (!Array.isArray(items)) return null;
|
|
|
|
const trackedSet = new Set(
|
|
(trackedTokens || []).map((t) => t.address.toLowerCase()),
|
|
);
|
|
|
|
const balances = [];
|
|
for (const item of items) {
|
|
if (item.token?.type !== "ERC-20") continue;
|
|
const decimals = parseInt(item.token.decimals || "18", 10);
|
|
const bal = formatTokenBalance(item.value || "0", decimals);
|
|
if (bal === "0.0") continue;
|
|
|
|
const tokenAddr = (item.token.address_hash || "").toLowerCase();
|
|
const holders = parseInt(item.token.holders_count || "0", 10);
|
|
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
|
|
const isTracked = trackedSet.has(tokenAddr);
|
|
const hasEnoughHolders = holders >= 1000;
|
|
|
|
// Skip spam tokens the user never asked to see
|
|
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
|
|
|
|
// Skip tokens spoofing a known symbol from a different address
|
|
const sym = (item.token.symbol || "").toUpperCase();
|
|
const legitAddr = KNOWN_SYMBOLS.get(sym);
|
|
if (
|
|
legitAddr !== undefined &&
|
|
legitAddr !== null &&
|
|
tokenAddr !== legitAddr
|
|
)
|
|
continue;
|
|
|
|
balances.push({
|
|
address: item.token.address_hash,
|
|
symbol: item.token.symbol || "???",
|
|
decimals: decimals,
|
|
balance: bal,
|
|
holders: holders,
|
|
});
|
|
}
|
|
return balances;
|
|
} catch (e) {
|
|
log.errorf("fetchTokenBalances failed:", e.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
|
|
async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
|
|
log.debugf("refreshBalances start, rpc:", rpcUrl);
|
|
const provider = getProvider(rpcUrl);
|
|
const updates = [];
|
|
|
|
for (const wallet of wallets) {
|
|
for (const addr of wallet.addresses) {
|
|
// ETH balance
|
|
updates.push(
|
|
provider
|
|
.getBalance(addr.address)
|
|
.then((bal) => {
|
|
addr.balance = formatBalance(bal);
|
|
log.debugf("ETH balance", addr.address, addr.balance);
|
|
})
|
|
.catch((e) => {
|
|
log.errorf(
|
|
"ETH balance failed",
|
|
addr.address,
|
|
e.shortMessage || e.message,
|
|
);
|
|
}),
|
|
);
|
|
|
|
// ENS reverse lookup
|
|
updates.push(
|
|
provider
|
|
.lookupAddress(addr.address)
|
|
.then((name) => {
|
|
addr.ensName = name || null;
|
|
})
|
|
.catch(() => {
|
|
addr.ensName = null;
|
|
}),
|
|
);
|
|
|
|
// ERC-20 token balances via Blockscout
|
|
updates.push(
|
|
fetchTokenBalances(
|
|
addr.address,
|
|
blockscoutUrl,
|
|
trackedTokens,
|
|
).then((balances) => {
|
|
if (balances !== null) {
|
|
addr.tokenBalances = balances;
|
|
log.debugf(
|
|
"Token balances",
|
|
addr.address,
|
|
balances.length,
|
|
"tokens",
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
await Promise.all(updates);
|
|
log.debugf("refreshBalances done");
|
|
}
|
|
|
|
// Look up token metadata from its contract.
|
|
// Calls symbol() and decimals() to verify it implements ERC-20.
|
|
async function lookupTokenInfo(contractAddress, rpcUrl) {
|
|
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
|
|
const provider = getProvider(rpcUrl);
|
|
const contract = new Contract(contractAddress, ERC20_ABI, provider);
|
|
|
|
let name, symbol, decimals;
|
|
try {
|
|
symbol = await contract.symbol();
|
|
log.debugf("symbol() =", symbol);
|
|
} catch (e) {
|
|
log.errorf("symbol() failed:", e.shortMessage || e.message);
|
|
throw new Error("Not a valid ERC-20 token (symbol() failed).");
|
|
}
|
|
|
|
try {
|
|
decimals = await contract.decimals();
|
|
log.debugf("decimals() =", decimals);
|
|
} catch (e) {
|
|
log.errorf("decimals() failed:", e.shortMessage || e.message);
|
|
throw new Error("Not a valid ERC-20 token (decimals() failed).");
|
|
}
|
|
|
|
try {
|
|
name = await contract.name();
|
|
log.debugf("name() =", name);
|
|
} catch (e) {
|
|
log.warnf("name() failed, using symbol as name:", e.message);
|
|
name = symbol;
|
|
}
|
|
|
|
// Truncate to prevent storage of excessively long values from RPC
|
|
name = String(name).slice(0, 64);
|
|
symbol = String(symbol).slice(0, 12);
|
|
|
|
log.infof("Token resolved:", symbol, "decimals", Number(decimals));
|
|
return { name, symbol, decimals: Number(decimals) };
|
|
}
|
|
|
|
// Derive HD addresses starting from index 0 and check for on-chain activity.
|
|
// Checks gapLimit addresses in parallel per batch. Stops when an entire
|
|
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
|
|
// Returns { addresses: [{ address, index }], nextIndex }.
|
|
async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
|
|
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
|
|
const provider = getProvider(rpcUrl);
|
|
const used = [];
|
|
let checked = 0;
|
|
let checkUpTo = gapLimit;
|
|
|
|
while (checked < checkUpTo) {
|
|
const batch = [];
|
|
for (let i = checked; i < checkUpTo; i++) {
|
|
const addr = deriveAddressFromXpub(xpub, i);
|
|
batch.push({ addr, index: i });
|
|
}
|
|
|
|
const results = await Promise.all(
|
|
batch.map(async ({ addr, index }) => {
|
|
try {
|
|
const [balance, txCount] = await Promise.all([
|
|
provider.getBalance(addr),
|
|
provider.getTransactionCount(addr),
|
|
]);
|
|
return { addr, index, isUsed: balance > 0n || txCount > 0 };
|
|
} catch (e) {
|
|
log.errorf(
|
|
"scanForAddresses check failed",
|
|
addr,
|
|
e.shortMessage || e.message,
|
|
);
|
|
return { addr, index, isUsed: false };
|
|
}
|
|
}),
|
|
);
|
|
|
|
checked = checkUpTo;
|
|
|
|
for (const r of results) {
|
|
if (r.isUsed) {
|
|
used.push({ address: r.addr, index: r.index });
|
|
log.debugf("scanForAddresses used", r.addr, "index:", r.index);
|
|
checkUpTo = Math.max(checkUpTo, r.index + 1 + gapLimit);
|
|
}
|
|
}
|
|
}
|
|
|
|
used.sort((a, b) => a.index - b.index);
|
|
const nextIndex = used.length > 0 ? used[used.length - 1].index + 1 : 1;
|
|
log.infof(
|
|
"scanForAddresses done, found:",
|
|
used.length,
|
|
"nextIndex:",
|
|
nextIndex,
|
|
);
|
|
return { addresses: used, nextIndex };
|
|
}
|
|
|
|
module.exports = {
|
|
refreshBalances,
|
|
lookupTokenInfo,
|
|
getProvider,
|
|
scanForAddresses,
|
|
};
|