diff --git a/README.md b/README.md index edd2b8f..838b8a5 100644 --- a/README.md +++ b/README.md @@ -1176,18 +1176,18 @@ number. They can read: - `Unlimited`: on the ERC-20 `Amount` line, an `approve` of the `uint256` maximum, an unbounded allowance. On the swap's `Amount` line, any amount at or above the `uint160` maximum, whichever step set the line: a `PERMIT2_PERMIT` - amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in, - V2 exact-out or `WRAP_ETH` amount that large, which is not an allowance. The + amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in + or exact-out or `WRAP_ETH` amount that large, which is not an allowance. The router's whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such amount. -- `Up to `: the swap's `Amount` line, when the transaction has a V2 - exact-out step, whichever step set the line, including the `WRAP_ETH` of a +- `Up to `: the swap's `Amount` line, when the transaction has a V2 or + V3 exact-out step, whichever step set the line, including the `WRAP_ETH` of a swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure, not necessarily all of it; the wait, success and error screens show it with the same words. `Unlimited`, `All available (V4 open delta)` and `Whatever an earlier step sent to the pair (V2 already paid)` keep their - wording. When a V2 exact-out step sets `Min. received`, that line shows its - `amountOut`, the exact amount it buys. + wording. When a V2 or V3 exact-out step sets `Min. received`, that line shows + its `amountOut`, the exact amount it buys. - `All available (V4 open delta)`: the swap's `Amount` line, when the amount it shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the whole open delta", so the calldata states no quantity. Transaction Decoding @@ -3008,10 +3008,14 @@ Currently supported: Router addresses Uniswap publishes for the network: on TxApproval the network the transaction is for, on TransactionDetail the network it was sent on. The same calldata sent to any other address is shown as raw data. Decodes Permit2, - V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap action is read only - in the parameter shape deployed on mainnet and Sepolia, not in the newer one - on `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in - any other shape is not read, and its step is listed as + V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V3 swap step's path is read + as the router reads it: a `V3_SWAP_EXACT_IN` path runs from the token it + spends to the token it buys, and a `V3_SWAP_EXACT_OUT` path from the token it + buys to the token it spends, so that step's input token is its path's last + token and its output token the first. A V4 swap action is read only in the + parameter shape deployed on mainnet and Sepolia, not in the newer one on + `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in any + other shape is not read, and its step is listed as `V4 Swap (could not be decoded)`. Each step is named and read as the command that the release of the router at that address runs for its command type, as that release's `contracts/libraries/Commands.sol` names it. The decoder reads @@ -3093,40 +3097,42 @@ Currently supported: as a swap paid in ETH does, but only when that amount is not `CONTRACT_BALANCE` and the router held no WETH from an earlier step when the `WRAP_ETH` ran: after two `WRAP_ETH`s of 0.5 ETH into the router, a swap of 1 - WETH from it reads as leaving the WETH there. A `SETTLE` of `OPEN_DELTA` - (zero), and a `SETTLE_ALL`, pay only what the step still owes of their token - when they run. The decoder takes that to be the amounts the step's swap - actions state they spend of that token only when every action in the step - could be read, every swap action names the token it spends and the token it - buys (a swap action with an empty path swaps nothing), the `SETTLE` or - `SETTLE_ALL` is the step's only `SETTLE` or `SETTLE_ALL` of that token and - comes after every swap action, no swap action buys that token, no `TAKE`, - `TAKE_ALL` or `TAKE_PORTION` in the step takes it (a `TAKE` of a stated amount - adds to what the step owes), and the swap actions that spend it state a - quantity together; otherwise it does not know the amount, and such a `SETTLE` - from the router counts as spending nothing. So the rest of a V4 `SETTLE` of - more than its swap spends, taken to the router, the WETH of a `WRAP_ETH`, and - a token a swap pays the router on the way to the output, each stays in the - router when later steps do not spend all of it, where anyone can take it in a - later transaction. The router sends ETH as written, so a `TRANSFER` of - `CONTRACT_BALANCE` of ETH always reverts and pays out nothing. 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) and no - `Paid to` line is shown. `Paid to` lines after `Token Out` name each other - address paid; one reads + WETH from it reads as leaving the WETH there. A V2 or V3 exact-out step paid + from the router spends at most its `amountInMax`, an amount the calldata does + not state, so the decoder counts it as spending nothing the router holds. A + `SETTLE` of `OPEN_DELTA` (zero), and a `SETTLE_ALL`, pay only what the step + still owes of their token when they run. The decoder takes that to be the + amounts the step's swap actions state they spend of that token only when every + action in the step could be read, every swap action names the token it spends + and the token it buys (a swap action with an empty path swaps nothing), the + `SETTLE` or `SETTLE_ALL` is the step's only `SETTLE` or `SETTLE_ALL` of that + token and comes after every swap action, no swap action buys that token, no + `TAKE`, `TAKE_ALL` or `TAKE_PORTION` in the step takes it (a `TAKE` of a + stated amount adds to what the step owes), and the swap actions that spend it + state a quantity together; otherwise it does not know the amount, and such a + `SETTLE` from the router counts as spending nothing. So the rest of a V4 + `SETTLE` of more than its swap spends, taken to the router, the WETH of a + `WRAP_ETH`, and a token a swap pays the router on the way to the output, each + stays in the router when later steps do not spend all of it, where anyone can + take it in a later transaction. The router sends ETH as written, so a + `TRANSFER` of `CONTRACT_BALANCE` of ETH always reverts and pays out nothing. 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) and no `Paid to` line is shown. `Paid to` lines after + `Token Out` name each other address paid; one reads `The bridge, which pays the deposit's recipient on another network` for a bridge deposit; one reads, for example, `The router, 2000.0000 USDT from the sender (later steps may not spend all of it)` @@ -3140,52 +3146,52 @@ Currently supported: `Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the minimum reads `Min. paid out`. The `Amount` line gives the total the steps take of `Token In`, from the step that sets the input side on: the first V2 or - V3 exact-in, V2 exact-out, `WRAP_ETH` or V4 swap step that names an input - token or amount. A V4 step with a swap action sets the input side when no - earlier step set it, even when it names neither (see Display Consistency). - That step counts its own amount, whoever pays it: a V2 exact-out step its + V3 exact-in or exact-out, `WRAP_ETH` or V4 swap step that names an input token + or amount. A V4 step with a swap action sets the input side when no earlier + step set it, even when it names neither (see Display Consistency). That step + counts its own amount, whoever pays it: a V2 or V3 exact-out step its `amountInMax`, and a V4 step what it takes of its input token from the user's wallet, as below, or, when the user pays it none of that token, the `amountIn` - of the swap action that spends it. Each later V2 or V3 exact-in or V2 - exact-out step whose `payerIsUser` is true takes its amount, found in the same - way, from the user's wallet. Every V4 step, with a swap action or without, - takes from the user's wallet what its `SETTLE`s paid by the user - (`payerIsUser` true) and its `SETTLE_ALL`s pay, for each token on its own; - what the step that sets the input side takes of any other token counts as a - later step's take. A `SETTLE` of any amount but `OPEN_DELTA` pays that amount, - and a `SETTLE` of `OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as - above. So a V4 step that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by - the user and the rest taken to another address, reads `5000.0000 USDT`. A - later step paid from the router spends what the router holds and takes - nothing, and so does a V2 exact-in whose `amountIn` is `ALREADY_PAID`. A later - take of `Token In` is added to the total while both are quantities: a V3 - exact-in of 600 USDT and a V2 exact-in of 400 USDT, both paid by the user, - read `1000.0000 USDT`. When the decoder does not know a take's amount, the - total is not known and there is no `Amount` line. `Token In` is the token of - the last `PERMIT2_PERMIT` the decoder reads, when there is one. Its amount, - `Unlimited` included, stands as the `Amount` figure in place of the total only - in the plain shape of Uniswap's own routes: it is the only `PERMIT2_PERMIT`, - its spender is the router, the router may not let it fail, no step before it - takes its token or a token the decoder does not know, and the decoder reads - every step after it in full. Permit2 then lets the router take no more of the - token than that amount, so the permit covers every take of it. A step the - decoder does not read in full may raise what Permit2 lets the router take, as - a `PERMIT2_PERMIT_BATCH` can: a command the decoder does not read (listed at - the end of this section), a step whose input it cannot read, in whole or in - part, and a step it fails on. In any other case the line gives the total, as - with no permit. When the step that sets the input side spends a token other - than the permit's, or one the decoder does not know, its amount gets lines of - its own and the total has no figure to start from: unless the permit's amount - stands there is no `Amount` line, and each take of the permit's token gets - lines of its own. Each take the total does not count gives three lines after - the `Amount` line (after `Token In` when there is none): one of a token other - than `Token In`; one of `Token In` after an amount that is not a quantity (a - V4 open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line, - or no stated amount, as of a V4 exact-out step); and one whose amount the - decoder does not know. When the step that sets the input side states no amount - and there is no `Amount` line, that step gets them too whenever another take's - lines give a figure, so that figure cannot read as all that leaves. They come - in step order, the step that sets the input side first when it has them: + of the swap action that spends it. Each later V2 or V3 exact-in or exact-out + step whose `payerIsUser` is true takes its amount, found in the same way, from + the user's wallet. Every V4 step, with a swap action or without, takes from + the user's wallet what its `SETTLE`s paid by the user (`payerIsUser` true) and + its `SETTLE_ALL`s pay, for each token on its own; what the step that sets the + input side takes of any other token counts as a later step's take. A `SETTLE` + of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of + `OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step + that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the + rest taken to another address, reads `5000.0000 USDT`. A later step paid from + the router spends what the router holds and takes nothing, and so does a V2 + exact-in whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is + added to the total while both are quantities: a V3 exact-in of 600 USDT and a + V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When + the decoder does not know a take's amount, the total is not known and there is + no `Amount` line. `Token In` is the token of the last `PERMIT2_PERMIT` the + decoder reads, when there is one. Its amount, `Unlimited` included, stands as + the `Amount` figure in place of the total only in the plain shape of Uniswap's + own routes: it is the only `PERMIT2_PERMIT`, its spender is the router, the + router may not let it fail, no step before it takes its token or a token the + decoder does not know, and the decoder reads every step after it in full. + Permit2 then lets the router take no more of the token than that amount, so + the permit covers every take of it. A step the decoder does not read in full + may raise what Permit2 lets the router take, as a `PERMIT2_PERMIT_BATCH` can: + a command the decoder does not read (listed at the end of this section), a + step whose input it cannot read, in whole or in part, and a step it fails on. + In any other case the line gives the total, as with no permit. When the step + that sets the input side spends a token other than the permit's, or one the + decoder does not know, its amount gets lines of its own and the total has no + figure to start from: unless the permit's amount stands there is no `Amount` + line, and each take of the permit's token gets lines of its own. Each take the + total does not count gives three lines after the `Amount` line (after + `Token In` when there is none): one of a token other than `Token In`; one of + `Token In` after an amount that is not a quantity (a V4 open delta, + `ALREADY_PAID`, which keep their wording on the `Amount` line, or no stated + amount, as of a V4 exact-out step); and one whose amount the decoder does not + know. When the step that sets the input side states no amount and there is no + `Amount` line, that step gets them too whenever another take's lines give a + figure, so that figure cannot read as all that leaves. They come in step + order, the step that sets the input side first when it has them: `Moved from the sender`, its amount, worded as the `Amount` line words one, or `Unknown (could not be decoded)` when the decoder does not know it; `Token moved`, giving the token's address as the `Token In` line does; and @@ -3193,12 +3199,11 @@ Currently supported: does not know gives the `Moved from the sender` line alone. A step may also take from the user's wallet what the decoder did not read: a V4 step whose input or one of whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the - router still runs; a V2 or V3 exact-in or V2 exact-out step or a - `PERMIT2_TRANSFER_FROM` whose input it cannot read; every - `PERMIT2_TRANSFER_FROM_BATCH`; every `V3_SWAP_EXACT_OUT`, which the decoder - does not read, wherever it stands; every `EXECUTE_SUB_PLAN`, wherever it - stands; and every step it fails on. Each such step gives one - `Moved from the sender` line among those, reading + router still runs; a V2 or V3 exact-in or exact-out step or a + `PERMIT2_TRANSFER_FROM` whose input it cannot read, such as a V3 step whose + path is shorter than one token address; every `PERMIT2_TRANSFER_FROM_BATCH`; + every `EXECUTE_SUB_PLAN`, wherever it stands; and every step it fails on. Each + such step gives one `Moved from the sender` line among those, reading `Unknown (could not be decoded)`. That line, or any line for a token the decoder does not know, leaves no `Amount` line, whose figure might be too low, and such a step before a `PERMIT2_PERMIT` keeps the permit's amount from @@ -3212,13 +3217,15 @@ Currently supported: `Token moved into the router` line already give the amount, the token and where it goes, and so its amount is shown once. These lines change neither the `Paid to` lines nor the minimum's label. A step goes unread when its recipient - does not decode as an address, 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`, - `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` - (`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and - the NFT commands among them), or a type its release names no command for. + does not decode as an address, when it is a V3 swap step whose path is shorter + than one token address, 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`, + `V3_SWAP_EXACT_OUT`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`, + `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, + `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`PERMIT2_TRANSFER_FROM_BATCH`, + `EXECUTE_SUB_PLAN` and the NFT commands among them), or a type its release + names no command for. ### Non-Goals Forever diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 6dff99e..1f9692d 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -592,13 +592,14 @@ function decodeV2SwapExactOut(input) { } } -// Decode V3 swap path (packed: token(20) + fee(3) + token(20) ...) +// The first and last token of a V3 swap path (packed: token(20) + fee(3) + +// token(20) ...), or null when it is shorter than one token. function decodeV3Path(pathHex) { const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex; if (hex.length < 40) return null; - const tokenIn = "0x" + hex.slice(0, 40); - const tokenOut = "0x" + hex.slice(-40); - return { tokenIn, tokenOut }; + const first = "0x" + hex.slice(0, 40); + const last = "0x" + hex.slice(-40); + return { first, last }; } // Decode V3_SWAP_EXACT_IN (command 0x00) input bytes. @@ -617,8 +618,39 @@ function decodeV3SwapExactIn(input) { recipient: d[0], amountIn: d[1], amountOutMin: d[2], - tokenIn: path.tokenIn, - tokenOut: path.tokenOut, + tokenIn: path.first, + tokenOut: path.last, + payerIsUser: d[4], + }; + } catch { + return null; + } +} + +// Decode V3_SWAP_EXACT_OUT (command 0x01) input bytes. +// ABI: (address recipient, uint256 amountOut, uint256 amountInMax, +// bytes path, bool payerIsUser) +// The router reads an exact-out path from the token it buys to the token it +// spends: it buys `amountOut` of the path's first token from the path's first +// pool and pays at most `amountInMax` of its last token to its last pool +// (`v3SwapExactOutput()` and `uniswapV3SwapCallback()` in each release's +// `contracts/modules/uniswap/v3/V3SwapRouter.sol`, at the commit linked for +// it above). +// With `payerIsUser` false the router pays the input from its own balance. +function decodeV3SwapExactOut(input) { + try { + const d = coder.decode( + ["address", "uint256", "uint256", "bytes", "bool"], + input, + ); + const path = decodeV3Path(d[3]); + if (!path) return null; + return { + recipient: d[0], + amountOut: d[1], + amountInMax: d[2], + tokenIn: path.last, + tokenOut: path.first, payerIsUser: d[4], }; } catch { @@ -1180,7 +1212,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { let minOutput = null; let outputEstablished = false; let hasUnwrapWeth = false; - let hasV2ExactOut = false; + let hasV2OrV3ExactOut = false; let swapUndecoded = false; let hasSubPlan = false; const commandNames = []; @@ -1236,9 +1268,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { }; // A step may take from the sender what this decoder did not read: a // V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it - // cannot read; a V2 or V3 exact-in or V2 exact-out step or - // PERMIT2_TRANSFER_FROM whose input it cannot read; a - // PERMIT2_TRANSFER_FROM_BATCH; a V3_SWAP_EXACT_OUT; an + // cannot read; a V2 or V3 swap step or PERMIT2_TRANSFER_FROM whose + // input it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; an // EXECUTE_SUB_PLAN; and a step it fails on. It is recorded with a null // token and amount: one line says so, there is no Amount line, whose // figure may then be too low, and the amount of a permit after it @@ -1493,14 +1524,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) { stepsUnread.push(i); spentUnread(); } - } else if (command === "V3_SWAP_EXACT_OUT") { - // This decoder does not read it - // (https://git.eeqj.de/sneak/AutistMask/issues/601). It - // may pay any address and take from the sender, so - // wherever it stands it leaves no Amount figure. - payeeUnread = true; - stepsUnread.push(i); - spentUnread(); } else if (command === "V2_SWAP_EXACT_IN") { const s = decodeV2SwapExactIn(inputs[i]); if (s) { @@ -1521,10 +1544,19 @@ function decode(data, toAddress, chainId, sources, fromAddress) { stepsUnread.push(i); spentUnread(); } - } else if (command === "V2_SWAP_EXACT_OUT") { + } else if ( + command === "V2_SWAP_EXACT_OUT" || + command === "V3_SWAP_EXACT_OUT" + ) { // Buys exactly amountOut and spends at most amountInMax. - hasV2ExactOut = true; - const s = decodeV2SwapExactOut(inputs[i]); + // Paid from the router, it spends an amount the calldata + // does not state, so it spends nothing spendFromRouter() + // could count. + hasV2OrV3ExactOut = true; + const s = + command === "V2_SWAP_EXACT_OUT" + ? decodeV2SwapExactOut(inputs[i]) + : decodeV3SwapExactOut(inputs[i]); if (s) { if (!inputEstablished) { setInputOnce(s.tokenIn, s.amountInMax); @@ -1943,12 +1975,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if (amount >= MAX_UINT160) { return { raw: "Unlimited", display: "Unlimited" }; } - if (hasV2ExactOut) { - // A V2 exact-out swap spends at most this figure, whichever - // steps gave it (an amountInMax, the WRAP_ETH of a swap paid - // in ETH, a permit, or several steps' total), so it is said to - // be a maximum, in `raw` too: the wait, success and error - // screens show `raw` as the transaction's amount. + if (hasV2OrV3ExactOut) { + // A V2 or V3 exact-out swap spends at most this figure, + // whichever steps gave it (an amountInMax, the WRAP_ETH of a + // swap paid in ETH, a permit, or several steps' total), so it + // is said to be a maximum, in `raw` too: the wait, success and + // error screens show `raw` as the transaction's amount. const most = amountText(amount, info); return { raw: "Up to " + most.raw, diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 998fdcd..f3a2aa7 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -88,17 +88,42 @@ function encodeV2SwapExactOut(recipient, amountOut, amountInMax, pathAddrs) { ); } -// Helper: encode a V3_SWAP_EXACT_IN input (command 0x00) -function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) { - // V3 path: token(20) + fee(3) + token(20) ... +// Helper: a V3 path: token(20) + fee(3) + token(20) ... +function encodeV3Path(pathTokens) { let pathHex = pathTokens[0].slice(2).toLowerCase(); for (let i = 1; i < pathTokens.length; i++) { pathHex += "000bb8"; // fee 3000 = 0x000bb8 pathHex += pathTokens[i].slice(2).toLowerCase(); } + return "0x" + pathHex; +} + +// Helper: encode a V3_SWAP_EXACT_IN input (command 0x00) +function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) { return coder.encode( ["address", "uint256", "uint256", "bytes", "bool"], - [recipient, amountIn, amountOutMin, "0x" + pathHex, true], + [recipient, amountIn, amountOutMin, encodeV3Path(pathTokens), true], + ); +} + +// Helper: encode a V3_SWAP_EXACT_OUT input (command 0x01). Its path lists +// the token bought first and the token spent last. +function encodeV3SwapExactOut( + recipient, + amountOut, + amountInMax, + pathTokens, + payerIsUser = true, +) { + return coder.encode( + ["address", "uint256", "uint256", "bytes", "bool"], + [ + recipient, + amountOut, + amountInMax, + encodeV3Path(pathTokens), + payerIsUser, + ], ); } @@ -644,6 +669,79 @@ describe("uniswap decoder", () => { expect(detail(result, "Min. received").value).toBe("1500.0000 USDC"); }); + // https://git.eeqj.de/sneak/AutistMask/issues/601 — a V3_SWAP_EXACT_OUT + // was named but not read, so its tokens read "Unknown (not named in the + // calldata)" and it showed no amounts. It is read as a V2_SWAP_EXACT_OUT + // is, with its path read as the router reads it: from the token it buys + // to the token it spends. Read the other way, each path below would be a + // swap of WETH for USDT. + test.each([ + { hops: "single-hop", path: [WETH_ADDR, USDT_ADDR] }, + { hops: "multi-hop", path: [WETH_ADDR, USDC_ADDR, USDT_ADDR] }, + ])( + "decodes a $hops V3_SWAP_EXACT_OUT, stating the input amount as a maximum", + ({ path }) => { + // Buy exactly 1 WETH for at most 2,000 USDT, paid by the user. + const data = buildExecute( + "0x01", // V3_SWAP_EXACT_OUT + [encodeV3SwapExactOut(USER_ADDR, ONE_WETH, 2000000000n, path)], + 9999999999n, + ); + + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); + expect(result.name).toBe("Swap USDT → WETH"); + expect(detail(result, "Token In").address).toBe( + USDT_ADDR.toLowerCase(), + ); + expect(detail(result, "Token Out").address).toBe( + WETH_ADDR.toLowerCase(), + ); + + const amount = detail(result, "Amount"); + expect(amount.value).toBe("Up to 2000.0000 USDT"); + expect(amount.rawValue).toBe("Up to 2000.0000"); + + expect(detail(result, "Min. received").value).toBe("1.0000 WETH"); + }, + ); + + // The V2 exact-out swap paid in ETH above, as a V3_SWAP_EXACT_OUT paid + // from the WETH the router holds: it takes nothing more from the user's + // wallet. + test("a V3 exact-out swap paid in ETH from the router shows the token it buys and a maximum", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8", "uint8"], [0x0b, 0x01, 0x0c]), + [ + encodeWrapEth(ROUTER_ADDR, 500000000000000000n), + encodeV3SwapExactOut( + USER_ADDR, + 1500000000n, // amountOut: 1,500 USDC + 500000000000000000n, // amountInMax: 0.5 WETH + [USDC_ADDR, WETH_ADDR], + false, // paid from the router + ), + encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH same encoding + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); + expect(result.name).toBe("Swap ETH → USDC"); + expect(detail(result, "Amount").value).toBe("Up to 0.5000 ETH"); + expect(detail(result, "Moved from the sender")).toBeUndefined(); + expect(detail(result, "Token Out").address).toBe( + USDC_ADDR.toLowerCase(), + ); + expect(detail(result, "Paid to")).toBeUndefined(); + expect(detail(result, "Min. received").value).toBe("1500.0000 USDC"); + }); + // Buy exactly 0.5 ETH for at most 1,500 USDC under a permit: the swap buys // WETH and UNWRAP_WETH turns it into ETH. test("a V2 exact-out swap that buys ETH shows ETH and the permit as a maximum", () => { @@ -2048,11 +2146,9 @@ describe("uniswap decoder", () => { ); // A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax` - // USDT for USDC. Its input is (recipient, amountOut, amountInMax, - // path, payerIsUser), laid out as encodeV3SwapExactIn() writes one; - // an exact-out path lists the token bought first. + // USDT for USDC. const v3ExactOut = (amountInMax) => - encodeV3SwapExactIn(USER_ADDR, 390000000n, amountInMax, [ + encodeV3SwapExactOut(USER_ADDR, 390000000n, amountInMax, [ USDC_ADDR, USDT_ADDR, ]); @@ -2070,14 +2166,16 @@ describe("uniswap decoder", () => { [USDC_ADDR, USER_ADDR, 0n], ), ]; - // This decoder does not read a V3_SWAP_EXACT_OUT, so what one takes - // leaves no Amount figure wherever it stands, and a permit's amount - // does not stand. + // What a V3_SWAP_EXACT_OUT takes, at most its amountInMax, counts + // as a V2_SWAP_EXACT_OUT's does, and the Amount line is a maximum. + // The permit of 10,000 USDT stands; the one of 600 USDT does not, + // because the step before it took its token. test.each([ { - kind: "a V3 exact-in of 600 USDT, then a V3_SWAP_EXACT_OUT", + kind: "a V3 exact-in of 600 USDT, then a V3_SWAP_EXACT_OUT of at most 400 USDT", commands: [0x00, 0x01], inputs: [V3_600, v3ExactOut(USDT_400)], + amount: "Up to 1000.0000 USDT", }, { kind: "a PERMIT2_PERMIT of 10,000 USDT, then a V3_SWAP_EXACT_OUT, a V2_SWAP_EXACT_OUT and a SWEEP", @@ -2086,11 +2184,13 @@ describe("uniswap decoder", () => { encodePermit2(USDT_ADDR, 10000000000n, ROUTER_ADDR), ...EXACT_OUT_SPLIT, ], + amount: "Up to 10000.0000 USDT", }, { kind: "a V3_SWAP_EXACT_OUT, a V2_SWAP_EXACT_OUT and a SWEEP", commands: [0x01, 0x09, 0x04], inputs: EXACT_OUT_SPLIT, + amount: "Up to 10000.0000 USDT", }, { kind: "a V3_SWAP_EXACT_OUT of at most 9,400 USDT, then a PERMIT2_PERMIT of 600 USDT and a V3 exact-in of 600 USDT", @@ -2100,17 +2200,33 @@ describe("uniswap decoder", () => { encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR), V3_600, ], + amount: "Up to 10000.0000 USDT", }, ])( - "$kind, all paid by the user, shows no Amount figure", - ({ commands, inputs }) => { + "$kind, all paid by the user, shows the total as a maximum", + ({ commands, inputs, amount }) => { const result = decodeSteps(commands, inputs); expect(linesBetweenTokens(result)).toEqual([ - ["Moved from the sender", UNDECODED], + ["Amount", amount], ]); }, ); + // A V3_SWAP_EXACT_OUT whose path is shorter than one token cannot be + // read. It may pay any address and take from the sender, so it + // leaves no Amount figure. + test("a V3 exact-in of 600 USDT, then a V3_SWAP_EXACT_OUT whose path cannot be read, shows no Amount figure", () => { + const unreadable = coder.encode( + ["address", "uint256", "uint256", "bytes", "bool"], + [USER_ADDR, 390000000n, USDT_400, "0x", true], + ); + const result = decodeSteps([0x00, 0x01], [V3_600, unreadable]); + expect(linesBetweenTokens(result)).toEqual([ + ["Moved from the sender", UNDECODED], + ]); + expect(detail(result, "Paid to").value).toBe(UNDECODED); + }); + // A later step's amount that cannot be added to the Amount line, // because the amount of the step that set the input side is not a // quantity, gets lines of its own. When that step stated no amount, diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js index 37c0291..fc4c786 100644 --- a/tests/uniswapRecipient.test.js +++ b/tests/uniswapRecipient.test.js @@ -387,6 +387,18 @@ describe("each swap command's recipient", () => { data: (r) => execute(swapToWeth(r)), min: "1.0000 WETH", }, + { + // Its input has V3_SWAP_EXACT_IN's layout, with amountOut and + // amountInMax in place of amountIn and amountOutMin, and a path + // that lists the token bought first. + kind: "V3_SWAP_EXACT_OUT", + data: (r) => + execute([ + 0x01, + v3SwapExactIn(r, ONE_WETH, USDT_2000, WETH, USDT)[1], + ]), + min: "1.0000 WETH", + }, { kind: "V4_SWAP with TAKE", data: (r) => @@ -2019,10 +2031,6 @@ describe("a payout that cannot be read is never said to be received", () => { ), ], }, - { - kind: "V3_SWAP_EXACT_OUT", - step: [0x01, swapToWeth(OTHER)[1]], - }, { kind: "EXECUTE_SUB_PLAN", step: [