// What the dApp approval screen says the output token of a V4 swap is when the // calldata did not name one. // // Issue #353: `src/shared/uniswap.js` took a V4 step's `amountOutMin` while // leaving `outputToken` null, and `tokenInfo(null)` answers // `{symbol: "ETH", decimals: 18}`. An undetermined output was therefore stated // to the user as ETH, with `Min. received` formatted at 18 decimals — the same // class as https://git.eeqj.de/sneak/AutistMask/issues/340 and // https://git.eeqj.de/sneak/AutistMask/issues/306, but naming the wrong asset // rather than the wrong scale. // // The determination this file pins: null is NOT how V4 spells native ETH. // v4-core declares `type Currency is address` and wraps `address(0)` for // native ETH, and a Currency is ABI-encoded as a plain address word, so a // native-ETH currency reaches the decoder as the string // "0x0000000000000000000000000000000000000000" — truthy, and already named // ETH by tokenInfo(). Both cases are asserted below: the zero address stays // ETH, and null refuses. const { AbiCoder, Interface, solidityPacked } = require("ethers"); const uniswap = require("../src/shared/uniswap"); const { unknownDecimalsAmount } = require("../src/shared/approvalAmount"); const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const ZERO = "0x0000000000000000000000000000000000000000"; // Both in the bundled list: USDT at 6 decimals, WETH at 18. const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const REFUSAL = "Unknown (not named in the calldata)"; // The figure whose asset is in question. At 18 decimals it renders as // 0.000000000001; in base units it renders as itself. const MIN_OUT = 1234567n; const HALF_ETH = 500000000000000000n; const V4_SWAP_EXACT_IN_SINGLE = 0x06; 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). // An empty path names no output currency at all. function exactInParams(currencyIn, path, amountIn, amountOutMin) { return coder.encode( [ "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", ], [[currencyIn, path, amountIn, amountOutMin]], ); } // IV4Router.ExactInputSingleParams: (PoolKey, bool zeroForOne, uint128 // amountIn, uint128 minOut, bytes hookData). function exactInSingleParams(currency0, currency1, zeroForOne, min) { return coder.encode( [ "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", ], [ [ [currency0, currency1, 500, 10, ZERO], zeroForOne, 1000000n, min, "0x", ], ], ); } // V3_SWAP_EXACT_IN (command 0x00) input: path is token(20) fee(3) token(20). function v3ExactIn(tokenIn, tokenOut, amountIn, amountOutMin) { const path = "0x" + tokenIn.slice(2).toLowerCase() + "000bb8" + tokenOut.slice(2).toLowerCase(); return coder.encode( ["address", "uint256", "uint256", "bytes", "bool"], [USER, amountIn, amountOutMin, path, true], ); } function detail(data, label) { const decoded = uniswap.decode(data, ROUTER, {}); expect(decoded).not.toBeNull(); return decoded.details.find((d) => d.label === label); } describe("a V4 step that states a Min. received but no output currency", () => { // SWAP_EXACT_IN with an empty path: amountOutMin decodes, no currency out // does, and there is no TAKE to supply one. const data = () => execute("0x10", [ v4Input( [V4_SWAP_EXACT_IN], [exactInParams(USDT, [], 5000000n, MIN_OUT)], ), ]); test("says the output token is unknown instead of naming ETH", () => { expect(detail(data(), "Token Out").value).toBe(REFUSAL); }); test("keeps a Token Out line, so the figure is never unattributed", () => { const out = detail(data(), "Token Out"); expect(out).toBeDefined(); // A refusal is not a token: nothing to link to an explorer. expect(out.address).toBeUndefined(); expect(out.isToken).toBeUndefined(); }); test("refuses to scale Min. received rather than assuming 18", () => { expect(detail(data(), "Min. received").value).toBe( unknownDecimalsAmount(MIN_OUT), ); }); test("does not name ETH in the swap title either", () => { const decoded = uniswap.decode(data(), ROUTER, {}); expect(decoded.name).toBe("Uniswap Swap"); }); }); describe("a V4 step whose Min. received supersedes an earlier step's", () => { // V3 USDT -> WETH, then a V4 step that carries the final Min. received but // names no currency. The figure belongs to the V4 step, so it must not be // read against the token the V3 step named. const data = () => execute(solidityPacked(["uint8", "uint8"], [0x00, 0x10]), [ v3ExactIn(USDT, WETH, 5000000n, HALF_ETH), v4Input( [V4_SWAP_EXACT_IN], [exactInParams(WETH, [], HALF_ETH, MIN_OUT)], ), ]); test("does not keep naming the earlier step's output token", () => { expect(detail(data(), "Token Out").value).toBe(REFUSAL); }); test("refuses to scale the superseding figure", () => { expect(detail(data(), "Min. received").value).toBe( unknownDecimalsAmount(MIN_OUT), ); }); }); describe("native ETH out, which V4 spells as the zero address", () => { // The pin on the other half of the determination. PoolKey currencies are // ordered, so native ETH is currency0; zeroForOne false swaps USDT in for // ETH out. TAKE names the same zero-address currency. const data = () => execute("0x10", [ v4Input( [V4_SETTLE, V4_SWAP_EXACT_IN_SINGLE, V4_TAKE], [ coder.encode( ["address", "uint256", "bool"], [USDT, 5000000n, true], ), exactInSingleParams(ZERO, USDT, false, HALF_ETH), coder.encode( ["address", "address", "uint256"], [ZERO, USER, 0n], ), ], ), ]); test("a Currency of address(0) decodes to a truthy address string", () => { // The fact the whole determination rests on: an absent output 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", "address", "uint256"], coder.encode(["address", "address", "uint256"], [ZERO, USER, 0n]), ); expect(currency).toBe(ZERO); expect(Boolean(currency)).toBe(true); }); test("is still named ETH and still scaled at 18 decimals", () => { expect(detail(data(), "Token Out").value).toBe("ETH"); expect(detail(data(), "Min. received").value).toBe("0.5000 ETH"); expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Swap USDT → ETH"); }); });