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
273 lines
8.7 KiB
JavaScript
273 lines
8.7 KiB
JavaScript
// 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([]);
|
|
});
|
|
});
|