fix: read USD prices from Chainlink price feeds through the RPC endpoint (closes #525)
CoinDesk now refuses requests without an API key, so mainnet showed no USD prices. Mainnet prices now come from Chainlink's USD price feeds, read with eth_call through the user's RPC endpoint; no other service is asked. The bundled list pairs each of 53 tokens with its feed by contract address, and ETH and WETH use ETH/USD. The popup reads the feeds of ETH and the tokens the user holds or tracks in one batched request, at most every 5 minutes. A reading older than its heartbeat plus 30 minutes, or not positive, is no price. A token with no feed shows no USD value. CoinDesk is gone. RULES.md still names the CoinDesk price API. Model: opus-5-5
This commit was merged in pull request #681.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
+13
-1
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user