fix: store an absent explorer decimals as unknown instead of fabricating 18 (closes #349)
fetchTokenBalances() did parseInt(item.token.decimals || "18", 10) before writing to state.wallets[].addresses[].tokenBalances[].decimals, so a token whose decimals() reverts -- one the block explorer reports no scale for -- was stored with a fabricated 18 that no reader could tell from a real one. That is upstream of a rule already merged. #306 made the ERC-20 approval amount line resolve the real scale or refuse to format, and #340 extended it to the swap lines; both read this stored value as an authoritative source, so the guess walked straight past refusals that were intact and simply never fired. A 1,000-unit approval of such a token rendered 0.000000001 on the one screen whose job is to state what is being authorized. The stored value is now the explorer's own answer or null, never a default. Both approval paths reach unknownDecimalsAmount() on a null, using the refusal that was already there. The history list's token transfers carried the same || "18" and now state exact base units with the scale unknown rather than a quantity at a guessed one. A holding whose scale nothing knows has no quantity either, so its balance is stored as null -- unknown, never zero -- and the balance list, the address USD total, the Send screen and the confirmation screen each say so rather than printing 0.0000 for money that is really there. The zero-balance filter moved onto the base-unit integer, where it needs no scale at all. The bundled token list and the user's tracked tokens already outrank the explorer, so a token either of them knows still displays its real quantity when the explorer's entry omits decimals; only what none of the three knows is unknown. The uint8 check is one shared toDecimals() rather than three copies of it, and it answers 0 for a real scale of zero: || "18" collapsed that to eighteen, the falsy-collapse trap of #246. Existing installs hold 18s that cannot be told apart retroactively -- that is the defect, and no migration can undo it. They display exactly as they do today until the next balance refresh, which rewrites tokenBalances wholesale and needs no user action. The schema version is not bumped: version 1 records stay valid and are read exactly as before. The only 18s left in src/ are native ETH's real scale in uniswap.js and the fixed-point comparison scale in txValidation.js.
This commit is contained in:
282
tests/fabricatedDecimals.test.js
Normal file
282
tests/fabricatedDecimals.test.js
Normal file
@@ -0,0 +1,282 @@
|
||||
// What the balance fetcher stores when the block explorer reports no decimals
|
||||
// for a token, and what the approval screens then display.
|
||||
//
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/349: `fetchTokenBalances()` did
|
||||
// `parseInt(item.token.decimals || "18", 10)` BEFORE writing the row, so a
|
||||
// token whose `decimals()` reverts — and which the explorer therefore reports
|
||||
// no scale for — was stored with a fabricated 18. Nothing downstream could
|
||||
// tell that from a real 18.
|
||||
//
|
||||
// That matters because it is upstream of two refusals that were already built
|
||||
// and already merged. https://git.eeqj.de/sneak/AutistMask/issues/306 made the
|
||||
// ERC-20 amount line resolve the real scale or refuse to format, and
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/340 did the same for the swap
|
||||
// lines. Both read this stored value as an authoritative source, so the guess
|
||||
// walked straight past them: the refusal was intact and simply never fired.
|
||||
//
|
||||
// So these tests run a real explorer response through the real fetcher and
|
||||
// assert on the real approval screens. A test that hand-writes `decimals: null`
|
||||
// onto state would pass on the broken build, because the fabrication is in the
|
||||
// writer, not the readers.
|
||||
|
||||
jest.mock("../src/shared/log", () => ({
|
||||
log: {
|
||||
debugf: () => {},
|
||||
infof: () => {},
|
||||
warnf: () => {},
|
||||
errorf: () => {},
|
||||
},
|
||||
debugFetch: jest.fn(),
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
global.fetch = jest.fn(() => {
|
||||
throw new Error("tests must not perform network requests");
|
||||
});
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
global.chrome = { storage: makeStorageStub() };
|
||||
|
||||
const { AbiCoder, Interface } = require("ethers");
|
||||
const { ERC20_ABI } = require("../src/shared/constants");
|
||||
const { fetchTokenBalances } = require("../src/shared/balances");
|
||||
const { debugFetch } = require("../src/shared/log");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
|
||||
const { decodeCalldata } = require("../src/popup/views/approval");
|
||||
const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");
|
||||
|
||||
const HOLDER = "0x" + "a".repeat(40);
|
||||
const BLOCKSCOUT = "https://blockscout.example/api/v2";
|
||||
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
|
||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||
const SPENDER = "0x1111111111111111111111111111111111111111";
|
||||
// Outside the bundled list and untracked, so the explorer is the only source
|
||||
// of a scale for it — which is the case the fabrication was hiding.
|
||||
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
|
||||
// In the bundled list, at 18 decimals, for the other side of a swap.
|
||||
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
|
||||
|
||||
// The holding the explorer reports, in base units. Large enough that it does
|
||||
// not round to zero even when divided by 10^18, which is what makes it the
|
||||
// case the laundering actually REACHED: a smaller holding formatted at the
|
||||
// fabricated 18 comes out "0.0", the balance list drops the row as dust, and
|
||||
// the approval screens then find no source for the scale and refuse anyway —
|
||||
// for the wrong reason, and only by luck.
|
||||
const HOLDING = 5000000000000000000n;
|
||||
|
||||
// The amount in the dApp's calldata, which is a separate number from the
|
||||
// holding. 1,000.00 of a 6-decimal token; formatted at the fabricated 18 it
|
||||
// reads 0.000000001, and at a real scale of 0 it reads 1000000000.
|
||||
const THOUSAND_AT_SIX = 1000000000n;
|
||||
const HALF_WETH = 500000000000000000n;
|
||||
|
||||
const erc20Iface = new Interface(ERC20_ABI);
|
||||
const coder = AbiCoder.defaultAbiCoder();
|
||||
const routerIface = new Interface([
|
||||
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
|
||||
]);
|
||||
|
||||
// One Blockscout token-balances row. `token` is spread last so a test can
|
||||
// override or blank a field; the base row carries no `decimals` at all, which
|
||||
// is exactly what a token whose decimals() reverts produces.
|
||||
function row(token = {}, value = HOLDING) {
|
||||
return {
|
||||
value: String(value),
|
||||
token: {
|
||||
type: "ERC-20",
|
||||
address_hash: NOVEL,
|
||||
symbol: "NOVEL",
|
||||
name: "Novel Token",
|
||||
// Well clear of the balance list's own spam floor, so the row is
|
||||
// admitted on its holder count alone: neither the bundled list nor
|
||||
// a tracked entry can supply a scale for it.
|
||||
holders_count: "50000",
|
||||
...token,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function respondWith(items) {
|
||||
debugFetch.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => items,
|
||||
}));
|
||||
}
|
||||
|
||||
// Fetch and place the result exactly where refreshBalances() places it, so the
|
||||
// approval screens read what a real refresh would have left on state.
|
||||
async function fetchOnto(items, trackedTokens = []) {
|
||||
respondWith(items);
|
||||
const balances = await fetchTokenBalances(
|
||||
HOLDER,
|
||||
BLOCKSCOUT,
|
||||
trackedTokens,
|
||||
);
|
||||
state.trackedTokens = trackedTokens;
|
||||
state.wallets = [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
addresses: [
|
||||
{ address: HOLDER, balance: "1.0", tokenBalances: balances },
|
||||
],
|
||||
},
|
||||
];
|
||||
return balances;
|
||||
}
|
||||
|
||||
// The ERC-20 approval screen's Amount line, and the swap decoder's.
|
||||
function erc20AmountLine(data, tokenAddress) {
|
||||
return decodeCalldata(data, tokenAddress).details.find(
|
||||
(d) => d.label === "Amount",
|
||||
).value;
|
||||
}
|
||||
|
||||
function swapAmountLine(data) {
|
||||
return decodeCalldata(data, ROUTER).details.find(
|
||||
(d) => d.label === "Amount",
|
||||
).value;
|
||||
}
|
||||
|
||||
function transferData(amount) {
|
||||
return erc20Iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
|
||||
}
|
||||
|
||||
function approveData(amount) {
|
||||
return erc20Iface.encodeFunctionData("approve", [SPENDER, amount]);
|
||||
}
|
||||
|
||||
function swapData(tokenIn, amountIn, tokenOut, amountOutMin) {
|
||||
const input = coder.encode(
|
||||
["address", "uint256", "uint256", "address[]", "bool"],
|
||||
[RECIPIENT, amountIn, amountOutMin, [tokenIn, tokenOut], true],
|
||||
);
|
||||
return routerIface.encodeFunctionData("execute", [
|
||||
"0x08",
|
||||
[input],
|
||||
9999999999n,
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
debugFetch.mockReset();
|
||||
state.trackedTokens = [];
|
||||
state.wallets = [];
|
||||
});
|
||||
|
||||
describe("what fetchTokenBalances stores for an absent scale", () => {
|
||||
test("the token is not in the bundled list, so the explorer is the only source", () => {
|
||||
expect(TOKEN_BY_ADDRESS.has(NOVEL.toLowerCase())).toBe(false);
|
||||
});
|
||||
|
||||
test("an absent decimals is stored as null, not as 18", async () => {
|
||||
const balances = await fetchOnto([row()]);
|
||||
expect(balances).toHaveLength(1);
|
||||
expect(balances[0].decimals).toBeNull();
|
||||
});
|
||||
|
||||
test("an explicit null decimals is stored as null too", async () => {
|
||||
const balances = await fetchOnto([row({ decimals: null })]);
|
||||
expect(balances[0].decimals).toBeNull();
|
||||
});
|
||||
|
||||
// The same explorer row twice, differing only in whether it reports a
|
||||
// scale of 18. Before the fix both stored 18 and no reader could tell
|
||||
// which one had actually been reported.
|
||||
test("a real 18 is stored as 18, and so is distinguishable from absent", async () => {
|
||||
const real = await fetchOnto([row({ decimals: "18" })]);
|
||||
expect(real[0].decimals).toBe(18);
|
||||
expect(real[0].balance).toBe("5.0");
|
||||
const absent = await fetchOnto([row()]);
|
||||
expect(absent[0].decimals).toBeNull();
|
||||
expect(real[0].decimals).not.toBe(absent[0].decimals);
|
||||
});
|
||||
|
||||
// The falsy-collapse trap of
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/246. `decimals || "18"` reads
|
||||
// a real scale of zero as absent and then as eighteen, which is eighteen
|
||||
// orders of magnitude of error in the direction that displays as nothing.
|
||||
test("a real scale of zero is stored as zero, not collapsed", async () => {
|
||||
for (const reported of ["0", 0]) {
|
||||
const balances = await fetchOnto([row({ decimals: reported })]);
|
||||
expect(balances[0].decimals).toBe(0);
|
||||
expect(balances[0].balance).toBe("5000000000000000000.0");
|
||||
}
|
||||
});
|
||||
|
||||
test("no quantity is stated for a holding whose scale is unknown", async () => {
|
||||
const balances = await fetchOnto([row()]);
|
||||
// Not "0.0": the holding is real and nonzero, and a zero here is the
|
||||
// same lie the approval screens refuse to tell.
|
||||
expect(balances[0].balance).toBeNull();
|
||||
});
|
||||
|
||||
// Zero base units is zero tokens at every scale, so this filter never
|
||||
// needed a scale in the first place and does not acquire one now.
|
||||
test("a holding of zero base units is still dropped without a scale", async () => {
|
||||
expect(await fetchOnto([row({}, 0n)])).toEqual([]);
|
||||
});
|
||||
|
||||
test("the bundled list still supplies a quantity the explorer omitted", async () => {
|
||||
const balances = await fetchOnto([
|
||||
row({ address_hash: WETH, symbol: "WETH" }),
|
||||
]);
|
||||
// The stored decimals stay the explorer's own answer — absent. Copying
|
||||
// another source in here would make explorerDecimals()'s disagreement
|
||||
// check compare something other than explorer values.
|
||||
expect(balances[0].decimals).toBeNull();
|
||||
// The displayed quantity still comes out right, because the bundled
|
||||
// list knows this token's scale and outranks the explorer anyway.
|
||||
expect(balances[0].balance).toBe("5.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ERC-20 approval line reaches its refusal", () => {
|
||||
test("a transfer of a token the explorer gave no scale for is not formatted", async () => {
|
||||
await fetchOnto([row()]);
|
||||
const line = erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL);
|
||||
expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
|
||||
// The defect: a fabricated 18 renders this as 0.000000001, a quantity,
|
||||
// and a wrong one.
|
||||
expect(line).not.toMatch(/^0\./);
|
||||
});
|
||||
|
||||
test("an approve of the same token is not formatted either", async () => {
|
||||
await fetchOnto([row()]);
|
||||
const line = erc20AmountLine(approveData(THOUSAND_AT_SIX), NOVEL);
|
||||
expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
|
||||
expect(line).not.toMatch(/^0\./);
|
||||
});
|
||||
|
||||
test("a scale the explorer did report still formats", async () => {
|
||||
await fetchOnto([row({ decimals: "6" })]);
|
||||
expect(erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL)).toBe(
|
||||
"1000.0000",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the swap approval line reaches its refusal", () => {
|
||||
test("a swap of a token the explorer gave no scale for is not formatted", async () => {
|
||||
await fetchOnto([row()]);
|
||||
const line = swapAmountLine(
|
||||
swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH),
|
||||
);
|
||||
expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
|
||||
expect(line).not.toMatch(/^0\./);
|
||||
});
|
||||
|
||||
test("a scale the explorer did report still formats", async () => {
|
||||
await fetchOnto([row({ decimals: "6" })]);
|
||||
expect(
|
||||
swapAmountLine(swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH)),
|
||||
).toBe("1000.0000");
|
||||
});
|
||||
});
|
||||
|
||||
test("no test in this file performed a network request", () => {
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
Reference in New Issue
Block a user