From a93eba3f46d51f2bb9a81d786808d2e313138401 Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 4 Oct 2026 08:29:40 +0000 Subject: [PATCH] fix: decode Uniswap V2 exact-out swaps, input amount shown as a maximum (closes #283) decode() had no arm for Universal Router command 0x09, so the approval screen named the step and showed no token or amount. It now takes the path's first token with amountInMax as the input side and the last token with amountOut as the output side, like 0x08. When the transaction has such a step, the Amount figure reads "Up to " whichever step set it, a WRAP_ETH or a permit included, and so does the value the wait, success and error screens show. In such a swap paid in ETH, the UNWRAP_WETH step that returns the ETH not spent does not make the output ETH. The eslint-disable comment on decodeV2SwapExactOut() is gone. Model: opus-5-5 --- README.md | 21 +++++++--- TODO.md | 11 ++++++ src/shared/uniswap.js | 36 +++++++++++++---- tests/uniswap.test.js | 92 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 1df5679..860c55d 100644 --- a/README.md +++ b/README.md @@ -962,16 +962,27 @@ 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 - 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. + 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 + 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 + 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` and `All available (V4 open delta)` keep their + wording. When a V2 exact-out step sets `Min. received`, that line shows its + `amountOut`, the exact amount it buys. In a V2 exact-out swap paid in ETH, the + `UNWRAP_WETH` step returns the ETH not spent, so `Token Out` is the token the + swap buys, not ETH. - `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. The line shows the 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, - `WRAP_ETH` or V4 swap step, a V4 swap step giving the `amountIn` of its first - readable exact-in action. + 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. - `None (no minimum guaranteed)`: the swap's `Min. received` line, when the minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a `BALANCE_CHECK_ERC20` step's `minBalance`. Before diff --git a/TODO.md b/TODO.md index 00b7845..981f28b 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,17 @@ but the review is broader than any of them. # Completed Steps +- 2026-10-04: A Uniswap V2 exact-out swap (Universal Router command `0x09`) is + decoded on the approval screen + ([#283](https://git.eeqj.de/sneak/AutistMask/issues/283)). `decode()` in + `src/shared/uniswap.js` had no arm for it, so the screen named the step and + showed no token or amount. The input side is the path's first token with + `amountInMax`, the output side the last token with `amountOut`. When the + transaction has such a step, the `Amount` figure reads `Up to `, + whichever step set it, there and on the wait, success and error screens. In + such a swap paid in ETH, the `UNWRAP_WETH` step that returns the ETH not spent + does not make `Token Out` ETH. V3 exact-out (`0x01`) is still not decoded. + - 2026-10-04: The Send and confirmation screens no longer show an ETH balance, a token balance or a network fee below 0.000001 as zero ([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored balances diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index c147728..9234a85 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -198,11 +198,6 @@ function decodeV2SwapExactIn(input) { // Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes. // ABI: (address recipient, uint256 amountOut, uint256 amountInMax, // address[] path, bool payerIsUser) -// -// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets -// its command name and no token or amount detail. Kept for the fix, which is -// https://git.eeqj.de/sneak/AutistMask/issues/283. -// eslint-disable-next-line no-unused-vars function decodeV2SwapExactOut(input) { try { const d = coder.decode( @@ -447,6 +442,7 @@ function decode(data, toAddress, sources) { let outputToken = null; let minOutput = null; let hasUnwrapWeth = false; + let hasV2ExactOut = false; const commandNames = []; // THE INVARIANT: an amount and the token it is counted in always come @@ -521,6 +517,16 @@ function decode(data, toAddress, sources) { } } + if (cmdId === 0x09) { + // Buys exactly amountOut and spends at most amountInMax. + hasV2ExactOut = true; + const s = decodeV2SwapExactOut(inputs[i]); + if (s) { + setInputOnce(s.tokenIn, s.amountInMax); + setOutput(s.tokenOut, s.amountOut); + } + } + if (cmdId === 0x0b) { const w = decodeWrapEth(inputs[i]); if (w) { @@ -561,10 +567,13 @@ function decode(data, toAddress, sources) { // named no currency for it; tokenInfo() refuses rather than calling it // ETH. UNWRAP_WETH is the one output that is ETH without a currency to // decode, and it is answered here rather than left to that rule. + // Except in a V2 exact-out swap paid in ETH: there UNWRAP_WETH returns + // the ETH the swap did not spend, and the output is the token it buys. const inInfo = tokenInfo(inputToken, sources); - const outInfo = hasUnwrapWeth - ? { symbol: "ETH", decimals: 18, address: null } - : tokenInfo(outputToken, sources); + const outInfo = + hasUnwrapWeth && !(hasV2ExactOut && inInfo.symbol === "ETH") + ? { symbol: "ETH", decimals: 18, address: null } + : tokenInfo(outputToken, sources); const inSymbol = inInfo.symbol; const outSymbol = outInfo.symbol; @@ -613,6 +622,17 @@ function decode(data, toAddress, sources) { amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; } else if (inputAmount >= MAX_UINT160) { amount = { raw: "Unlimited", display: "Unlimited" }; + } else if (hasV2ExactOut) { + // A V2 exact-out swap spends at most this figure, whichever + // step set the line (its amountInMax, the WRAP_ETH of a swap + // paid in ETH, a permit), 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(inputAmount, inInfo); + amount = { + raw: "Up to " + most.raw, + display: "Up to " + most.display, + }; } else { amount = amountText(inputAmount, inInfo); } diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index e014f17..d6570ed 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -75,6 +75,14 @@ function encodeV2SwapExactIn(recipient, amountIn, amountOutMin, pathAddrs) { ); } +// Helper: encode a V2_SWAP_EXACT_OUT input (command 0x09) +function encodeV2SwapExactOut(recipient, amountOut, amountInMax, pathAddrs) { + return coder.encode( + ["address", "uint256", "uint256", "address[]", "bool"], + [recipient, amountOut, amountInMax, pathAddrs, true], + ); +} + // Helper: encode a V3_SWAP_EXACT_IN input (command 0x00) function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) { // V3 path: token(20) + fee(3) + token(20) ... @@ -223,6 +231,90 @@ describe("uniswap decoder", () => { expect(minOut.value).toContain("WETH"); }); + // Buy exactly 0.5 WETH for at most 1,500 USDC, paid by the user, sent to + // the caller (the router's MSG_SENDER recipient, address(1)). + test("decodes V2_SWAP_EXACT_OUT, stating the input amount as a maximum", () => { + const data = buildExecute( + "0x09", // V2_SWAP_EXACT_OUT + [ + encodeV2SwapExactOut( + "0x0000000000000000000000000000000000000001", + 500000000000000000n, // amountOut: 0.5 WETH + 1500000000n, // amountInMax: 1,500 USDC (6 decimals) + [USDC_ADDR, WETH_ADDR], + ), + ], + 1767225600n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result.name).toBe("Swap USDC → WETH"); + expect(detail(result, "Token In").address).toBe(USDC_ADDR); + expect(detail(result, "Token Out").address).toBe(WETH_ADDR); + + // The wait, success and error screens show rawValue as the amount, so + // it says "Up to" as well. + const amount = detail(result, "Amount"); + expect(amount.value).toBe("Up to 1500.0000 USDC"); + expect(amount.rawValue).toBe("Up to 1500.0000"); + + expect(detail(result, "Min. received").value).toBe("0.5000 WETH"); + }); + + // Buy exactly 1,500 USDC for at most 0.5 ETH: WRAP_ETH of the maximum, the + // swap, then UNWRAP_WETH of 0, which returns the ETH the swap did not spend. + test("a V2 exact-out swap paid in ETH shows the token it buys and a maximum", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8", "uint8"], [0x0b, 0x09, 0x0c]), + [ + encodeWrapEth(ROUTER_ADDR, 500000000000000000n), + encodeV2SwapExactOut( + USER_ADDR, + 1500000000n, // amountOut: 1,500 USDC + 500000000000000000n, // amountInMax: 0.5 WETH + [WETH_ADDR, USDC_ADDR], + ), + encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH same encoding + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result.name).toBe("Swap ETH → USDC"); + + const amount = detail(result, "Amount"); + expect(amount.value).toBe("Up to 0.5000 ETH"); + expect(amount.rawValue).toBe("Up to 0.5000"); + + expect(detail(result, "Token Out").address).toBe(USDC_ADDR); + 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", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x09, 0x0c]), + [ + encodePermit2(USDC_ADDR, 1500000000n, ROUTER_ADDR), + encodeV2SwapExactOut( + ROUTER_ADDR, + 500000000000000000n, // amountOut: 0.5 WETH + 1500000000n, // amountInMax: 1,500 USDC + [USDC_ADDR, WETH_ADDR], + ), + encodeWrapEth(USER_ADDR, 500000000000000000n), // UNWRAP_WETH + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result.name).toBe("Swap USDC → ETH"); + expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC"); + expect(detail(result, "Token Out").value).toBe("ETH"); + expect(detail(result, "Min. received").value).toBe("0.5000 ETH"); + }); + test("decodes V3_SWAP_EXACT_IN with known tokens", () => { const data = buildExecute( "0x00", // V3_SWAP_EXACT_IN