The stored ETH and token balances and the send-confirm screen's fee were each cut to six decimal places, and a token holding cut to zero was dropped, so a value below 0.000001 read as zero. Balances are now stored exactly, whatever decimals a token declares, and every nonzero token holding is kept; the balance check reads a token balance to its first 18 places. The balance lists, the send-screen token selector, the address total and the remove-address warning leave out a holding below 0.000001 themselves, through isBelowOneMillionth(). The send and send-confirm screens' balances, reserve and insufficient-balance messages go through truncateAmountNeverZero(). The send-confirm and approval screens both render the fee through formatFee(), which prices the exact fee in USD. Model: opus-5-5
386 lines
16 KiB
JavaScript
386 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;
|
|
}
|
|
|
|
// A token balance as an exact decimal string, never cut: a cut stores a small
|
|
// nonzero holding as zero. fetchTokenBalances() stores every nonzero holding of
|
|
// a token it admits, however small; the screens that leave out one below
|
|
// 0.000001 decide that themselves, through isBelowOneMillionth() in
|
|
// src/shared/amountDisplay.js.
|
|
function formatTokenBalance(raw, decimals) {
|
|
const val = formatUnits(raw, decimals);
|
|
const parts = val.split(".");
|
|
if (parts.length === 1) return val + ".0";
|
|
const dec = parts[1].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.
|
|
const bal = scale === null ? null : formatTokenBalance(raw, scale);
|
|
// 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) => {
|
|
// Exact, never cut: a cut here stores a small nonzero
|
|
// balance as zero.
|
|
addr.balance = formatEther(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,
|
|
};
|