fetchTokenBalances() did parseInt(item.token.decimals || "18", 10) before writing to state.wallets[].addresses[].tokenBalances[].decimals, so a token whose decimals() reverts -- one the block explorer reports no scale for -- was stored with a fabricated 18 that no reader could tell from a real one. That is upstream of a rule already merged. #306 made the ERC-20 approval amount line resolve the real scale or refuse to format, and #340 extended it to the swap lines; both read this stored value as an authoritative source, so the guess walked straight past refusals that were intact and simply never fired. A 1,000-unit approval of such a token rendered 0.000000001 on the one screen whose job is to state what is being authorized. The stored value is now the explorer's own answer or null, never a default. Both approval paths reach unknownDecimalsAmount() on a null, using the refusal that was already there. The history list's token transfers carried the same || "18" and now state exact base units with the scale unknown rather than a quantity at a guessed one. A holding whose scale nothing knows has no quantity either, so its balance is stored as null -- unknown, never zero -- and the balance list, the address USD total, the Send screen and the confirmation screen each say so rather than printing 0.0000 for money that is really there. The zero-balance filter moved onto the base-unit integer, where it needs no scale at all. The bundled token list and the user's tracked tokens already outrank the explorer, so a token either of them knows still displays its real quantity when the explorer's entry omits decimals; only what none of the three knows is unknown. Which makes the stored field the explorer's answer alone, and NOT the scale a screen renders at. Those are two questions, and every screen that needs the second one asks resolveTokenDecimals(). The Send screen did not: it read tokenBalances[].decimals raw and carried it onto the pending transaction, so a bundled or tracked token whose explorer row omits decimals reached displayedDecimals(null) inside estimateGas(). That throws, is caught as an unavailable fee, and disables Send behind "The network fee could not be estimated ... Please go back and try again" -- untrue, unactionable, and for a token such as WETH whose scale was never in doubt. The balance and the amount on the same screen were correct throughout, and validateTransfer() had nothing to object to, so nothing named the real reason. Before this change the fabricated 18 happened to be that token's real scale and the send completed, so this is a capability regression and not an inherited one. Send now resolves the scale exactly as the balance list resolved it, with no fallback: null still goes forward when resolution genuinely answers null, and the confirmation screen's "this token's balance is unknown" is then the message the user gets. The uint8 check is one shared toDecimals() rather than three copies of it, and it answers 0 for a real scale of zero: || "18" collapsed that to eighteen, the falsy-collapse trap of #246. The reader half is asserted, not just the writer half. Each of the six sites that now distinguishes an unknown quantity from a zero one -- balanceLine(), balanceLinesForAddress(), addressHoldsFunds(), getAddressValue()'s partial flag, the Send balance line and the confirmation screen's balance and insufficient-balance wording -- is tested on the PAIR, because an assertion about null alone still passes on a build that renders both as zero. The Send and confirmation cases run the real explorer response through the real fetcher, the real review handler and the real confirmation screen, so they show which of the two scale questions each screen is asking. Existing installs hold 18s that cannot be told apart retroactively -- that is the defect, and no migration can undo it. They display exactly as they do today until the next balance refresh, which rewrites tokenBalances wholesale and needs no user action. The schema version is not bumped: version 1 records stay valid and are read exactly as before. The only 18s left in src/ are native ETH's real scale in uniswap.js and the fixed-point comparison scale in txValidation.js.
151 lines
5.3 KiB
JavaScript
151 lines
5.3 KiB
JavaScript
// Price fetching with 5-minute cache, USD formatting, value aggregation.
|
|
|
|
const { getTopTokenPrices } = require("./tokenList");
|
|
|
|
const PRICE_CACHE_TTL = 300000; // 5 minutes
|
|
|
|
const prices = {};
|
|
let lastFetchedAt = 0;
|
|
|
|
async function refreshPrices() {
|
|
// Testnet tokens have no real market value — skip price fetching
|
|
// and clear any stale mainnet prices so the UI shows no USD values.
|
|
const { currentNetwork } = require("./state");
|
|
if (currentNetwork().isTestnet) {
|
|
clearPrices();
|
|
return;
|
|
}
|
|
const now = Date.now();
|
|
if (now - lastFetchedAt < PRICE_CACHE_TTL) return;
|
|
try {
|
|
const fetched = await getTopTokenPrices(25);
|
|
Object.assign(prices, fetched);
|
|
lastFetchedAt = now;
|
|
} catch {
|
|
// prices stay stale on error
|
|
}
|
|
}
|
|
|
|
// Clear all cached prices and reset the fetch timestamp so the
|
|
// next refreshPrices() call will fetch fresh data.
|
|
function clearPrices() {
|
|
for (const key of Object.keys(prices)) {
|
|
delete prices[key];
|
|
}
|
|
lastFetchedAt = 0;
|
|
}
|
|
|
|
// Return the USD price for a symbol, or null on testnet / unknown.
|
|
function getPrice(symbol) {
|
|
const { currentNetwork } = require("./state");
|
|
if (currentNetwork().isTestnet) return null;
|
|
return prices[symbol] || null;
|
|
}
|
|
|
|
function formatUsd(amount) {
|
|
if (amount === null || amount === undefined || isNaN(amount)) return "";
|
|
if (amount === 0) return "$0.00";
|
|
if (amount < 0.01) return "< $0.01";
|
|
return (
|
|
"$" +
|
|
amount.toLocaleString("en-US", {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
})
|
|
);
|
|
}
|
|
|
|
// What an address is worth, as { usd, partial }.
|
|
//
|
|
// Prices are fetched for the top 25 tokens only, so an address can hold real
|
|
// assets this code has no price for. Adding up the priced ones and calling the
|
|
// result the total states a number the holdings do not support: an address
|
|
// holding nothing but unpriced tokens comes out at $0.00, which tells the user
|
|
// their address is worth nothing when it may hold a great deal. Worth zero and
|
|
// worth an unknown amount are separate facts and get separate fields, the same
|
|
// way an absent holders_count is not a count of zero.
|
|
//
|
|
// usd: the value of the holdings a price is known for, or null when
|
|
// nothing is knowable at all — testnet, or before the first fetch.
|
|
// partial: the address also holds a token with no price, so usd is a floor
|
|
// and not the total.
|
|
//
|
|
// Render it through formatAddressTotal() rather than reading usd alone.
|
|
function getAddressValue(addr) {
|
|
const { currentNetwork } = require("./state");
|
|
if (currentNetwork().isTestnet) return { usd: null, partial: false };
|
|
if (!prices.ETH) return { usd: null, partial: false };
|
|
let usd = parseFloat(addr.balance || "0") * prices.ETH;
|
|
let partial = false;
|
|
for (const token of addr.tokenBalances || []) {
|
|
// A null balance is a holding whose scale nothing knows, so it has no
|
|
// quantity to price — but it is still a holding, and a total that
|
|
// silently omits it would read as complete. That is exactly what
|
|
// `partial` is for (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
|
if (token.balance == null) {
|
|
partial = true;
|
|
continue;
|
|
}
|
|
const tokenBal = parseFloat(token.balance);
|
|
// A balance of zero is not a holding: it can neither add to the total
|
|
// nor make it incomplete. Anything that is not a number at all is not
|
|
// a holding this can price either, and is left to the same rule.
|
|
if (!(tokenBal > 0)) continue;
|
|
if (prices[token.symbol]) {
|
|
usd += tokenBal * prices[token.symbol];
|
|
} else {
|
|
partial = true;
|
|
}
|
|
}
|
|
return { usd, partial };
|
|
}
|
|
|
|
// The same pair for a whole wallet, and for every wallet at once. One
|
|
// unpriced holding anywhere makes the sum a floor, so partial carries up.
|
|
function getWalletValue(wallet) {
|
|
return sumValues(wallet.addresses.map(getAddressValue));
|
|
}
|
|
|
|
function getTotalValue(wallets) {
|
|
return sumValues(wallets.map(getWalletValue));
|
|
}
|
|
|
|
function sumValues(values) {
|
|
let usd = null;
|
|
let partial = false;
|
|
for (const value of values) {
|
|
if (value.usd === null) continue;
|
|
usd = (usd === null ? 0 : usd) + value.usd;
|
|
partial = partial || value.partial;
|
|
}
|
|
return { usd, partial };
|
|
}
|
|
|
|
// The one rendering of an address total, so no screen says it differently.
|
|
//
|
|
// A partial total is shown and named as partial: the figure is the ETH and
|
|
// priced tokens the user does hold, which is worth having, and suppressing it
|
|
// would throw away a number that is correct as far as it goes. What is never
|
|
// shown is a figure covering no holdings at all — the $0.00 sum of an empty
|
|
// set beside a list of tokens is the bug this replaces.
|
|
function formatAddressTotal(value) {
|
|
if (!value || value.usd === null) return "";
|
|
if (!value.partial) return "Total: " + formatUsd(value.usd);
|
|
if (value.usd > 0) {
|
|
return "Total: " + formatUsd(value.usd) + " plus unpriced tokens";
|
|
}
|
|
return "Total: unpriced tokens only";
|
|
}
|
|
|
|
module.exports = {
|
|
prices,
|
|
refreshPrices,
|
|
clearPrices,
|
|
getPrice,
|
|
formatUsd,
|
|
formatAddressTotal,
|
|
getAddressValue,
|
|
getWalletValue,
|
|
getTotalValue,
|
|
};
|