// What the dApp approval screen says the input token of a swap is when the // calldata did not name one. // // Issue #357, the twin on the input side of // https://git.eeqj.de/sneak/AutistMask/issues/353: `tokenInfo(null)` answered // `{symbol: "ETH", decimals: 18}`, so a null `inputToken` rendered as // `Token In: ETH (native)` and titled the swap `Swap ETH -> X`. An // undetermined input was therefore asserted to the user as native ETH — the // same class as https://git.eeqj.de/sneak/AutistMask/issues/340 and // https://git.eeqj.de/sneak/AutistMask/issues/306, naming the wrong asset // rather than merely mis-scaling it. // // The determination this file pins is the one #353 established, checked here // on the input side: null is NOT how native ETH arrives. v4-core declares // `type Currency is address` and wraps `address(0)` for native ETH, and a // user-defined value type over `address` carries the plain `address` ABI // encoding, so a native-ETH currency reaches the decoder as the truthy string // "0x0000000000000000000000000000000000000000". WRAP_ETH sets that same // explicit zero address. Both halves are asserted below: the zero address // stays ETH, and null refuses. const { AbiCoder, Interface, solidityPacked } = require("ethers"); const uniswap = require("../src/shared/uniswap"); const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const ZERO = "0x0000000000000000000000000000000000000000"; // Both in the bundled list: USDT at 6 decimals, USDC at 6. const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // The same wording the output side refuses with — one screen, one vocabulary. const REFUSAL = "Unknown (not named in the calldata)"; const ONE_ETH = 1000000000000000000n; const V4_SWAP_EXACT_IN = 0x07; const V4_SETTLE = 0x0b; const V4_TAKE = 0x0e; const coder = AbiCoder.defaultAbiCoder(); const routerIface = new Interface([ "function execute(bytes commands, bytes[] inputs, uint256 deadline)", ]); function execute(commands, inputs) { return routerIface.encodeFunctionData("execute", [ commands, inputs, 9999999999n, ]); } function v4Input(actions, params) { return coder.encode( ["bytes", "bytes[]"], [new Uint8Array(actions), params], ); } // IV4Router.ExactInputParams as the decoder reads it: // (Currency currencyIn, PathKey[] path, uint128 amountIn, uint128 minOut). function exactInParams(currencyIn, path, amountIn, amountOutMin) { return coder.encode( [ "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", ], [[currencyIn, path, amountIn, amountOutMin]], ); } // BALANCE_CHECK_ERC20 (command 0x0e): (address owner, address token, // uint256 minBalance). It names the output token and nothing about the input. function balanceCheck(token, minBalance) { return coder.encode( ["address", "address", "uint256"], [USER, token, minBalance], ); } function detail(data, label) { const decoded = uniswap.decode(data, ROUTER, {}); expect(decoded).not.toBeNull(); return decoded.details.find((d) => d.label === label); } describe("an execute() whose input currency never decoded", () => { // A V4_SWAP whose sub-action params do not decode — an action encoding // this decoder does not know — leaves every V4 token null. The // BALANCE_CHECK still names the output, so the screen has a Min. received // figure and a Token Out, and previously claimed the user was paying ETH // for them. const data = () => execute(solidityPacked(["uint8", "uint8"], [0x10, 0x0e]), [ coder.encode(["bytes", "bytes[]"], ["0x07", ["0x"]]), balanceCheck(USDC, 2000000n), ]); test("says the input token is unknown instead of naming ETH", () => { expect(detail(data(), "Token In").value).toBe(REFUSAL); }); test("keeps a Token In line, so the screen never omits what is paid", () => { const tokenIn = detail(data(), "Token In"); expect(tokenIn).toBeDefined(); // A refusal is not a token: nothing to link to an explorer. expect(tokenIn.address).toBeUndefined(); expect(tokenIn.isToken).toBeUndefined(); }); test("does not name ETH in the swap title either", () => { expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Uniswap Swap"); }); }); describe("an execute() that names only an output token", () => { // A lone BALANCE_CHECK_ERC20: nothing in it says what is being paid. const data = () => execute("0x0e", [balanceCheck(USDT, 2000000n)]); test("refuses the input rather than defaulting it to ETH", () => { expect(detail(data(), "Token In").value).toBe(REFUSAL); expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Uniswap Swap"); }); test("still states the output it did establish", () => { expect(detail(data(), "Token Out").value).toContain("USDT"); expect(detail(data(), "Min. received").value).toBe("2.0000 USDT"); }); }); describe("native ETH in, which V4 spells as the zero address", () => { test("a Currency of address(0) decodes to a truthy address string", () => { // The fact the whole determination rests on: an absent input currency // and a native-ETH one are distinguishable here, because the ABI // decoder never yields null for an address word. const [currency] = coder.decode( ["address", "uint256", "bool"], coder.encode(["address", "uint256", "bool"], [ZERO, ONE_ETH, true]), ); expect(currency).toBe(ZERO); expect(Boolean(currency)).toBe(true); }); test("a V4 SETTLE of the zero address is still ETH at 18 decimals", () => { const data = execute("0x10", [ v4Input( [V4_SETTLE, V4_SWAP_EXACT_IN, V4_TAKE], [ coder.encode( ["address", "uint256", "bool"], [ZERO, ONE_ETH, true], ), exactInParams( ZERO, [[USDT, 500, 10, ZERO, "0x"]], ONE_ETH, 2000000n, ), coder.encode( ["address", "address", "uint256"], [USDT, USER, 0n], ), ], ), ]); expect(detail(data, "Token In").value).toBe("ETH (native)"); expect(detail(data, "Amount").value).toBe("1.0000 ETH"); expect(uniswap.decode(data, ROUTER, {}).name).toBe("Swap ETH → USDT"); }); test("WRAP_ETH is still ETH at 18 decimals", () => { // WRAP_ETH names no currency of its own; the decoder supplies the // explicit zero address for it, which is why it survives this change. const data = execute("0x0b", [ coder.encode(["address", "uint256"], [ROUTER, ONE_ETH]), ]); expect(detail(data, "Token In").value).toBe("ETH (native)"); expect(detail(data, "Amount").value).toBe("1.0000 ETH"); }); });