// 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), the allow-revert flag // on the steps that pay it // (https://git.eeqj.de/sneak/AutistMask/issues/602), the command each router // release runs for a command type // (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose // balance a BALANCE_CHECK_ERC20 checks // (https://git.eeqj.de/sneak/AutistMask/issues/600), any token a swap // leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606), and // what each PERMIT2_TRANSFER_FROM moves // (https://git.eeqj.de/sneak/AutistMask/issues/611). // // Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION, // PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 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"; // UniversalRouterV1_2_V2Support on Ethereum Mainnet. const ROUTER_V1_2 = "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD"; 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"; // A token the wallet does not know. const UNKNOWN_TOKEN = "0x5555555555555555555555555555555555555555"; 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 PAID_TO_BRIDGE = "The bridge, which pays the deposit's recipient on another network"; // The Paid to line for a PERMIT2_TRANSFER_FROM of `amount` into the router. const movedIntoRouter = (amount) => "The router, " + amount + " from the sender (later steps may not spend all of it)"; 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], ), ]; } // PERMIT2_PERMIT (0x0a) of `amount` of `token`, for the router to spend. function permit2Permit(token, amount) { return [ 0x0a, coder.encode( [ "tuple(tuple(address,uint160,uint48,uint48),address,uint256)", "bytes", ], [[[token, amount, 0, 0], ROUTER, 0n], "0x"], ), ]; } function permit2TransferFrom(token, recipient, amount) { return [ 0x02, coder.encode( ["address", "address", "uint160"], [token, recipient, amount], ), ]; } // ACROSS_V4_DEPOSIT_V3 (0x40) of `inputAmount` of `inputToken`, bridged to // chain id 8453. function acrossDeposit( depositor, recipient, inputToken, inputAmount, useNative = false, ) { return [ 0x40, coder.encode( [ "tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)", ], [ [ depositor, recipient, inputToken, inputToken, inputAmount, 1n, 8453n, ZERO, 0, 0, 0, "0x", useNative, ], ], ), ]; } function wrapEth(recipient, amount) { return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])]; } function unwrapWeth(recipient) { return [0x0c, coder.encode(["address", "uint256"], [recipient, 0n])]; } // BALANCE_CHECK_ERC20 (0x0e) of `owner`'s balance of `token`. function balanceCheck(token, minBalance, owner = USER) { return [ 0x0e, coder.encode( ["address", "address", "uint256"], [owner, token, minBalance], ), ]; } // `step` with the allow-revert flag set on its command byte. const flagged = ([command, input]) => [command | 0x80, input]; // `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"]], ), ]; const SWAP_USDT_FOR_ETH = [ 0x06, // SWAP_EXACT_IN_SINGLE coder.encode( [ "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", ], [[[ZERO, USDT, 500, 10, ZERO], false, USDT_2000, ONE_WETH, "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", ]; // `lines` with the lines of one PERMIT2_TRANSFER_FROM after Amount. const withMove = (lines) => lines.flatMap((l) => l === "Amount" ? [l, "Moved from the sender", "Token moved", "Moved to"] : [l], ); // 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/606: any token a step pays the // router stays there unless a later step pays out or spends all of it, not // only the swap's output. A V4 SETTLE can take more of the user's tokens than // the step's swap spends, a WRAP_ETH wraps the user's ETH into the router, and // a swap can leave there the token it bought on the way to the output. describe("any token left in the router is not received", () => { // The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it. const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"; const USDT_5000 = 5000000000n; // V4 SETTLE of `amount` of `currency` (zero: what the step's swap // spends), paid by the user, or by the router when `payerIsUser` is false. function settle(currency, amount, payerIsUser) { return [ 0x0b, coder.encode( ["address", "uint256", "bool"], [currency, amount, payerIsUser], ), ]; } // V4 SWAP_EXACT_IN_SINGLE of `amountIn` of `currencyIn` (zero: the open // delta) for at least `amountOutMin` of `currencyOut`. function swapExactInSingle( currencyIn, currencyOut, amountIn, amountOutMin, ) { return [ 0x06, coder.encode( [ "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", ], [ [ [currencyIn, currencyOut, 500, 10, ZERO], true, amountIn, amountOutMin, "0x", ], ], ), ]; } // V4 SWAP_EXACT_IN of `amountIn` of `currencyIn` through one pool for // each currency of `path` in turn, for at least `amountOutMin` of the // last. An empty path swaps nothing. function swapExactIn(currencyIn, path, amountIn, amountOutMin) { return [ 0x07, coder.encode( [ "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", ], [ [ currencyIn, path.map((c) => [c, 500, 10, ZERO, "0x"]), amountIn, amountOutMin, ], ], ), ]; } // V4 SETTLE_ALL of `currency`, paid by the user. function settleAll(currency, maxAmount) { return [ 0x0c, coder.encode(["address", "uint256"], [currency, maxAmount]), ]; } // The line giving a token left in the router. const tokenLine = (token, symbol) => ({ label: "Token moved into the router", value: symbol + " (" + token + ")", address: token, isToken: true, }); test.each([ { kind: "a V4 SETTLE of 5,000 USDT for a swap of 2,000, the rest taken to the router by TAKE", data: execute( v4Swap( settle(USDT, USDT_5000, true), SWAP_USDT_FOR_USDC, take(USDT, ADDRESS_THIS), takeAll(USDC), ), ), left: tokenLine(USDT, "USDT"), }, { kind: "a V4 SETTLE of 5,000 USDT for a swap of 2,000, the rest taken to the router by TAKE_PORTION", data: execute( v4Swap( settle(USDT, USDT_5000, true), SWAP_USDT_FOR_USDC, takeAll(USDC), takePortion(USDT, ADDRESS_THIS, 10000n), ), ), left: tokenLine(USDT, "USDT"), }, { kind: "a WRAP_ETH into the router that nothing spends", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC), ), left: tokenLine(WETH, "WETH"), spent: "2000.0000 USDT", }, { kind: "a WRAP_ETH of 1 ETH into the router, then a swap of 0.5 WETH from the router", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v3SwapExactIn( MSG_SENDER, ONE_WETH / 2n, USDC_1990, WETH, USDC, false, ), ), left: tokenLine(WETH, "WETH"), }, // Only a swap the router pays from its own balance spends the WETH // a WRAP_ETH paid it, and its stated amount is followed only when the // router held no WETH before the WRAP_ETH. { kind: "a WRAP_ETH of 1 ETH into the router, then a V2 swap of 1 WETH that the user pays for", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]), ), left: tokenLine(WETH, "WETH"), spent: "1.0000 WETH", }, { kind: "a WRAP_ETH of 1 ETH into the router, then a V3 swap of 1 WETH that the user pays for", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v3SwapExactIn(MSG_SENDER, ONE_WETH, USDC_1990, WETH, USDC), ), left: tokenLine(WETH, "WETH"), spent: "1.0000 WETH", }, { kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE of 1 WETH by the user", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v4Swap( swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990), settle(WETH, ONE_WETH, true), takeAll(USDC), ), ), left: tokenLine(WETH, "WETH"), spent: "1.0000 WETH", }, { kind: "a swap paying WETH to the router, a WRAP_ETH of 1 ETH into the router, then a swap of 1 WETH from the router", data: execute( swapToWeth(ADDRESS_THIS), wrapEth(ADDRESS_THIS, ONE_WETH), v3SwapExactIn( MSG_SENDER, ONE_WETH, USDC_1990, WETH, USDC, false, ), ), left: tokenLine(WETH, "WETH"), }, // A SETTLE of OPEN_DELTA from the router pays only what the step // still owes of its currency when it runs. { kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 0.5 WETH with two SETTLEs of OPEN_DELTA from the router", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v4Swap( swapExactInSingle(WETH, USDC, ONE_WETH / 2n, USDC_1990), settle(WETH, 0n, false), settle(WETH, 0n, false), takeAll(USDC), ), ), left: tokenLine(WETH, "WETH"), }, { kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 0.75 WETH with a SETTLE of 0.25 WETH and one of OPEN_DELTA from the router", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v4Swap( swapExactInSingle( WETH, USDC, (ONE_WETH * 3n) / 4n, USDC_1990, ), settle(WETH, ONE_WETH / 4n, false), settle(WETH, 0n, false), takeAll(USDC), ), ), left: tokenLine(WETH, "WETH"), }, { kind: "a WRAP_ETH of 1 ETH into the router, then a V4 step that also buys WETH back, with a SETTLE of OPEN_DELTA from the router", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v4Swap( swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990), swapExactInSingle(USDC, WETH, 1000000n, 1n), settle(WETH, 0n, false), takeAll(USDC), ), ), left: tokenLine(WETH, "WETH"), }, { kind: "a WRAP_ETH of 1 ETH into the router, then a V4 SETTLE of OPEN_DELTA from the router before the swap, which the user settles", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v4Swap( settle(WETH, 0n, false), swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990), settle(WETH, 0n, true), takeAll(USDC), ), ), left: tokenLine(WETH, "WETH"), spent: "1.0000 WETH", }, // The user pays for the swap by a SETTLE_ALL or SETTLE whose currency // the decoder cannot read, so the SETTLE from the router pays nothing. { kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE_ALL that cannot be read and a SETTLE of OPEN_DELTA from the router", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v4Swap( swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990), withDirtyWord(settleAll(WETH, ONE_WETH), 0), settle(WETH, 0n, false), takeAll(USDC), ), ), left: tokenLine(WETH, "WETH"), }, { kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE by the user that cannot be read and a SETTLE of OPEN_DELTA from the router", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v4Swap( swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990), withDirtyWord(settle(WETH, ONE_WETH, true), 0), settle(WETH, 0n, false), takeAll(USDC), ), ), left: tokenLine(WETH, "WETH"), }, { kind: "a WRAP_ETH of 1 ETH into the router, then a V4 step with an exact-in of 1 WETH less 1,000 wei and an empty path, a swap of 1,000 wei, and a SETTLE of OPEN_DELTA from the router", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v4Swap( swapExactIn(WETH, [], ONE_WETH - 1000n, 0n), swapExactInSingle(WETH, USDC, 1000n, USDC_1990), settle(WETH, 0n, false), takeAll(USDC), ), ), left: tokenLine(WETH, "WETH"), }, { kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of all of it settled from the router, with an action the decoder does not read", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v4Swap( swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990), settle(WETH, 0n, false), takeAll(USDC), [0x14, coder.encode(["address"], [WETH])], ), ), left: tokenLine(WETH, "WETH"), }, { kind: "a swap paying WETH to the router, a TRANSFER of 1 wei of it to the WETH/USDC pair, then a V2 swap of what the pair holds", data: execute( swapToWeth(ADDRESS_THIS), transfer(0x05, WETH, PAIR, 1n), v2Swap(0x08, MSG_SENDER, 0n, USDC_1990, [WETH, USDC], false), ), left: tokenLine(WETH, "WETH"), }, { kind: "a swap paying WETH to the router, then a swap of 1 wei of it from the router", data: execute( swapToWeth(ADDRESS_THIS), v2Swap(0x08, MSG_SENDER, 1n, USDC_1990, [WETH, USDC], false), ), left: tokenLine(WETH, "WETH"), }, { kind: "a swap paying WETH to the router, an UNWRAP_WETH into the router, then another swap", data: execute( swapToWeth(ADDRESS_THIS), unwrapWeth(ADDRESS_THIS), v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC), ), left: { label: "Token moved into the router", value: "ETH" }, }, ])("$kind", ({ data, left, spent }) => { const result = decode(data); expect(labels(result)).toEqual([ "Protocol", "Token In", "Amount", // A later swap step the user pays for spends `spent` of a token // other than Token In (https://git.eeqj.de/sneak/AutistMask/issues/625). ...(spent ? ["Moved from the sender", "Token moved", "Moved to"] : []), "Token Out", "Paid to", "Token moved into the router", "Min. paid out", "Steps", "Deadline", ]); if (spent) { expect(detail(result, "Moved from the sender").value).toBe(spent); } expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); expect(detail(result, "Token moved into the router")).toEqual(left); expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC"); }); // Uniswap's own routes: a swap paid in ETH wraps into the router what its // swap steps then spend from it, and a route through several pools pays // each token it buys on the way to the router and spends all of it in the // next step (CONTRACT_BALANCE), turning ETH into WETH or back where the // pools need it. test.each([ { kind: "a V4 SETTLE of what its swap spends", data: execute( v4Swap( settle(USDT, USDT_2000, true), SWAP_USDT_FOR_USDC, takeAll(USDC), ), ), min: "1990.0000 USDC", }, { kind: "a V4 SETTLE of more than its swap spends, the rest taken to the user", data: execute( v4Swap( settle(USDT, USDT_5000, true), SWAP_USDT_FOR_USDC, take(USDT, MSG_SENDER), takeAll(USDC), ), ), min: "1990.0000 USDC", }, { kind: "a WRAP_ETH into the router, then a swap of all of it from the router", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v3SwapExactIn( MSG_SENDER, ONE_WETH, USDC_1990, WETH, USDC, false, ), ), min: "1990.0000 USDC", }, { kind: "a WRAP_ETH into the router, then two swaps from the router that together spend all of it", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v3SwapExactIn( MSG_SENDER, (ONE_WETH * 3n) / 5n, 1n, WETH, USDC, false, ), v2Swap( 0x08, MSG_SENDER, (ONE_WETH * 2n) / 5n, USDC_1990, [WETH, USDC], false, ), ), min: "1990.0000 USDC", }, { kind: "a WRAP_ETH into the router, then a V4 swap of all of it settled from the router", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v4Swap( swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990), settle(WETH, 0n, false), takeAll(USDC), ), ), min: "1990.0000 USDC", }, { kind: "a WRAP_ETH into the router, then a V4 swap of all of it through two pools, settled from the router", data: execute( wrapEth(ADDRESS_THIS, ONE_WETH), v4Swap( swapExactIn(WETH, [USDT, USDC], ONE_WETH, USDC_1990), settle(WETH, 0n, false), takeAll(USDC), ), ), min: "1990.0000 USDC", }, { kind: "a WRAP_ETH of all the router's ETH, then a swap of all its WETH", data: execute( wrapEth(ADDRESS_THIS, CONTRACT_BALANCE), v3SwapExactIn( MSG_SENDER, CONTRACT_BALANCE, USDC_1990, WETH, USDC, false, ), ), min: "1990.0000 USDC", }, { kind: "a swap paying WETH to the router, then a V2 swap of all of it", data: execute( swapToWeth(ADDRESS_THIS), v2Swap( 0x08, MSG_SENDER, CONTRACT_BALANCE, USDC_1990, [WETH, USDC], false, ), ), min: "1990.0000 USDC", }, { kind: "a swap paying WETH to the router, then a V4 swap settling all of it from the router", data: execute( swapToWeth(ADDRESS_THIS), v4Swap( settle(WETH, CONTRACT_BALANCE, false), swapExactInSingle(WETH, USDC, 0n, USDC_1990), takeAll(USDC), ), ), min: "1990.0000 USDC", }, { kind: "a V4 swap taking USDC to the router, then a V3 swap of all of it", data: execute( v4Swap( SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, ADDRESS_THIS), ), v3SwapExactIn( MSG_SENDER, CONTRACT_BALANCE, ONE_WETH, USDC, WETH, false, ), ), min: "1.0000 WETH", }, { kind: "a V4 swap taking ETH to the router, a WRAP_ETH of all of it, then a V3 swap of all the WETH", data: execute( v4Swap( SWAP_USDT_FOR_ETH, SETTLE_USDT, take(ZERO, ADDRESS_THIS), ), wrapEth(ADDRESS_THIS, CONTRACT_BALANCE), v3SwapExactIn( MSG_SENDER, CONTRACT_BALANCE, USDC_1990, WETH, USDC, false, ), ), min: "1990.0000 USDC", }, { kind: "a swap paying WETH to the router, an UNWRAP_WETH into the router, then a V4 swap settling all the ETH from the router", data: execute( swapToWeth(ADDRESS_THIS), unwrapWeth(ADDRESS_THIS), v4Swap( settle(ZERO, CONTRACT_BALANCE, false), swapExactInSingle(ZERO, USDC, 0n, USDC_1990), takeAll(USDC), ), ), min: "1990.0000 USDC", }, ])("$kind reads as a swap paid to the user", ({ data, min }) => { const result = decode(data); expect(labels(result)).toEqual(PAID_TO_USER); expect(detail(result, "Min. received").value).toBe(min); }); }); // 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. 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 it pays no one else, to another address that address is named", () => { for (const r of [MSG_SENDER, USER]) { const result = decode(movedThenSwapped(r)); expect(labels(result)).toEqual(withMove(PAID_TO_USER)); expect(detail(result, "Min. received").value).toBe("1.0000 WETH"); } // A move to the From address as written shows that address, not The // sender. expect(detail(decode(movedThenSwapped(USER)), "Moved to")).toEqual({ label: "Moved to", value: USER, address: USER, }); const toOther = decode(movedThenSwapped(OTHER)); expect(labels(toOther)).toEqual(withMove(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]); }); // Later steps may leave part of what a move into the router moves there, // where anyone can sweep it in a later transaction. test.each([ { kind: "address(2)", r: ADDRESS_THIS }, { kind: "the router's own address", r: ROUTER }, ])( "PERMIT2_TRANSFER_FROM into the router as $kind names what it moves, and nothing is received", ({ r }) => { const result = decode(movedThenSwapped(r)); expect(labels(result)).toEqual([ "Protocol", "Token In", "Amount", "Token Out", "Paid to", "Token moved into the router", "Min. paid out", "Steps", "Deadline", ]); expect(paidTo(result)).toEqual([movedIntoRouter("2000.0000 USDT")]); expect(detail(result, "Token moved into the router")).toEqual({ label: "Token moved into the router", value: "USDT (" + USDT + ")", address: USDT, isToken: true, }); expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); }, ); test.each([ { kind: "from the router's balance", payerIsUser: false }, { kind: "from the user's", payerIsUser: true }, ])( "a move of 2,000 USDT into the router, then a swap of 1 USDT $kind paid to the user", ({ payerIsUser }) => { const result = decode( execute( permit2TransferFrom(USDT, ADDRESS_THIS, USDT_2000), v3SwapExactIn( MSG_SENDER, 1000000n, 1n, USDT, WETH, payerIsUser, ), ), ); expect(detail(result, "Amount").value).toBe("1.0000 USDT"); expect(paidTo(result)).toEqual([movedIntoRouter("2000.0000 USDT")]); expect(detail(result, "Min. received")).toBeUndefined(); expect(detail(result, "Min. paid out")).toBeDefined(); }, ); test("a move into the router of a token the wallet does not know gives the token's address", () => { const result = decode( execute( permit2TransferFrom(UNKNOWN_TOKEN, ADDRESS_THIS, 5n), swapToWeth(MSG_SENDER), ), ); expect(paidTo(result)).toEqual([ movedIntoRouter("5 base units (decimals unknown)"), ]); expect(detail(result, "Token moved into the router")).toEqual({ label: "Token moved into the router", value: UNKNOWN_TOKEN, address: UNKNOWN_TOKEN, isToken: true, }); }); 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, "Moved to")).toEqual({ label: "Moved to", value: r, address: 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("TAKE_PORTION does not set Token Out: a TAKE_PORTION of another currency after the TAKE_ALL of the bought one", () => { const result = decode( execute( v4Swap( SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC), takePortion(USDT, OTHER, 25n), ), ), ); expect(detail(result, "Token Out").address).toBe(USDC); expect(paidTo(result)).toEqual([OTHER]); expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC"); }); 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(); }); }); // https://git.eeqj.de/sneak/AutistMask/issues/611: after the swap's own // Amount, each PERMIT2_TRANSFER_FROM but one into the router gives the amount // of the sender's tokens it moves, the token, and where it goes, whoever // counts as paid. describe("what each PERMIT2_TRANSFER_FROM moves", () => { // The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it. const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"; const USDT_1 = 1000000n; const USDT_1000000 = 1000000000000n; // The lines between Amount and Token Out. function afterAmount(result) { const all = labels(result); return result.details.slice( all.indexOf("Amount") + 1, all.indexOf("Token Out"), ); } // The lines of a move of `amount` of `token`, then `to`. const moved = (amount, token, symbol, to) => [ { label: "Moved from the sender", value: amount }, { label: "Token moved", value: symbol + " (" + token + ")", address: token, isToken: true, }, to, ]; const movedTo = (value) => ({ label: "Moved to", value }); const movedToAddress = (address) => ({ label: "Moved to", value: address, address, }); test("1,000,000 USDT moved to another address, then a swap of 1 USDT", () => { const result = decode( execute( permit2TransferFrom(USDT, OTHER, USDT_1000000), v3SwapExactIn(MSG_SENDER, USDT_1, ONE_WETH, USDT, WETH), ), ); expect(detail(result, "Amount").value).toBe("1.0000 USDT"); expect(afterAmount(result)).toEqual( moved("1000000.0000 USDT", USDT, "USDT", movedToAddress(OTHER)), ); expect(paidTo(result)).toEqual([OTHER]); expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); }); test("1 WETH moved to the pair the next V2 swap spends", () => { const result = decode( execute( permit2TransferFrom(WETH, PAIR, ONE_WETH), v2Swap(0x08, MSG_SENDER, 0n, USDC_1990, [WETH, USDC], false), ), ); expect(detail(result, "Amount").value).toBe( "Whatever an earlier step sent to the pair (V2 already paid)", ); expect(afterAmount(result)).toEqual( moved( "1.0000 WETH", WETH, "WETH", movedTo("The V2 pair the next swap spends"), ), ); expect(paidTo(result)).toEqual([]); expect(detail(result, "Min. received").value).toBe("1990.0000 USDC"); }); // Its Paid to and Token moved into the router lines already say what a // move into the router moves, so it gets no lines after Amount. test("2,000 USDT moved into the router, then a swap of 1 USDT from the router's balance, shows the 2,000 USDT once", () => { const result = decode( execute( permit2TransferFrom(USDT, ADDRESS_THIS, USDT_2000), v3SwapExactIn(MSG_SENDER, USDT_1, ONE_WETH, USDT, WETH, false), ), ); expect(detail(result, "Amount").value).toBe("1.0000 USDT"); expect(afterAmount(result)).toEqual([]); expect(paidTo(result)).toEqual([movedIntoRouter("2000.0000 USDT")]); expect( result.details.filter((d) => d.value.includes("2000.0000 USDT")), ).toHaveLength(1); expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); }); test("a move to address(1) goes to the sender, and each move is shown in step order", () => { const result = decode( execute( permit2TransferFrom(USDT, MSG_SENDER, USDT_2000), permit2TransferFrom(USDC, OTHER, USDC_1990), swapToWeth(MSG_SENDER), ), ); expect(afterAmount(result)).toEqual([ ...moved("2000.0000 USDT", USDT, "USDT", movedTo("The sender")), ...moved("1990.0000 USDC", USDC, "USDC", movedToAddress(OTHER)), ]); }); }); // 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"); }); // The router sends ETH as written, so a TRANSFER of CONTRACT_BALANCE of // ETH always reverts; PAY_PORTION reads the router's ETH balance. test("a TRANSFER of CONTRACT_BALANCE of ETH pays out nothing; a PAY_PORTION of 10,000 bips of ETH pays out all of it", () => { const ethToRouter = v4Swap( SWAP_USDT_FOR_ETH, SETTLE_USDT, take(ZERO, ADDRESS_THIS), ); const transferred = decode( execute( ethToRouter, transfer(0x05, ZERO, MSG_SENDER, CONTRACT_BALANCE), ), ); expect(detail(transferred, "Token Out").value).toBe("ETH"); expect(paidTo(transferred)).toEqual([STAYS_IN_ROUTER]); expect(detail(transferred, "Min. received")).toBeUndefined(); const portion = decode( execute(ethToRouter, transfer(0x06, ZERO, MSG_SENDER, 10000n)), ); expect(labels(portion)).toEqual(PAID_TO_USER); expect(detail(portion, "Min. received").value).toBe("1.0000 ETH"); }); // 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); }, ); }); // ACROSS_V4_DEPOSIT_V3 hands the bridge what the router holds. The bridge // pays the deposit's recipient on another network, and refunds a deposit // nobody fills to its depositor; the router passes both on as written. describe("a bridge deposit", () => { // A swap of 2,000 USDT for at least 1 WETH paid to the router, then a // deposit of all the router's WETH. const bridged = (depositor, recipient) => execute( swapToWeth(ADDRESS_THIS), acrossDeposit(depositor, recipient, WETH, CONTRACT_BALANCE), ); test.each([ { kind: "the user is its recipient and its depositor", recipient: USER, depositor: USER, named: [], }, { kind: "another address is its recipient", recipient: OTHER, depositor: USER, named: [OTHER], }, { kind: "another address is its depositor", recipient: USER, depositor: OTHER, named: [OTHER], }, { kind: "address(1) is its recipient and address(2) its depositor", recipient: MSG_SENDER, depositor: ADDRESS_THIS, named: [MSG_SENDER, ADDRESS_THIS], }, ])( "when $kind, each that is not the user is named, and nothing is received", ({ recipient, depositor, named }) => { const result = decode( bridged(depositor, recipient), USER, ROUTER_V2_1_2, ); expect(detail(result, "Steps").value).toBe( "V3 Swap (Exact In) → Across Bridge Deposit", ); expect(paidTo(result)).toEqual([...named, PAID_TO_BRIDGE]); expect(detail(result, "Min. received")).toBeUndefined(); expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); }, ); test("a deposit of part of the router's WETH leaves the rest there", () => { const result = decode( execute( swapToWeth(ADDRESS_THIS), acrossDeposit(USER, USER, WETH, ONE_WETH / 2n), ), USER, ROUTER_V2_1_2, ); expect(paidTo(result)).toEqual([PAID_TO_BRIDGE, STAYS_IN_ROUTER]); }); test("a deposit of all the router's ETH pays out the ETH it holds; one of all its WETH does not", () => { const ethToRouter = v4Swap( SWAP_USDT_FOR_ETH, SETTLE_USDT, take(ZERO, ADDRESS_THIS), ); const native = decode( execute( ethToRouter, acrossDeposit(USER, USER, WETH, CONTRACT_BALANCE, true), ), USER, ROUTER_V2_1_2, ); expect(detail(native, "Token Out").value).toBe("ETH"); expect(paidTo(native)).toEqual([PAID_TO_BRIDGE]); const weth = decode( execute( ethToRouter, acrossDeposit(USER, USER, WETH, CONTRACT_BALANCE), ), USER, ROUTER_V2_1_2, ); expect(paidTo(weth)).toEqual([PAID_TO_BRIDGE, STAYS_IN_ROUTER]); }); }); // 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, }, { kind: "ACROSS_V4_DEPOSIT_V3", step: acrossDeposit(USER, OTHER, WETH, 1n), word: 2, }, ])( "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]]], ), ], }, ])("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]); }); }); // https://git.eeqj.de/sneak/AutistMask/issues/602: the top bit of a command // byte (0x80) is the allow-revert flag. The router lets a step with it fail // and goes on to the next step only for some commands, none of them a swap or // a payment; on every other command the flag has no effect. describe("the allow-revert flag", () => { // UniversalRouterV2_1_1 on Ethereum Mainnet. const ROUTER_V2_1_1 = "0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA"; // The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it. const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"; // `steps` with the flag set on the one at `index`. const flagStep = (steps, index) => steps.map((s, i) => (i === index ? flagged(s) : s)); const usdcToRouter = v3SwapExactIn( ADDRESS_THIS, USDT_2000, USDC_1990, USDT, USDC, ); const wethToPair = v3SwapExactIn(PAIR, USDT_2000, 0n, USDT, WETH); const pairSpends = v2Swap( 0x08, MSG_SENDER, 0n, USDC_1990, [WETH, USDC], false, ); // The flag is set on the step at `index`: a swap, a payment, or the V2 // swap that a payment to its pair hops into. test.each([ { kind: "V2_SWAP_EXACT_IN", steps: [ v2Swap(0x08, MSG_SENDER, USDT_2000, ONE_WETH, [USDT, WETH]), ], index: 0, }, { kind: "V2_SWAP_EXACT_OUT", steps: [ v2Swap(0x09, MSG_SENDER, ONE_WETH, USDT_2000, [USDT, WETH]), ], index: 0, }, { kind: "V3_SWAP_EXACT_IN", steps: [swapToWeth(MSG_SENDER)], index: 0, }, { kind: "V4_SWAP", steps: [v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))], index: 0, }, { kind: "SWEEP", steps: [usdcToRouter, sweep(USDC, MSG_SENDER)], index: 1, }, { kind: "TRANSFER", steps: [ usdcToRouter, transfer(0x05, USDC, MSG_SENDER, CONTRACT_BALANCE), ], index: 1, }, { kind: "PAY_PORTION", steps: [usdcToRouter, transfer(0x06, USDC, MSG_SENDER, 10000n)], index: 1, }, { kind: "PAY_PORTION_FULL_PRECISION", steps: [usdcToRouter, transfer(0x07, USDC, MSG_SENDER, 10n ** 18n)], index: 1, }, { kind: "UNWRAP_WETH", steps: [swapToWeth(ADDRESS_THIS), unwrapWeth(MSG_SENDER)], index: 1, }, { kind: "WRAP_ETH", steps: [ wrapEth(ADDRESS_THIS, ONE_WETH), v3SwapExactIn( MSG_SENDER, ONE_WETH, USDC_1990, WETH, USDC, false, ), ], index: 0, }, { kind: "ACROSS_V4_DEPOSIT_V3", steps: [ usdcToRouter, acrossDeposit(USER, USER, USDC, CONTRACT_BALANCE), ], index: 1, }, { kind: "PERMIT2_TRANSFER_FROM to the pair the next V2 swap spends", steps: [permit2TransferFrom(WETH, PAIR, ONE_WETH), pairSpends], index: 0, }, { kind: "a swap paid to the pair the next V2 swap spends", steps: [wethToPair, pairSpends], index: 0, }, { kind: "the V2 swap after a swap paid to its pair", steps: [wethToPair, pairSpends], index: 1, }, ])( "$kind reads the same with the flag as without it", ({ steps, index }) => { const plain = decode(execute(...steps), USER, ROUTER_V2_1_2); const withFlag = decode( execute(...flagStep(steps, index)), USER, ROUTER_V2_1_2, ); expect(withFlag).toEqual(plain); expect(detail(withFlag, "Steps").value).not.toContain( "allowed to fail", ); }, ); test.each([ { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 }, { router: "UniversalRouterV2", address: ROUTER }, { router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2 }, ])( "on $router, a BALANCE_CHECK_ERC20 with the flag is allowed to fail and does not replace the swap's minimum", ({ address }) => { const check = balanceCheck(WETH, 2n * ONE_WETH); const plain = decode( execute(swapToWeth(MSG_SENDER), check), USER, address, ); expect(detail(plain, "Steps").value).toBe( "V3 Swap (Exact In) → Balance Check", ); expect(detail(plain, "Min. received").value).toBe("2.0000 WETH"); const withFlag = decode( execute(swapToWeth(MSG_SENDER), flagged(check)), USER, address, ); expect(detail(withFlag, "Steps").value).toBe( "V3 Swap (Exact In) → Balance Check (allowed to fail)", ); expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH"); }, ); test("a BALANCE_CHECK_ERC20 with the flag, and no minimum before it, states none", () => { const check = balanceCheck(WETH, ONE_WETH); expect(detail(decode(execute(check)), "Min. paid out").value).toBe( "1.0000 WETH", ); const withFlag = decode(execute(flagged(check))); expect(detail(withFlag, "Token Out").address).toBe(WETH); expect(detail(withFlag, "Min. paid out").value).toBe( "None (no minimum guaranteed)", ); }); // Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail. test.each([ { router: "UniversalRouterV1", address: ROUTER_V1, mark: "" }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, mark: "", }, { router: "UniversalRouterV2", address: ROUTER, mark: " (allowed to fail)", }, { router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2, mark: " (allowed to fail)", }, ])( "on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap reads as without the flag", ({ address, mark }) => { const steps = [ permit2Permit(USDT, USDT_2000), swapToWeth(MSG_SENDER), ]; const plain = decode(execute(...steps), USER, address); const withFlag = decode( execute(...flagStep(steps, 0)), USER, address, ); expect(detail(withFlag, "Steps").value).toBe( "Permit2 Permit" + mark + " → V3 Swap (Exact In)", ); const withoutSteps = (r) => r.details.filter((d) => d.label !== "Steps"); expect(withoutSteps(withFlag)).toEqual(withoutSteps(plain)); expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH"); }, ); test("a step the decoder does not read is marked too", () => { const positionManagerCall = [0x12, "0x"]; const plain = decode( execute(swapToWeth(MSG_SENDER), positionManagerCall), ); expect(detail(plain, "Steps").value).toBe( "V3 Swap (Exact In) → V3 Position Mgr Call", ); const withFlag = decode( execute(swapToWeth(MSG_SENDER), flagged(positionManagerCall)), ); expect(detail(withFlag, "Steps").value).toBe( "V3 Swap (Exact In) → V3 Position Mgr Call (allowed to fail)", ); expect(paidTo(withFlag)).toEqual([UNDECODED]); }); // A command from each router's list, by its name in that release's // Commands.sol. test.each([ { router: "UniversalRouterV1", address: ROUTER_V1, command: "FOUNDATION", id: 0x1c, name: "Foundation", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, command: "ELEMENT_MARKET", id: 0x1e, name: "Element Market", }, { router: "UniversalRouterV2", address: ROUTER, command: "PERMIT2_PERMIT_BATCH", id: 0x03, name: "Permit2 Permit Batch", }, { router: "UniversalRouterV2_1_1", address: ROUTER_V2_1_1, command: "V4_INITIALIZE_POOL", id: 0x13, name: "V4 Initialize Pool", }, { router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2, command: "V4_POSITION_MANAGER_CALL", id: 0x14, name: "V4 Position Mgr Call", }, ])( "on $router, $command with the flag is listed as '$name (allowed to fail)'", ({ address, id, name }) => { const step = [id, "0x"]; const plain = decode( execute(swapToWeth(MSG_SENDER), step), USER, address, ); expect(detail(plain, "Steps").value).toBe( "V3 Swap (Exact In) → " + name, ); const withFlag = decode( execute(swapToWeth(MSG_SENDER), flagged(step)), USER, address, ); expect(detail(withFlag, "Steps").value).toBe( "V3 Swap (Exact In) → " + name + " (allowed to fail)", ); }, ); }); // https://git.eeqj.de/sneak/AutistMask/issues/592: each release runs its own // command for a command type, as its Commands.sol names it. The decoder named // and read every type as UniversalRouterV2 does, so on the two older routers // an NFT command read as a V4 swap, and on UniversalRouterV1, which reverts on // 0x0e, that type read as a balance check that set the minimum. describe("the command each release runs for a command type", () => { // Inputs UniversalRouterV2 reads as a V4 swap of 2,000 USDT for at least // 1,990 USDC paid to the user, and as a check that the user holds at // least 1 WETH. const swapInput = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))[1]; const checkInput = balanceCheck(WETH, ONE_WETH)[1]; // The lines of a transaction whose one step the decoder does not read. const NOT_READ = [ "Protocol", "Token In", "Token Out", "Paid to", "Steps", "Deadline", ]; test.each([ { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x0e, input: checkInput, name: "Command 0x0e", }, { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x10, input: swapInput, name: "Seaport", }, { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x11, input: swapInput, name: "LooksRare 721", }, { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x12, input: swapInput, name: "NFTX", }, { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x13, input: swapInput, name: "CryptoPunks", }, { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x14, input: swapInput, name: "LooksRare 1155", }, { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x15, input: swapInput, name: "Owner Check 721", }, { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x16, input: swapInput, name: "Owner Check 1155", }, { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x17, input: swapInput, name: "Sweep ERC-721", }, { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x18, input: swapInput, name: "X2Y2 721", }, { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x19, input: swapInput, name: "Sudoswap", }, { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x1a, input: swapInput, name: "NFT20", }, { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x1b, input: swapInput, name: "X2Y2 1155", }, { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x1c, input: swapInput, name: "Foundation", }, { router: "UniversalRouterV1", address: ROUTER_V1, id: 0x1d, input: swapInput, name: "Sweep ERC-1155", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x10, input: swapInput, name: "Seaport V1.5", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x11, input: swapInput, name: "LooksRare V2", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x12, input: swapInput, name: "NFTX", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x13, input: swapInput, name: "CryptoPunks", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x14, input: swapInput, name: "Command 0x14", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x15, input: swapInput, name: "Owner Check 721", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x16, input: swapInput, name: "Owner Check 1155", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x17, input: swapInput, name: "Sweep ERC-721", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x18, input: swapInput, name: "X2Y2 721", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x19, input: swapInput, name: "Sudoswap", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x1a, input: swapInput, name: "NFT20", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x1b, input: swapInput, name: "X2Y2 1155", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x1c, input: swapInput, name: "Foundation", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x1d, input: swapInput, name: "Sweep ERC-1155", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x1e, input: swapInput, name: "Element Market", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x20, input: swapInput, name: "Seaport V1.4", }, { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2, id: 0x22, input: swapInput, name: "Approve ERC-20", }, { router: "UniversalRouterV2", address: ROUTER, id: 0x11, input: swapInput, name: "V3 Position Mgr Permit", }, { router: "UniversalRouterV2", address: ROUTER, id: 0x12, input: swapInput, name: "V3 Position Mgr Call", }, { router: "UniversalRouterV2", address: ROUTER, id: 0x13, input: swapInput, name: "V4 Initialize Pool", }, ])( "on $router, '$name' is a step the decoder does not read", ({ address, id, input, name }) => { const result = decode(execute([id, input]), USER, address); expect(detail(result, "Steps").value).toBe(name); expect(result.name).toBe("Uniswap Swap"); expect(labels(result)).toEqual(NOT_READ); expect(paidTo(result)).toEqual([UNDECODED]); }, ); test.each([ { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 }, { router: "UniversalRouterV2", address: ROUTER }, ])( "on $router, 0x0e is a balance check, which sets the minimum", ({ address }) => { const result = decode(execute([0x0e, checkInput]), USER, address); expect(detail(result, "Steps").value).toBe("Balance Check"); expect(detail(result, "Token Out").address).toBe(WETH); expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); }, ); test("on UniversalRouterV2, 0x10 is a V4 swap", () => { const result = decode(execute([0x10, swapInput]), USER, ROUTER); expect(detail(result, "Steps").value).toBe("V4 Swap"); expect(result.name).toBe("Swap USDT → USDC"); expect(detail(result, "Min. received").value).toBe("1990.0000 USDC"); }); }); // https://git.eeqj.de/sneak/AutistMask/issues/600: on every release that has // BALANCE_CHECK_ERC20, the router checks the balance of the owner as written, // without map(), so address(1) and address(2) there are those addresses, not // the sender and the router. Only a check of the sender's own balance says // anything about what the user receives. describe.each([ { router: "UniversalRouterV1_2_V2Support", address: ROUTER_V1_2 }, { router: "UniversalRouterV2", address: ROUTER }, { router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2 }, ])("on $router, the owner of a BALANCE_CHECK_ERC20", ({ address }) => { // A swap of 2,000 USDT for WETH with no minimum, paid to the user. const swap = v3SwapExactIn(MSG_SENDER, USDT_2000, 0n, USDT, WETH); const NO_MINIMUM = "None (no minimum guaranteed)"; test("the sender: the check sets the minimum, unless the router lets it fail", () => { const check = balanceCheck(WETH, ONE_WETH, USER); const plain = decode(execute(swap, check), USER, address); expect(detail(plain, "Steps").value).toBe( "V3 Swap (Exact In) → Balance Check", ); expect(detail(plain, "Min. received").value).toBe("1.0000 WETH"); // The sender as the From address gives it, in lower case. expect( decode(execute(swap, check), USER.toLowerCase(), address), ).toEqual(plain); const withFlag = decode(execute(swap, flagged(check)), USER, address); expect(detail(withFlag, "Steps").value).toBe( "V3 Swap (Exact In) → Balance Check (allowed to fail)", ); expect(detail(withFlag, "Min. received").value).toBe(NO_MINIMUM); }); const notTheSender = [ { kind: "address(1)", owner: MSG_SENDER }, { kind: "address(2)", owner: ADDRESS_THIS }, { kind: "another address", owner: OTHER }, ]; test.each(notTheSender)( "$kind: after a swap paid to the user, the check sets no minimum and is said to be of another address", ({ owner }) => { const check = balanceCheck(WETH, ONE_WETH, owner); const plain = decode(execute(swap, check), USER, address); expect(detail(plain, "Steps").value).toBe( "V3 Swap (Exact In) → Balance Check (of another address)", ); expect(paidTo(plain)).toEqual([]); expect(detail(plain, "Min. received").value).toBe(NO_MINIMUM); const withFlag = decode( execute(swap, flagged(check)), USER, address, ); expect(detail(withFlag, "Steps").value).toBe( "V3 Swap (Exact In) → Balance Check (of another address) (allowed to fail)", ); expect(paidTo(withFlag)).toEqual([]); expect(detail(withFlag, "Min. received").value).toBe(NO_MINIMUM); }, ); test.each(notTheSender)( "$kind: a check alone names no output token and states no minimum", ({ owner }) => { const check = balanceCheck(WETH, ONE_WETH, owner); for (const step of [check, flagged(check)]) { const result = decode(execute(step), USER, address); expect(detail(result, "Token Out").value).toBe(UNNAMED); expect(detail(result, "Min. received")).toBeUndefined(); expect(detail(result, "Min. paid out")).toBeUndefined(); } }, ); test("an owner word with a nonzero top byte: the check is said not to decode and sets no minimum", () => { // The router checks the balance of the address in the word's last 20 // bytes, here OTHER's. const check = withDirtyWord(balanceCheck(WETH, ONE_WETH, OTHER), 0); const plain = decode(execute(swap, check), USER, address); expect(detail(plain, "Steps").value).toBe( "V3 Swap (Exact In) → Balance Check (could not be decoded)", ); expect(detail(plain, "Min. received").value).toBe(NO_MINIMUM); const withFlag = decode(execute(swap, flagged(check)), USER, address); expect(detail(withFlag, "Steps").value).toBe( "V3 Swap (Exact In) → Balance Check (could not be decoded) (allowed to fail)", ); expect(detail(withFlag, "Min. received").value).toBe(NO_MINIMUM); }); }); // 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"); }); }); // The address of each token moved into the router, a known one and one the // wallet does not know, is shown as every other address is: with an explorer // link, and copied when clicked. Neither token is the swap's. describe("the screens show each token moved into the router as an address", () => { const twoMovesThenSwap = execute( permit2TransferFrom(USDC, ADDRESS_THIS, USDC_1990), permit2TransferFrom(UNKNOWN_TOKEN, ADDRESS_THIS, 5n), swapToWeth(MSG_SENDER), ); test.each([ { screen: "approval", show: showApproval }, { screen: "transaction detail", show: showDetail }, ])("the $screen screen", async ({ show }) => { const html = await show(twoMovesThenSwap); for (const token of [USDC, UNKNOWN_TOKEN]) { expect(html).toContain('data-copy="' + token + '"'); expect(html).toContain("/address/" + token + '"'); } }); }); // The issue's example: 1,000,000 USDT moved to another address, then a swap // of 1 USDT. Both screens show the amount moved and where it goes. describe("the screens show what a PERMIT2_TRANSFER_FROM moves", () => { const millionToOther = execute( permit2TransferFrom(USDT, OTHER, 1000000000000n), v3SwapExactIn(MSG_SENDER, 1000000n, ONE_WETH, USDT, WETH), ); test.each([ { screen: "approval", show: showApproval }, { screen: "transaction detail", show: showDetail }, ])("the $screen screen", async ({ show }) => { const html = await show(millionToOther); expect(html).toContain("1000000.0000 USDT"); // Each line is drawn in a div of its own; the Paid to line names // OTHER too, so the Moved to line's own div is checked. const movedToLine = html .split('