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

CoinDesk now refuses requests without an API key, so mainnet showed no USD prices. Mainnet prices now come from Chainlink's USD price feeds, read with eth_call through the user's RPC endpoint; no other service is asked. The bundled list pairs each of 53 tokens with its feed by contract address, and ETH and WETH use ETH/USD. The popup reads the feeds of ETH and the tokens the user holds or tracks in one batched request, at most every 5 minutes. A reading older than its heartbeat plus 30 minutes, or not positive, is no price. A token with no feed shows no USD value. CoinDesk is gone.

RULES.md still names the CoinDesk price API.

Model: opus-5-5
This commit was merged in pull request #681.
This commit is contained in:
2026-10-27 04:23:17 +01:00
parent 53551c6382
commit b4e166cdc7
15 changed files with 797 additions and 123 deletions
+4 -4
View File
@@ -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;
+4 -3
View File
@@ -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"),
+1 -1
View File
@@ -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
View File
@@ -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];
}
+9 -6
View File
@@ -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) {
+272
View File
@@ -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([]);
});
});
+3 -3
View File
@@ -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({