From 46a39fd96b9620cccc4e19eeec2cade7e9b5a42f Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 19 Oct 2026 09:56:15 +0000 Subject: [PATCH] fix: the swap decoder reads who the router's transfer commands pay (closes #599) TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION (0x07, on the routers with the 0x7f mask), PERMIT2_TRANSFER_FROM and V4 TAKE_PORTION were payouts the decoder did not read, so a swap using one read Min. paid out with an unknown payee. Each recipient is now read under the rule swap steps follow: the sender, the router, a hop into the next V2 pair, or another address, which is named. Paying all the router holds of a token pays it out as SWEEP does; a part leaves the rest in the router. UniversalRouterV1 pays a PERMIT2_TRANSFER_FROM to address(1) or address(2) as written. 0x07 and ACROSS_V4_DEPOSIT_V3 (0x40) are named in Steps; the deposit stays unread. Model: opus-5-5 --- README.md | 83 ++++---- src/shared/uniswap.js | 178 ++++++++++++++--- tests/uniswap.test.js | 23 ++- tests/uniswapRecipient.test.js | 356 ++++++++++++++++++++++++++++++--- 4 files changed, 539 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 4c8360e..8ab0d98 100644 --- a/README.md +++ b/README.md @@ -2955,45 +2955,58 @@ Currently supported: address uses: `0x1f` for `UniversalRouterV1`, `0x3f` for `UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for `UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`. The allow-revert flag - (`0x80`) therefore does not change a step's name. The decoder does not read - the commands inside an `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that - step stands they may spend the user's tokens or pay the user. That step is - listed as `Execute Sub-Plan (its steps are not shown)`, and whatever the other - steps state, the summary names no swap direction, `Token In` and `Token Out` - read `Unknown (could not be decoded)`, and there is no `Amount` or - `Min. received` line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and - V4 `TAKE` names the address it pays, and `TAKE_ALL` pays the transaction's - sender. The router reads `address(1)` as the transaction's sender, and - `address(2)` as the router itself, which holds what it is paid until a later - step pays it out; a payment to the router, as `address(2)` or by its own - address, is not counted as paid out. When a swap step or `TAKE` pays the - router the swap's output token, the output stays in the router unless a later - `SWEEP` of that token, or a later `UNWRAP_WETH` when it is WETH, pays it out. - A swap step or `TAKE` paying a token to the V2 pair that the very next step - spends, as a V2 exact-in of that token whose `amountIn` is `ALREADY_PAID` - (zero) or `CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes - pay the next V2 pair directly, and the router enforces that swap's own - minimum. The decoder checks that the address paid is that pair, deriving it - from the V2 factory and pair init code hash the network's routers were - deployed with and the first two tokens of that step's path; a payment to any - other address is named. (Sepolia's `UniversalRouterV1_2_V2Support` was - deployed with placeholders for both values, so no V2 swap step can succeed on - that router.) Every other payment counts, in whichever step and of whatever - token. The minimum reads `Min. received` only when some step pays the - transaction's sender (the From address on TxApproval, the sender the explorer - reports on TransactionDetail), no step pays any other address, no step that - may pay an address went unread, and the output does not stay in the router. - Otherwise it reads `Min. paid out`, a `Paid to` line after `Token Out` names - each other address paid, one reads + (`0x80`) therefore does not change a step's name. Only the routers with the + `0x7f` mask have `PAY_PORTION_FULL_PRECISION` (`0x07`), listed as + `Pay Portion (Full Precision)`, and `ACROSS_V4_DEPOSIT_V3` (`0x40`), listed as + `Across Bridge Deposit`; the other routers revert on `0x07`, and it is listed + there as `Command 0x07`. The decoder does not read the commands inside an + `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that step stands they may spend + the user's tokens or pay the user. That step is listed as + `Execute Sub-Plan (its steps are not shown)`, and whatever the other steps + state, the summary names no swap direction, `Token In` and `Token Out` read + `Unknown (could not be decoded)`, and there is no `Amount` or `Min. received` + line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH`, `TRANSFER`, + `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `PERMIT2_TRANSFER_FROM` (which + moves the user's own tokens) and V4 `TAKE` and `TAKE_PORTION` names the + address it pays, and `TAKE_ALL` pays the transaction's sender. The router + reads `address(1)` as the transaction's sender, and `address(2)` as the router + itself, which holds what it is paid until a later step pays it out; a payment + to the router, as `address(2)` or by its own address, is not counted as paid + out. `UniversalRouterV1` alone pays a `PERMIT2_TRANSFER_FROM` to `address(1)` + or `address(2)` as those addresses, and the decoder names them. When a swap + step, `TAKE` or `TAKE_PORTION` pays the router the swap's output token, the + output stays in the router unless a later step pays all the router holds of it + to anyone but the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is + WETH, or a `TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or + a `PAY_PORTION_FULL_PRECISION` of 1e18 of that token. A step paying a token to + the V2 pair that the very next step spends, as a V2 exact-in of that token + whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a hop, not + a payout: Uniswap's mixed routes pay the next V2 pair directly, and the router + enforces that swap's own minimum. The decoder checks that the address paid is + that pair, deriving it from the V2 factory and pair init code hash the + network's routers were deployed with and the first two tokens of that step's + path; a payment to any other address is named. (Sepolia's + `UniversalRouterV1_2_V2Support` was deployed with placeholders for both + values, so no V2 swap step can succeed on that router.) Every other payment + counts, in whichever step and of whatever token, a share of the output + included: an interface fee paid to another address by `PAY_PORTION`, + `TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`, + because the swap's minimum includes the fee. The minimum reads `Min. received` + only when some step pays the transaction's sender (the From address on + TxApproval, the sender the explorer reports on TransactionDetail), no step + pays any other address, no step that may pay an address went unread, and the + output does not stay in the router. Otherwise it reads `Min. paid out`, a + `Paid to` line after `Token Out` names each other address paid, one reads `The router, where it stays (no later step pays it out)` when the output stays in the router, and one more reads `Unknown (could not be decoded)` when such a step went unread, or `Unknown (not named in the calldata)` when no step pays anyone. A step goes unread when its recipient does not decode as an address, - or when it is a command or V4 action the decoder does not read: every command - other than `PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, - `V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and - `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and - `EXECUTE_SUB_PLAN` among them), and V4 `TAKE_PORTION`. + or when it is a command the decoder does not read: every command other than + `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, + `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`, + `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `WRAP_ETH`, `UNWRAP_WETH` and + `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH`, + `EXECUTE_SUB_PLAN` and `ACROSS_V4_DEPOSIT_V3` among them). ### Non-Goals Forever diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 6b25b50..a55644c 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -123,7 +123,10 @@ function v2PairFor(chainId, tokenA, tokenB) { } // Universal Router command types, each read from its command byte with the -// router's mask (see ROUTER_ADDRESSES) +// router's mask (see ROUTER_ADDRESSES). Only the routers with the 0x7f mask +// have 0x07 and 0x40: 0x40 is above the other masks, and the other routers +// revert on 0x07, so decode() names it there as it names a command not +// listed here. const COMMAND_NAMES = { 0x00: "V3 Swap (Exact In)", 0x01: "V3 Swap (Exact Out)", @@ -132,6 +135,7 @@ const COMMAND_NAMES = { 0x04: "Sweep", 0x05: "Transfer", 0x06: "Pay Portion", + 0x07: "Pay Portion (Full Precision)", 0x08: "V2 Swap (Exact In)", 0x09: "V2 Swap (Exact Out)", 0x0a: "Permit2 Permit", @@ -145,6 +149,7 @@ const COMMAND_NAMES = { 0x13: "V4 Initialize Pool", 0x14: "V4 Position Mgr Call", 0x21: "Execute Sub-Plan", + 0x40: "Across Bridge Deposit", }; // The swap's Amount and Min. received lines land on the same approval screen, @@ -251,9 +256,11 @@ const WETH_ADDRESSES = [ // Two recipient values the router reads as placeholders, not as addresses: // MSG_SENDER pays the caller of execute(), which is the transaction's sender, // and ADDRESS_THIS pays the router itself, which holds what it is paid until a -// later step pays it out. The swap commands, SWEEP, WRAP_ETH and UNWRAP_WETH -// map their recipient through the dispatcher's map(), and a V4 TAKE through -// V4Router's _mapRecipient(); TAKE_ALL always pays the caller. +// later step pays it out. The swap commands, SWEEP, WRAP_ETH, UNWRAP_WETH, +// TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION and, on every router but +// UniversalRouterV1, PERMIT2_TRANSFER_FROM map their recipient through the +// dispatcher's map(), and a V4 TAKE and TAKE_PORTION through V4Router's +// _mapRecipient(); TAKE_ALL always pays the caller. // - constants: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Constants.sol // - map(), UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Recipient.sol // - map(), UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol @@ -426,6 +433,46 @@ function decodeSweep(input) { } } +// TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION and PERMIT2_TRANSFER_FROM +// read their parameters as each release's dispatcher reads them: +// - UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/base/Dispatcher.sol +// - UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/blob/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts/base/Dispatcher.sol +// - UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol +// - UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/blob/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts/base/Dispatcher.sol +// - UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/base/Dispatcher.sol + +// Decode TRANSFER (command 0x05) input bytes, and those of PAY_PORTION (0x06) +// and PAY_PORTION_FULL_PRECISION (0x07), which share its layout. Each pays +// `token` out of the router to the recipient. TRANSFER pays `amount`, or all +// the router holds when `amount` is CONTRACT_BALANCE. PAY_PORTION pays +// `amount` ten-thousandths of what the router holds, and +// PAY_PORTION_FULL_PRECISION `amount` 1e18ths of it. +// - UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/modules/Payments.sol +// - UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/modules/Payments.sol +// ABI: (address token, address recipient, uint256 amount) +function decodeTransfer(input) { + try { + const d = coder.decode(["address", "address", "uint256"], input); + return { token: d[0], recipient: d[1], amount: d[2] }; + } catch { + return null; + } +} + +// Decode PERMIT2_TRANSFER_FROM (command 0x02) input bytes. Permit2 moves +// `token` from the transaction's sender to the recipient. UniversalRouterV1 +// hands Permit2 the recipient as written, without map(), so on that router +// address(1) and address(2) are paid as those addresses. +// ABI: (address token, address recipient, uint160 amount) +function decodePermit2TransferFrom(input) { + try { + const d = coder.decode(["address", "address", "uint160"], input); + return { token: d[0], recipient: d[1] }; + } catch { + return null; + } +} + // Decode WRAP_ETH (command 0x0b) input bytes. The router pays the WETH to the // recipient. // ABI: (address recipient, uint256 amount) @@ -615,9 +662,11 @@ function totalAmount(amounts) { // swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL // name. // -// `takes` is every TAKE and TAKE_ALL, as the currency it takes and its -// recipient as written, MSG_SENDER for a TAKE_ALL. `recipientUnread` is set -// when the step has a payout this decoder did not read. +// `takes` is every TAKE, TAKE_ALL and TAKE_PORTION, as the currency it takes +// and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes +// a share of the currency, so it does not name the output side as TAKE does. +// `recipientUnread` is set when the step has a payout this decoder did not +// read. function decodeV4Swap(input) { try { const d = coder.decode(["bytes", "bytes[]"], input); @@ -663,6 +712,13 @@ function decodeV4Swap(input) { const t = coder.decode(["address", "uint256"], params[i]); takeToken = t[0]; takes.push({ currency: t[0], recipient: MSG_SENDER }); + } else if (actionId === V4_TAKE_PORTION) { + // TAKE_PORTION: (address currency, address recipient, uint256 bips) + const t = coder.decode( + ["address", "address", "uint256"], + params[i], + ); + takes.push({ currency: t[0], recipient: t[1] }); } else if ( actionId === V4_SWAP_EXACT_IN_SINGLE || actionId === V4_SWAP_EXACT_IN || @@ -674,17 +730,18 @@ function decodeV4Swap(input) { } catch { swapUndecoded = true; } - } else if (actionId === V4_TAKE_PORTION) { - // It pays a share of a currency to an address this decoder - // does not read - // (https://git.eeqj.de/sneak/AutistMask/issues/599). - // V4Router reverts on any action not named here. - recipientUnread = true; } + // V4Router reverts on any action not named above, so none + // of them pays anyone. } catch { - // Skip sub-actions we can't decode. A TAKE or TAKE_ALL among - // them is a payout this decoder did not read. - if (actionId === V4_TAKE || actionId === V4_TAKE_ALL) { + // Skip sub-actions we can't decode. A TAKE, TAKE_ALL or + // TAKE_PORTION among them is a payout this decoder did not + // read. + if ( + actionId === V4_TAKE || + actionId === V4_TAKE_ALL || + actionId === V4_TAKE_PORTION + ) { recipientUnread = true; } } @@ -824,31 +881,41 @@ function decode(data, toAddress, chainId, sources, fromAddress) { const paidElsewhere = []; // Set when a step may pay an address this decoder did not read. let payeeUnread = false; - // The tokens, lowercased, that a swap step or a V4 TAKE paid to the - // router and no later SWEEP of that token, or UNWRAP_WETH of WETH, - // paid out. + // The tokens, lowercased, that a swap step or a V4 TAKE or + // TAKE_PORTION paid to the router and no later step paid out of it in + // full: a SWEEP of that token, an UNWRAP_WETH of WETH, or a TRANSFER, + // PAY_PORTION or PAY_PORTION_FULL_PRECISION of all the router holds of + // that token. const heldByRouter = new Set(); const isRouter = (recipient) => { const r = recipient.toLowerCase(); return r === ADDRESS_THIS || r === toAddress.toLowerCase(); }; + // A step pays `recipient`, an address other than the sender's or the + // router's, as written. + const addPaidElsewhere = (recipient) => { + const r = recipient.toLowerCase(); + if (!paidElsewhere.some((a) => a.toLowerCase() === r)) { + paidElsewhere.push(recipient); + } + }; // A step pays `recipient`. `output` is the token a swap step or a V4 - // TAKE pays; other steps pass none. + // TAKE or TAKE_PORTION pays; other steps pass none. const addPayee = (recipient, output) => { const r = recipient.toLowerCase(); if (isRouter(recipient)) { if (present(output)) heldByRouter.add(output.toLowerCase()); } else if (r === MSG_SENDER || r === sender) { paidToSender = true; - } else if (!paidElsewhere.some((a) => a.toLowerCase() === r)) { - paidElsewhere.push(recipient); + } else { + addPaidElsewhere(recipient); } }; - // A swap step or V4 TAKE that pays `token` to `recipient` is a hop, - // not a payout, when the very next step is a V2 exact-in whose input - // is already paid, whose path starts at `token`, and whose pair (see - // v2PairFor()) is `recipient`: Uniswap's mixed routes pay the next V2 - // pair directly, and the router enforces that next swap's own minimum. + // A step that pays `token` to `recipient` is a hop, not a payout, + // when the very next step is a V2 exact-in whose input is already + // paid, whose path starts at `token`, and whose pair (see v2PairFor()) + // is `recipient`: Uniswap's mixed routes pay the next V2 pair + // directly, and the router enforces that next swap's own minimum. // Its input is already paid when its amountIn is ALREADY_PAID, or // CONTRACT_BALANCE, which Uniswap's routes send and with which the // router adds only what it holds. @@ -870,9 +937,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) { for (let i = 0; i < commandsBytes.length; i++) { const cmdId = commandsBytes[i] & commandTypeMask; + // Only the routers with the 0x7f mask have command 0x07 (see + // COMMAND_NAMES). + const name = + cmdId === 0x07 && commandTypeMask !== 0x7f + ? null + : COMMAND_NAMES[cmdId]; commandNames.push( - COMMAND_NAMES[cmdId] || - "Command 0x" + cmdId.toString(16).padStart(2, "0"), + name || "Command 0x" + cmdId.toString(16).padStart(2, "0"), ); try { @@ -890,6 +962,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // input side an earlier hop established without breaking // the invariant. if (p) setInput(p.token, p.amount); + } else if (cmdId === 0x02) { + const p = decodePermit2TransferFrom(inputs[i]); + if (!p) { + payeeUnread = true; + } else if ( + commandTypeMask === 0x1f && + [MSG_SENDER, ADDRESS_THIS].includes( + p.recipient.toLowerCase(), + ) + ) { + // UniversalRouterV1, the router with the 0x1f mask, + // pays address(1) and address(2) as written (see + // decodePermit2TransferFrom()). + addPaidElsewhere(p.recipient); + } else if (!isHop(i, p.token, p.recipient)) { + addPayee(p.recipient); + } } else if (cmdId === 0x0e) { const b = decodeBalanceCheck(inputs[i]); // The router passes this check whenever the owner holds at @@ -948,6 +1037,31 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else { payeeUnread = true; } + } else if ( + cmdId === 0x05 || + cmdId === 0x06 || + (cmdId === 0x07 && commandTypeMask === 0x7f) + ) { + const t = decodeTransfer(inputs[i]); + if (t) { + if (!isHop(i, t.token, t.recipient)) { + addPayee(t.recipient); + } + // The amount with which TRANSFER, PAY_PORTION or + // PAY_PORTION_FULL_PRECISION pays all the router + // holds of the token, as SWEEP does. + const all = + cmdId === 0x05 + ? CONTRACT_BALANCE + : cmdId === 0x06 + ? 10000n + : 10n ** 18n; + if (t.amount === all && !isRouter(t.recipient)) { + heldByRouter.delete(t.token.toLowerCase()); + } + } else { + payeeUnread = true; + } } else if (cmdId === 0x0b) { const w = decodeWrapEth(inputs[i]); if (w) { @@ -1016,9 +1130,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } else { // Any other command may pay an address this decoder does - // not read: V3_SWAP_EXACT_OUT does, and so do TRANSFER, - // PAY_PORTION and the others - // https://git.eeqj.de/sneak/AutistMask/issues/599 lists. + // not read: V3_SWAP_EXACT_OUT, + // PERMIT2_TRANSFER_FROM_BATCH and ACROSS_V4_DEPOSIT_V3 + // among them. payeeUnread = true; } } catch { diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index b839c3e..c2fdcf4 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -954,7 +954,28 @@ describe("uniswap decoder", () => { router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2_ADDR, command: "0x40", - steps: "Command 0x40", + steps: "Across Bridge Deposit", + }, + // Only the routers with the 0x7f mask have 0x07, as + // PAY_PORTION_FULL_PRECISION; the others revert on it + // (https://git.eeqj.de/sneak/AutistMask/issues/599). + { + router: "UniversalRouterV1", + address: ROUTER_V1_ADDR, + command: "0x07", + steps: "Command 0x07", + }, + { + router: "UniversalRouterV2", + address: ROUTER_ADDR, + command: "0x07", + steps: "Command 0x07", + }, + { + router: "UniversalRouterV2_1_2", + address: ROUTER_V2_1_2_ADDR, + command: "0x07", + steps: "Pay Portion (Full Precision)", }, ])( "command $command sent to $router is listed as $steps", diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js index a08ad72..00a921b 100644 --- a/tests/uniswapRecipient.test.js +++ b/tests/uniswapRecipient.test.js @@ -1,12 +1,14 @@ // The address a swap's output is paid to -// (https://git.eeqj.de/sneak/AutistMask/issues/594). +// (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 and V4 TAKE 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. +// 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. @@ -41,6 +43,8 @@ const transactionDetail = require("../src/popup/views/transactionDetail"); 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"; @@ -53,6 +57,8 @@ 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)"; @@ -89,7 +95,14 @@ function v2Swap(command, recipient, amount, limit, path, payerIsUser = true) { ]; } -function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) { +function v3SwapExactIn( + recipient, + amountIn, + amountOutMin, + tokenIn, + tokenOut, + payerIsUser = true, +) { return [ 0x00, coder.encode( @@ -102,7 +115,7 @@ function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) { ["address", "uint24", "address"], [tokenIn, 3000, tokenOut], ), - true, + payerIsUser, ], ), ]; @@ -115,6 +128,28 @@ function sweep(token, recipient) { ]; } +// 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])]; } @@ -168,6 +203,15 @@ function take(currency, recipient) { 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) { @@ -422,6 +466,216 @@ describe("an output left in the router is not received", () => { }); }); +// 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 @@ -430,7 +684,6 @@ describe("an output left in the router is not received", () => { 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 CONTRACT_BALANCE = 2n ** 255n; const AMOUNTS_IN = [ { kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE }, { kind: "ALREADY_PAID", amountIn: 0n }, @@ -498,6 +751,36 @@ describe("a swap paid to the pair the next V2 swap spends", () => { }, ); + 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. @@ -661,13 +944,17 @@ describe("every step's payee counts, whichever step sets the output", () => { }); describe("a payout that cannot be read is never said to be received", () => { - test("a V4 TAKE whose recipient word has its top byte set", () => { + 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(take(USDC, OTHER), 1), + withDirtyWord(action, 1), + takeAll(USDC), ), ), ); @@ -689,11 +976,33 @@ describe("a payout that cannot be read is never said to be received", () => { { 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(); @@ -719,17 +1028,8 @@ describe("a payout that cannot be read is never said to be received", () => { expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); }); - // https://git.eeqj.de/sneak/AutistMask/issues/599 is to read these. Until - // then each is a payout this decoder does not read. - const transferShape = (command) => [ - command, - coder.encode(["address", "address", "uint256"], [WETH, OTHER, 1n]), - ]; + // Each is a payout this decoder does not read. test.each([ - { kind: "PERMIT2_TRANSFER_FROM", step: transferShape(0x02) }, - { kind: "TRANSFER", step: transferShape(0x05) }, - { kind: "PAY_PORTION", step: transferShape(0x06) }, - { kind: "PAY_PORTION_FULL_PRECISION", step: transferShape(0x07) }, { kind: "PERMIT2_TRANSFER_FROM_BATCH", step: [ @@ -750,7 +1050,7 @@ describe("a payout that cannot be read is never said to be received", () => { 0x21, coder.encode( ["bytes", "bytes[]"], - ["0x05", [transferShape(0x05)[1]]], + ["0x05", [transfer(0x05, WETH, OTHER, 1n)[1]]], ), ], }, @@ -758,16 +1058,6 @@ describe("a payout that cannot be read is never said to be received", () => { kind: "ACROSS_V4_DEPOSIT_V3", step: [0x40, coder.encode(["address"], [OTHER])], }, - { - kind: "V4 TAKE_PORTION", - step: v4Swap([ - 0x10, - coder.encode( - ["address", "address", "uint256"], - [WETH, OTHER, 10000n], - ), - ]), - }, ])("a swap paid to the user, then $kind", ({ step }) => { const result = decode( execute(swapToWeth(MSG_SENDER), step),