Files
AutistMask/tests/fabricatedDecimals.test.js
clawbot 1b52aa1723
All checks were successful
check / check (push) Successful in 34s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 30s
fix: store an absent explorer decimals as unknown instead of fabricating 18 (closes #349)
parseInt(decimals || "18") ran before writing stored tokenBalances[].decimals, so an explorer reporting no decimals produced a fabricated 18 indistinguishable from a real one at read time. That defeated the resolve-or-refuse guarantees of #306 and #340: their refusal paths were intact but never fired, because the guess was laundered upstream of them.

An absent scale is now stored as unknown, and a holding whose scale nothing knows carries a null balance -- unknown, never zero -- with six reader sites saying so rather than printing 0.0000. The Send screen resolves the display scale rather than reading the stored one, so a bundled token whose explorer row omits decimals still sends; when the scale cannot be resolved the stored quantity is withdrawn too, so the user is told the balance is unknown rather than only that the fee failed.

Existing fabricated 18s cannot be told apart retroactively and are replaced wholesale on the next balance refresh. An explorer-sourced scale stays trusted -- only fabrication is removed; the reasoning is recorded on the issue.
2026-08-23 21:19:04 +02:00

283 lines
11 KiB
JavaScript

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