Files
AutistMask/tests/uniswapUnknownDecimals.test.js
clawbot a658fde62d
Some checks failed
check / check (push) Successful in 30s
e2e / e2e-chrome (push) Failing after 1m25s
e2e / e2e-firefox (push) Successful in 21s
harden: resolve the swap approval screen's token scale, or refuse to format (closes #340)
tokenInfo() in src/shared/uniswap.js returned decimals: 18 for any token absent from the bundled token list, the same guessed scale that issue 306 removed from the ERC-20 amount line of the same screen. A 1,000-token swap of a 6-decimal token was therefore stated as 0.000000001, a wrong number rather than an imprecise one, and every newly listed token reached it. The swap's Amount and Min. received lines now resolve the scale through resolveTokenDecimals() -- the bundled list, then the tokens the user tracks, then the decimals the block explorer reported -- and where none of those answers they render unknownDecimalsAmount(), the base-unit integer with the scale stated, which is the same refusal the ERC-20 line already makes. No new data source and no network call: the scale comes only from what the wallet already holds, so the screen still makes no request before showing what is being signed. uniswap.decode() takes those sources as a third argument, supplied by decodeCalldata() from state alongside the ones the ERC-20 path already used. An unbounded permit needs no scale to describe and is still shown as Unlimited. README.md records the rule as a Display Consistency exception.
2026-08-23 13:49:52 +00:00

177 lines
6.8 KiB
JavaScript

// The scale the swap lines of the dApp approval screen are displayed with.
//
// Issue #340: `tokenInfo()` in `src/shared/uniswap.js` returned `decimals: 18`
// for any token absent from the bundled list — the same guess
// https://git.eeqj.de/sneak/AutistMask/issues/306 removed from the ERC-20
// amount line, still live on the swap path. A 1,000-token swap of a 6-decimal
// token then rendered as `0.000000001` on the one screen whose job is to state
// what is being authorized, and every newly listed token reaches it.
//
// What is asserted here is the rule #306 established: resolve the real scale
// wherever the wallet already has it, and where nothing has it refuse to
// format — base units with the scale stated, never a quantity.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { AbiCoder, Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// Outside the bundled list, as every newly listed token is.
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
// Also outside it, standing in for the swap's output side.
const NOVEL_OUT = "0xd0d0000000000000000000000000000000000d0d";
// In the bundled list, at 18 decimals.
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
// 1,000.00 of a 6-decimal token — the amount from the issue, which the 18
// guess rendered as 0.000000001.
const THOUSAND_AT_SIX = 1000000000n;
// 0.5 WETH out, so the Min. received line has a real number of its own.
const HALF_WETH = 500000000000000000n;
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// A V2_SWAP_EXACT_IN (command 0x08) execute() call: `amountIn` of `tokenIn`
// for at least `amountOutMin` of `tokenOut`.
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,
]);
}
// A wallet holding `token` with the decimals the block explorer reported,
// shaped as balances.js writes it onto state.
function walletsHolding(token, decimals) {
return [
{
name: "Wallet 1",
addresses: [
{
address: "0x" + "a".repeat(40),
balance: "1.0",
tokenBalances: [
{
address: token,
symbol: "NOVEL",
decimals,
balance: "1000.0",
},
],
},
],
},
];
}
// The swap detail line as the approval screen renders it. It goes through
// decodeCalldata() rather than uniswap.decode() directly, because the scale
// sources the screen supplies are part of what is under test.
function swapDetail(data, label) {
const decoded = decodeCalldata(data, ROUTER);
return decoded.details.find((d) => d.label === label);
}
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("a swap of a token outside the bundled list", () => {
const data = () => swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH);
test("shows the true quantity when the user tracks the token", () => {
state.trackedTokens = [
{ address: NOVEL, symbol: "NOVEL", decimals: 6 },
];
expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
});
test("shows the true quantity from the explorer's decimals", () => {
state.wallets = walletsHolding(NOVEL, "6");
expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
});
test("refuses to format when nothing knows the scale", () => {
const detail = swapDetail(data(), "Amount");
expect(detail.value).toBe("1000000000 base units (decimals unknown)");
expect(detail.value).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
// The defect: an 18-decimal guess renders this swap as 0.000000001, a
// quantity, and a wrong one.
expect(detail.value).not.toMatch(/^0\./);
expect(detail.value).not.toMatch(/[0-9]\.[0-9]/);
});
test("the amount carried to the status screens is the same refusal", () => {
expect(swapDetail(data(), "Amount").rawValue).toBe(
"1000000000 base units (decimals unknown)",
);
});
test("a bundled token on the other side still formats", () => {
expect(swapDetail(data(), "Min. received").value).toBe("0.5000 WETH");
});
});
describe("the Min. received line takes the same rule", () => {
test("refuses to format an output token of unknown scale", () => {
const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
const detail = swapDetail(data, "Min. received");
expect(detail.value).toBe("1000000000 base units (decimals unknown)");
expect(detail.value).not.toMatch(/[0-9]\.[0-9]/);
});
test("shows the true quantity when the user tracks the output token", () => {
state.trackedTokens = [
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
];
const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
expect(swapDetail(data, "Min. received").value).toBe("1000.0000");
});
});
describe("the permit amount takes the same rule", () => {
// PERMIT2_PERMIT (command 0x0a): the input token and amount come from the
// permit rather than from a swap step.
function permitData(token, amount) {
const input = coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
"bytes",
],
[[[token, amount, 0, 0], ROUTER, 9999999999], "0x1234"],
);
return routerIface.encodeFunctionData("execute", [
"0x0a",
[input],
9999999999n,
]);
}
test("refuses to format a permit on a token of unknown scale", () => {
const detail = swapDetail(permitData(NOVEL, THOUSAND_AT_SIX), "Amount");
expect(detail.value).toBe("1000000000 base units (decimals unknown)");
});
test("an unbounded permit is still named, with or without a scale", () => {
const maxUint160 = (1n << 160n) - 1n;
expect(swapDetail(permitData(NOVEL, maxUint160), "Amount").value).toBe(
"Unlimited",
);
});
});