diff --git a/README.md b/README.md index 85cb8d7..a068572 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,11 @@ and don't implement any crypto. By default the wallet sends user-specific data only to the Ethereum RPC endpoint. The user can change that endpoint in Settings. The endpoint defaults to a public node. -Out of the box the extension contacts two services: +Out of the box the extension contacts one service, the configured RPC node. The +wallet uses the node for: -- the configured RPC node, for blockchain interactions -- a public CoinDesk API (no API key), for realtime prices. The API receives - token symbols and nothing else. +- blockchain interactions +- USD prices on mainnet, read from Chainlink price feed contracts Two more services are sent none of the user's data until the user ticks a checkbox for each. Welcome and Settings both offer the two checkboxes: @@ -902,13 +902,14 @@ src/ constants.js — chain IDs, default RPC endpoint, ERC-20 ABI ens.js — ENS forward/reverse resolution (popup only) holders.js — holder-count parsing and the low-holder rule - prices.js — ETH/USD and token/USD via CoinDesk API + prices.js — ETH/USD and token/USD from Chainlink price feeds scamlist.js — known fraud contract addresses state.js — persisted state (extension storage) storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan", read from storage for the requests those settings gate symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces - tokenList.js — top ERC-20 tokens by market cap (hardcoded) + tokenList.js — top ERC-20 tokens by market cap, and their Chainlink + price feeds (hardcoded) transactions.js — tx history fetching + anti-poisoning filters uniswap.js — Uniswap Universal Router calldata decoder vault.js — password-based encryption via libsodium @@ -1371,10 +1372,10 @@ whole swap undecoded. #### Partial USD totals -Prices are fetched for the top 25 tokens only, so an address can hold assets the -extension has no price for. Worth zero and worth an unknown amount are different -facts and are never collapsed into one number. `getAddressValue()` in -`src/shared/prices.js` returns `{ usd, partial }` — the value of the priced +Only a token with a Chainlink price feed has a price, so an address can hold +assets the extension has no price for. Worth zero and worth an unknown amount +are different facts and are never collapsed into one number. `getAddressValue()` +in `src/shared/prices.js` returns `{ usd, partial }` — the value of the priced holdings, and whether an unpriced holding was left out of it — and every screen renders it through `formatAddressTotal()`, so the wording cannot drift: @@ -2854,9 +2855,9 @@ view would leave a wallet one click from deletion. ### External Services -AutistMask is not a fully self-contained offline tool. By default it sends the -user's data only to the Ethereum RPC endpoint. It also asks the CoinDesk price -API for prices, sending token symbols and nothing else. +AutistMask is not a fully self-contained offline tool. By default it contacts +only the Ethereum RPC endpoint. USD prices are read from Chainlink price feed +contracts through that endpoint. Two more services are sent none of the user's data until the user turns on a setting for each: @@ -2880,21 +2881,18 @@ Contacted by default: estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`), broadcast transactions (`eth_sendRawTransaction`), and check transaction receipts. Every ENS lookup is made through the RPC endpoint alone. So a name - does not resolve when its resolver says the name is resolved off-chain. The - default endpoint is a public RPC (configurable by the user to any endpoint - they prefer, including a local node). By default the extension talks to + does not resolve when its resolver says the name is resolved off-chain. On + mainnet the extension also reads USD prices from Chainlink price feed + contracts (`latestRoundData`). The prices of ETH and of each held or tracked + token with a feed are read as one batched request. That request is sent at + most every 5 minutes while the popup is open. The default endpoint is a public + RPC (configurable by the user to any endpoint they prefer, including a local + node). By default the extension talks to `https://ethereum-rpc.publicnode.com`. - **Data sent**: Ethereum addresses, transaction data, contract call parameters. The RPC endpoint can see all on-chain queries and submitted transactions. -- **CoinDesk CADLI price API**: Used to fetch ETH/USD and token/USD prices for - displaying fiat values. The price is cached for 5 minutes to avoid excessive - requests. No API key required. No user data is sent — only a list of token - symbols. Note that CoinDesk will receive your client IP. - - **Data sent**: Token symbol strings only (e.g. "ETH", "USDC"). No - addresses or user-specific data. - Sent the user's data only while its setting is on: - **Blockscout block-explorer API** ("Use Blockscout"): The default endpoint is @@ -3070,14 +3068,13 @@ hardcoded test phrase. At the raised log level the console also shows the wallet's addresses with their balances and ENS names, the token contracts looked up, and a line for each -request made through `debugFetch` in `src/shared/log.js` (the explorer, the -price feed, the RPC calls a site makes, and the endpoint checks in settings) and -for its response. A request is logged by its HTTP method, the origin of its URL -(scheme, host and port) and, for a JSON-RPC call, the method name; the balance -refresh, the token lookup and a failed endpoint check in settings name the -endpoint by its origin too. The URL's path and query string, where RPC providers -put API keys, any user name and password in it, and the request body are never -logged. +request made through `debugFetch` in `src/shared/log.js` (the explorer, the RPC +calls a site makes, and the endpoint checks in settings) and for its response. A +request is logged by its HTTP method, the origin of its URL (scheme, host and +port) and, for a JSON-RPC call, the method name; the balance refresh, the token +lookup and a failed endpoint check in settings name the endpoint by its origin +too. The URL's path and query string, where RPC providers put API keys, any user +name and password in it, and the request body are never logged. ### Key Decisions diff --git a/docs/README.md b/docs/README.md index 4dc0812..9d8465f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -70,9 +70,9 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that. ## How It Works AutistMask is a browser extension that runs entirely in your browser. It does -not have a backend server. Out of the box it sends your data only to the -Ethereum RPC endpoint. It also asks the CoinDesk price API for prices, sending -token symbols and nothing else. +not have a backend server. Out of the box it contacts only the Ethereum RPC +endpoint. USD prices are read from Chainlink price feed contracts through that +endpoint. Blockscout and Etherscan are sent none of your data until you turn on a setting for each. The settings are "Use Blockscout" and "Check recipients on Etherscan". @@ -89,10 +89,16 @@ verify confirmations. The default is a free public RPC endpoint. You can change this in Settings to any Ethereum JSON-RPC endpoint, including your own local node. +On mainnet, AutistMask also reads USD prices through this endpoint. The prices +come from Chainlink's price feed contracts. A token with no Chainlink feed shows +no USD value. + When it is contacted: - on every balance refresh (every 10 seconds while the popup is open, every 60 seconds in the background) +- on mainnet, to read USD prices, at most every 5 minutes while the popup is + open - when you review a send to an ENS name - when a send is prepared and broadcast - while a pending transaction is polled for its receipt @@ -131,19 +137,6 @@ sends none of your data. What gets sent: your Ethereum addresses (to look up balances and transactions) and the hash of each transaction you open. -**CoinDesk CADLI price API** (`data-api.coindesk.com`) - -Used to fetch current USD prices for ETH and the top 25 tokens. Prices are -cached for 5 minutes. No API key is required. This endpoint is not -user-configurable, and it is not contacted at all while you are on a testnet, -where no USD values are shown. - -When it is contacted: while the popup is open, at most once every 5 minutes. - -What gets sent: token symbol names (e.g. "ETH", "USDC"). No addresses, no -balances, no identifying information. As with any request, CoinDesk sees your IP -address. - **Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia) While "Check recipients on Etherscan" is on, AutistMask fetches the recipient's diff --git a/src/popup/index.js b/src/popup/index.js index cbcf699..f7cc77f 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -96,7 +96,7 @@ async function doRefreshAndRender() { const networkId = state.networkId; const wallets = structuredClone(state.wallets); await Promise.all([ - refreshPrices(), + refreshPrices(pageClosed.signal), refreshBalances( wallets, state.rpcUrl, diff --git a/src/popup/views/addressToken.js b/src/popup/views/addressToken.js index e873068..1af8e46 100644 --- a/src/popup/views/addressToken.js +++ b/src/popup/views/addressToken.js @@ -69,7 +69,7 @@ function show() { // null when the scale is unknown: no quantity to show, and none to // price. balanceLine() states that rather than printing 0.0000. amount = tb ? unknownableAmount(tb.balance) : 0; - price = getPrice(symbol); + price = getPrice(tokenId); } currentSymbol = symbol; diff --git a/src/popup/views/confirmTx.js b/src/popup/views/confirmTx.js index 77d8cd3..2c7c0a9 100644 --- a/src/popup/views/confirmTx.js +++ b/src/popup/views/confirmTx.js @@ -129,7 +129,7 @@ function renderAmount(txInfo) { const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : nativeCurrencyByChainId(txInfo.chainId); - const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId); + const price = getPrice(txInfo.token, txInfo.chainId); const amountUsd = price ? parseFloat(txInfo.amount) * price : null; $("confirm-amount").textContent = valueWithUsd( shownAmount(txInfo) + " " + displaySymbol(rawSymbol), @@ -157,7 +157,7 @@ function renderBalance(txInfo) { $("confirm-balance").textContent = "unknown (" + symbol + ")"; return; } - const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId); + const price = getPrice(txInfo.token, txInfo.chainId); const balUsd = price ? parseFloat(bal) * price : null; $("confirm-balance").textContent = valueWithUsd( truncateAmountNeverZero(bal) + " " + symbol, diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js index 7152a48..fc56238 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -439,7 +439,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) { html += balanceLine( t.symbol, bal, - getPrice(t.symbol), + getPrice(t.address), t.address.toLowerCase(), ); seen.add(t.address.toLowerCase()); @@ -450,7 +450,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) { html += balanceLine( t.symbol, 0, - getPrice(t.symbol), + getPrice(t.address), t.address.toLowerCase(), ); } diff --git a/src/shared/prices.js b/src/shared/prices.js index bd28473..240fb59 100644 --- a/src/shared/prices.js +++ b/src/shared/prices.js @@ -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; } diff --git a/src/shared/tokenList.js b/src/shared/tokenList.js index 107955e..33ccbca 100644 --- a/src/shared/tokenList.js +++ b/src/shared/tokenList.js @@ -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, }; diff --git a/tests/addressValue.test.js b/tests/addressValue.test.js index bd0a462..ea5fe0a 100644 --- a/tests/addressValue.test.js +++ b/tests/addressValue.test.js @@ -1,9 +1,9 @@ // The USD total of an address that holds something this build cannot price // (issue #261). // -// Prices exist for the top 25 tokens only, so an address can hold real assets -// with no price attached. Summing what is priced and printing the result as -// the total says "$0.00" for an address holding nothing but unpriced tokens — +// Only a token with a price feed has a price, so an address can hold real +// assets with no price attached. Summing what is priced and printing the result +// as the total says "$0.00" for an address holding nothing but unpriced tokens — // worth-nothing and worth-an-unknown-amount collapsed into one number, in the // direction that matters. The two are separate facts here, the same way an // absent holders_count is not a count of zero. @@ -63,7 +63,7 @@ const PARTLY_PRICED = { beforeEach(() => { clearPrices(); prices.ETH = 2000; - prices.USDC = 1; + prices[USDC] = 1; state.wallets = []; state.trackedTokens = []; state.showZeroBalanceTokens = false; diff --git a/tests/confirmTxNetworkSwitch.test.js b/tests/confirmTxNetworkSwitch.test.js index 116ccf2..4d19860 100644 --- a/tests/confirmTxNetworkSwitch.test.js +++ b/tests/confirmTxNetworkSwitch.test.js @@ -226,6 +226,7 @@ const PRIVATE_KEY = "0x" + "11".repeat(32); const HOLDER = new Wallet(PRIVATE_KEY).address; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; const TOKEN = "0x7070707070707070707070707070707070707070"; +const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; const PASSWORD = "correct horse battery staple"; function el(id) { @@ -318,7 +319,7 @@ async function switchReopenAndGoBack(reviewedOn, switchedTo) { setBackRenderer(makeBackRenderer(state, { confirmTx })); confirmTx.init({}); prices.ETH = 2000; - prices.USDC = 1; + prices[USDC] = 1; mockNode.asked = []; global.fetch.mockClear(); goBack(); @@ -641,7 +642,7 @@ describe.each([ test("of an ERC-20 token, links the token contract to that network's explorer and prices the token by that network", async () => { mockNode.tokenUnits[reviewedOn] = 50000000n; await review(reviewedOn, { - token: TOKEN, + token: USDC, tokenSymbol: "USDC", tokenDecimals: 6, amount: "10", @@ -652,7 +653,7 @@ describe.each([ "ERC-20 token transfer (USDC)", ); expect(el("confirm-token-contract").innerHTML).toContain( - `href="${explorerUrl}/address/${TOKEN}"`, + `href="${explorerUrl}/address/${USDC}"`, ); expect(el("confirm-amount").textContent).toBe( "10.0000 USDC" + usdOn(reviewedOn, "$10.00"), diff --git a/tests/deleteAddress.test.js b/tests/deleteAddress.test.js index f59455e..5d04911 100644 --- a/tests/deleteAddress.test.js +++ b/tests/deleteAddress.test.js @@ -153,7 +153,7 @@ describe("the balance warning on the removal confirmation", () => { test("the USD total is shown when prices are known", () => { prices.ETH = 2000; - prices.USDC = 1; + prices[USDC] = 1; expect(balanceWarningHtml(TOKEN_ONLY)).toContain("Total: $2,500.00"); expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00"); }); diff --git a/tests/e2e/dapp.js b/tests/e2e/dapp.js index 493f56a..0d3c197 100644 --- a/tests/e2e/dapp.js +++ b/tests/e2e/dapp.js @@ -43,12 +43,15 @@ const GAS_PRICE_WEI = BASE_FEE_WEI + PRIORITY_FEE_WEI; const STUB_BLOCK_NUMBER = 21000000; -// A 32-byte zero word, returned for every eth_call. It is what makes ethers' +// A 32-byte zero word, returned for every eth_call but a price feed's +// latestRoundData() (see rpcResult() below). It is what makes ethers' // ENS reverse lookup resolve to "no resolver set" instead of throwing, and a // throw there reaches the console through src/shared/log.js, which fails the // run on its own. const ZERO_WORD = "0x" + "0".repeat(64); +const SELECTOR_LATEST_ROUND_DATA = "0xfeaf968c"; + // One ETH, so the popup's balance lines render something and the wallet does // not look empty on the approval screen. const STUB_BALANCE_WEI = 10n ** 18n; @@ -111,6 +114,15 @@ function rpcResult(req, state) { return latestBlock(); } + // A price feed's latestRoundData() gets five zero words, as in network.js: + // a price of zero, which the wallet counts as no price. + const data = String( + (req.params && req.params[0] && req.params[0].data) || "", + ); + if (method === "eth_call" && data.startsWith(SELECTOR_LATEST_ROUND_DATA)) { + return "0x" + "0".repeat(64 * 5); + } + if (Object.prototype.hasOwnProperty.call(RPC_RESULTS, method)) { return RPC_RESULTS[method]; } diff --git a/tests/e2e/network.js b/tests/e2e/network.js index 9587453..43afac5 100644 --- a/tests/e2e/network.js +++ b/tests/e2e/network.js @@ -251,8 +251,13 @@ const SELECTOR_DECIMALS = "0x313ce567"; const SELECTOR_SYMBOL = "0x95d89b41"; const SELECTOR_NAME = "0x06fdde03"; const SELECTOR_BALANCE_OF = "0x70a08231"; +const SELECTOR_LATEST_ROUND_DATA = "0xfeaf968c"; -// Every eth_call still answers with a zero word except decimals(), symbol(), +// On any contract, a latestRoundData() call is answered with five zero words. +// That is a round with a price of zero, which the wallet counts as no +// price. So no screen shows a USD value beside the amounts the tests compare. +// +// Every other eth_call answers with a zero word except decimals(), symbol(), // name() and balanceOf() on the stub token. The wallet reads decimals() back // at signing time to compare with the scale the confirmation screen rendered // (issue #305). Adding the token by its contract address reads the first three @@ -273,6 +278,9 @@ function ethCallResult(req, opts) { if (!call || typeof call !== "object") return ZERO_WORD; const data = String(call.data || call.input || "").toLowerCase(); const to = String(call.to || "").toLowerCase(); + if (data.startsWith(SELECTOR_LATEST_ROUND_DATA)) { + return "0x" + "0".repeat(64 * 5); + } if (to !== STUB_TOKEN.address) return ZERO_WORD; if (data.startsWith(SELECTOR_DECIMALS)) { return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.decimals); @@ -764,11 +772,6 @@ async function installNetworkStubs(ctx, opts) { } } - // CoinDesk price tick - if (url.hostname.endsWith("coindesk.com")) { - return jsonResponse(route, { Data: {} }); - } - // The interception canary's own request. Answered with nothing: what // is being observed is that it arrived here at all. if (url.href === WORKER_PROBE_URL) { diff --git a/tests/priceFeeds.test.js b/tests/priceFeeds.test.js new file mode 100644 index 0000000..dc25c3f --- /dev/null +++ b/tests/priceFeeds.test.js @@ -0,0 +1,272 @@ +// USD prices are read from Chainlink price feeds on mainnet, through the RPC +// endpoint (https://git.eeqj.de/sneak/AutistMask/issues/525). +// +// The real refreshPrices() runs against a fake JSON-RPC node, answered through +// ethers' own request hook. The hook also records the body of every HTTP +// request, so a test can count them. + +// prices.js pulls in state.js, which reads chrome.storage.local at load. +globalThis.chrome = { + storage: { local: { get: async () => ({}), set: async () => {} } }, +}; + +const { AbiCoder, FetchRequest } = require("ethers"); +const { + prices, + refreshPrices, + clearPrices, + getPrice, + getAddressValue, +} = require("../src/shared/prices"); +const { state } = require("../src/shared/state"); +const { + TOKENS, + ETH_USD_FEED, + USD_PRICE_FEEDS, + PRICE_FEED_BY_ADDRESS, +} = require("../src/shared/tokenList"); + +const RPC_URL = "https://rpc.example.invalid"; +const NOW = Date.UTC(2026, 9, 27, 12, 0, 0); +const NOW_SECONDS = NOW / 1000; + +const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; +const LINK = "0x514910771af9ca656af840dff83e8264ecf986ca"; +const DAI = "0x6b175474e89094c44da98b954eedeac495271d0f"; +// A token at an address of its own that calls itself USDC. +const FAKE_USDC = "0x9999999999999999999999999999999999999999"; + +const ETH_FEED = ETH_USD_FEED.address.toLowerCase(); +const USDC_FEED = PRICE_FEED_BY_ADDRESS.get(USDC).address.toLowerCase(); +const LINK_FEED = PRICE_FEED_BY_ADDRESS.get(LINK).address.toLowerCase(); +const DAI_FEED = PRICE_FEED_BY_ADDRESS.get(DAI).address.toLowerCase(); + +// What each feed answers, by its lowercased address: the price in the feed's +// own units and the time it was written. A feed that is not here makes the +// call fail. +let rounds; +// The parsed body of every HTTP request the node received. +let requests; +let logged; + +function round(answer, updatedAt = NOW_SECONDS) { + return { answer, updatedAt }; +} + +// One JSON-RPC answer, to a latestRoundData() call. +function reply(req) { + const envelope = { jsonrpc: "2.0", id: req.id }; + const answer = rounds[req.params[0].to.toLowerCase()]; + if (!answer) { + return { + ...envelope, + error: { code: -32000, message: "fixture: read failed" }, + }; + } + return { + ...envelope, + result: AbiCoder.defaultAbiCoder().encode( + ["uint80", "int256", "uint256", "uint256", "uint80"], + [1, answer.answer, answer.updatedAt, answer.updatedAt, 1], + ), + }; +} + +// The feed addresses one HTTP request read, sorted. +function feedsRead(body) { + return [body] + .flat() + .map((req) => req.params[0].to.toLowerCase()) + .sort(); +} + +// One address that holds `held`, a list of [contract address, symbol]. +function holdTokens(held) { + state.wallets = [ + { + addresses: [ + { + address: "0x" + "a".repeat(40), + balance: "1.0", + tokenBalances: held.map(([address, symbol]) => ({ + address, + symbol, + balance: "10.0", + })), + }, + ], + }, + ]; +} + +beforeEach(() => { + clearPrices(); + state.networkId = "mainnet"; + state.rpcUrl = RPC_URL; + state.wallets = []; + state.trackedTokens = []; + rounds = { [ETH_FEED]: round(2000n * 10n ** 8n) }; + requests = []; + logged = []; + jest.spyOn(Date, "now").mockReturnValue(NOW); + for (const level of ["error", "warn"]) { + jest.spyOn(console, level).mockImplementation((...args) => { + logged.push(args.map(String).join(" ")); + }); + } + FetchRequest.registerGetUrl(async (req) => { + const body = JSON.parse(Buffer.from(req.body).toString("utf8")); + requests.push(body); + const answer = Array.isArray(body) ? body.map(reply) : reply(body); + return { + statusCode: 200, + statusMessage: "OK", + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify(answer)), + }; + }); +}); + +afterEach(() => { + FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc()); + jest.restoreAllMocks(); + clearPrices(); +}); + +describe("refreshPrices", () => { + test("reads ETH and each held or tracked token that has a feed in one HTTP request", async () => { + holdTokens([ + [USDC, "USDC"], + [LINK, "LINK"], + ]); + state.trackedTokens = [{ address: DAI, symbol: "DAI", decimals: 18 }]; + rounds[USDC_FEED] = round(10n ** 8n); + rounds[LINK_FEED] = round(1150000000n); + rounds[DAI_FEED] = round(99990000n); + + await refreshPrices(); + + expect(requests).toHaveLength(1); + expect(feedsRead(requests[0])).toEqual( + [ETH_FEED, USDC_FEED, LINK_FEED, DAI_FEED].sort(), + ); + expect(getPrice("ETH")).toBe(2000); + expect(getPrice(USDC)).toBe(1); + expect(getPrice(LINK)).toBe(11.5); + expect(getPrice(DAI)).toBe(0.9999); + }); + + test("reads again only once 5 minutes have passed", async () => { + await refreshPrices(); + Date.now.mockReturnValue(NOW + 299999); + await refreshPrices(); + expect(requests).toHaveLength(1); + + Date.now.mockReturnValue(NOW + 300000); + rounds[ETH_FEED] = round(2100n * 10n ** 8n, NOW_SECONDS + 300); + await refreshPrices(); + expect(requests).toHaveLength(2); + expect(getPrice("ETH")).toBe(2100); + }); + + test("on Sepolia, reads nothing and keeps no price", async () => { + await refreshPrices(); + expect(getPrice("ETH")).toBe(2000); + + state.networkId = "sepolia"; + await refreshPrices(); + expect(requests).toHaveLength(1); + expect(Object.keys(prices)).toEqual([]); + }); +}); + +describe("pairing by contract address", () => { + test("a token that copies a bundled token's symbol gets no price", async () => { + holdTokens([ + [USDC, "USDC"], + [FAKE_USDC, "USDC"], + ]); + rounds[USDC_FEED] = round(10n ** 8n); + + await refreshPrices(); + + expect(feedsRead(requests[0])).toEqual([ETH_FEED, USDC_FEED].sort()); + expect(getPrice(USDC)).toBe(1); + expect(getPrice(FAKE_USDC)).toBeNull(); + // 1 ETH and 10 real USDC are priced; the 10 look-alikes are not. + expect(getAddressValue(state.wallets[0].addresses[0])).toEqual({ + usd: 2010, + partial: true, + }); + }); +}); + +describe("freshness", () => { + test("a reading older than its feed's heartbeat plus 30 minutes is no price", async () => { + const heartbeatAgo = + NOW_SECONDS - PRICE_FEED_BY_ADDRESS.get(USDC).heartbeat; + holdTokens([[USDC, "USDC"]]); + + rounds[USDC_FEED] = round(10n ** 8n, heartbeatAgo - 1800 - 1); + await refreshPrices(); + expect(getPrice(USDC)).toBeNull(); + + clearPrices(); + rounds[USDC_FEED] = round(10n ** 8n, heartbeatAgo - 1800); + await refreshPrices(); + expect(getPrice(USDC)).toBe(1); + }); + + test.each([0n, -1n])("an answer of %s is no price", async (answer) => { + rounds[ETH_FEED] = round(answer); + await refreshPrices(); + expect(getPrice("ETH")).toBeNull(); + }); +}); + +describe("a failed read", () => { + test("leaves the token with no price and is logged with its error", async () => { + holdTokens([[USDC, "USDC"]]); + rounds[USDC_FEED] = round(10n ** 8n); + await refreshPrices(); + expect(getPrice(USDC)).toBe(1); + + Date.now.mockReturnValue(NOW + 300000); + delete rounds[USDC_FEED]; + await refreshPrices(); + + expect(getPrice(USDC)).toBeNull(); + expect(getPrice("ETH")).toBe(2000); + expect( + logged.some( + (line) => + line.includes("price feed read failed") && + line.includes(USDC), + ), + ).toBe(true); + }); + + test("an HTTP error is logged with its status", async () => { + FetchRequest.registerGetUrl(async () => ({ + statusCode: 401, + statusMessage: "Unauthorized", + headers: { "content-type": "text/plain" }, + body: Buffer.from("API key required"), + })); + + await refreshPrices(); + + expect(getPrice("ETH")).toBeNull(); + expect(logged.some((line) => line.includes("401"))).toBe(true); + }); +}); + +describe("the feed table", () => { + test("names each token by its address as the bundled list spells it", () => { + const listed = new Set(TOKENS.map((t) => t.address)); + const unlisted = Object.keys(USD_PRICE_FEEDS).filter( + (address) => !listed.has(address), + ); + expect(unlisted).toEqual([]); + }); +}); diff --git a/tests/unknownScaleDisplay.test.js b/tests/unknownScaleDisplay.test.js index 1c02207..e83be6e 100644 --- a/tests/unknownScaleDisplay.test.js +++ b/tests/unknownScaleDisplay.test.js @@ -103,7 +103,7 @@ describe("balanceLine", () => { }); test("an unknown quantity produces no fiat figure, a zero one does", () => { - prices.NOVEL = 3; + prices[NOVEL] = 3; const unknown = balanceLine("NOVEL", null, 3, NOVEL); const zero = balanceLine("NOVEL", 0, 3, NOVEL); // A price times an unknown quantity is not $0.00: that is the same @@ -162,7 +162,7 @@ describe("getAddressValue", () => { // make it incomplete. test("an unknown balance makes the total partial, a zero balance does not", () => { prices.ETH = 2000; - prices.NOVEL = 3; + prices[NOVEL] = 3; const unknown = getAddressValue(address(null)); const zero = getAddressValue(address("0.0")); expect(unknown).not.toEqual(zero); @@ -172,7 +172,7 @@ describe("getAddressValue", () => { test("an unknown balance is not priced as zero of the token", () => { prices.ETH = 2000; - prices.NOVEL = 3; + prices[NOVEL] = 3; // The same row with a real quantity of 10 is worth $30. Neither that // figure nor a confident $0.00 may be stated for the unknown one. expect(getAddressValue(address("10.0"))).toEqual({