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