diff --git a/README.md b/README.md index a4fa830..d77648f 100644 --- a/README.md +++ b/README.md @@ -1253,14 +1253,24 @@ Decoding). So a step whose USDT-for-USDC swap action could not be read, with a 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. +An `UNWRAP_WETH` step makes `Token Out` ETH only when no step set the output +side, or when the output side is WETH, on mainnet or Sepolia, that the step that +set it paid to the router and that the router still holds when the `UNWRAP_WETH` +runs (Transaction Decoding below gives what the router holds). The router +unwraps only the WETH it holds, so WETH that step paid to the user, or to anyone +else, stays WETH: a swap that pays the router WETH, then a swap that pays the +user WETH, then an unwrap, shows WETH with the second swap's minimum. The +minimum reads in ETH when that step paid all its WETH to the router. When it +paid only part of it to the router, as a V4 step does that `TAKE`s some of its +WETH to the user and the rest to the router, or paid the router WETH beside a +payout the decoder cannot read, the swap shows ETH with no minimum: the decoder +does not count how much of that step's minimum reaches the router, nor read the +`UNWRAP_WETH`'s own minimum. A later step that sets the output side sets it +anew. 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 3f9177d..f50b646 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -1219,6 +1219,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) { let minOutput = null; let outputEstablished = false; let hasUnwrapWeth = false; + // Set when an UNWRAP_WETH pays out, as ETH, the output side's WETH, + // which the router holds; cleared whenever the output side is set + // again. + let outputUnwrapped = false; + // Where the step that set the output side paid its output: the + // recipient of a swap step, as written, or of each TAKE, TAKE_ALL and + // TAKE_PORTION of it in a V4 step, a TAKE_ALL as paid to the sender, + // with null for a payout of that step this decoder did not read. A + // BALANCE_CHECK_ERC20 pays nobody. + let outputRecipients = []; let hasV2OrV3ExactOut = false; let swapUndecoded = false; let hasSubPlan = false; @@ -1255,11 +1265,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if (!present(token) && !present(amount)) return; setInput(token, amount); }; - const setOutput = (token, amount) => { + const setOutput = (token, amount, recipients) => { if (!present(token) && !present(amount)) return; outputToken = present(token) ? token : null; minOutput = present(amount) ? amount : null; outputEstablished = true; + outputUnwrapped = false; + outputRecipients = recipients; }; // What the steps take from the sender beyond the amount of the step @@ -1505,7 +1517,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // does. It pays nobody. const minBalance = allowedToFail ? 0n : b.minBalance; if (!(minBalance === 0n && present(minOutput))) { - setOutput(b.token, minBalance); + setOutput(b.token, minBalance, []); } } } else if (command === "V3_SWAP_EXACT_IN") { @@ -1519,7 +1531,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // Always update output: in multi-step swaps (V3 → V4), // the last swap step determines the final output token // and minimum received amount. - setOutput(s.tokenOut, s.amountOutMin); + setOutput(s.tokenOut, s.amountOutMin, [s.recipient]); if (!s.payerIsUser) { spendFromRouter(s.tokenIn, s.amountIn); } @@ -1539,7 +1551,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (s.payerIsUser) { addUserPaid(s.tokenIn, s.amountIn); } - setOutput(s.tokenOut, s.amountOutMin); + setOutput(s.tokenOut, s.amountOutMin, [s.recipient]); if (!s.payerIsUser) { spendFromRouter(s.tokenIn, s.amountIn); } @@ -1570,7 +1582,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (s.payerIsUser) { addUserPaid(s.tokenIn, s.amountInMax); } - setOutput(s.tokenOut, s.amountOut); + setOutput(s.tokenOut, s.amountOut, [s.recipient]); if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); } @@ -1728,6 +1740,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) { (p) => p.currency === v4.tokenIn, ); const amountIn = paidIn ? paidIn.amount : v4?.amountIn; + // Where the step pays its output, if it sets the output + // side (see outputRecipients). + const takeRecipients = v4 + ? v4.takes + .filter((t) => t.currency === v4.tokenOut) + .map((t) => t.recipient) + .concat(v4.recipientUnread ? [null] : []) + : []; if (v4 && v4.hasSwapAction) { // A step with a swap action always sets the output // side, as the last swap step wins, and sets the @@ -1744,13 +1764,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) { outputToken = v4.tokenOut; minOutput = v4.amountOutMin; outputEstablished = true; + outputUnwrapped = false; + outputRecipients = takeRecipients; } 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, amountIn); - setOutput(v4.tokenOut, v4.amountOutMin); + setOutput(v4.tokenOut, v4.amountOutMin, takeRecipients); } // Every V4 step, with a swap action or without, takes // from the sender what the user pays it (an amount an @@ -1772,6 +1794,29 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } else if (command === "UNWRAP_WETH") { hasUnwrapWeth = true; + // The router unwraps all the WETH it holds, even when the + // recipient cannot be read. The output side's WETH is + // among it when the step that set the output side paid it + // to the router and the router still holds WETH. WETH + // that step paid to the user, or to anyone else, stays + // WETH, so when that step also paid some of its output + // elsewhere, or made a payout this decoder did not read, + // only part of it may be unwrapped. This decoder does not + // count how much of that step's minimum then reaches the + // router, nor read the UNWRAP_WETH's own minimum, so the + // output side keeps no figure. + const toRouter = (r) => present(r) && isRouter(r); + if ( + present(outputToken) && + WETH_ADDRESSES.includes(outputToken.toLowerCase()) && + heldByRouter.has(outputToken.toLowerCase()) && + outputRecipients.some(toRouter) + ) { + outputUnwrapped = true; + if (!outputRecipients.every(toRouter)) { + minOutput = null; + } + } const u = decodeUnwrapWeth(inputs[i]); if (u) { addPayee(u.recipient); @@ -1912,23 +1957,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) { outputToken = null; minOutput = null; outputEstablished = true; + outputUnwrapped = false; } // Resolve token info. A null token on either side means no step named // a currency for it, a V4 step with a swap action left it unknown, or // an EXECUTE_SUB_PLAN step did; 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 - // V4 step with a swap action, or an EXECUTE_SUB_PLAN step, 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()); + // when it unwraps the output side's WETH (`outputUnwrapped`), or when + // no step set the output side. A V4 step with a swap action, or + // an EXECUTE_SUB_PLAN step, did set it, even when it set it unknown. + // Any other output keeps its own token and figure: a swap that pays + // its WETH to the user and then unwraps receives WETH, and one that + // buys USDC and then unwraps the WETH it did not spend receives USDC. const inInfo = tokenInfo(inputToken, sources); const outInfo = - hasUnwrapWeth && (outputIsWeth || !outputEstablished) + outputUnwrapped || (hasUnwrapWeth && !outputEstablished) ? { symbol: "ETH", decimals: 18, address: null } : tokenInfo(outputToken, sources); diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 277201f..9144d6a 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -875,7 +875,7 @@ describe("uniswap decoder", () => { const data = buildExecute( solidityPacked(["uint8", "uint8"], [0x08, 0x0c]), [ - encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [ + encodeV2SwapExactIn(ROUTER_ADDR, 1000000n, 500000000000000n, [ USDC_ADDR, SEPOLIA_WETH_ADDR, ]), @@ -958,7 +958,7 @@ describe("uniswap decoder", () => { const data = buildExecute( solidityPacked(["uint8", "uint8"], [0x08, 0x0c]), [ - encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [ + encodeV2SwapExactIn(ROUTER_ADDR, 1000000n, 500000000000000n, [ USDT_ADDR, WETH_ADDR, ]), diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js index fc4c786..84710d2 100644 --- a/tests/uniswapRecipient.test.js +++ b/tests/uniswapRecipient.test.js @@ -7,9 +7,11 @@ // (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose // balance a BALANCE_CHECK_ERC20 checks // (https://git.eeqj.de/sneak/AutistMask/issues/600), any token a swap -// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606), and +// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606), // what each PERMIT2_TRANSFER_FROM moves -// (https://git.eeqj.de/sneak/AutistMask/issues/611). +// (https://git.eeqj.de/sneak/AutistMask/issues/611), and when an +// UNWRAP_WETH makes the output ETH +// (https://git.eeqj.de/sneak/AutistMask/issues/607). // // Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION, // PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and @@ -268,18 +270,28 @@ const SWAP_USDT_FOR_ETH = [ [[[ZERO, USDT, 500, 10, ZERO], false, USDT_2000, ONE_WETH, "0x"]], ), ]; +const SWAP_USDT_FOR_WETH = [ + 0x06, // SWAP_EXACT_IN_SINGLE + coder.encode( + [ + "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", + ], + [[[USDT, WETH, 500, 10, ZERO], true, USDT_2000, ONE_WETH, "0x"]], + ), +]; // SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read. const UNREADABLE_SWAP = [0x06, "0x"]; const SETTLE_USDT = [ 0x0b, coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]), ]; -function take(currency, recipient) { +// A TAKE of `amount`, by default zero: the open delta, all the step owes. +function take(currency, recipient, amount = 0n) { return [ 0x0e, coder.encode( ["address", "address", "uint256"], - [currency, recipient, 0n], + [currency, recipient, amount], ), ]; } @@ -531,7 +543,7 @@ describe("an output left in the router is not received", () => { { kind: "an UNWRAP_WETH to the user, then the swap", data: execute(unwrapWeth(MSG_SENDER), toRouter), - min: "1.0000 ETH", + min: "1.0000 WETH", }, { kind: "the swap, then a WRAP_ETH of zero to the user", @@ -568,6 +580,171 @@ describe("an output left in the router is not received", () => { }); }); +// https://git.eeqj.de/sneak/AutistMask/issues/607: an UNWRAP_WETH pays out, +// as ETH, the WETH the router holds when it runs. Token Out read ETH whenever +// the output side was WETH, so a swap that paid its WETH to the user before +// the UNWRAP_WETH read as ETH, though the user receives WETH. +describe("an UNWRAP_WETH makes the output ETH only when the router holds it", () => { + test("a swap paying its WETH to the user, then an UNWRAP_WETH, reads as WETH", () => { + const result = decode( + execute(swapToWeth(MSG_SENDER), unwrapWeth(MSG_SENDER)), + ); + expect(result.name).toBe("Swap USDT → WETH"); + expect(labels(result)).toEqual(PAID_TO_USER); + expect(detail(result, "Min. received").value).toBe("1.0000 WETH"); + }); + + test("a swap paying its WETH to the router, then an UNWRAP_WETH to the user, reads as ETH", () => { + const result = decode( + execute(swapToWeth(ADDRESS_THIS), unwrapWeth(MSG_SENDER)), + ); + expect(result.name).toBe("Swap USDT → ETH"); + expect(labels(result)).toEqual(PAID_TO_USER); + expect(detail(result, "Min. received").value).toBe("1.0000 ETH"); + }); + + // The SWEEP pays the user all the WETH the router holds, so the + // UNWRAP_WETH finds none of it. + test("a swap paying its WETH to the router, then a SWEEP of it to the user, then an UNWRAP_WETH, reads as WETH", () => { + const result = decode( + execute( + swapToWeth(ADDRESS_THIS), + sweep(WETH, MSG_SENDER), + unwrapWeth(MSG_SENDER), + ), + ); + expect(result.name).toBe("Swap USDT → WETH"); + expect(detail(result, "Min. received").value).toBe("1.0000 WETH"); + }); + + // The user receives the first step's WETH as WETH and the second's as + // ETH. The second step sets the output side and pays its WETH to the + // router, so the output reads as ETH with the second step's minimum. + test("two swaps, the second paying its WETH to the router, then an UNWRAP_WETH, reads as ETH", () => { + const result = decode( + execute( + swapToWeth(MSG_SENDER), + v2Swap(0x08, ADDRESS_THIS, USDC_1990, ONE_WETH / 2n, [ + USDC, + WETH, + ]), + unwrapWeth(MSG_SENDER), + ), + ); + expect(result.name).toBe("Swap USDT → ETH"); + expect(detail(result, "Min. received").value).toBe("0.5000 ETH"); + }); + + // The same two swaps the other way round. The user receives the first + // step's WETH as ETH and the second's as WETH. The second step sets the + // output side and pays its WETH to the user, so the output reads as WETH + // with the second step's minimum, though the router holds the first's. + test("two swaps, the second paying its WETH to the user, then an UNWRAP_WETH, reads as WETH", () => { + const result = decode( + execute( + v2Swap(0x08, ADDRESS_THIS, USDC_1990, ONE_WETH / 2n, [ + USDC, + WETH, + ]), + swapToWeth(MSG_SENDER), + unwrapWeth(MSG_SENDER), + ), + ); + expect(result.name).toBe("Swap USDC → WETH"); + expect(detail(result, "Min. received").value).toBe("1.0000 WETH"); + }); + + // One V4 step buys at least 1 WETH and TAKEs 0.6 WETH to the user and the + // rest to the router. The user receives 0.6 WETH and the rest as ETH. + // Neither minimum is shown: the decoder does not count how much of the + // step's minimum reaches the router, nor read the UNWRAP_WETH's own. + test("one V4 step paying part of its WETH to the user and the rest to the router, then an UNWRAP_WETH, reads as ETH with no minimum", () => { + const result = decode( + execute( + v4Swap( + SWAP_USDT_FOR_WETH, + SETTLE_USDT, + take(WETH, MSG_SENDER, (ONE_WETH * 6n) / 10n), + take(WETH, ADDRESS_THIS), + ), + unwrapWeth(MSG_SENDER), + ), + ); + expect(result.name).toBe("Swap USDT → ETH"); + expect(labels(result)).toEqual([ + "Protocol", + "Token In", + "Amount", + "Token Out", + "Steps", + "Deadline", + ]); + }); + + // A TAKE the decoder cannot read may pay part of the WETH to the user. + test("one V4 step paying WETH to the router beside a TAKE that cannot be read, then an UNWRAP_WETH, reads as ETH with no minimum", () => { + const result = decode( + execute( + v4Swap( + SWAP_USDT_FOR_WETH, + SETTLE_USDT, + withDirtyWord(take(WETH, MSG_SENDER), 1), + take(WETH, ADDRESS_THIS), + ), + unwrapWeth(MSG_SENDER), + ), + ); + expect(result.name).toBe("Swap USDT → ETH"); + expect(detail(result, "Min. received")).toBeUndefined(); + expect(detail(result, "Min. paid out")).toBeUndefined(); + }); + + // A step after the UNWRAP_WETH that sets the output side sets it anew. + test("a swap paying WETH to the user after an UNWRAP_WETH of an earlier swap's WETH reads as WETH", () => { + const result = decode( + execute( + swapToWeth(ADDRESS_THIS), + unwrapWeth(MSG_SENDER), + v2Swap(0x08, MSG_SENDER, USDC_1990, ONE_WETH / 2n, [ + USDC, + WETH, + ]), + ), + ); + expect(result.name).toBe("Swap USDT → WETH"); + expect(detail(result, "Min. received").value).toBe("0.5000 WETH"); + }); + + // The EXECUTE_SUB_PLAN's own steps, which the decoder does not read, buy + // the user USDC as well, so Token Out is not named. + test("a swap paying its WETH to the router, then an UNWRAP_WETH, then an EXECUTE_SUB_PLAN, names no Token Out", () => { + const subPlan = [ + 0x21, + coder.encode( + ["bytes", "bytes[]"], + [ + "0x00", + [ + v3SwapExactIn( + MSG_SENDER, + USDT_2000, + USDC_1990, + USDT, + USDC, + )[1], + ], + ], + ), + ]; + const result = decode( + execute(swapToWeth(ADDRESS_THIS), unwrapWeth(MSG_SENDER), subPlan), + ); + expect(result.name).toBe("Uniswap Swap"); + expect(detail(result, "Token Out").value).toBe(UNDECODED); + expect(detail(result, "Min. received")).toBeUndefined(); + }); +}); + // https://git.eeqj.de/sneak/AutistMask/issues/606: any token a step pays the // router stays there unless a later step pays out or spends all of it, not // only the swap's output. A V4 SETTLE can take more of the user's tokens than