diff --git a/README.md b/README.md index bb0d196..b7e4566 100644 --- a/README.md +++ b/README.md @@ -953,16 +953,23 @@ 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 step that set it is a V2 + exact-out swap. The figure is that step's `amountInMax`, the most the swap may + spend, not the amount it spends; the wait, success and error screens show it + with the same words. When such a step also 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 diff --git a/TODO.md b/TODO.md index 843c842..9226d2b 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,16 @@ 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`. Because + `amountInMax` is a ceiling, the `Amount` line reads `Up to `, there + and on the wait, success and error screens. V3 exact-out (`0x01`) is still not + decoded. + - 2026-10-04: The flash line keeps to the one line it reserves at any message length ([#252](https://git.eeqj.de/sneak/AutistMask/issues/252)). A message that wrapped pushed the whole screen below it down. `#flash-msg` no longer diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index c147728..c4b01f8 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( @@ -443,6 +438,7 @@ function decode(data, toAddress, sources) { let inputToken = null; let inputAmount = null; + let inputAmountIsMax = false; let inputEstablished = false; let outputToken = null; let minOutput = null; @@ -465,15 +461,20 @@ function decode(data, toAddress, sources) { // output side by the last, because the final leg is what the user // receives. A half the establishing hop did not state stays null and // the line says so, rather than being filled in from a different hop. - const setInput = (token, amount) => { + // + // `isMax` comes from that same hop: true when its amount is the most + // the swap may spend (an exact-out swap's amountInMax), not the + // amount spent. + const setInput = (token, amount, isMax = false) => { inputToken = present(token) ? token : null; inputAmount = present(amount) ? amount : null; + inputAmountIsMax = isMax; inputEstablished = true; }; - const setInputOnce = (token, amount) => { + const setInputOnce = (token, amount, isMax = false) => { if (inputEstablished) return; if (!present(token) && !present(amount)) return; - setInput(token, amount); + setInput(token, amount, isMax); }; const setOutput = (token, amount) => { if (!present(token) && !present(amount)) return; @@ -521,6 +522,15 @@ function decode(data, toAddress, sources) { } } + if (cmdId === 0x09) { + // Buys exactly amountOut and spends at most amountInMax. + const s = decodeV2SwapExactOut(inputs[i]); + if (s) { + setInputOnce(s.tokenIn, s.amountInMax, true); + setOutput(s.tokenOut, s.amountOut); + } + } + if (cmdId === 0x0b) { const w = decodeWrapEth(inputs[i]); if (w) { @@ -613,6 +623,15 @@ 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 (inputAmountIsMax) { + // A maximum is said to be one, in `raw` too: the wait and + // error screens show `raw` as the transaction's amount, and + // there a bare figure would read as the amount spent. + 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..9ab8b3c 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,36 @@ 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 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"); + }); + test("decodes V3_SWAP_EXACT_IN with known tokens", () => { const data = buildExecute( "0x00", // V3_SWAP_EXACT_IN