Files
AutistMask/tests/fabricatedDecimals.test.js
T
clawbot 2fe6447625
check / check (push) Failing after 0s
e2e / e2e-firefox (push) Failing after 0s
e2e / e2e-chrome (push) Failing after 1m27s
fix: name a tracked or explorer-known token instead of "Unknown token" (closes #323)
The approval and transaction-status screens read a token's scale from the
bundled list, the tokens the user tracks, then the block explorer, but read
its symbol from the bundled list alone. A token the user added by hand was
scaled correctly yet labelled "Unknown token", and a non-bundled ERC-20 was
carried onto the wait screen as ETH.

resolveTokenSymbol() now draws the symbol through the same sources and
precedence as the scale, and the ERC-20 and Uniswap swap lines both use it. A
tracked or explorer-reported name stays subject to the spoof rule, so it
cannot claim a bundled or native ticker.

Folds in #354.

Model: opus-4-8
Co-authored-by: clawbot <clawbot@noreply.example.org>
2026-09-21 21:28:06 +02:00

284 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" })]);
// The same explorer entry now also names the token (issue #323).
expect(erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL)).toBe(
"1000.0000 NOVEL",
);
});
});
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 NOVEL");
});
});
test("no test in this file performed a network request", () => {
expect(global.fetch).not.toHaveBeenCalled();
});