CoinDesk now refuses requests without an API key, so mainnet showed no USD prices. Mainnet prices now come from Chainlink's USD price feeds, read with eth_call through the user's RPC endpoint; no other service is asked. The bundled list pairs each of 53 tokens with its feed by contract address, and ETH and WETH use ETH/USD. The popup reads the feeds of ETH and the tokens the user holds or tracks in one batched request, at most every 5 minutes. A reading older than its heartbeat plus 30 minutes, or not positive, is no price. A token with no feed shows no USD value. CoinDesk is gone. RULES.md still names the CoinDesk price API. Model: opus-5-5
212 lines
8.3 KiB
JavaScript
212 lines
8.3 KiB
JavaScript
// 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,
|
|
};
|