// The address a swap's output is paid to // (https://git.eeqj.de/sneak/AutistMask/issues/594 and // https://git.eeqj.de/sneak/AutistMask/issues/599). // // Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION, // PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM and V4 TAKE and // TAKE_PORTION names the address it pays, and TAKE_ALL pays the sender. 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 sender and address(2) as the router itself, which holds // what it is paid 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"; // UniversalRouterV2_1_2 on Ethereum Mainnet, whose mask reads 0x07 and 0x40. const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85"; // UniversalRouterV1 on Ethereum Mainnet. const ROUTER_V1 = "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B"; 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; // The router's whole-balance amount. const CONTRACT_BALANCE = 2n ** 255n; // The Paid to line's value when no address can be named. const UNDECODED = "Unknown (could not be decoded)"; const UNNAMED = "Unknown (not named in the calldata)"; const STAYS_IN_ROUTER = "The router, where it stays (no later step pays it out)"; 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, payerIsUser = true) { return [ command, coder.encode( ["address", "uint256", "uint256", "address[]", "bool"], [recipient, amount, limit, path, payerIsUser], ), ]; } function v3SwapExactIn( recipient, amountIn, amountOutMin, tokenIn, tokenOut, payerIsUser = true, ) { return [ 0x00, coder.encode( ["address", "uint256", "uint256", "bytes", "bool"], [ recipient, amountIn, amountOutMin, solidityPacked( ["address", "uint24", "address"], [tokenIn, 3000, tokenOut], ), payerIsUser, ], ), ]; } function sweep(token, recipient) { return [ 0x04, coder.encode(["address", "address", "uint256"], [token, recipient, 0n]), ]; } // TRANSFER (0x05), PAY_PORTION (0x06) and PAY_PORTION_FULL_PRECISION (0x07) // share one layout. `amount` is a quantity, bips, or 1e18ths respectively. function transfer(command, token, recipient, amount) { return [ command, coder.encode( ["address", "address", "uint256"], [token, recipient, amount], ), ]; } function permit2TransferFrom(token, recipient, amount) { return [ 0x02, coder.encode( ["address", "address", "uint160"], [token, recipient, amount], ), ]; } function wrapEth(recipient, amount) { return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])]; } function unwrapWeth(recipient) { return [0x0c, coder.encode(["address", "uint256"], [recipient, 0n])]; } function balanceCheck(token, minBalance) { return [ 0x0e, coder.encode( ["address", "address", "uint256"], [USER, token, minBalance], ), ]; } // `step`, a [command or action, input] pair, with the top byte of the input's // word `index` set. The decoder cannot read that word as an address. function withDirtyWord([id, input], index) { const at = 2 + index * 64; return [id, input.slice(0, at) + "ff" + input.slice(at + 2)]; } // 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"]], ), ]; // SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read. const UNREADABLE_SWAP = [0x06, "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])]; } function takePortion(currency, recipient, bips) { return [ 0x10, coder.encode( ["address", "address", "uint256"], [currency, recipient, bips], ), ]; } // 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, router = ROUTER) { 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.value); } // 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", ]; // A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`. const swapToWeth = (recipient) => v3SwapExactIn(recipient, USDT_2000, ONE_WETH, USDT, WETH); 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(swapToWeth(r)), 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 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("with nothing paying it out, it stays in the router", () => { const result = decode(execute(toRouter)); expect(labels(result)).toEqual(PAID_ELSEWHERE); expect(paidTo(result)).toEqual([STAYS_IN_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([ STAYS_IN_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 = swapToWeth(ADDRESS_THIS); 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("WRAP_ETH pays its WETH to the WRAP_ETH's recipient", () => { const toUser = decode( execute(wrapEth(ADDRESS_THIS, ONE_WETH), unwrapWeth(MSG_SENDER)), ); expect(detail(toUser, "Token Out").value).toBe("ETH"); expect(paidTo(toUser)).toEqual([]); const toOther = decode( execute(wrapEth(OTHER, ONE_WETH), unwrapWeth(MSG_SENDER)), ); 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]); }); }); // Only a later SWEEP of the output token, or UNWRAP_WETH of WETH, pays the // router's output out. A payment to the user of another token, of nothing, or // before the swap leaves the output in the router. describe("an output left in the router is not received", () => { const toRouter = swapToWeth(ADDRESS_THIS); test.each([ { kind: "a SWEEP of WETH to the user, then the swap", data: execute(sweep(WETH, MSG_SENDER), toRouter), min: "1.0000 WETH", }, { kind: "an UNWRAP_WETH to the user, then the swap", data: execute(unwrapWeth(MSG_SENDER), toRouter), min: "1.0000 ETH", }, { kind: "the swap, then a WRAP_ETH of zero to the user", data: execute(toRouter, wrapEth(MSG_SENDER, 0n)), min: "1.0000 WETH", }, { kind: "the swap, then a SWEEP of a token it does not buy to the user", data: execute(toRouter, sweep(USDC, MSG_SENDER)), min: "1.0000 WETH", }, ])("$kind", ({ data, min }) => { const result = decode(data); expect(labels(result)).toEqual(PAID_ELSEWHERE); expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); expect(detail(result, "Min. paid out").value).toBe(min); }); test("one V4 step: a TAKE of what it buys to the router, then a TAKE_ALL of it, which takes nothing", () => { const result = decode( execute( v4Swap( SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, ADDRESS_THIS), takeAll(USDC), ), ), ); expect(detail(result, "Steps").value).toBe("V4 Swap"); expect(labels(result)).toEqual(PAID_ELSEWHERE); expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC"); }); }); // https://git.eeqj.de/sneak/AutistMask/issues/599: TRANSFER, PAY_PORTION, // PAY_PORTION_FULL_PRECISION and V4 TAKE_PORTION pay a token to an address, // and PERMIT2_TRANSFER_FROM moves the user's own tokens to one. describe("each transfer command's recipient", () => { // Each pays out all of a swap's output. Paid to another address, this is // the first shape the issue reports. test.each([ { kind: "TRANSFER of CONTRACT_BALANCE", data: (r) => execute( swapToWeth(ADDRESS_THIS), transfer(0x05, WETH, r, CONTRACT_BALANCE), ), min: "1.0000 WETH", }, { kind: "PAY_PORTION of 10,000 bips", data: (r) => execute( swapToWeth(ADDRESS_THIS), transfer(0x06, WETH, r, 10000n), ), min: "1.0000 WETH", }, { kind: "PAY_PORTION_FULL_PRECISION of 1e18", data: (r) => execute( swapToWeth(ADDRESS_THIS), transfer(0x07, WETH, r, 10n ** 18n), ), router: ROUTER_V2_1_2, min: "1.0000 WETH", }, { kind: "V4 TAKE_PORTION of 10,000 bips", data: (r) => execute( v4Swap( SWAP_USDT_FOR_USDC, SETTLE_USDT, takePortion(USDC, r, 10000n), ), ), min: "1990.0000 USDC", }, ])( "$kind: paid to the user it is received, to the router it stays there, to another address that address is named", ({ data, router = ROUTER, min }) => { const toUser = decode(data(MSG_SENDER), USER, router); expect(labels(toUser)).toEqual(PAID_TO_USER); expect(detail(toUser, "Min. received").value).toBe(min); expect(decode(data(USER), USER, router).details).toEqual( toUser.details, ); const toRouter = decode(data(ADDRESS_THIS), USER, router); expect(labels(toRouter)).toEqual(PAID_ELSEWHERE); expect(paidTo(toRouter)).toEqual([STAYS_IN_ROUTER]); expect(detail(toRouter, "Min. paid out").value).toBe(min); const toOther = decode(data(OTHER), USER, router); expect(labels(toOther)).toEqual(PAID_ELSEWHERE); expect(paidTo(toOther)).toEqual([OTHER]); expect(detail(toOther, "Min. paid out").value).toBe(min); }, ); // The user's USDT, moved to `r`, then a swap of it paid to the user. // The router spends what it is moved, so a move to the router pays no // one. A move to another address is the issue's second shape. const movedThenSwapped = (r) => execute( permit2TransferFrom(USDT, r, USDT_2000), v3SwapExactIn(MSG_SENDER, USDT_2000, ONE_WETH, USDT, WETH, false), ); test("PERMIT2_TRANSFER_FROM: to the user or the router it pays no one else, to another address that address is named", () => { for (const r of [MSG_SENDER, USER, ADDRESS_THIS, ROUTER]) { const result = decode(movedThenSwapped(r)); expect(labels(result)).toEqual(PAID_TO_USER); expect(detail(result, "Min. received").value).toBe("1.0000 WETH"); } const toOther = decode(movedThenSwapped(OTHER)); expect(labels(toOther)).toEqual(PAID_ELSEWHERE); expect(paidTo(toOther)).toEqual([OTHER]); expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH"); const alone = decode( execute(permit2TransferFrom(USDT, OTHER, USDT_2000)), ); expect(detail(alone, "Steps").value).toBe("Permit2 Transfer"); expect(paidTo(alone)).toEqual([OTHER]); }); test("UniversalRouterV1 pays a PERMIT2_TRANSFER_FROM to address(1) or address(2) as written, and maps a TRANSFER's", () => { for (const r of [MSG_SENDER, ADDRESS_THIS]) { const result = decode(movedThenSwapped(r), USER, ROUTER_V1); expect(paidTo(result)).toEqual([r]); expect(detail(result, "Min. received")).toBeUndefined(); } const transferred = decode( execute( swapToWeth(ADDRESS_THIS), transfer(0x05, WETH, MSG_SENDER, CONTRACT_BALANCE), ), USER, ROUTER_V1, ); expect(labels(transferred)).toEqual(PAID_TO_USER); }); test("a router without command 0x07 does not read it as PAY_PORTION_FULL_PRECISION", () => { const result = decode( execute( swapToWeth(MSG_SENDER), transfer(0x07, WETH, MSG_SENDER, 10n ** 18n), ), ); expect(detail(result, "Steps").value).toBe( "V3 Swap (Exact In) → Command 0x07", ); expect(paidTo(result)).toEqual([UNDECODED]); expect(detail(result, "Min. received")).toBeUndefined(); }); }); // A payout of only part of what the router holds leaves the rest there. describe("a payout of part of the router's output", () => { test.each([ { kind: "a TRANSFER of 1 base unit", step: transfer(0x05, WETH, MSG_SENDER, 1n), }, { kind: "a PAY_PORTION of 5,000 bips", step: transfer(0x06, WETH, MSG_SENDER, 5000n), }, { kind: "a PAY_PORTION_FULL_PRECISION of 1e18 less 1", step: transfer(0x07, WETH, MSG_SENDER, 10n ** 18n - 1n), }, ])("$kind to the user leaves the rest in the router", ({ step }) => { const result = decode( execute(swapToWeth(ADDRESS_THIS), step), USER, ROUTER_V2_1_2, ); expect(labels(result)).toEqual(PAID_ELSEWHERE); expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); }); // An interface fee: a share of the output to another address, the rest // to the user. The swap's minimum includes the fee, so it is not what the // user receives. test.each([ { kind: "PAY_PORTION of 25 bips, then SWEEP", data: execute( swapToWeth(ADDRESS_THIS), transfer(0x06, WETH, OTHER, 25n), sweep(WETH, MSG_SENDER), ), min: "1.0000 WETH", }, { kind: "TRANSFER of a flat fee, then SWEEP", data: execute( swapToWeth(ADDRESS_THIS), transfer(0x05, WETH, OTHER, ONE_WETH / 400n), sweep(WETH, MSG_SENDER), ), min: "1.0000 WETH", }, { kind: "PAY_PORTION of 25 bips, then UNWRAP_WETH", data: execute( swapToWeth(ADDRESS_THIS), transfer(0x06, WETH, OTHER, 25n), unwrapWeth(MSG_SENDER), ), min: "1.0000 ETH", }, { kind: "V4 TAKE_PORTION of 25 bips, then TAKE_ALL", data: execute( v4Swap( SWAP_USDT_FOR_USDC, SETTLE_USDT, takePortion(USDC, OTHER, 25n), takeAll(USDC), ), ), min: "1990.0000 USDC", }, ])( "a fee by $kind names the fee's recipient and calls nothing received", ({ data, min }) => { const result = decode(data); expect(labels(result)).toEqual(PAID_ELSEWHERE); expect(paidTo(result)).toEqual([OTHER]); expect(detail(result, "Min. paid out").value).toBe(min); }, ); }); // Uniswap's mixed routes pay a V2 pair directly when the next step swaps // through it. That V2 swap spends what the pair holds: its amountIn is // CONTRACT_BALANCE, paid from the router, or ALREADY_PAID. The pairs below // are the addresses the routers find for the first two tokens of that V2 // swap's path. describe("a swap paid to the pair the next V2 swap spends", () => { // The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it. const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"; const AMOUNTS_IN = [ { kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE }, { kind: "ALREADY_PAID", amountIn: 0n }, ]; const swapTo = (recipient) => v3SwapExactIn(recipient, USDT_2000, 0n, USDT, WETH); const swapToPair = swapTo(PAIR); const pairSpends = (amountIn, path) => v2Swap(0x08, MSG_SENDER, amountIn, USDC_1990, path, false); test.each(AMOUNTS_IN)( "a V3 swap, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user", ({ amountIn }) => { const result = decode( execute(swapToPair, pairSpends(amountIn, [WETH, USDC])), ); expect(result.name).toBe("Swap USDT → USDC"); expect(labels(result)).toEqual(PAID_TO_USER); expect(detail(result, "Amount").value).toBe("2000.0000 USDT"); expect(detail(result, "Min. received").value).toBe( "1990.0000 USDC", ); }, ); test.each(AMOUNTS_IN)( "a V3 swap paid to another address, then a V2 swap whose amountIn is $kind, names that address", ({ amountIn }) => { const result = decode( execute(swapTo(OTHER), pairSpends(amountIn, [WETH, USDC])), ); expect(labels(result)).toEqual(PAID_ELSEWHERE); expect(paidTo(result)).toEqual([OTHER]); expect(detail(result, "Min. paid out").value).toBe( "1990.0000 USDC", ); }, ); test.each([ { recipient: PAIR, lines: PAID_TO_USER, paid: [] }, { recipient: OTHER, lines: PAID_ELSEWHERE, paid: [OTHER] }, ])( "a V4 step whose TAKE pays $recipient, then the V2 swap", ({ recipient, lines, paid }) => { const result = decode( execute( v4Swap( SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, recipient), ), v2Swap( 0x08, MSG_SENDER, CONTRACT_BALANCE, ONE_WETH, [USDC, WETH], false, ), ), ); expect(labels(result)).toEqual(lines); expect(paidTo(result)).toEqual(paid); }, ); test.each([ { kind: "ETH wrapped into the router, then a TRANSFER of all of it", pay: (r) => [ wrapEth(ADDRESS_THIS, ONE_WETH), transfer(0x05, WETH, r, CONTRACT_BALANCE), ], }, { kind: "a PERMIT2_TRANSFER_FROM of the user's WETH", pay: (r) => [permit2TransferFrom(WETH, r, ONE_WETH)], }, ])( "$kind, paid to the pair, is a hop; paid to another address, it is named", ({ pay }) => { const route = (r) => execute(...pay(r), pairSpends(0n, [WETH, USDC])); const toPair = decode(route(PAIR)); expect(paidTo(toPair)).toEqual([]); expect(detail(toPair, "Min. received").value).toBe( "1990.0000 USDC", ); const toOther = decode(route(OTHER)); expect(paidTo(toOther)).toEqual([OTHER]); expect(detail(toOther, "Min. received")).toBeUndefined(); }, ); describe("on Sepolia", () => { const SEPOLIA = "0xaa36a7"; // UniversalRouterV2_1_2 on Sepolia. const SEPOLIA_ROUTER = "0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3"; // The WETH/USDC pair the routers on Sepolia find. Sepolia's V2 // factory reports another address for it, and no contract sits at // this one (see V2_FACTORIES in src/shared/uniswap.js). const SEPOLIA_PAIR = "0x541b50d32B73E769ac42F0FddD86D15FcAa861Fb"; const SEPOLIA_UNI = "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984"; const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"; const SEPOLIA_USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"; // UNI for WETH, paid to `recipient`, then WETH for USDC through the // pair. const route = (recipient, amountIn) => execute( v3SwapExactIn( recipient, ONE_WETH, 0n, SEPOLIA_UNI, SEPOLIA_WETH, ), v2Swap( 0x08, MSG_SENDER, amountIn, USDC_1990, [SEPOLIA_WETH, SEPOLIA_USDC], false, ), ); test.each(AMOUNTS_IN)( "paid to the pair, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user", ({ amountIn }) => { const result = uniswap.decode( route(SEPOLIA_PAIR, amountIn), SEPOLIA_ROUTER, SEPOLIA, {}, USER, ); expect(labels(result)).toEqual(PAID_TO_USER); }, ); test.each(AMOUNTS_IN)( "paid to another address, then a V2 swap whose amountIn is $kind, names that address", ({ amountIn }) => { const result = uniswap.decode( route(OTHER, amountIn), SEPOLIA_ROUTER, SEPOLIA, {}, USER, ); expect(labels(result)).toEqual(PAID_ELSEWHERE); expect(paidTo(result)).toEqual([OTHER]); }, ); test("the same route on Ethereum Mainnet names the Sepolia pair", () => { const result = decode(route(SEPOLIA_PAIR, 0n)); expect(paidTo(result)).toEqual([SEPOLIA_PAIR]); }); }); test.each([ { kind: "takes its input from the user", next: v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]), }, { kind: "spends another token", next: pairSpends(0n, [USDC, WETH]), }, ])("when the V2 swap $kind, the pair is named", ({ next }) => { const result = decode(execute(swapToPair, next)); expect(paidTo(result)).toEqual([PAIR]); expect(detail(result, "Min. received")).toBeUndefined(); }); }); describe("every step's payee counts, whichever step sets the output", () => { test("TAKE_ALL pays the user; a TAKE to another address beside it is named", () => { 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]); expect(detail(split, "Min. received")).toBeUndefined(); expect(detail(split, "Min. paid out").value).toBe("1990.0000 USDC"); }); test("a swap paid to another address, then a V4 step that only takes the same token", () => { const emptyStep = v4Swap(takeAll(WETH)); const result = decode(execute(swapToWeth(OTHER), emptyStep)); expect(paidTo(result)).toEqual([OTHER]); expect(detail(result, "Min. received")).toBeUndefined(); const checked = decode( execute(swapToWeth(OTHER), emptyStep, balanceCheck(WETH, ONE_WETH)), ); expect(paidTo(checked)).toEqual([OTHER]); expect(detail(checked, "Min. received")).toBeUndefined(); expect(detail(checked, "Min. paid out").value).toBe("1.0000 WETH"); }); test("a swap paid to the router, a V4 step that only takes, then SWEEP to another address", () => { const result = decode( execute( v3SwapExactIn(ADDRESS_THIS, USDT_2000, USDC_1990, USDT, USDC), v4Swap(takeAll(USDC)), sweep(USDC, OTHER), ), ); expect(paidTo(result)).toEqual([OTHER]); expect(detail(result, "Min. received")).toBeUndefined(); }); test("a swap paid to another address, then a small swap paid to the user", () => { const result = decode( execute( swapToWeth(OTHER), v2Swap(0x08, MSG_SENDER, 1000000n, 1n, [USDT, WETH]), ), ); expect(paidTo(result)).toEqual([OTHER]); expect(detail(result, "Min. received")).toBeUndefined(); expect(detail(result, "Min. paid out")).toBeDefined(); }); test("one V4 step: a TAKE of what it buys to another address, then TAKE_ALL of another currency", () => { const result = decode( execute( v4Swap( SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, OTHER), takeAll(USDT), ), ), ); expect(paidTo(result)).toEqual([OTHER]); expect(detail(result, "Min. received")).toBeUndefined(); }); }); describe("a payout that cannot be read is never said to be received", () => { test.each([ { kind: "TAKE", action: take(USDC, OTHER) }, { kind: "TAKE_PORTION", action: takePortion(USDC, OTHER, 25n) }, ])("a V4 $kind whose recipient word has its top byte set", ({ action }) => { const result = decode( execute( v4Swap( SWAP_USDT_FOR_USDC, SETTLE_USDT, withDirtyWord(action, 1), takeAll(USDC), ), ), ); expect(paidTo(result)).toEqual([UNDECODED]); expect(detail(result, "Min. received")).toBeUndefined(); expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC"); }); test.each([ { kind: "V2_SWAP_EXACT_IN", step: v2Swap(0x08, OTHER, 1n, 1n, [USDT, WETH]), }, { kind: "V2_SWAP_EXACT_OUT", step: v2Swap(0x09, OTHER, 1n, 1n, [USDT, WETH]), }, { kind: "V3_SWAP_EXACT_IN", step: swapToWeth(OTHER) }, { kind: "SWEEP", step: sweep(WETH, OTHER), word: 1 }, { kind: "WRAP_ETH", step: wrapEth(OTHER, 1n) }, { kind: "UNWRAP_WETH", step: unwrapWeth(OTHER) }, { kind: "TRANSFER", step: transfer(0x05, WETH, OTHER, 1n), word: 1, }, { kind: "PAY_PORTION", step: transfer(0x06, WETH, OTHER, 1n), word: 1, }, { kind: "PAY_PORTION_FULL_PRECISION", step: transfer(0x07, WETH, OTHER, 1n), word: 1, }, { kind: "PERMIT2_TRANSFER_FROM", step: permit2TransferFrom(WETH, OTHER, 1n), word: 1, }, ])( "a $kind whose recipient word has its top byte set", ({ step, word = 0 }) => { const result = decode( execute(swapToWeth(MSG_SENDER), withDirtyWord(step, word)), USER, ROUTER_V2_1_2, ); expect(paidTo(result)).toEqual([UNDECODED]); expect(detail(result, "Min. received")).toBeUndefined(); expect(detail(result, "Min. paid out")).toBeDefined(); }, ); // The router reads only the low 32 bits of the action list's length, so // the step still runs, and may pay anyone. test("a V4_SWAP whose action list's length word has its top byte set", () => { const step = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, OTHER)); const result = decode( execute(swapToWeth(MSG_SENDER), withDirtyWord(step, 2)), ); expect(paidTo(result)).toEqual([UNDECODED]); expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); }); test("an output set only by BALANCE_CHECK_ERC20", () => { const result = decode(execute(balanceCheck(WETH, ONE_WETH))); expect(paidTo(result)).toEqual([UNNAMED]); expect(detail(result, "Min. received")).toBeUndefined(); expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); }); // Each is a payout this decoder does not read. test.each([ { kind: "PERMIT2_TRANSFER_FROM_BATCH", step: [ 0x0d, coder.encode( ["tuple(address,address,uint160,address)[]"], [[[USER, OTHER, 1n, WETH]]], ), ], }, { kind: "V3_SWAP_EXACT_OUT", step: [0x01, swapToWeth(OTHER)[1]], }, { kind: "EXECUTE_SUB_PLAN", step: [ 0x21, coder.encode( ["bytes", "bytes[]"], ["0x05", [transfer(0x05, WETH, OTHER, 1n)[1]]], ), ], }, { kind: "ACROSS_V4_DEPOSIT_V3", step: [0x40, coder.encode(["address"], [OTHER])], }, ])("a swap paid to the user, then $kind", ({ step }) => { const result = decode( execute(swapToWeth(MSG_SENDER), step), USER, ROUTER_V2_1_2, ); expect(paidTo(result)).toEqual([UNDECODED]); expect(detail(result, "Min. received")).toBeUndefined(); }); }); describe("paths whose recipients were untested", () => { test("BALANCE_CHECK_ERC20 after a swap leaves who is paid as the swap says", () => { const check = balanceCheck(WETH, ONE_WETH); const toUser = decode(execute(swapToWeth(MSG_SENDER), check)); expect(paidTo(toUser)).toEqual([]); expect(detail(toUser, "Min. received").value).toBe("1.0000 WETH"); const toOther = decode(execute(swapToWeth(OTHER), check)); expect(paidTo(toOther)).toEqual([OTHER]); expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH"); }); test("a V4 step whose swap action cannot be read still has its TAKE read", () => { const step = (r) => v4Swap(UNREADABLE_SWAP, SETTLE_USDT, take(USDC, r)); const toUser = decode(execute(step(MSG_SENDER))); expect(detail(toUser, "Steps").value).toBe( "V4 Swap (could not be decoded)", ); expect(paidTo(toUser)).toEqual([]); expect(paidTo(decode(execute(step(OTHER))))).toEqual([OTHER]); }); }); // 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(swapToWeth(recipient)); // 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"); }); });