// 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([]); }); });