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