// The address a swap's output is paid to // (https://git.eeqj.de/sneak/AutistMask/issues/594). // // Every swap command, SWEEP, UNWRAP_WETH and V4 TAKE names the address it // pays, and the decoder read none of them: a swap whose output went to // another address still read "Min. received", as if the user received it. The // router reads address(1) as the caller and address(2) as the router itself, // which holds the output until a later step pays it out. // // The screens are driven against a minimal DOM stub in the shape // tests/uniswapRouterAddress.test.js uses. const { AbiCoder, Interface, solidityPacked } = require("ethers"); jest.mock("../src/shared/log", () => ({ log: { debugf: () => {}, infof: () => {}, warnf: () => {}, errorf: () => {}, }, debugFetch: async (url) => ({ ok: true, json: async () => mockExplorerAnswer(url), }), setRuntimeDebug: () => {}, isDebug: () => false, })); globalThis.chrome = { storage: { local: { get: async () => ({}), set: async () => {} } }, }; const uniswap = require("../src/shared/uniswap"); const { state } = require("../src/shared/state"); const approval = require("../src/popup/views/approval"); const transactionDetail = require("../src/popup/views/transactionDetail"); // UniversalRouterV2 on Ethereum Mainnet. const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const OTHER = "0x7777777777777777777777777777777777777777"; const MSG_SENDER = "0x0000000000000000000000000000000000000001"; const ADDRESS_THIS = "0x0000000000000000000000000000000000000002"; const ZERO = "0x0000000000000000000000000000000000000000"; const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const ONE_WETH = 1000000000000000000n; const USDT_2000 = 2000000000n; const USDC_1990 = 1990000000n; const coder = AbiCoder.defaultAbiCoder(); const routerIface = new Interface([ "function execute(bytes commands, bytes[] inputs, uint256 deadline)", ]); // execute() of `steps`, each [command, input]. function execute(...steps) { const commands = steps.map(([command]) => command); return routerIface.encodeFunctionData("execute", [ solidityPacked( commands.map(() => "uint8"), commands, ), steps.map(([, input]) => input), 9999999999n, ]); } // V2_SWAP_EXACT_IN (0x08) and V2_SWAP_EXACT_OUT (0x09) share one layout. function v2Swap(command, recipient, amount, limit, path) { return [ command, coder.encode( ["address", "uint256", "uint256", "address[]", "bool"], [recipient, amount, limit, path, true], ), ]; } function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) { return [ 0x00, coder.encode( ["address", "uint256", "uint256", "bytes", "bool"], [ recipient, amountIn, amountOutMin, solidityPacked( ["address", "uint24", "address"], [tokenIn, 3000, tokenOut], ), true, ], ), ]; } function sweep(token, recipient) { return [ 0x04, coder.encode(["address", "address", "uint256"], [token, recipient, 0n]), ]; } function wrapEth(recipient, amount) { return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])]; } function unwrapWeth(recipient) { return [0x0c, coder.encode(["address", "uint256"], [recipient, 0n])]; } // The V4 actions a step below is built from. const SWAP_USDT_FOR_USDC = [ 0x06, // SWAP_EXACT_IN_SINGLE coder.encode( [ "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", ], [[[USDT, USDC, 100, 1, ZERO], true, USDT_2000, USDC_1990, "0x"]], ), ]; const SETTLE_USDT = [ 0x0b, coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]), ]; function take(currency, recipient) { return [ 0x0e, coder.encode( ["address", "address", "uint256"], [currency, recipient, 0n], ), ]; } function takeAll(currency) { return [0x0f, coder.encode(["address", "uint256"], [currency, 0n])]; } // A V4_SWAP step of `actions`, each [action, params]. function v4Swap(...actions) { return [ 0x10, coder.encode( ["bytes", "bytes[]"], [ solidityPacked( actions.map(() => "uint8"), actions.map(([action]) => action), ), actions.map(([, params]) => params), ], ), ]; } function decode(data, sender = USER) { return uniswap.decode(data, ROUTER, "0x1", {}, sender); } function labels(result) { return result.details.map((d) => d.label); } function detail(result, label) { return result.details.find((d) => d.label === label); } function paidTo(result) { return result.details .filter((d) => d.label === "Paid to") .map((d) => d.address.toLowerCase()); } // A swap paid to the user, the lines it has always had. const PAID_TO_USER = [ "Protocol", "Token In", "Amount", "Token Out", "Min. received", "Steps", "Deadline", ]; const PAID_ELSEWHERE = [ "Protocol", "Token In", "Amount", "Token Out", "Paid to", "Min. paid out", "Steps", "Deadline", ]; beforeEach(() => { state.trackedTokens = []; state.wallets = []; }); describe("each swap command's recipient", () => { test.each([ { kind: "V2_SWAP_EXACT_IN", data: (r) => execute(v2Swap(0x08, r, USDT_2000, ONE_WETH, [USDT, WETH])), min: "1.0000 WETH", }, { kind: "V2_SWAP_EXACT_OUT", data: (r) => execute(v2Swap(0x09, r, ONE_WETH, USDT_2000, [USDT, WETH])), min: "1.0000 WETH", }, { kind: "V3_SWAP_EXACT_IN", data: (r) => execute(v3SwapExactIn(r, USDT_2000, ONE_WETH, USDT, WETH)), min: "1.0000 WETH", }, { kind: "V4_SWAP with TAKE", data: (r) => execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, r))), min: "1990.0000 USDC", }, ])( "$kind: paid to the user it reads as before; paid to another address it names that address", ({ data, min }) => { const viaPlaceholder = decode(data(MSG_SENDER)); expect(labels(viaPlaceholder)).toEqual(PAID_TO_USER); expect(detail(viaPlaceholder, "Min. received").value).toBe(min); // The sender's own address, in any case, reads the same. expect(decode(data(USER), USER.toLowerCase()).details).toEqual( viaPlaceholder.details, ); const toOther = decode(data(OTHER)); expect(labels(toOther)).toEqual(PAID_ELSEWHERE); expect(paidTo(toOther)).toEqual([OTHER]); expect(detail(toOther, "Min. paid out").value).toBe(min); }, ); }); describe("an output the router holds", () => { const toRouter = v3SwapExactIn( ADDRESS_THIS, USDT_2000, USDC_1990, USDT, USDC, ); test("SWEEP of the output token pays it to the SWEEP's recipient", () => { const direct = decode( execute( v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC), ), ); const toUser = decode(execute(toRouter, sweep(USDC, MSG_SENDER))); expect(labels(toUser)).toEqual(PAID_TO_USER); const withoutSteps = (r) => r.details.filter((d) => d.label !== "Steps"); expect(withoutSteps(toUser)).toEqual(withoutSteps(direct)); const toOther = decode(execute(toRouter, sweep(USDC, OTHER))); expect(labels(toOther)).toEqual(PAID_ELSEWHERE); expect(paidTo(toOther)).toEqual([OTHER]); expect(detail(toOther, "Min. paid out").value).toBe("1990.0000 USDC"); }); test("is paid to the router when nothing pays it out", () => { // A SWEEP of another token leaves it there. for (const data of [ execute(toRouter), execute(toRouter, sweep(USDT, MSG_SENDER)), ]) { const result = decode(data); expect(labels(result)).toEqual(PAID_ELSEWHERE); expect(paidTo(result)).toEqual([ROUTER]); } }); test("the router's own address as a recipient is read as address(2) is", () => { const toRouterAddress = v3SwapExactIn( ROUTER, USDT_2000, USDC_1990, USDT, USDC, ); expect(paidTo(decode(execute(toRouterAddress)))).toEqual([ROUTER]); expect( labels(decode(execute(toRouterAddress, sweep(USDC, MSG_SENDER)))), ).toEqual(PAID_TO_USER); }); test("UNWRAP_WETH pays its WETH, as ETH, to the UNWRAP_WETH's recipient", () => { const toWeth = v3SwapExactIn( ADDRESS_THIS, USDT_2000, ONE_WETH, USDT, WETH, ); const toUser = decode(execute(toWeth, unwrapWeth(MSG_SENDER))); expect(labels(toUser)).toEqual(PAID_TO_USER); expect(detail(toUser, "Token Out").value).toBe("ETH"); expect(detail(toUser, "Min. received").value).toBe("1.0000 ETH"); const toOther = decode(execute(toWeth, unwrapWeth(OTHER))); expect(labels(toOther)).toEqual(PAID_ELSEWHERE); expect(paidTo(toOther)).toEqual([OTHER]); expect(detail(toOther, "Min. paid out").value).toBe("1.0000 ETH"); }); test("with no step setting the output, UNWRAP_WETH's recipient is paid the ETH", () => { const wrap = wrapEth(ADDRESS_THIS, ONE_WETH); const toUser = decode(execute(wrap, unwrapWeth(MSG_SENDER))); expect(detail(toUser, "Token Out").value).toBe("ETH"); expect(paidTo(toUser)).toEqual([]); const toOther = decode(execute(wrap, unwrapWeth(OTHER))); expect(detail(toOther, "Token Out").value).toBe("ETH"); expect(paidTo(toOther)).toEqual([OTHER]); }); test("a V4 TAKE to the router, then SWEEP, pays the SWEEP's recipient", () => { const step = v4Swap( SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, ADDRESS_THIS), ); const toUser = decode(execute(step, sweep(USDC, MSG_SENDER))); expect(labels(toUser)).toEqual(PAID_TO_USER); const toOther = decode(execute(step, sweep(USDC, OTHER))); expect(labels(toOther)).toEqual(PAID_ELSEWHERE); expect(paidTo(toOther)).toEqual([OTHER]); }); }); describe("several V4 takes of the output", () => { test("TAKE_ALL pays the user; a TAKE to another address beside it names both", () => { const toUser = decode( execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))), ); expect(labels(toUser)).toEqual(PAID_TO_USER); // TAKE with an amount of zero takes the whole open credit, so // TAKE_ALL after it pays the user nothing. const split = decode( execute( v4Swap( SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, OTHER), takeAll(USDC), ), ), ); expect(paidTo(split)).toEqual([OTHER, USER.toLowerCase()]); expect(detail(split, "Min. received")).toBeUndefined(); expect(detail(split, "Min. paid out").value).toBe("1990.0000 USDC"); }); }); // The DOM stub both screens are drawn into. function makeElement(id) { const classes = new Set(); const el = { id, textContent: "", value: "", innerHTML: "", style: {}, dataset: {}, classList: { add: (...names) => names.forEach((n) => classes.add(n)), remove: (...names) => names.forEach((n) => classes.delete(n)), contains: (n) => classes.has(n), toggle: () => false, }, addEventListener: () => {}, querySelectorAll: () => [], appendChild: () => {}, }; Object.defineProperty(el, "parentElement", { get: () => node(id + "-parent"), }); return el; } function makeDocument() { const els = new Map(); return { getElementById(id) { if (id === "debug-banner") return null; if (!els.has(id)) els.set(id, makeElement(id)); return els.get(id); }, createElement: () => makeElement("created"), body: { prepend: () => {} }, }; } function node(id) { return globalThis.document.getElementById(id); } // A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`. const swapPaidTo = (recipient) => execute(v3SwapExactIn(recipient, USDT_2000, ONE_WETH, USDT, WETH)); // Open the approval screen for a transaction from USER sending `data` to the // router. async function showApproval(data) { globalThis.document = makeDocument(); globalThis.window = { location: { search: "" }, close: () => {} }; globalThis.chrome.runtime = { connect: () => ({ postMessage: () => {} }), sendMessage: (_msg, reply) => reply({ type: "tx", origin: "https://dapp.example", isPhishingDomain: false, approvedFrom: USER, approvedTx: { type: 2, from: USER, chainId: "0x1", nonce: "0x7", gasLimit: "0x30d40", maxPriorityFeePerGas: "0x3b9aca00", maxFeePerGas: "0x77359400", to: ROUTER, value: "0x0", data, accessList: [], }, }), }; state.networkId = "mainnet"; state.currentView = null; await approval.show(1); return node("approve-tx-decoded-details").innerHTML; } // The transaction the explorer reports: from USER, sending `mockSentData` to // the router. const TX_HASH = "0x" + "5".repeat(64); let mockSentData = null; function mockExplorerAnswer(url) { if (url.endsWith("/transactions/" + TX_HASH)) { return { from: { hash: USER }, to: { hash: ROUTER }, raw_input: mockSentData, method: "execute", }; } return {}; } // Open the transaction detail screen for that transaction, sending `data`. async function showDetail(data) { mockSentData = data; globalThis.document = makeDocument(); globalThis.window = { location: { search: "" } }; state.networkId = "mainnet"; state.blockscoutUrl = "https://explorer.example/api/v2"; state.viewData = {}; state.viewStack = []; state.currentView = null; transactionDetail.show({ hash: TX_HASH, from: USER, to: ROUTER, value: "", symbol: "ETH", timestamp: 1790000000, isError: false, direction: "contract", directionLabel: "Swap", method: "execute", chainId: "0x1", }); for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); return node("tx-detail-calldata-details").innerHTML; } describe("the screens judge the output against the transaction's sender", () => { test("the approval screen, against its From address", async () => { const toUser = await showApproval(swapPaidTo(USER)); expect(toUser).toContain("Min. received"); expect(toUser).not.toContain("Paid to"); const toOther = await showApproval(swapPaidTo(OTHER)); expect(toOther).toContain("Paid to"); expect(toOther).toContain(OTHER); expect(toOther).not.toContain("Min. received"); }); test("the transaction detail screen, against the sender the explorer reports", async () => { const toUser = await showDetail(swapPaidTo(USER)); expect(toUser).toContain("Min. received"); expect(toUser).not.toContain("Paid to"); const toOther = await showDetail(swapPaidTo(OTHER)); expect(toOther).toContain("Paid to"); expect(toOther).not.toContain("Min. received"); }); });