diff --git a/README.md b/README.md index dd58982..74915a5 100644 --- a/README.md +++ b/README.md @@ -1171,8 +1171,8 @@ read: amount of one step that names an input token or amount: the last `PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its - `amountInMax`, and a V4 swap step the `amountIn` of its first readable - exact-in action. + `amountInMax`, and a V4 swap step the `amountIn` of the swap action that + spends its input currency, as described below. - `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's `Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero. The router reads that zero as "the pair already holds the input tokens": the @@ -1194,16 +1194,37 @@ The swap's `Token In` and `Token Out` lines name a currency, not an amount; each reads `Unknown (not named in the calldata)` when the decoder found no token for that side, or `Unknown (could not be decoded)` instead when a V4 swap action in the transaction could not be read (see Transaction Decoding below). A V4 step -whose swap action could not be read still sets each side as a swap step does, -and a side its `SETTLE` or `TAKE` did not name is then unknown: no other step's -token or figure is shown for it. An `UNWRAP_WETH` step makes `Token Out` ETH -only when the output side is WETH, on mainnet or Sepolia, or when no step set -the output side; a WETH `Min. received` figure then reads in ETH. Otherwise -`Token Out` and `Min. received` keep the output side's own token and figure, -whether the swap was paid in ETH or in a token: a V2 exact-out swap that buys -USDC and then unwraps the WETH it did not spend shows USDC. The token permission -warning on the signature screen has its own amount wording, including `Unknown`; -the SignApproval section below describes it. +with a swap action sets both sides as a swap step does, following three rules: + +1. Its input side is the currency its `SETTLE` or `SETTLE_ALL` names, or else + the one currency that some swap action in the step spends and none buys. Its + output side is the currency its `TAKE` or `TAKE_ALL` names, or else the one + currency that some swap action buys and none spends. If there is not exactly + one such currency, that side is unknown, and no other step's token or figure + is shown for it. A step that buys WETH with USDT and USDC with that WETH, + with nothing naming its sides, reads `Swap USDT → USDC`. +2. `Amount` is the `amountIn` of the swap action that spends the input currency. + If several exact-in actions spend it, each with a stated amount, it is their + sum; in any other case there is no `Amount` line. The decoder reads no amount + from an exact-out action, and an open delta is not a stated amount. + `Min. received` is found the same way, from the `amountOutMin` of the actions + that buy the output currency. +3. If any swap action in the step could not be read, only `SETTLE`, + `SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and there is no `Amount` + or `Min. received` line. A side they did not name is unknown, and no other + step's token or figure is shown for it. + +A V4 step with no swap action sets only the sides its `SETTLE`, `SETTLE_ALL`, +`TAKE` or `TAKE_ALL` names. + +An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side is WETH, +on mainnet or Sepolia, or when no step set the output side; a WETH +`Min. received` figure then reads in ETH. Otherwise `Token Out` and +`Min. received` keep the output side's own token and figure, whether the swap +was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then +unwraps the WETH it did not spend shows USDC. The token permission warning on +the signature screen has its own amount wording, including `Unknown`; the +SignApproval section below describes it. The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g. `2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00 diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 4bcd9c9..f15e658 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -290,7 +290,9 @@ const V4_SWAP_EXACT_IN = 0x07; const V4_SWAP_EXACT_OUT_SINGLE = 0x08; const V4_SWAP_EXACT_OUT = 0x09; const V4_SETTLE = 0x0b; +const V4_SETTLE_ALL = 0x0c; const V4_TAKE = 0x0e; +const V4_TAKE_ALL = 0x0f; // A V4 exact-in `amountIn`, read the way V4Router reads it: zero is // ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA @@ -318,10 +320,132 @@ function decodeExactly(types, data) { return decoded; } +// Decode one V4 swap action's parameters into the currency it spends +// (`tokenIn`), the currency it buys (`tokenOut`) and, for an exact-in action, +// its `amountIn` and `amountOutMin`. The exact-out actions decode no amounts. +// A field the action does not state is null. Throws when the parameters are +// not in the deployed shape (see decodeExactly()). +function decodeV4SwapAction(actionId, param) { + if (actionId === V4_SWAP_EXACT_IN) { + // ExactInputParams: (address currencyIn, + // tuple(address,uint24,int24,address,bytes)[] path, + // uint128 amountIn, uint128 amountOutMin) + const s = decodeExactly( + [ + "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", + ], + param, + ); + const path = s[0][1]; + return { + tokenIn: s[0][0], + tokenOut: path.length > 0 ? path[path.length - 1][0] : null, + amountIn: v4ExactInAmount(s[0][2]), + amountOutMin: s[0][3], + }; + } + if (actionId === V4_SWAP_EXACT_IN_SINGLE) { + // ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey, + // bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData) + const s = decodeExactly( + [ + "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", + ], + param, + ); + const poolKey = s[0][0]; + const zeroForOne = s[0][1]; + return { + tokenIn: zeroForOne ? poolKey[0] : poolKey[1], + tokenOut: zeroForOne ? poolKey[1] : poolKey[0], + amountIn: v4ExactInAmount(s[0][2]), + amountOutMin: s[0][3], + }; + } + if (actionId === V4_SWAP_EXACT_OUT) { + // ExactOutputParams: (address currencyOut, + // tuple(address,uint24,int24,address,bytes)[] path, + // uint128 amountOut, uint128 amountInMaximum) + const s = decodeExactly( + [ + "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", + ], + param, + ); + // The router works through the path from its last entry back to its + // first, starting from currencyOut, so the first entry is what the + // action spends. + const path = s[0][1]; + return { + tokenIn: path.length > 0 ? path[0][0] : null, + tokenOut: s[0][0], + amountIn: null, + amountOutMin: null, + }; + } + // V4_SWAP_EXACT_OUT_SINGLE. ExactOutputSingleParams: (poolKey as above, + // bool zeroForOne, uint128 amountOut, uint128 amountInMaximum, + // bytes hookData) + const s = decodeExactly( + [ + "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", + ], + param, + ); + const poolKey = s[0][0]; + const zeroForOne = s[0][1]; + return { + tokenIn: zeroForOne ? poolKey[0] : poolKey[1], + tokenOut: zeroForOne ? poolKey[1] : poolKey[0], + amountIn: null, + amountOutMin: null, + }; +} + +// The currencies in `list` that `other` does not hold, each listed once. +function currenciesNotIn(list, other) { + return [...new Set(list.filter((c) => !other.includes(c)))]; +} + +// The amount that the swap actions on one side of a V4 step state together: +// the one action's own amount, or the sum when there are several and each +// states a quantity. Exact-out actions state none here, and the open delta is +// not a quantity. Otherwise null. +function totalAmount(amounts) { + if (amounts.length === 1) return amounts[0]; + if (amounts.length === 0) return null; + if (!amounts.every((a) => typeof a === "bigint")) return null; + return amounts.reduce((sum, a) => sum + a, 0n); +} + // Decode V4_SWAP (command 0x10) input bytes. -// The input is ABI-encoded as (bytes actions, bytes[] params). -// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions, -// and swap amounts from the swap sub-actions. +// The input is ABI-encoded as (bytes actions, bytes[] params). Each swap +// action gives the currency it spends and the currency it buys (see +// decodeV4SwapAction()). The step's sides then follow three rules: +// +// 1. Token In is the currency SETTLE or SETTLE_ALL names; otherwise the one +// currency that some swap action spends and none buys. Token Out is the +// currency TAKE or TAKE_ALL names; otherwise the one currency that some +// swap action buys and none spends. If there is not exactly one such +// currency, that side is unknown. +// 2. Amount is the amountIn of the swap action that spends Token In. If +// several exact-in actions spend it, each with a stated amount, it is +// their sum; in any other case there is none. Min. received is the same +// for amountOutMin and the actions that buy Token Out. +// 3. If any swap action in the step cannot be read, only SETTLE, SETTLE_ALL, +// TAKE and TAKE_ALL name the sides, and there is no Amount or Min. +// received. +// +// A currency a swap action does not name, such as what an exact-in action +// with an empty path buys, is null and counts in rules 1 and 2 as a currency +// like any other: a step of only that action has an unknown Token Out and +// the action's amountOutMin as Min. received. +// +// `hasSwapAction` is true when the step has a swap action, read or not. +// decode() then sets both sides from this step, a side these rules leave +// unknown included, so no other step's token or figure shows for it. A step +// with no swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and +// TAKE_ALL name. function decodeV4Swap(input) { try { const d = coder.decode(["bytes", "bytes[]"], input); @@ -330,11 +454,12 @@ function decodeV4Swap(input) { let settleToken = null; let takeToken = null; - let amountIn = null; - let amountOutMin = null; + // The step's swap actions that could be read, as decodeV4SwapAction() + // reads them. + const swaps = []; // Set when a swap action's parameters are not in the deployed shape - // (see decodeExactly()), so nothing was read from that action. SETTLE - // and TAKE may still name both currencies. + // (see decodeExactly()), so nothing was read from that action. SETTLE, + // SETTLE_ALL, TAKE and TAKE_ALL may still name both currencies. let swapUndecoded = false; for (let i = 0; i < actions.length; i++) { @@ -347,6 +472,10 @@ function decodeV4Swap(input) { params[i], ); settleToken = s[0]; + } else if (actionId === V4_SETTLE_ALL) { + // SETTLE_ALL: (address currency, uint256 maxAmount) + const s = coder.decode(["address", "uint256"], params[i]); + settleToken = s[0]; } else if (actionId === V4_TAKE) { // TAKE: (address currency, address recipient, uint256 amount) const t = coder.decode( @@ -354,107 +483,20 @@ function decodeV4Swap(input) { params[i], ); takeToken = t[0]; + } else if (actionId === V4_TAKE_ALL) { + // TAKE_ALL: (address currency, uint256 minAmount) + const t = coder.decode(["address", "uint256"], params[i]); + takeToken = t[0]; } else if ( + actionId === V4_SWAP_EXACT_IN_SINGLE || actionId === V4_SWAP_EXACT_IN || - actionId === V4_SWAP_EXACT_IN_SINGLE + actionId === V4_SWAP_EXACT_OUT_SINGLE || + actionId === V4_SWAP_EXACT_OUT ) { - // Extract amounts from exact-in swap actions - if (actionId === V4_SWAP_EXACT_IN) { - // ExactInputParams: (address currencyIn, - // tuple(address,uint24,int24,address,bytes)[] path, - // uint128 amountIn, uint128 amountOutMin) - try { - const s = decodeExactly( - [ - "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", - ], - params[i], - ); - if (!present(settleToken)) settleToken = s[0][0]; - const path = s[0][1]; - if (path.length > 0 && !present(takeToken)) { - takeToken = path[path.length - 1][0]; - } - if (!present(amountIn)) { - amountIn = v4ExactInAmount(s[0][2]); - } - if (!present(amountOutMin)) { - amountOutMin = s[0][3]; - } - } catch { - swapUndecoded = true; - } - } else { - // ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey, - // bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData) - try { - const s = decodeExactly( - [ - "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", - ], - params[i], - ); - const poolKey = s[0][0]; - const zeroForOne = s[0][1]; - if (!present(settleToken)) - settleToken = zeroForOne - ? poolKey[0] - : poolKey[1]; - if (!present(takeToken)) - takeToken = zeroForOne - ? poolKey[1] - : poolKey[0]; - if (!present(amountIn)) { - amountIn = v4ExactInAmount(s[0][2]); - } - if (!present(amountOutMin)) { - amountOutMin = s[0][3]; - } - } catch { - swapUndecoded = true; - } - } - } else if ( - actionId === V4_SWAP_EXACT_OUT || - actionId === V4_SWAP_EXACT_OUT_SINGLE - ) { - if (actionId === V4_SWAP_EXACT_OUT) { - try { - const s = decodeExactly( - [ - "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", - ], - params[i], - ); - if (!present(takeToken)) takeToken = s[0][0]; - const path = s[0][1]; - if (path.length > 0 && !present(settleToken)) { - settleToken = path[path.length - 1][0]; - } - } catch { - swapUndecoded = true; - } - } else { - try { - const s = decodeExactly( - [ - "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", - ], - params[i], - ); - const poolKey = s[0][0]; - const zeroForOne = s[0][1]; - if (!present(settleToken)) - settleToken = zeroForOne - ? poolKey[0] - : poolKey[1]; - if (!present(takeToken)) - takeToken = zeroForOne - ? poolKey[1] - : poolKey[0]; - } catch { - swapUndecoded = true; - } + try { + swaps.push(decodeV4SwapAction(actionId, params[i])); + } catch { + swapUndecoded = true; } } } catch { @@ -462,12 +504,54 @@ function decodeV4Swap(input) { } } + // Rule 3. + if (swapUndecoded) { + return { + tokenIn: settleToken, + tokenOut: takeToken, + amountIn: null, + amountOutMin: null, + swapUndecoded, + hasSwapAction: true, + }; + } + + const spent = swaps.map((s) => s.tokenIn); + const bought = swaps.map((s) => s.tokenOut); + + // Rules 1 and 2 for Token In. + let tokenIn = null; + let amountIn = null; + const spentOnly = currenciesNotIn(spent, bought); + if (present(settleToken) || spentOnly.length === 1) { + tokenIn = present(settleToken) ? settleToken : spentOnly[0]; + amountIn = totalAmount( + swaps + .filter((s) => s.tokenIn === tokenIn) + .map((s) => s.amountIn), + ); + } + + // Rules 1 and 2 for Token Out. + let tokenOut = null; + let amountOutMin = null; + const boughtOnly = currenciesNotIn(bought, spent); + if (present(takeToken) || boughtOnly.length === 1) { + tokenOut = present(takeToken) ? takeToken : boughtOnly[0]; + amountOutMin = totalAmount( + swaps + .filter((s) => s.tokenOut === tokenOut) + .map((s) => s.amountOutMin), + ); + } + return { - tokenIn: settleToken, - tokenOut: takeToken, + tokenIn, + tokenOut, amountIn, amountOutMin, swapUndecoded, + hasSwapAction: swaps.length > 0, }; } catch { return null; @@ -491,6 +575,7 @@ function decode(data, toAddress, sources) { let inputEstablished = false; let outputToken = null; let minOutput = null; + let outputEstablished = false; let hasUnwrapWeth = false; let hasV2ExactOut = false; let swapUndecoded = false; @@ -526,6 +611,7 @@ function decode(data, toAddress, sources) { if (!present(token) && !present(amount)) return; outputToken = present(token) ? token : null; minOutput = present(amount) ? amount : null; + outputEstablished = true; }; for (let i = 0; i < commandsBytes.length; i++) { @@ -597,30 +683,33 @@ function decode(data, toAddress, sources) { if (cmdId === 0x10) { const v4 = decodeV4Swap(inputs[i]); if (v4 && v4.swapUndecoded) { - // The unread swap action named this step's currencies - // and amounts, so the step is marked and still sets - // both sides as a swap step does. A side its SETTLE or - // TAKE did not name is set undetermined, not left to - // an earlier or later step's token and figure. + // A swap action of this step went unread, so the step + // is marked. swapUndecoded = true; commandNames[i] += " (could not be decoded)"; + } + if (v4 && v4.hasSwapAction) { + // A step with a swap action sets both sides as a swap + // step does, the input when no earlier step set it and + // the output always, as the last swap step wins. Each + // side is set as a pair, so a side decodeV4Swap() left + // unknown is set unknown with no figure, rather than + // left to another step's token and figure. A token + // stated without a figure, or a figure without a + // token, likewise never sits beside an earlier step's + // other half. if (!inputEstablished) { setInput(v4.tokenIn, v4.amountIn); } outputToken = v4.tokenOut; minOutput = v4.amountOutMin; + outputEstablished = true; } else if (v4) { + // A step with no swap action sets only the sides its + // SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names: + // setInputOnce() and setOutput() leave alone a side + // whose two halves are both unstated. setInputOnce(v4.tokenIn, v4.amountIn); - // Always update output: last swap step wins. A step - // that carries the Min. received figure but decoded no - // output currency makes the output *undetermined* — it - // is neither ETH nor whatever an earlier step named, - // and that figure is no longer counted in that token. - // Equally, a step that names an output currency but no - // minimum leaves Min. received unstated rather than - // keeping an earlier step's figure beside the new - // token. setOutput() is both rules; a step that states - // neither half leaves the output alone. setOutput(v4.tokenOut, v4.amountOutMin); } } @@ -634,21 +723,19 @@ function decode(data, toAddress, sources) { } // Resolve token info. A null token on either side means no step named - // a currency for it, or a V4 step whose swap action went unread left - // it undetermined; tokenInfo() refuses rather than calling it ETH. + // a currency for it, or a V4 step with a swap action left it + // unknown; tokenInfo() refuses rather than calling it ETH. // UNWRAP_WETH turns WETH into ETH, so it makes the output ETH when the - // output side is WETH, or when no step set the output side. A step - // whose swap action went unread did set it, to undetermined. Any other + // output side is WETH, or when no step set the output side. A V4 step + // with a swap action did set it, even when it set it unknown. Any other // output keeps its own token and figure: a swap that buys USDC and // then unwraps the WETH it did not spend receives USDC. const outputIsWeth = present(outputToken) && WETH_ADDRESSES.includes(outputToken.toLowerCase()); - const outputUnset = - !present(outputToken) && !present(minOutput) && !swapUndecoded; const inInfo = tokenInfo(inputToken, sources); const outInfo = - hasUnwrapWeth && (outputIsWeth || outputUnset) + hasUnwrapWeth && (outputIsWeth || !outputEstablished) ? { symbol: "ETH", decimals: 18, address: null } : tokenInfo(outputToken, sources); diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 8fa6606..7d87934 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -107,6 +107,7 @@ function encodeV4Swap(actions, params) { // V4 inner action IDs, as src/shared/uniswap.js names them. const V4_SWAP_EXACT_IN = 0x07; const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06; +const V4_SWAP_EXACT_OUT_SINGLE_ID = 0x08; const V4_SETTLE_ID = 0x0b; const V4_TAKE_ID = 0x0e; const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; @@ -163,6 +164,31 @@ function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) { ); } +// Helper: V4 ExactOutputSingleParams — +// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne, +// uint128 amountOut, uint128 amountInMaximum, bytes hookData) +function encodeV4ExactOutSingle( + currency0, + currency1, + amountOut, + amountInMaximum, +) { + return coder.encode( + [ + "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", + ], + [ + [ + [currency0, currency1, 100, 1, ZERO_ADDR], + true, // zeroForOne: in = currency0, out = currency1 + amountOut, + amountInMaximum, + "0x", + ], + ], + ); +} + // Each V4 swap action's parameters in two shapes: the struct V4Router has // deployed on mainnet and Sepolia, and the one v4-periphery `main` has since // moved to, which adds a `minHopPriceX36` field. Each swaps WETH for USDC. @@ -267,9 +293,7 @@ function v4SwapCalldata(actionId, swapParams) { ); } -// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount). The -// decoder reads neither, so in a V4_SWAP of a swap action and these, only the -// swap action names the currencies. +// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount). const V4_SETTLE_ALL_ID = 0x0c; const V4_TAKE_ALL_ID = 0x0f; function encodeV4SettleAll(currency, maxAmount) { @@ -279,6 +303,12 @@ function encodeV4TakeAll(currency, minAmount) { return coder.encode(["address", "uint256"], [currency, minAmount]); } +// V4 CLOSE_CURRENCY (currency), which the decoder does not read. +const V4_CLOSE_CURRENCY_ID = 0x12; +function encodeV4CloseCurrency(currency) { + return coder.encode(["address"], [currency]); +} + function detail(result, label) { return result.details.find((d) => d.label === label); } @@ -358,10 +388,9 @@ describe("uniswap decoder", () => { }, ); - // Only the unread swap action names the currencies here. The token lines - // must not say the calldata did not name them. + // SETTLE_ALL and TAKE_ALL name both sides as SETTLE and TAKE do. test.each(V4_SWAP_ACTION_SHAPES)( - "a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, says both tokens could not be decoded", + "a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, takes both tokens from them", ({ id, newer }) => { const data = buildExecute( "0x10", @@ -381,6 +410,27 @@ describe("uniswap decoder", () => { expect(detail(result, "Steps").value).toBe( "V4 Swap (could not be decoded)", ); + expect(detail(result, "Token In").address).toBe(WETH_ADDR); + expect(detail(result, "Token Out").address).toBe(USDC_ADDR); + expect(detail(result, "Amount")).toBeUndefined(); + expect(detail(result, "Min. received")).toBeUndefined(); + }, + ); + + // Only the unread swap action names the currencies here. The token lines + // must not say the calldata did not name them. + test.each(V4_SWAP_ACTION_SHAPES)( + "a V4 step of only a $action in the newer shape says both tokens could not be decoded", + ({ id, newer }) => { + const data = buildExecute( + "0x10", + [encodeV4Swap(new Uint8Array([id]), [newer])], + 9999999999n, + ); + const result = uniswap.decode(data, ROUTER_ADDR); + expect(detail(result, "Steps").value).toBe( + "V4 Swap (could not be decoded)", + ); expect(detail(result, "Token In").value).toBe( "Unknown (could not be decoded)", ); @@ -394,7 +444,7 @@ describe("uniswap decoder", () => { // action names. The V3 step's WETH and its Min. received figure were // shown instead. test.each(V4_SWAP_ACTION_SHAPES)( - "a V3 step, then a V4 $action in the newer shape and TAKE_ALL, does not show the V3 step's output", + "a V3 step, then a V4 step of only a $action in the newer shape, does not show the V3 step's output", ({ id, newer }) => { const data = buildExecute( solidityPacked(["uint8", "uint8"], [0x00, 0x10]), @@ -403,10 +453,7 @@ describe("uniswap decoder", () => { USDT_ADDR, WETH_ADDR, ]), - encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [ - newer, - encodeV4TakeAll(USDC_ADDR, 990000n), - ]), + encodeV4Swap(new Uint8Array([id]), [newer]), ], 9999999999n, ); @@ -423,15 +470,12 @@ describe("uniswap decoder", () => { // The user pays the V4 step's WETH, which only its unread swap action // names. The V3 step's USDC and its amount were shown instead. test.each(V4_SWAP_ACTION_SHAPES)( - "a V4 $action in the newer shape and SETTLE_ALL, then a V3 step, does not show the V3 step's input", + "a V4 step of only a $action in the newer shape, then a V3 step, does not show the V3 step's input", ({ id, newer }) => { const data = buildExecute( solidityPacked(["uint8", "uint8"], [0x10, 0x00]), [ - encodeV4Swap(new Uint8Array([id, V4_SETTLE_ALL_ID]), [ - newer, - encodeV4SettleAll(WETH_ADDR, ONE_WETH), - ]), + encodeV4Swap(new Uint8Array([id]), [newer]), encodeV3SwapExactIn( USER_ADDR, 990000n, @@ -458,10 +502,7 @@ describe("uniswap decoder", () => { const data = buildExecute( solidityPacked(["uint8", "uint8"], [0x10, 0x0c]), [ - encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [ - newer, - encodeV4TakeAll(USDC_ADDR, 990000n), - ]), + encodeV4Swap(new Uint8Array([id]), [newer]), encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding ], 9999999999n, @@ -1124,6 +1165,419 @@ describe("uniswap decoder", () => { expect(detail(result, "Min. received").value).toBe("0.9900 USDC"); }); + // https://git.eeqj.de/sneak/AutistMask/issues/563 — a V4 step of several + // swap actions. The screen showed the first action's output as Token Out. + // Every step below pays USDT and receives USDC, so it reads Swap USDT → + // USDC whichever kind of swap actions it holds and in whatever order they + // are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL, + // SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the + // sides to the swap actions alone. + const V4_STEP_ENDINGS = [ + { + ending: "SETTLE_ALL and TAKE_ALL", + ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID], + params: [ + encodeV4SettleAll(USDT_ADDR, 1000000000n), + encodeV4TakeAll(USDC_ADDR, 990000000n), + ], + }, + { + ending: "SETTLE and TAKE", + ids: [V4_SETTLE_ID, V4_TAKE_ID], + params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)], + }, + { + ending: "CLOSE_CURRENCY", + ids: [ + V4_CLOSE_CURRENCY_ID, + V4_CLOSE_CURRENCY_ID, + V4_CLOSE_CURRENCY_ID, + ], + params: [ + encodeV4CloseCurrency(USDT_ADDR), + encodeV4CloseCurrency(WETH_ADDR), + encodeV4CloseCurrency(USDC_ADDR), + ], + }, + ]; + // 1,000 USDT for at least 0.5 WETH + const USDT_FOR_WETH_IN = { + id: V4_SWAP_EXACT_IN_SINGLE_ID, + param: encodeV4ExactInSingle( + USDT_ADDR, + WETH_ADDR, + 1000000000n, + 500000000000000000n, + ), + }; + // 0.5 WETH for at least 990 USDC + const WETH_FOR_USDC_IN = { + id: V4_SWAP_EXACT_IN_SINGLE_ID, + param: encodeV4ExactInSingle( + WETH_ADDR, + USDC_ADDR, + 500000000000000000n, + 990000000n, + ), + }; + // exactly 0.5 WETH for at most 1,000 USDT + const USDT_FOR_WETH_OUT = { + id: V4_SWAP_EXACT_OUT_SINGLE_ID, + param: encodeV4ExactOutSingle( + USDT_ADDR, + WETH_ADDR, + 500000000000000000n, + 1000000000n, + ), + }; + // exactly 990 USDC for at most 0.5 WETH + const WETH_FOR_USDC_OUT = { + id: V4_SWAP_EXACT_OUT_SINGLE_ID, + param: encodeV4ExactOutSingle( + WETH_ADDR, + USDC_ADDR, + 990000000n, + 500000000000000000n, + ), + }; + // 400 USDT for at least 396 USDC + const USDT_FOR_USDC_IN = { + id: V4_SWAP_EXACT_IN_SINGLE_ID, + param: encodeV4ExactInSingle( + USDT_ADDR, + USDC_ADDR, + 400000000n, + 396000000n, + ), + }; + // 600 USDT through WETH for at least 594 USDC + const USDT_FOR_USDC_THROUGH_WETH_IN = { + id: V4_SWAP_EXACT_IN, + param: encodeV4ExactIn( + USDT_ADDR, + [WETH_ADDR, USDC_ADDR], + 600000000n, + 594000000n, + ), + }; + // the open delta of USDT through WETH for at least 594 USDC + const USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN = { + id: V4_SWAP_EXACT_IN, + param: encodeV4ExactIn( + USDT_ADDR, + [WETH_ADDR, USDC_ADDR], + 0n, + 594000000n, + ), + }; + // Amount is the amountIn of the action that spends USDT, or the sum when + // several exact-in actions each state one. Min. received is the same for + // the actions that buy USDC. The decoder reads no amount from an exact-out + // action, and an open delta is not a stated amount. + const V4_STEPS = [ + { + step: "USDT for WETH exact-in, then WETH for USDC exact-in", + swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_IN], + amount: "1000.0000 USDT", + min: "990.0000 USDC", + }, + { + step: "WETH for USDC exact-in, then USDT for WETH exact-in", + swaps: [WETH_FOR_USDC_IN, USDT_FOR_WETH_IN], + amount: "1000.0000 USDT", + min: "990.0000 USDC", + }, + { + step: "WETH for USDC exact-out, then USDT for WETH exact-out", + swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_OUT], + amount: undefined, + min: undefined, + }, + { + step: "USDT for WETH exact-out, then WETH for USDC exact-out", + swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_OUT], + amount: undefined, + min: undefined, + }, + { + step: "USDT for WETH exact-in, then WETH for USDC exact-out", + swaps: [USDT_FOR_WETH_IN, WETH_FOR_USDC_OUT], + amount: "1000.0000 USDT", + min: undefined, + }, + { + step: "WETH for USDC exact-out, then USDT for WETH exact-in", + swaps: [WETH_FOR_USDC_OUT, USDT_FOR_WETH_IN], + amount: "1000.0000 USDT", + min: undefined, + }, + { + step: "USDT for WETH exact-out, then WETH for USDC exact-in", + swaps: [USDT_FOR_WETH_OUT, WETH_FOR_USDC_IN], + amount: undefined, + min: "990.0000 USDC", + }, + { + step: "USDT for USDC, then USDT for USDC through WETH, both exact-in", + swaps: [USDT_FOR_USDC_IN, USDT_FOR_USDC_THROUGH_WETH_IN], + amount: "1000.0000 USDT", + min: "990.0000 USDC", + }, + { + step: "USDT for USDC, then the open delta of USDT for USDC through WETH, both exact-in", + swaps: [USDT_FOR_USDC_IN, USDT_OPEN_DELTA_FOR_USDC_THROUGH_WETH_IN], + amount: undefined, + min: "990.0000 USDC", + }, + ]; + test.each( + V4_STEPS.flatMap((step) => + V4_STEP_ENDINGS.map((ending) => ({ ...step, ...ending })), + ), + )( + "a V4 step of $step, then $ending, reads Swap USDT → USDC", + ({ swaps, ids, params, amount, min }) => { + const data = buildExecute( + "0x10", + [ + encodeV4Swap( + new Uint8Array([...swaps.map((s) => s.id), ...ids]), + [...swaps.map((s) => s.param), ...params], + ), + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result.name).toBe("Swap USDT → USDC"); + expect(detail(result, "Token In").address).toBe(USDT_ADDR); + expect(detail(result, "Token Out").address).toBe(USDC_ADDR); + expect(detail(result, "Amount")?.value).toBe(amount); + expect(detail(result, "Min. received")?.value).toBe(min); + }, + ); + + // A step with a swap action that could not be read is named only by its + // SETTLE_ALL, TAKE_ALL, SETTLE and TAKE, and states no figure: its readable + // USDT for WETH action names neither side. The unread action is + // V4_SWAP_ACTION_SHAPES[0].newer, WETH for USDC exact-in in the newer + // shape. + test.each([ + { + ...V4_STEP_ENDINGS[0], + tokenIn: `USDT (${USDT_ADDR})`, + tokenOut: `USDC (${USDC_ADDR})`, + }, + { + ...V4_STEP_ENDINGS[1], + tokenIn: `USDT (${USDT_ADDR})`, + tokenOut: `USDC (${USDC_ADDR})`, + }, + { + ...V4_STEP_ENDINGS[2], + tokenIn: "Unknown (could not be decoded)", + tokenOut: "Unknown (could not be decoded)", + }, + ])( + "a V4 step of a readable swap action and an unread one, then $ending, shows no figure", + ({ ids, params, tokenIn, tokenOut }) => { + const data = buildExecute( + "0x10", + [ + encodeV4Swap( + new Uint8Array([ + USDT_FOR_WETH_IN.id, + V4_SWAP_ACTION_SHAPES[0].id, + ...ids, + ]), + [ + USDT_FOR_WETH_IN.param, + V4_SWAP_ACTION_SHAPES[0].newer, + ...params, + ], + ), + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(detail(result, "Steps").value).toBe( + "V4 Swap (could not be decoded)", + ); + expect(detail(result, "Token In").value).toBe(tokenIn); + expect(detail(result, "Token Out").value).toBe(tokenOut); + expect(detail(result, "Amount")).toBeUndefined(); + expect(detail(result, "Min. received")).toBeUndefined(); + }, + ); + + // A V4 step that spends WETH on USDC and on DAI. Its swap actions buy two + // currencies that none of them spends, and CLOSE_CURRENCY, which the + // decoder does not read, names no side, so its output side is unknown. + const WETH_FOR_USDC_AND_DAI = encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_CLOSE_CURRENCY_ID, + V4_CLOSE_CURRENCY_ID, + V4_CLOSE_CURRENCY_ID, + ]), + [ + // 0.25 WETH for at least 495 USDC + encodeV4ExactInSingle( + WETH_ADDR, + USDC_ADDR, + 250000000000000000n, + 495000000n, + ), + // 0.25 WETH for at least 495 DAI + encodeV4ExactInSingle( + WETH_ADDR, + DAI_ADDR, + 250000000000000000n, + 495000000000000000000n, + ), + encodeV4CloseCurrency(WETH_ADDR), + encodeV4CloseCurrency(USDC_ADDR), + encodeV4CloseCurrency(DAI_ADDR), + ], + ); + + // The user ends with USDC and DAI, not the V3 step's WETH. That WETH and + // the V3 step's Min. received figure were shown instead. + test("a V3 step, then a V4 step whose swap actions buy two currencies, does not show the V3 step's output", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x00, 0x10]), + [ + encodeV3SwapExactIn( + USER_ADDR, + 1000000000n, + 500000000000000000n, + [USDT_ADDR, WETH_ADDR], + ), + WETH_FOR_USDC_AND_DAI, + ], + 9999999999n, + ); + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result.name).toBe("Uniswap Swap"); + expect(detail(result, "Token In").address).toBe( + USDT_ADDR.toLowerCase(), + ); + expect(detail(result, "Amount").value).toBe("1000.0000 USDT"); + expect(detail(result, "Token Out").value).toBe( + "Unknown (not named in the calldata)", + ); + expect(detail(result, "Min. received")).toBeUndefined(); + }); + + // The input side of the same gap: the V4 step's swap actions spend USDC + // and DAI, two currencies that none of them buys, and nothing names its + // input side. The V3 step's WETH and its amount were shown instead. + test("a V4 step whose swap actions spend two currencies, then a V3 step, does not show the V3 step's input", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x10, 0x00]), + [ + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_CLOSE_CURRENCY_ID, + V4_CLOSE_CURRENCY_ID, + V4_CLOSE_CURRENCY_ID, + ]), + [ + // 500 USDC for at least 0.25 WETH + encodeV4ExactInSingle( + USDC_ADDR, + WETH_ADDR, + 500000000n, + 250000000000000000n, + ), + // 500 DAI for at least 0.25 WETH + encodeV4ExactInSingle( + DAI_ADDR, + WETH_ADDR, + 500000000000000000000n, + 250000000000000000n, + ), + encodeV4CloseCurrency(USDC_ADDR), + encodeV4CloseCurrency(DAI_ADDR), + encodeV4CloseCurrency(WETH_ADDR), + ], + ), + encodeV3SwapExactIn( + USER_ADDR, + 500000000000000000n, + 990000000n, + [WETH_ADDR, USDT_ADDR], + ), + ], + 9999999999n, + ); + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result.name).toBe("Uniswap Swap"); + expect(detail(result, "Token In").value).toBe( + "Unknown (not named in the calldata)", + ); + expect(detail(result, "Amount")).toBeUndefined(); + expect(detail(result, "Token Out").address).toBe( + USDT_ADDR.toLowerCase(), + ); + expect(detail(result, "Min. received").value).toBe("990.0000 USDT"); + }); + + // UNWRAP_WETH makes Token Out ETH when no step set the output side. A V4 + // step with a swap action did set it, to unknown here. + test("an UNWRAP_WETH after a V4 step whose swap actions buy two currencies does not make Token Out ETH", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x10, 0x0c]), + [ + WETH_FOR_USDC_AND_DAI, + encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding + ], + 9999999999n, + ); + const result = uniswap.decode(data, ROUTER_ADDR); + expect(detail(result, "Token Out").value).toBe( + "Unknown (not named in the calldata)", + ); + }); + + // The router works through an exact-out path from its last entry back to + // its first: the WETH entry buys the USDC, then the USDT entry buys that + // WETH. The user pays the first entry's USDT, not the last entry's WETH. + test("a two-hop V4 exact-out action shows its first path entry as Token In", () => { + const data = buildExecute( + "0x10", + [ + encodeV4Swap(new Uint8Array([0x09]), [ + // SWAP_EXACT_OUT: exactly 990 USDC for at most 1,000 USDT + coder.encode( + [`tuple(address,${PATH_KEY}[],uint128,uint128)`], + [ + [ + USDC_ADDR, + [ + [USDT_ADDR, 100, 1, ZERO_ADDR, "0x"], + [WETH_ADDR, 100, 1, ZERO_ADDR, "0x"], + ], + 990000000n, + 1000000000n, + ], + ], + ), + ]), + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result.name).toBe("Swap USDT → USDC"); + expect(detail(result, "Token In").address).toBe(USDT_ADDR); + expect(detail(result, "Token Out").address).toBe(USDC_ADDR); + }); + // https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2 // exact-in reads an amountIn of zero as universal-router // Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and