fix: read USD prices from Chainlink price feeds through the RPC endpoint (closes #525)
CoinDesk now refuses every request without an API key, so mainnet showed no USD prices. On mainnet the wallet now reads latestRoundData() from Chainlink's USD price feeds through the configured RPC endpoint. ETH and the held or tracked tokens with a feed are read as one batched request, at most every 5 minutes. src/shared/tokenList.js lists the feeds of 53 bundled tokens, keyed by contract address, and the ETH/USD feed. A reading older than its feed's heartbeat plus 30 minutes, or not positive, is no price. A failed read leaves no price and is logged. getPrice() takes a contract address instead of a symbol. The CoinDesk request is removed. Model: opus-5-5
This commit is contained in:
@@ -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
|
only to the Ethereum RPC endpoint. The user can change that endpoint in
|
||||||
Settings. The endpoint defaults to a public node.
|
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
|
- blockchain interactions
|
||||||
- a public CoinDesk API (no API key), for realtime prices. The API receives
|
- USD prices on mainnet, read from Chainlink price feed contracts
|
||||||
token symbols and nothing else.
|
|
||||||
|
|
||||||
Two more services are sent none of the user's data until the user ticks a
|
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:
|
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
|
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
|
||||||
ens.js — ENS forward/reverse resolution (popup only)
|
ens.js — ENS forward/reverse resolution (popup only)
|
||||||
holders.js — holder-count parsing and the low-holder rule
|
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
|
scamlist.js — known fraud contract addresses
|
||||||
state.js — persisted state (extension storage)
|
state.js — persisted state (extension storage)
|
||||||
storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan",
|
storedSettings.js — "Use Blockscout" and "Check recipients on Etherscan",
|
||||||
read from storage for the requests those settings gate
|
read from storage for the requests those settings gate
|
||||||
symbolSpoof.js — the known-symbol spoof rule, shared by all surfaces
|
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
|
transactions.js — tx history fetching + anti-poisoning filters
|
||||||
uniswap.js — Uniswap Universal Router calldata decoder
|
uniswap.js — Uniswap Universal Router calldata decoder
|
||||||
vault.js — password-based encryption via libsodium
|
vault.js — password-based encryption via libsodium
|
||||||
@@ -1371,10 +1372,10 @@ whole swap undecoded.
|
|||||||
|
|
||||||
#### Partial USD totals
|
#### Partial USD totals
|
||||||
|
|
||||||
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
Only a token with a Chainlink price feed has a price, so an address can hold
|
||||||
extension has no price for. Worth zero and worth an unknown amount are different
|
assets the extension has no price for. Worth zero and worth an unknown amount
|
||||||
facts and are never collapsed into one number. `getAddressValue()` in
|
are different facts and are never collapsed into one number. `getAddressValue()`
|
||||||
`src/shared/prices.js` returns `{ usd, partial }` — the value of the priced
|
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
|
holdings, and whether an unpriced holding was left out of it — and every screen
|
||||||
renders it through `formatAddressTotal()`, so the wording cannot drift:
|
renders it through `formatAddressTotal()`, so the wording cannot drift:
|
||||||
|
|
||||||
@@ -2851,9 +2852,9 @@ view would leave a wallet one click from deletion.
|
|||||||
|
|
||||||
### External Services
|
### External Services
|
||||||
|
|
||||||
AutistMask is not a fully self-contained offline tool. By default it sends the
|
AutistMask is not a fully self-contained offline tool. By default it contacts
|
||||||
user's data only to the Ethereum RPC endpoint. It also asks the CoinDesk price
|
only the Ethereum RPC endpoint. USD prices are read from Chainlink price feed
|
||||||
API for prices, sending token symbols and nothing else.
|
contracts through that endpoint.
|
||||||
|
|
||||||
Two more services are sent none of the user's data until the user turns on a
|
Two more services are sent none of the user's data until the user turns on a
|
||||||
setting for each:
|
setting for each:
|
||||||
@@ -2877,21 +2878,18 @@ Contacted by default:
|
|||||||
estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
|
estimate gas (`eth_estimateGas`), fetch nonces (`eth_getTransactionCount`),
|
||||||
broadcast transactions (`eth_sendRawTransaction`), and check transaction
|
broadcast transactions (`eth_sendRawTransaction`), and check transaction
|
||||||
receipts. Every ENS lookup is made through the RPC endpoint alone. So a name
|
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
|
does not resolve when its resolver says the name is resolved off-chain. On
|
||||||
default endpoint is a public RPC (configurable by the user to any endpoint
|
mainnet the extension also reads USD prices from Chainlink price feed
|
||||||
they prefer, including a local node). By default the extension talks to
|
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`.
|
`https://ethereum-rpc.publicnode.com`.
|
||||||
- **Data sent**: Ethereum addresses, transaction data, contract call
|
- **Data sent**: Ethereum addresses, transaction data, contract call
|
||||||
parameters. The RPC endpoint can see all on-chain queries and submitted
|
parameters. The RPC endpoint can see all on-chain queries and submitted
|
||||||
transactions.
|
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:
|
Sent the user's data only while its setting is on:
|
||||||
|
|
||||||
- **Blockscout block-explorer API** ("Use Blockscout"): The default endpoint is
|
- **Blockscout block-explorer API** ("Use Blockscout"): The default endpoint is
|
||||||
@@ -3067,14 +3065,13 @@ hardcoded test phrase.
|
|||||||
|
|
||||||
At the raised log level the console also shows the wallet's addresses with their
|
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
|
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
|
request made through `debugFetch` in `src/shared/log.js` (the explorer, the RPC
|
||||||
price feed, the RPC calls a site makes, and the endpoint checks in settings) and
|
calls a site makes, and the endpoint checks in settings) and for its response. A
|
||||||
for its response. A request is logged by its HTTP method, the origin of its URL
|
request is logged by its HTTP method, the origin of its URL (scheme, host and
|
||||||
(scheme, host and port) and, for a JSON-RPC call, the method name; the balance
|
port) and, for a JSON-RPC call, the method name; the balance refresh, the token
|
||||||
refresh, the token lookup and a failed endpoint check in settings name the
|
lookup and a failed endpoint check in settings name the endpoint by its origin
|
||||||
endpoint by its origin too. The URL's path and query string, where RPC providers
|
too. The URL's path and query string, where RPC providers put API keys, any user
|
||||||
put API keys, any user name and password in it, and the request body are never
|
name and password in it, and the request body are never logged.
|
||||||
logged.
|
|
||||||
|
|
||||||
### Key Decisions
|
### Key Decisions
|
||||||
|
|
||||||
|
|||||||
+9
-16
@@ -70,9 +70,9 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
|
|||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
AutistMask is a browser extension that runs entirely in your browser. It does
|
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
|
not have a backend server. Out of the box it contacts only the Ethereum RPC
|
||||||
Ethereum RPC endpoint. It also asks the CoinDesk price API for prices, sending
|
endpoint. USD prices are read from Chainlink price feed contracts through that
|
||||||
token symbols and nothing else.
|
endpoint.
|
||||||
|
|
||||||
Blockscout and Etherscan are sent none of your data until you turn on a setting
|
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".
|
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
|
this in Settings to any Ethereum JSON-RPC endpoint, including your own local
|
||||||
node.
|
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:
|
When it is contacted:
|
||||||
|
|
||||||
- on every balance refresh (every 10 seconds while the popup is open, every 60
|
- on every balance refresh (every 10 seconds while the popup is open, every 60
|
||||||
seconds in the background)
|
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 you review a send to an ENS name
|
||||||
- when a send is prepared and broadcast
|
- when a send is prepared and broadcast
|
||||||
- while a pending transaction is polled for its receipt
|
- 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)
|
What gets sent: your Ethereum addresses (to look up balances and transactions)
|
||||||
and the hash of each transaction you open.
|
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)
|
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
|
||||||
|
|
||||||
While "Check recipients on Etherscan" is on, AutistMask fetches the recipient's
|
While "Check recipients on Etherscan" is on, AutistMask fetches the recipient's
|
||||||
|
|||||||
+1
-1
@@ -96,7 +96,7 @@ async function doRefreshAndRender() {
|
|||||||
const networkId = state.networkId;
|
const networkId = state.networkId;
|
||||||
const wallets = structuredClone(state.wallets);
|
const wallets = structuredClone(state.wallets);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refreshPrices(),
|
refreshPrices(pageClosed.signal),
|
||||||
refreshBalances(
|
refreshBalances(
|
||||||
wallets,
|
wallets,
|
||||||
state.rpcUrl,
|
state.rpcUrl,
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function show() {
|
|||||||
// null when the scale is unknown: no quantity to show, and none to
|
// null when the scale is unknown: no quantity to show, and none to
|
||||||
// price. balanceLine() states that rather than printing 0.0000.
|
// price. balanceLine() states that rather than printing 0.0000.
|
||||||
amount = tb ? unknownableAmount(tb.balance) : 0;
|
amount = tb ? unknownableAmount(tb.balance) : 0;
|
||||||
price = getPrice(symbol);
|
price = getPrice(tokenId);
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSymbol = symbol;
|
currentSymbol = symbol;
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ function renderAmount(txInfo) {
|
|||||||
const rawSymbol = isErc20
|
const rawSymbol = isErc20
|
||||||
? txInfo.tokenSymbol || "?"
|
? txInfo.tokenSymbol || "?"
|
||||||
: nativeCurrencyByChainId(txInfo.chainId);
|
: 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;
|
const amountUsd = price ? parseFloat(txInfo.amount) * price : null;
|
||||||
$("confirm-amount").textContent = valueWithUsd(
|
$("confirm-amount").textContent = valueWithUsd(
|
||||||
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
|
shownAmount(txInfo) + " " + displaySymbol(rawSymbol),
|
||||||
@@ -157,7 +157,7 @@ function renderBalance(txInfo) {
|
|||||||
$("confirm-balance").textContent = "unknown (" + symbol + ")";
|
$("confirm-balance").textContent = "unknown (" + symbol + ")";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const price = getPrice(isErc20 ? rawSymbol : "ETH", txInfo.chainId);
|
const price = getPrice(txInfo.token, txInfo.chainId);
|
||||||
const balUsd = price ? parseFloat(bal) * price : null;
|
const balUsd = price ? parseFloat(bal) * price : null;
|
||||||
$("confirm-balance").textContent = valueWithUsd(
|
$("confirm-balance").textContent = valueWithUsd(
|
||||||
truncateAmountNeverZero(bal) + " " + symbol,
|
truncateAmountNeverZero(bal) + " " + symbol,
|
||||||
|
|||||||
@@ -439,7 +439,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
|||||||
html += balanceLine(
|
html += balanceLine(
|
||||||
t.symbol,
|
t.symbol,
|
||||||
bal,
|
bal,
|
||||||
getPrice(t.symbol),
|
getPrice(t.address),
|
||||||
t.address.toLowerCase(),
|
t.address.toLowerCase(),
|
||||||
);
|
);
|
||||||
seen.add(t.address.toLowerCase());
|
seen.add(t.address.toLowerCase());
|
||||||
@@ -450,7 +450,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
|||||||
html += balanceLine(
|
html += balanceLine(
|
||||||
t.symbol,
|
t.symbol,
|
||||||
0,
|
0,
|
||||||
getPrice(t.symbol),
|
getPrice(t.address),
|
||||||
t.address.toLowerCase(),
|
t.address.toLowerCase(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-21
@@ -1,31 +1,104 @@
|
|||||||
// 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 { isBelowOneMillionth } = require("./amountDisplay");
|
||||||
const { networkByChainId } = require("./networks");
|
const { networkByChainId } = require("./networks");
|
||||||
|
const { log } = require("./log");
|
||||||
|
|
||||||
const PRICE_CACHE_TTL = 300000; // 5 minutes
|
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 = {};
|
const prices = {};
|
||||||
let lastFetchedAt = 0;
|
let lastFetchedAt = 0;
|
||||||
|
|
||||||
async function refreshPrices() {
|
// Reads the price of ether and of each token any address holds or the user
|
||||||
// Testnet tokens have no real market value — skip price fetching
|
// tracks, when that token has a feed. Mainnet only. The reads go to the RPC
|
||||||
// and clear any stale mainnet prices so the UI shows no USD values.
|
// endpoint as one batched request, at most every 5 minutes. A failed read is
|
||||||
const { currentNetwork } = require("./state");
|
// logged, and that token then has no price.
|
||||||
if (currentNetwork().isTestnet) {
|
//
|
||||||
|
// `signal`, passed by the popup, is aborted when the popup closes. A read that
|
||||||
|
// fails after that was cancelled by the closing and is not logged.
|
||||||
|
async function refreshPrices(signal) {
|
||||||
|
const { state, currentNetwork } = require("./state");
|
||||||
|
if (currentNetwork().id !== "mainnet") {
|
||||||
clearPrices();
|
clearPrices();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastFetchedAt < PRICE_CACHE_TTL) return;
|
if (now - lastFetchedAt < PRICE_CACHE_TTL) return;
|
||||||
try {
|
const feeds = new Map([["ETH", ETH_USD_FEED]]);
|
||||||
const fetched = await getTopTokenPrices(25);
|
for (const address of heldOrTrackedTokens(state)) {
|
||||||
Object.assign(prices, fetched);
|
const feed = PRICE_FEED_BY_ADDRESS.get(address);
|
||||||
lastFetchedAt = now;
|
if (feed) feeds.set(address, feed);
|
||||||
} catch {
|
|
||||||
// prices stay stale on error
|
|
||||||
}
|
}
|
||||||
|
// 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
|
// Clear all cached prices and reset the fetch timestamp so the
|
||||||
@@ -37,14 +110,15 @@ function clearPrices() {
|
|||||||
lastFetchedAt = 0;
|
lastFetchedAt = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the USD price for a symbol on the network with `chainId`, or on the
|
// Return the USD price of the token at the contract address `token`, or of
|
||||||
// active network when no chain id is given; null on testnet, on a chain id
|
// ether when `token` is "ETH". The price is for the network with `chainId`, or
|
||||||
// that names no network, or for a symbol with no price.
|
// for the active network when no chain id is given. Returns null on testnet,
|
||||||
function getPrice(symbol, chainId) {
|
// on a chain id that names no network, and for a token with no price.
|
||||||
|
function getPrice(token, chainId) {
|
||||||
const { currentNetwork } = require("./state");
|
const { currentNetwork } = require("./state");
|
||||||
const network = chainId ? networkByChainId(chainId) : currentNetwork();
|
const network = chainId ? networkByChainId(chainId) : currentNetwork();
|
||||||
if (!network || network.isTestnet) return null;
|
if (!network || network.isTestnet) return null;
|
||||||
return prices[symbol] || null;
|
return prices[token === "ETH" ? "ETH" : token.toLowerCase()] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUsd(amount) {
|
function formatUsd(amount) {
|
||||||
@@ -62,7 +136,7 @@ function formatUsd(amount) {
|
|||||||
|
|
||||||
// What an address is worth, as { usd, partial }.
|
// 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
|
// 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
|
// 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
|
// holding nothing but unpriced tokens comes out at $0.00, which tells the user
|
||||||
@@ -99,8 +173,9 @@ function getAddressValue(addr) {
|
|||||||
// nor make it incomplete. Anything that is not a number at all is not
|
// 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.
|
// a holding this can price either, and is left to the same rule.
|
||||||
if (!(tokenBal > 0)) continue;
|
if (!(tokenBal > 0)) continue;
|
||||||
if (prices[token.symbol]) {
|
const price = prices[token.address.toLowerCase()];
|
||||||
usd += tokenBal * prices[token.symbol];
|
if (price) {
|
||||||
|
usd += tokenBal * price;
|
||||||
} else {
|
} else {
|
||||||
partial = true;
|
partial = true;
|
||||||
}
|
}
|
||||||
|
|||||||
+351
-31
@@ -4,12 +4,12 @@
|
|||||||
// Addresses are EIP-55 checksummed.
|
// Addresses are EIP-55 checksummed.
|
||||||
//
|
//
|
||||||
// 511 tokens.
|
// 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 { NETWORKS } = require("./networks");
|
||||||
|
|
||||||
const COINDESK_API = "https://data-api.coindesk.com/index/cc/v1/latest/tick";
|
|
||||||
|
|
||||||
const TOKENS = [
|
const TOKENS = [
|
||||||
{
|
{
|
||||||
address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
|
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.
|
// Return the top N tokens from the list.
|
||||||
function getTopTokens(n) {
|
function getTopTokens(n) {
|
||||||
return TOKENS.slice(0, 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 "?".
|
// Resolve a token symbol from multiple sources, never returning "?".
|
||||||
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
|
function resolveSymbol(tokenAddress, tokenBalances, trackedTokens) {
|
||||||
const lower = (tokenAddress || "").toLowerCase();
|
const lower = (tokenAddress || "").toLowerCase();
|
||||||
@@ -3690,7 +4008,9 @@ module.exports = {
|
|||||||
TOKENS,
|
TOKENS,
|
||||||
TOKEN_BY_ADDRESS,
|
TOKEN_BY_ADDRESS,
|
||||||
KNOWN_SYMBOLS,
|
KNOWN_SYMBOLS,
|
||||||
|
ETH_USD_FEED,
|
||||||
|
USD_PRICE_FEEDS,
|
||||||
|
PRICE_FEED_BY_ADDRESS,
|
||||||
getTopTokens,
|
getTopTokens,
|
||||||
getTopTokenPrices,
|
|
||||||
resolveSymbol,
|
resolveSymbol,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// The USD total of an address that holds something this build cannot price
|
// The USD total of an address that holds something this build cannot price
|
||||||
// (issue #261).
|
// (issue #261).
|
||||||
//
|
//
|
||||||
// Prices exist for the top 25 tokens only, so an address can hold real assets
|
// Only a token with a price feed has a price, so an address can hold real
|
||||||
// with no price attached. Summing what is priced and printing the result as
|
// assets with no price attached. Summing what is priced and printing the result
|
||||||
// the total says "$0.00" for an address holding nothing but unpriced tokens —
|
// 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
|
// 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
|
// direction that matters. The two are separate facts here, the same way an
|
||||||
// absent holders_count is not a count of zero.
|
// absent holders_count is not a count of zero.
|
||||||
@@ -63,7 +63,7 @@ const PARTLY_PRICED = {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clearPrices();
|
clearPrices();
|
||||||
prices.ETH = 2000;
|
prices.ETH = 2000;
|
||||||
prices.USDC = 1;
|
prices[USDC] = 1;
|
||||||
state.wallets = [];
|
state.wallets = [];
|
||||||
state.trackedTokens = [];
|
state.trackedTokens = [];
|
||||||
state.showZeroBalanceTokens = false;
|
state.showZeroBalanceTokens = false;
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ const PRIVATE_KEY = "0x" + "11".repeat(32);
|
|||||||
const HOLDER = new Wallet(PRIVATE_KEY).address;
|
const HOLDER = new Wallet(PRIVATE_KEY).address;
|
||||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||||
const TOKEN = "0x7070707070707070707070707070707070707070";
|
const TOKEN = "0x7070707070707070707070707070707070707070";
|
||||||
|
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
||||||
const PASSWORD = "correct horse battery staple";
|
const PASSWORD = "correct horse battery staple";
|
||||||
|
|
||||||
function el(id) {
|
function el(id) {
|
||||||
@@ -318,7 +319,7 @@ async function switchReopenAndGoBack(reviewedOn, switchedTo) {
|
|||||||
setBackRenderer(makeBackRenderer(state, { confirmTx }));
|
setBackRenderer(makeBackRenderer(state, { confirmTx }));
|
||||||
confirmTx.init({});
|
confirmTx.init({});
|
||||||
prices.ETH = 2000;
|
prices.ETH = 2000;
|
||||||
prices.USDC = 1;
|
prices[USDC] = 1;
|
||||||
mockNode.asked = [];
|
mockNode.asked = [];
|
||||||
global.fetch.mockClear();
|
global.fetch.mockClear();
|
||||||
goBack();
|
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 () => {
|
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;
|
mockNode.tokenUnits[reviewedOn] = 50000000n;
|
||||||
await review(reviewedOn, {
|
await review(reviewedOn, {
|
||||||
token: TOKEN,
|
token: USDC,
|
||||||
tokenSymbol: "USDC",
|
tokenSymbol: "USDC",
|
||||||
tokenDecimals: 6,
|
tokenDecimals: 6,
|
||||||
amount: "10",
|
amount: "10",
|
||||||
@@ -652,7 +653,7 @@ describe.each([
|
|||||||
"ERC-20 token transfer (USDC)",
|
"ERC-20 token transfer (USDC)",
|
||||||
);
|
);
|
||||||
expect(el("confirm-token-contract").innerHTML).toContain(
|
expect(el("confirm-token-contract").innerHTML).toContain(
|
||||||
`href="${explorerUrl}/address/${TOKEN}"`,
|
`href="${explorerUrl}/address/${USDC}"`,
|
||||||
);
|
);
|
||||||
expect(el("confirm-amount").textContent).toBe(
|
expect(el("confirm-amount").textContent).toBe(
|
||||||
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
|
"10.0000 USDC" + usdOn(reviewedOn, "$10.00"),
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ describe("the balance warning on the removal confirmation", () => {
|
|||||||
|
|
||||||
test("the USD total is shown when prices are known", () => {
|
test("the USD total is shown when prices are known", () => {
|
||||||
prices.ETH = 2000;
|
prices.ETH = 2000;
|
||||||
prices.USDC = 1;
|
prices[USDC] = 1;
|
||||||
expect(balanceWarningHtml(TOKEN_ONLY)).toContain("Total: $2,500.00");
|
expect(balanceWarningHtml(TOKEN_ONLY)).toContain("Total: $2,500.00");
|
||||||
expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00");
|
expect(balanceWarningHtml(ETH_ONLY)).toContain("Total: $3,000.00");
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-1
@@ -43,12 +43,15 @@ const GAS_PRICE_WEI = BASE_FEE_WEI + PRIORITY_FEE_WEI;
|
|||||||
|
|
||||||
const STUB_BLOCK_NUMBER = 21000000;
|
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
|
// 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
|
// throw there reaches the console through src/shared/log.js, which fails the
|
||||||
// run on its own.
|
// run on its own.
|
||||||
const ZERO_WORD = "0x" + "0".repeat(64);
|
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
|
// One ETH, so the popup's balance lines render something and the wallet does
|
||||||
// not look empty on the approval screen.
|
// not look empty on the approval screen.
|
||||||
const STUB_BALANCE_WEI = 10n ** 18n;
|
const STUB_BALANCE_WEI = 10n ** 18n;
|
||||||
@@ -111,6 +114,15 @@ function rpcResult(req, state) {
|
|||||||
return latestBlock();
|
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)) {
|
if (Object.prototype.hasOwnProperty.call(RPC_RESULTS, method)) {
|
||||||
return RPC_RESULTS[method];
|
return RPC_RESULTS[method];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,8 +251,13 @@ const SELECTOR_DECIMALS = "0x313ce567";
|
|||||||
const SELECTOR_SYMBOL = "0x95d89b41";
|
const SELECTOR_SYMBOL = "0x95d89b41";
|
||||||
const SELECTOR_NAME = "0x06fdde03";
|
const SELECTOR_NAME = "0x06fdde03";
|
||||||
const SELECTOR_BALANCE_OF = "0x70a08231";
|
const SELECTOR_BALANCE_OF = "0x70a08231";
|
||||||
|
const SELECTOR_LATEST_ROUND_DATA = "0xfeaf968c";
|
||||||
|
|
||||||
// Every eth_call still answers with a zero word except decimals(), symbol(),
|
// A price feed's latestRoundData() is answered on any contract 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
|
// name() and balanceOf() on the stub token. The wallet reads decimals() back
|
||||||
// at signing time to compare with the scale the confirmation screen rendered
|
// 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
|
// (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;
|
if (!call || typeof call !== "object") return ZERO_WORD;
|
||||||
const data = String(call.data || call.input || "").toLowerCase();
|
const data = String(call.data || call.input || "").toLowerCase();
|
||||||
const to = String(call.to || "").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 (to !== STUB_TOKEN.address) return ZERO_WORD;
|
||||||
if (data.startsWith(SELECTOR_DECIMALS)) {
|
if (data.startsWith(SELECTOR_DECIMALS)) {
|
||||||
return word(opts.tokenDecimalsOverride ?? STUB_TOKEN.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
|
// The interception canary's own request. Answered with nothing: what
|
||||||
// is being observed is that it arrived here at all.
|
// is being observed is that it arrived here at all.
|
||||||
if (url.href === WORKER_PROBE_URL) {
|
if (url.href === WORKER_PROBE_URL) {
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -103,7 +103,7 @@ describe("balanceLine", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("an unknown quantity produces no fiat figure, a zero one does", () => {
|
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 unknown = balanceLine("NOVEL", null, 3, NOVEL);
|
||||||
const zero = balanceLine("NOVEL", 0, 3, NOVEL);
|
const zero = balanceLine("NOVEL", 0, 3, NOVEL);
|
||||||
// A price times an unknown quantity is not $0.00: that is the same
|
// A price times an unknown quantity is not $0.00: that is the same
|
||||||
@@ -162,7 +162,7 @@ describe("getAddressValue", () => {
|
|||||||
// make it incomplete.
|
// make it incomplete.
|
||||||
test("an unknown balance makes the total partial, a zero balance does not", () => {
|
test("an unknown balance makes the total partial, a zero balance does not", () => {
|
||||||
prices.ETH = 2000;
|
prices.ETH = 2000;
|
||||||
prices.NOVEL = 3;
|
prices[NOVEL] = 3;
|
||||||
const unknown = getAddressValue(address(null));
|
const unknown = getAddressValue(address(null));
|
||||||
const zero = getAddressValue(address("0.0"));
|
const zero = getAddressValue(address("0.0"));
|
||||||
expect(unknown).not.toEqual(zero);
|
expect(unknown).not.toEqual(zero);
|
||||||
@@ -172,7 +172,7 @@ describe("getAddressValue", () => {
|
|||||||
|
|
||||||
test("an unknown balance is not priced as zero of the token", () => {
|
test("an unknown balance is not priced as zero of the token", () => {
|
||||||
prices.ETH = 2000;
|
prices.ETH = 2000;
|
||||||
prices.NOVEL = 3;
|
prices[NOVEL] = 3;
|
||||||
// The same row with a real quantity of 10 is worth $30. Neither that
|
// 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.
|
// figure nor a confident $0.00 may be stated for the unknown one.
|
||||||
expect(getAddressValue(address("10.0"))).toEqual({
|
expect(getAddressValue(address("10.0"))).toEqual({
|
||||||
|
|||||||
Reference in New Issue
Block a user