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
186 lines
6.9 KiB
JavaScript
186 lines
6.9 KiB
JavaScript
// What the screens that READ a stored token balance do with a holding whose
|
|
// scale nothing knows.
|
|
//
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/349 stopped `fetchTokenBalances()`
|
|
// fabricating a scale of 18, so a row it cannot state a quantity for is now
|
|
// stored with `balance: null`. Every reader of that field therefore has two
|
|
// distinct inputs where it used to have one, and the property that has to hold
|
|
// at each of them is the same one this codebase keeps losing:
|
|
//
|
|
// null (unknown) and 0 (genuinely zero) must produce DIFFERENT output.
|
|
//
|
|
// Losing it is what https://git.eeqj.de/sneak/AutistMask/issues/246,
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/306,
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/322,
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/359 and
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/364 each were. So every case
|
|
// below asserts the pair, not just that the null branch does something
|
|
// reasonable: an assertion on the null alone still passes on a build that
|
|
// renders both as zero, which is precisely the build being guarded against.
|
|
//
|
|
// The writer half — that the fetcher stores null rather than 18 — is in
|
|
// tests/fabricatedDecimals.test.js, and the Send and confirmation screens are
|
|
// in tests/unknownScaleSend.test.js.
|
|
|
|
"use strict";
|
|
|
|
// helpers.js reaches for both at module scope through the modules it pulls in.
|
|
globalThis.chrome = {
|
|
storage: {
|
|
local: {
|
|
get: () => Promise.resolve({}),
|
|
set: () => Promise.resolve(),
|
|
},
|
|
},
|
|
runtime: { sendMessage: () => {} },
|
|
};
|
|
globalThis.document = {
|
|
getElementById: () => null,
|
|
createElement: () => ({ style: {}, classList: { toggle() {} } }),
|
|
body: { prepend: () => {} },
|
|
addEventListener: () => {},
|
|
};
|
|
|
|
const {
|
|
balanceLine,
|
|
balanceLinesForAddress,
|
|
addressHoldsFunds,
|
|
} = require("../src/popup/views/helpers");
|
|
const {
|
|
prices,
|
|
clearPrices,
|
|
getAddressValue,
|
|
} = require("../src/shared/prices");
|
|
const { state } = require("../src/shared/state");
|
|
|
|
const NOVEL = "0x1111111111111111111111111111111111111111";
|
|
|
|
// One stored tokenBalances row. `balance: null` is what balances.js writes for
|
|
// a holding whose scale nothing knows; "0.0" is a quantity that was actually
|
|
// established and is zero.
|
|
function holding(balance) {
|
|
return {
|
|
address: NOVEL,
|
|
symbol: "NOVEL",
|
|
decimals: balance === null ? null : 18,
|
|
balance,
|
|
holders: 50000,
|
|
};
|
|
}
|
|
|
|
function address(balance) {
|
|
return {
|
|
address: "0x" + "a".repeat(40),
|
|
balance: "0",
|
|
tokenBalances: [holding(balance)],
|
|
};
|
|
}
|
|
|
|
// The quantity cell of a rendered row, which is the second of the two spans
|
|
// inside the fixed-width span.
|
|
function quantities(html) {
|
|
return [...html.matchAll(/<span>([^<]*)<\/span>/g)].map((m) => m[1]);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearPrices();
|
|
state.wallets = [];
|
|
state.trackedTokens = [];
|
|
state.activeAddress = null;
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearPrices();
|
|
});
|
|
|
|
describe("balanceLine", () => {
|
|
test("an unknown quantity and a zero one render differently", () => {
|
|
const unknown = balanceLine("NOVEL", null, null, NOVEL);
|
|
const zero = balanceLine("NOVEL", 0, null, NOVEL);
|
|
expect(unknown).not.toBe(zero);
|
|
expect(quantities(unknown)).toEqual(["NOVEL", "quantity unknown"]);
|
|
expect(quantities(zero)).toEqual(["NOVEL", "0.0000"]);
|
|
});
|
|
|
|
test("an unknown quantity produces no fiat figure, a zero one does", () => {
|
|
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
|
|
// claim of "nothing here" the quantity cell just refused to make.
|
|
expect(unknown).toContain(
|
|
'<span class="text-right text-muted flex-1"> </span>',
|
|
);
|
|
expect(zero).toContain(
|
|
'<span class="text-right text-muted flex-1">$0.00</span>',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("balanceLinesForAddress", () => {
|
|
// The show-zero setting is a statement about zeroes. An unknown quantity
|
|
// is not one, so hiding the row would assert the zero nobody established
|
|
// and the holding would vanish from the list entirely.
|
|
test("hiding zero balances hides the zero row and keeps the unknown one", () => {
|
|
const unknown = balanceLinesForAddress(address(null), [], false);
|
|
const zero = balanceLinesForAddress(address("0.0"), [], false);
|
|
expect(unknown).not.toBe(zero);
|
|
expect(unknown).toContain("quantity unknown");
|
|
expect(unknown).toContain("NOVEL");
|
|
expect(zero).not.toContain("NOVEL");
|
|
});
|
|
|
|
test("showing zero balances still tells the two apart", () => {
|
|
const unknown = balanceLinesForAddress(address(null), [], true);
|
|
const zero = balanceLinesForAddress(address("0.0"), [], true);
|
|
expect(unknown).not.toBe(zero);
|
|
expect(quantities(unknown)).toEqual([
|
|
"ETH",
|
|
"0.0000",
|
|
"NOVEL",
|
|
"quantity unknown",
|
|
]);
|
|
expect(quantities(zero)).toEqual(["ETH", "0.0000", "NOVEL", "0.0000"]);
|
|
});
|
|
});
|
|
|
|
describe("addressHoldsFunds", () => {
|
|
// Read by deleteAddress.js to decide whether removing the address is
|
|
// warned about. balances.js drops a row of zero base units before any
|
|
// scale is consulted, so a row that survived with no quantity is holding
|
|
// something, and the warning must err towards warning.
|
|
test("an unknown balance holds funds, a zero balance does not", () => {
|
|
expect(addressHoldsFunds(address(null))).toBe(true);
|
|
expect(addressHoldsFunds(address("0.0"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("getAddressValue", () => {
|
|
// `usd` is the value of what could be priced and `partial` says it is a
|
|
// floor rather than the total. An unpriceable holding is exactly what
|
|
// `partial` exists for; a holding of zero can neither add to the total nor
|
|
// make it incomplete.
|
|
test("an unknown balance makes the total partial, a zero balance does not", () => {
|
|
prices.ETH = 2000;
|
|
prices[NOVEL] = 3;
|
|
const unknown = getAddressValue(address(null));
|
|
const zero = getAddressValue(address("0.0"));
|
|
expect(unknown).not.toEqual(zero);
|
|
expect(unknown).toEqual({ usd: 0, partial: true });
|
|
expect(zero).toEqual({ usd: 0, partial: false });
|
|
});
|
|
|
|
test("an unknown balance is not priced as zero of the token", () => {
|
|
prices.ETH = 2000;
|
|
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({
|
|
usd: 30,
|
|
partial: false,
|
|
});
|
|
expect(getAddressValue(address(null)).usd).toBe(0);
|
|
expect(getAddressValue(address(null)).partial).toBe(true);
|
|
});
|
|
});
|