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.
391 lines
16 KiB
JavaScript
391 lines
16 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 { NETWORKS } = require("./networks");
|
|
const { log, debugFetch } = require("./log");
|
|
const { deriveAddressFromXpub } = require("./wallet");
|
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
|
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
|
const { isSpoofedSymbol } = require("./symbolSpoof");
|
|
const { toDecimals } = require("./transferAmount");
|
|
const { resolveTokenDecimals } = require("./approvalAmount");
|
|
|
|
// Use a static network to skip auto-detection (which can fail and cause
|
|
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
|
//
|
|
// `networkId` is REQUIRED, and is one of the ids in networks.js. It used to be
|
|
// optional, falling back to currentNetwork() — the module-level `state`
|
|
// singleton, which the MV3 service worker never populates. The endpoint then
|
|
// came out right and the static hint came out mainnet, so ethers fixed
|
|
// `chainId` at 0x1 and every non-mainnet dApp send was prepared for the wrong
|
|
// chain and then refused by the wallet's own verifier
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/320). Requiring it is what
|
|
// stops that from coming back: a caller that has no network to name has no
|
|
// business constructing a provider, and there is no longer a default for it
|
|
// to get silently wrong.
|
|
//
|
|
// Validated against NETWORKS rather than passed straight to Network.from():
|
|
// ethers knows chains this wallet does not, so an id that is not one of ours
|
|
// is a caller bug and must not resolve to a working provider for some other
|
|
// chain.
|
|
function getProvider(rpcUrl, networkId) {
|
|
const net = Network.from(requireNetworkId(networkId).id);
|
|
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
|
|
}
|
|
|
|
function requireNetworkId(networkId) {
|
|
const net = NETWORKS[networkId];
|
|
if (!net) {
|
|
throw new Error(
|
|
"getProvider requires the id of a supported network; got " +
|
|
JSON.stringify(networkId),
|
|
);
|
|
}
|
|
return net;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// The explorer's reported holding as an exact base-unit integer, or null when
|
|
// it reported nothing usable. Base units carry no scale, so this value is
|
|
// meaningful before the scale is known — which is what lets a holding of zero
|
|
// be recognised as zero without guessing a scale to divide it by.
|
|
function rawUnits(value) {
|
|
if (typeof value === "bigint") return value >= 0n ? value : null;
|
|
if (typeof value === "number") {
|
|
return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
|
|
}
|
|
if (typeof value !== "string" || !/^[0-9]+$/.test(value)) return null;
|
|
return BigInt(value);
|
|
}
|
|
|
|
// Fetch token balances for a single address from Blockscout.
|
|
// Returns [{ address, name, symbol, decimals, balance, holders }].
|
|
// Filters out spam: only shows tokens that are in the known token list,
|
|
// explicitly tracked by the user, or have >= 1000 holders.
|
|
//
|
|
// `decimals` and `balance` are each null when the answer is unknown, the same
|
|
// way `holders` already is. Absence is never filled in here: this is the
|
|
// upstream of every screen that displays a token amount, so a value invented
|
|
// at this point is indistinguishable from a real one everywhere below it.
|
|
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) {
|
|
// Case-insensitive: the token type is an explorer's label, not a
|
|
// protocol value, and an exact comparison silently drops a real
|
|
// holding if one ever writes "erc-20". Which types are admitted
|
|
// is unchanged.
|
|
const type = String(item.token?.type || "").toUpperCase();
|
|
if (type !== "ERC-20") continue;
|
|
|
|
const tokenAddr = (item.token.address_hash || "").toLowerCase();
|
|
|
|
// What the explorer reported, or null. NEVER a default: this
|
|
// value is written to state and every later reader — the approval
|
|
// screen's amount line, the swap lines, the Send screen — takes it
|
|
// as the token's resolved scale. A fabricated 18 reads exactly
|
|
// like a real 18 at that point, so it does not merely display the
|
|
// wrong quantity, it walks straight past the refusal those screens
|
|
// already have for a scale nobody knows
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
|
const decimals = toDecimals(item.token.decimals);
|
|
|
|
const raw = rawUnits(item.value);
|
|
// No usable amount at all is nothing to list, exactly as a
|
|
// formatted "0.0" was before. Checked on the base-unit integer so
|
|
// it does not depend on knowing the scale: zero base units is zero
|
|
// tokens at every scale, and a value the explorer did not report
|
|
// as an integer is not a holding.
|
|
if (raw === null || raw === 0n) continue;
|
|
|
|
// The scale this row's balance is DISPLAYED at, which is not the
|
|
// same question as what the explorer said. The bundled list and
|
|
// the tokens the user tracks both outrank the explorer already
|
|
// (resolveTokenDecimals), so a token they know keeps showing its
|
|
// real quantity even when the explorer's entry omits decimals.
|
|
// Only what neither of them nor the explorer knows is unknown.
|
|
// The stored `decimals` above stays the explorer's own answer
|
|
// either way: copying another source into it would make
|
|
// explorerDecimals()'s disagreement check compare something other
|
|
// than explorer values.
|
|
const known = resolveTokenDecimals(tokenAddr, { trackedTokens });
|
|
const scale = known !== null ? known : decimals;
|
|
// null is a holding of an amount that cannot be stated, which is
|
|
// not the same as a holding of zero, and must never render as one.
|
|
// With a scale, the display filter proper applies: a balance that
|
|
// rounds to zero at six places is dust and is not listed. Without
|
|
// one there is no such judgement to make, and the row is kept.
|
|
const bal = scale === null ? null : formatTokenBalance(raw, scale);
|
|
if (bal === "0.0") continue;
|
|
// null means the explorer reported no count, which is not the
|
|
// same as a count of zero. This gate is not the low-holder
|
|
// display filter: it has no user-facing off switch and governs
|
|
// the whole balance list, so it stays strict and admits a token
|
|
// only on a reported count — an unreported one is no evidence.
|
|
// A legitimate token still reaches the list through the known
|
|
// token list or by the user tracking it, and the null is carried
|
|
// through to the views, where the two low-holder filters treat
|
|
// an unknown count as "do not judge" rather than as zero.
|
|
const holders = parseHoldersCount(item.token.holders_count);
|
|
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
|
|
const isTracked = trackedSet.has(tokenAddr);
|
|
const hasEnoughHolders =
|
|
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
|
|
|
|
// Skip spam tokens the user never asked to see
|
|
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
|
|
|
|
// Skip tokens spoofing a known symbol from a different address.
|
|
// Every row here is an ERC-20 the explorer reported, so it has a
|
|
// contract address; the native ETH balance is fetched over RPC in
|
|
// refreshBalances and never passes through this loop.
|
|
if (isSpoofedSymbol(item.token.symbol, tokenAddr)) continue;
|
|
|
|
balances.push({
|
|
address: item.token.address_hash,
|
|
name: item.token.name || "",
|
|
symbol: item.token.symbol || "???",
|
|
// null means the explorer reported no usable scale — unknown,
|
|
// not 18. Distinguishable from a real 18 at read time is the
|
|
// entire point: resolveTokenDecimals() falls through a null to
|
|
// its refusal, and takes an 18 as the answer.
|
|
decimals: decimals,
|
|
// null means nothing anywhere knows the scale, so there is no
|
|
// token quantity to state. Not "0.0": a nonzero holding shown
|
|
// as zero is the same lie in the balance list that the
|
|
// approval screens refuse to tell.
|
|
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,
|
|
networkId,
|
|
) {
|
|
log.debugf("refreshBalances start, rpc:", rpcUrl);
|
|
const provider = getProvider(rpcUrl, networkId);
|
|
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 — only overwrite on success so that
|
|
// transient RPC errors don't wipe a previously resolved name.
|
|
updates.push(
|
|
provider
|
|
.lookupAddress(addr.address)
|
|
.then((name) => {
|
|
addr.ensName = name || null;
|
|
log.debugf(
|
|
"ENS reverse",
|
|
addr.address,
|
|
"->",
|
|
addr.ensName,
|
|
);
|
|
})
|
|
.catch((e) => {
|
|
log.errorf(
|
|
"ENS reverse failed",
|
|
addr.address,
|
|
e.message,
|
|
);
|
|
// Keep existing addr.ensName if we had one
|
|
}),
|
|
);
|
|
|
|
// 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, networkId) {
|
|
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
|
|
const provider = getProvider(rpcUrl, networkId);
|
|
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, networkId, gapLimit = 5) {
|
|
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
|
|
const provider = getProvider(rpcUrl, networkId);
|
|
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 = {
|
|
fetchTokenBalances,
|
|
refreshBalances,
|
|
lookupTokenInfo,
|
|
getProvider,
|
|
scanForAddresses,
|
|
};
|