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
284 righe
11 KiB
JavaScript
284 righe
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();
|
|
});
|