fix: read USD prices from Chainlink price feeds through the RPC endpoint (closes #525)
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
This commit was merged in pull request #681.
This commit is contained in:
+97
-21
@@ -1,31 +1,105 @@
|
||||
// Price fetching with 5-minute cache, USD formatting, value aggregation.
|
||||
// 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 { getTopTokenPrices } = require("./tokenList");
|
||||
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;
|
||||
|
||||
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) {
|
||||
// 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;
|
||||
try {
|
||||
const fetched = await getTopTokenPrices(25);
|
||||
Object.assign(prices, fetched);
|
||||
lastFetchedAt = now;
|
||||
} catch {
|
||||
// prices stay stale on error
|
||||
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
|
||||
@@ -37,14 +111,15 @@ function clearPrices() {
|
||||
lastFetchedAt = 0;
|
||||
}
|
||||
|
||||
// Return the USD price for a symbol on the network with `chainId`, or on the
|
||||
// active network when no chain id is given; null on testnet, on a chain id
|
||||
// that names no network, or for a symbol with no price.
|
||||
function getPrice(symbol, chainId) {
|
||||
// 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[symbol] || null;
|
||||
return prices[token === "ETH" ? "ETH" : token.toLowerCase()] || null;
|
||||
}
|
||||
|
||||
function formatUsd(amount) {
|
||||
@@ -62,7 +137,7 @@ function formatUsd(amount) {
|
||||
|
||||
// What an address is worth, as { usd, partial }.
|
||||
//
|
||||
// Prices are fetched for the top 25 tokens only, so an address can hold real
|
||||
// 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
|
||||
@@ -99,8 +174,9 @@ function getAddressValue(addr) {
|
||||
// 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];
|
||||
const price = prices[token.address.toLowerCase()];
|
||||
if (price) {
|
||||
usd += tokenBal * price;
|
||||
} else {
|
||||
partial = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user