// USD prices, read from Chainlink price feeds on Ethereum mainnet, with USD // formatting and value aggregation. The feeds are in src/shared/tokenList.js. const { Contract } = require("ethers"); const { ETH_USD_FEED, PRICE_FEED_BY_ADDRESS } = require("./tokenList"); const { getProvider } = require("./balances"); const { isBelowOneMillionth } = require("./amountDisplay"); const { networkByChainId } = require("./networks"); const { log } = require("./log"); const PRICE_CACHE_TTL = 300000; // 5 minutes // A reading older than its feed's heartbeat plus this many seconds is no price. const STALE_MARGIN = 1800; // 30 minutes const FEED_ABI = [ "function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)", ]; // USD prices: ether's under "ETH", each token's under its lowercased contract // address. const prices = {}; let lastFetchedAt = 0; // Reads the price of ether and of each token any address holds or the user // tracks, when that token has a feed. Mainnet only. The reads go to the RPC // endpoint as one batched request, at most every 5 minutes. A failed read is // logged, and that token then has no price. // // `signal`, passed by the popup, is aborted when the popup closes. A read that // fails once `signal` is aborted is not logged, because closing the popup // cancelled it. async function refreshPrices(signal) { const { state, currentNetwork } = require("./state"); if (currentNetwork().id !== "mainnet") { clearPrices(); return; } const now = Date.now(); if (now - lastFetchedAt < PRICE_CACHE_TTL) return; const feeds = new Map([["ETH", ETH_USD_FEED]]); for (const address of heldOrTrackedTokens(state)) { const feed = PRICE_FEED_BY_ADDRESS.get(address); if (feed) feeds.set(address, feed); } // ethers sends calls made together on one provider as one batch. const provider = getProvider(state.rpcUrl, state.networkId); const read = {}; await Promise.all( [...feeds].map(async ([key, feed]) => { try { const contract = new Contract(feed.address, FEED_ABI, provider); const round = await contract.latestRoundData(); const price = priceFromRound(round, feed, now); if (price === null) { log.warnf( "price feed reading is stale or not positive", key, ); return; } read[key] = price; } catch (e) { if (signal?.aborted) return; log.errorf( "price feed read failed", key, e.shortMessage || e.message, ); } }), ); clearPrices(); Object.assign(prices, read); lastFetchedAt = now; } // The lowercased contract address of each token that any address holds or // that the user tracks. function heldOrTrackedTokens(state) { const addresses = new Set(); for (const wallet of state.wallets) { for (const addr of wallet.addresses) { for (const token of addr.tokenBalances || []) { addresses.add(token.address.toLowerCase()); } } } for (const token of state.trackedTokens || []) { addresses.add(token.address.toLowerCase()); } return addresses; } // The USD price in a feed's latestRoundData() result, or null when the answer // is not positive or was written longer ago than the feed's heartbeat plus // STALE_MARGIN. `now` is in milliseconds. function priceFromRound(round, feed, now) { const ageSeconds = now / 1000 - Number(round.updatedAt); if (ageSeconds > feed.heartbeat + STALE_MARGIN) return null; if (round.answer <= 0n) return null; return Number(round.answer) / 10 ** feed.decimals; } // 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 of the token at the contract address `token`, or of // ether when `token` is "ETH". The price is for the network with `chainId`, or // for the active network when no chain id is given. Returns null on testnet, // on a chain id that names no network, and for a token with no price. function getPrice(token, chainId) { const { currentNetwork } = require("./state"); const network = chainId ? networkByChainId(chainId) : currentNetwork(); if (!network || network.isTestnet) return null; return prices[token === "ETH" ? "ETH" : token.toLowerCase()] || 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 }. // // Only a token with a price feed has a price, 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 holding below 0.000001 is left out, as the balance lists leave it // out, so the total never counts a holding the list does not show. if (isBelowOneMillionth(token.balance)) continue; // 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; const price = prices[token.address.toLowerCase()]; if (price) { usd += tokenBal * price; } else { partial = true; } } 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, };