diff --git a/README.md b/README.md index 1df5679..3ef3bf8 100644 --- a/README.md +++ b/README.md @@ -962,16 +962,25 @@ 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. - `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 @@ -980,9 +989,14 @@ read: 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. The token permission warning on the signature screen has its own -amount wording, including `Unknown`; the SignApproval section below describes -it. +that side. 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. #### Partial USD totals diff --git a/TODO.md b/TODO.md index 00b7845..d90f5f3 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,21 @@ 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, + except where it reads `Unlimited` (an unbounded `PERMIT2_PERMIT`, or any + amount at or above the `uint160` maximum) or `All available (V4 open delta)`. + In every swap, `UNWRAP_WETH` makes `Token Out` ETH only when the output side + is WETH, on mainnet or Sepolia, or when no step set the output side; otherwise + `Token Out` and `Min. received` keep the output side's own token and figure. + 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..7602a00 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -100,6 +100,13 @@ const NO_MINIMUM = "None (no minimum guaranteed)"; // Permit2 amounts are uint160; the maximum is Permit2's "unbounded". const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff"); +// WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia. +// decode() is not told the network, so it takes either. +const WETH_ADDRESSES = [ + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", +]; + // `decimals` is null when nothing knows this token's scale. It is not // defaulted to 18: the swap lines land on the same approval screen as the // ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as @@ -198,11 +205,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 +449,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 +524,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) { @@ -559,12 +572,19 @@ function decode(data, toAddress, sources) { // Resolve token info. A null token on either side means the calldata // 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. + // 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. + // 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); const inInfo = tokenInfo(inputToken, sources); - const outInfo = hasUnwrapWeth - ? { symbol: "ETH", decimals: 18, address: null } - : tokenInfo(outputToken, sources); + const outInfo = + hasUnwrapWeth && (outputIsWeth || outputUnset) + ? { symbol: "ETH", decimals: 18, address: null } + : tokenInfo(outputToken, sources); const inSymbol = inInfo.symbol; const outSymbol = outInfo.symbol; @@ -613,6 +633,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..f72e4d8 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -5,6 +5,8 @@ const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const DAI_ADDR = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; +const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"; const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; // AutistMask's first-ever swap, 2026-02-27. @@ -75,6 +77,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 +233,204 @@ 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"); + }); + + // Paid in a token, an UNWRAP_WETH of 0 after a swap that buys something + // other than WETH leaves the output side as it is: Token Out and Min. + // received are the token bought and its figure, not ETH. + test.each([ + { + paidIn: "WETH", + tokenIn: WETH_ADDR, + amountInMax: 500000000000000000n, // 0.5 WETH + tokenOut: USDC_ADDR, + amountOut: 1300000000n, // 1,300 USDC + minReceived: "1300.0000 USDC", + }, + { + paidIn: "USDC", + tokenIn: USDC_ADDR, + amountInMax: 8000000n, // 8 USDC + tokenOut: DAI_ADDR, + amountOut: 7000000000000000000n, // 7 DAI + minReceived: "7.0000 DAI", + }, + ])( + "a V2 exact-out swap paid in $paidIn, then UNWRAP_WETH, shows the token it buys", + ({ tokenIn, amountInMax, tokenOut, amountOut, minReceived }) => { + const data = buildExecute( + solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x09, 0x0c]), + [ + encodePermit2(tokenIn, amountInMax, ROUTER_ADDR), + encodeV2SwapExactOut(USER_ADDR, amountOut, amountInMax, [ + tokenIn, + tokenOut, + ]), + encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(detail(result, "Token Out").address).toBe(tokenOut); + expect(detail(result, "Min. received").value).toBe(minReceived); + }, + ); + + // An exact-in swap is held to the same rule: buying USDC, then UNWRAP_WETH, + // receives USDC. + test("an exact-in swap to a token other than WETH, then UNWRAP_WETH, shows that token", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x08, 0x0c]), + [ + encodeV2SwapExactIn(USER_ADDR, 2000000n, 1900000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result.name).toBe("Swap USDT → USDC"); + expect(detail(result, "Token Out").address).toBe(USDC_ADDR); + expect(detail(result, "Min. received").value).toBe("1.9000 USDC"); + }); + + // Paid in ETH, with an exact-out step, the last swap step buys WETH, so + // UNWRAP_WETH makes the output ETH. + test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => { + const data = buildExecute( + solidityPacked( + ["uint8", "uint8", "uint8", "uint8"], + [0x0b, 0x09, 0x08, 0x0c], + ), + [ + encodeWrapEth(ROUTER_ADDR, 500000000000000000n), + encodeV2SwapExactOut( + ROUTER_ADDR, + 1500000000n, // amountOut: 1,500 USDC + 500000000000000000n, // amountInMax: 0.5 WETH + [WETH_ADDR, USDC_ADDR], + ), + encodeV2SwapExactIn( + ROUTER_ADDR, + 1500000000n, // amountIn: 1,500 USDC + 400000000000000000n, // amountOutMin: 0.4 WETH + [USDC_ADDR, WETH_ADDR], + ), + encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(detail(result, "Token Out").value).toBe("ETH"); + expect(detail(result, "Min. received").value).toBe("0.4000 ETH"); + }); + + // Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too. + test("a swap to Sepolia WETH, then UNWRAP_WETH, shows ETH", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x08, 0x0c]), + [ + encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [ + USDC_ADDR, + SEPOLIA_WETH_ADDR, + ]), + encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH + ], + 9999999999n, + ); + + const result = uniswap.decode(data, ROUTER_ADDR); + expect(detail(result, "Token Out").value).toBe("ETH"); + expect(detail(result, "Min. received").value).toBe("0.0005 ETH"); + }); + test("decodes V3_SWAP_EXACT_IN with known tokens", () => { const data = buildExecute( "0x00", // V3_SWAP_EXACT_IN