fix: read USD prices from Chainlink price feeds through the RPC endpoint (closes #525)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

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:
2026-10-27 04:23:17 +01:00
parent 53551c6382
commit b4e166cdc7
15 changed files with 797 additions and 123 deletions
+97 -21
View File
@@ -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;
}
+351 -31
View File
@@ -4,12 +4,12 @@
// Addresses are EIP-55 checksummed.
//
// 511 tokens.
//
// Below the list, USD_PRICE_FEEDS gives the Chainlink USD price feed of each
// token that has one.
const { debugFetch } = require("./log");
const { NETWORKS } = require("./networks");
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
const TOKENS = [
{
address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
@@ -3597,6 +3597,351 @@ const TOKENS = [
},
];
// Chainlink price feeds on Ethereum mainnet. Each feed is a contract whose
// latestRoundData() returns a USD price and the time the price was written.
// The fields of a feed:
//
// - address: the feed's contract
// - heartbeat: the most seconds the feed goes without a new price
// - decimals: the number of decimal places in the price
//
// Each feed's address and heartbeat come from Chainlink's published list of
// mainnet feeds. Each feed was checked on mainnet by reading its description()
// and decimals().
// The ETH/USD feed, which prices ether and WETH.
const ETH_USD_FEED = {
address: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419",
heartbeat: 3600,
decimals: 8,
};
// The USD feed of each token in TOKENS that has one, keyed by the token's
// contract address as TOKENS spells it. A token is paired with its feed by its
// address alone, so a token that copies a listed token's symbol gets no feed.
const USD_PRICE_FEEDS = {
// WETH
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2": ETH_USD_FEED,
// USDT
"0xdAC17F958D2ee523a2206206994597C13D831ec7": {
address: "0x3E7d1eAB13ad0104d2750B8863b489D65364e32D",
heartbeat: 86400,
decimals: 8,
},
// BNB
"0xB8c77482e45F1F44dE1745F52C74426C631bDD52": {
address: "0x14e613AC84a31f709eadbdF89C6CC390fDc9540A",
heartbeat: 86400,
decimals: 8,
},
// USDC
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": {
address: "0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6",
heartbeat: 82800,
decimals: 8,
},
// USDS
"0xdC035D45d973E3EC169d2276DDab16f1e407384F": {
address: "0xfF30586cD0F29eD462364C7e81375FC0C71219b1",
heartbeat: 82800,
decimals: 8,
},
// LINK
"0x514910771AF9Ca656af840dff83E8264EcF986CA": {
address: "0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c",
heartbeat: 3600,
decimals: 8,
},
// USDE
"0x4c9EDD5852cd905f086C759E8383e09bff1E68B3": {
address: "0xa569d910839Ae8865Da8F8e70FfFb0cBA869F961",
heartbeat: 82800,
decimals: 8,
},
// USD1
"0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d": {
address: "0xF0d9bb015Cd7BfAb877B7156146dc09Bf461370d",
heartbeat: 86400,
decimals: 8,
},
// DAI
"0x6B175474E89094C44Da98b954EedeAC495271d0F": {
address: "0xAed0c38402a5d19df6E4c03F4E2DceD6e29c1ee9",
heartbeat: 3600,
decimals: 8,
},
// PYUSD
"0x6c3ea9036406852006290770BEdFcAbA0e23A0e8": {
address: "0x8f1dF6D7F2db73eECE86a18b4381F4707b918FB1",
heartbeat: 86400,
decimals: 8,
},
// WLFI
"0xdA5e1988097297dCdc1f90D4dFE7909e847CBeF6": {
address: "0x14E5FC91Ddb3f97C33013Cc9fA74F54062Ad1Aa1",
heartbeat: 86400,
decimals: 8,
},
// UNI
"0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984": {
address: "0x553303d460EE0afB37EdFf9bE42922D8FF63220e",
heartbeat: 3600,
decimals: 8,
},
// PAXG
"0x45804880De22913dAFE09f4980848ECE6EcbAf78": {
address: "0x9944D86CEB9160aF5C5feB251FD671923323f8C3",
heartbeat: 86400,
decimals: 8,
},
// AAVE
"0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9": {
address: "0x547a514d5e3769680Ce22B2361c10Ea13619e8a9",
heartbeat: 3600,
decimals: 8,
},
// USDF
"0xFa2B947eEc368f42195f24F36d2aF29f7c24CeC2": {
address: "0xb177857a1799aA5F7fEb5799Fdf12CbE8fdF78B1",
heartbeat: 86400,
decimals: 8,
},
// USDG
"0xe343167631d89B6Ffc58B88d6b7fB0228795491D": {
address: "0x14f0737d6b705259e521EA6E9E3506AC78dBd311",
heartbeat: 86400,
decimals: 8,
},
// SKY
"0x56072C95FAA701256059aa122697B133aDEd9279": {
address: "0xee10fE5E7aa92dd7b136597449c3d5813cFC5F18",
heartbeat: 86400,
decimals: 8,
},
// RLUSD
"0x8292Bb45bf1Ee4d140127049757C2E0fF06317eD": {
address: "0x26C46B7aD0012cA71F2298ada567dC9Af14E7f2A",
heartbeat: 86400,
decimals: 8,
},
// USDTB
"0xC139190F447e929f090Edeb554D95AbB8b18aC1C": {
address: "0x66704DAD467A7cA508B3be15865D9B9F3E186c90",
heartbeat: 86400,
decimals: 8,
},
// ARB
"0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1": {
address: "0x31697852a68433DbCc2Ff612c516d69E3D9bd08F",
heartbeat: 86400,
decimals: 8,
},
// USD0
"0x73A15FeD60Bf67631dC6cd7Bc5B6e8da8190aCF5": {
address: "0x7e891DEbD8FA0A4Cf6BE58Ddff5a8ca174FebDCB",
heartbeat: 86400,
decimals: 8,
},
// GHO
"0x40D16FC0246aD3160Ccc09B8D0D3A2cD28aE6C2f": {
address: "0x3f12643D3f6f874d39C2a4c9f2Cd6f2DbAC877FC",
heartbeat: 86400,
decimals: 8,
},
// TUSD
"0x0000000000085d4780B73119b644AE5ecd22b376": {
address: "0xec746eCF986E2927Abd291a2A1716c940100f8Ba",
heartbeat: 86400,
decimals: 8,
},
// EURC
"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c": {
address: "0x04F84020Fdf10d9ee64D1dcC2986EDF2F556DA11",
heartbeat: 86400,
decimals: 8,
},
// FDUSD
"0xc5f0f7b66764F6ec8C8Dff7BA683102295E16409": {
address: "0xfAA9147190c2C2cc5B8387B4f49016bDB3380572",
heartbeat: 86400,
decimals: 8,
},
// CRV
"0xD533a949740bb3306d119CC777fa900bA034cd52": {
address: "0xCd627aA160A6fA45Eb793D19Ef54f5062F20f33f",
heartbeat: 86400,
decimals: 8,
},
// GRT
"0xc944E90C64B2c07662A292be6244BDf05Cda44a7": {
address: "0x86cF33a451dE9dc61a2862FD94FF4ad4Bd65A5d2",
heartbeat: 86400,
decimals: 8,
},
// CRVUSD
"0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E": {
address: "0xEEf0C605546958c1f899b6fB336C20671f9cD49F",
heartbeat: 86400,
decimals: 8,
},
// FRAX, the Legacy Frax Dollar, priced by Chainlink's FRAX / USD feed
"0x853d955aCEf822Db058eb8505911ED77F175b99e": {
address: "0xB9E1E3A9feFf48998E45Fa90847ed4D467E8BcfD",
heartbeat: 3600,
decimals: 8,
},
// ENS
"0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72": {
address: "0x5C00128d4d1c2F4f652C267d7bcdD7aC99C16E16",
heartbeat: 86400,
decimals: 8,
},
// SAND
"0x3845badAde8e6dFF049820680d1F14bD3903a5d0": {
address: "0x35E3f7E558C04cE7eEE1629258EcbbA03B36Ec56",
heartbeat: 86400,
decimals: 8,
},
// AUSD
"0x00000000eFE302BEAA2b3e6e1b18d08D69a9012a": {
address: "0xB00341502DfEA6Ced8A5786b4059d29dA5E4D1FD",
heartbeat: 86400,
decimals: 18,
},
// COMP
"0xc00e94Cb662C3520282E6f5717214004A7f26888": {
address: "0xdbd020CAeF83eFd542f4De03e3cF0C28A4428bd5",
heartbeat: 3600,
decimals: 8,
},
// XCN
"0xA2cd3D43c775978A96BdBf12d733D5A1ED94fb18": {
address: "0xeb988B77b94C186053282BfcD8B7ED55142D3cAB",
heartbeat: 86400,
decimals: 8,
},
// CVX
"0x4e3FBD56CD56c3e72c1403e103b45Db9da5B9D2B": {
address: "0xd962fC30A72A84cE50161031391756Bf2876Af5D",
heartbeat: 86400,
decimals: 8,
},
// IMX
"0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF": {
address: "0xBAEbEFc1D023c0feCcc047Bff42E75F15Ff213E6",
heartbeat: 86400,
decimals: 8,
},
// 1INCH
"0x111111111117dC0aa78b770fA6A738034120C302": {
address: "0xc929ad75B72593967DE83E7F7Cda0493458261D9",
heartbeat: 86400,
decimals: 8,
},
// SNX
"0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F": {
address: "0xDC3EA94CD0AC27d9A86C180091e7f78C683d3699",
heartbeat: 86400,
decimals: 8,
},
// DOLA
"0x865377367054516e17014CcdED1e7d814EDC9ce4": {
address: "0xE33592594f72Cc7ec8a05788be8E8455746C3a32",
heartbeat: 86400,
decimals: 8,
},
// FRXUSD
"0xCAcd6fd266aF91b8AeD52aCCc382b4e165586E29": {
address: "0x9B4a96210bc8D9D55b1908B465D8B0de68B7fF83",
heartbeat: 86400,
decimals: 8,
},
// YFI
"0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e": {
address: "0xA027702dbb89fbd58938e4324ac03B58d812b0E1",
heartbeat: 86400,
decimals: 8,
},
// RSR
"0x320623b8E4fF03373931769A31Fc52A4E78B5d70": {
address: "0x759bBC1be8F90eE6457C44abc7d443842a976d02",
heartbeat: 86400,
decimals: 8,
},
// ZRX
"0xE41d2489571d322189246DaFA5ebDe1F4699F498": {
address: "0x2885d15b8Af22648b98B122b22FDF4D2a56c6023",
heartbeat: 86400,
decimals: 8,
},
// FRAX, formerly FXS, priced by Chainlink's FXS / USD feed
"0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0": {
address: "0x6Ebc52C8C1089be9eB3945C4350B68B8E4C2233f",
heartbeat: 86400,
decimals: 8,
},
// SXT
"0xE6Bfd33F52d82Ccb5b37E16D3dD81f9FFDAbB195": {
address: "0x2D27d9e1b74936D8E83c4BA118F09A4c4a897f62",
heartbeat: 86400,
decimals: 8,
},
// SUSHI
"0x6B3595068778DD592e39A122f4f5a5cF09C90fE2": {
address: "0xCc70F09A6CC17553b2E31954cD36E4A2d89501f7",
heartbeat: 86400,
decimals: 8,
},
// RPL
"0xD33526068D116cE69F19A9ee46F0bd304F21A51f": {
address: "0x4E155eD98aFE9034b7A5962f6C84c86d869daA9d",
heartbeat: 86400,
decimals: 8,
},
// USDP
"0x8E870D67F660D95d5be530380D0eC0bd388289E1": {
address: "0x09023c0DA49Aaf8fc3fA3ADF34C6A7016D38D5e3",
heartbeat: 86400,
decimals: 8,
},
// SPELL
"0x090185f2135308BaD17527004364eBcC2D37e5F6": {
address: "0x8c110B94C5f1d347fAcF5E1E938AB2db60E3c9a8",
heartbeat: 86400,
decimals: 8,
},
// LUSD
"0x5f98805A4E8be255a32880FDeC7F6728C6568bA0": {
address: "0x3D7aE7E594f2f2091Ad8798313450130d0Aba3a0",
heartbeat: 86400,
decimals: 8,
},
// STG
"0xAf5191B0De278C7286d6C7CC6ab6BB8A73bA2Cd6": {
address: "0x7A9f34a0Aa917D438e9b6E630067062B7F8f6f3d",
heartbeat: 86400,
decimals: 8,
},
// KNC
"0xdeFA4e8a7bcBA345F687a2f1456F5Edd9CE97202": {
address: "0xf8fF43E991A81e6eC886a3D281A2C6cC19aE70Fc",
heartbeat: 86400,
decimals: 8,
},
// MUSD (the other MUSD in TOKENS has no feed)
"0xacA92E438df0B2401fF60dA7E4337B687a2435DA": {
address: "0xc90E3460424fb8ea79775089E9053113FEE34Ed0",
heartbeat: 86400,
decimals: 8,
},
};
// Build a map of token address (lowercased) -> its USD price feed.
const PRICE_FEED_BY_ADDRESS = new Map();
for (const [address, feed] of Object.entries(USD_PRICE_FEEDS)) {
PRICE_FEED_BY_ADDRESS.set(address.toLowerCase(), feed);
}
// Return the top N tokens from the list.
function getTopTokens(n) {
return TOKENS.slice(0, n);
@@ -3643,33 +3988,6 @@ for (const t of TOKENS) {
}
}
// Fetch USD prices for the top N tokens from CoinDesk.
// Also always includes ETH. Returns { ETH: 1234.56, LINK: 8.60, ... }.
// n must be <= 30 (API and sanity limit).
async function getTopTokenPrices(n) {
if (n > 30) {
throw new Error("getTopTokenPrices: n must be <= 30, got " + n);
}
const symbols = ["ETH", ...TOKENS.slice(0, n).map((t) => t.symbol)];
const unique = [...new Set(symbols)];
const instruments = unique.map((s) => s + "-USD").join(",");
const url =
COINDESK_API +
"?market=cadli&instruments=" +
instruments +
"&apply_mapping=true";
const resp = await debugFetch(url);
const json = await resp.json();
const prices = {};
for (const [instrument, data] of Object.entries(json.Data || {})) {
const symbol = instrument.split("-")[0];
if (data.VALUE !== undefined) {
prices[symbol] = data.VALUE;
}
}
return prices;
}
// Resolve a token symbol from multiple sources, never returning "?".
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
const lower = (tokenAddress || "").toLowerCase();
@@ -3690,7 +4008,9 @@ module.exports = {
TOKENS,
TOKEN_BY_ADDRESS,
KNOWN_SYMBOLS,
ETH_USD_FEED,
USD_PRICE_FEEDS,
PRICE_FEED_BY_ADDRESS,
getTopTokens,
getTopTokenPrices,
resolveSymbol,
};