Files
AutistMask/tests/uniswapUnknownDecimals.test.js
T
clawbot ab86707a77
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
fix: the approval screen names the address a swap's output is paid to (closes #594)
The swap decoder read no recipient, so a swap paying its output to another
address still showed Min. received. It now collects the address that every
swap step, SWEEP, WRAP_ETH, UNWRAP_WETH, V4 TAKE and TAKE_ALL pays, across the
whole transaction, leaving out payments to the router itself. The minimum
reads Min. received only when some step pays the sender, none pays another
address, and no step that may pay an address went unread: a recipient that
does not decode, or a command or V4 action the decoder does not read.
Otherwise it reads Min. paid out, with a Paid to line for each other address
and one saying when who is paid is unknown. The approval and transaction
detail screens pass the sender in.

Model: opus-5-5
2026-10-19 06:58:59 +00:00

192 lines
7.6 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) {
// Sent by the address the swap pays, so the output is the user's.
const decoded = decodeCalldata(data, ROUTER, "0x1", RECIPIENT);
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 },
];
// The tracked entry names the token as well as scaling it (issue #323).
expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL");
});
test("shows the true quantity from the explorer's decimals", () => {
state.wallets = walletsHolding(NOVEL, "6");
expect(swapDetail(data(), "Amount").value).toBe("1000.0000 NOVEL");
});
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");
});
// A token added by hand carries whatever its decimals() returned, and a
// uint8 reaches 255, but formatUnits() throws above 80. The throw left the
// whole swap undecoded rather than refused
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
test("refuses to format when the token reports more than 80 decimals", () => {
state.trackedTokens = [
{ address: NOVEL, symbol: "NOVEL", decimals: 81 },
];
expect(swapDetail(data(), "Amount").value).toBe(
"1000000000 base units (decimals unknown)",
);
});
});
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 NOVEL");
});
});
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",
);
});
});