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.
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,
|
|
};
|