diff --git a/README.md b/README.md index 8d973af..a393165 100644 --- a/README.md +++ b/README.md @@ -1243,7 +1243,11 @@ only when no earlier step set it. It finds each side by three rules: is their sum; in any other case the step gives no `Amount`. The decoder reads no amount from an exact-out action, and an open delta is not a stated amount. Its `Min. received` is found the same way, from the `amountOutMin` of the - actions that buy the output currency. + actions that buy the output currency, except that the step gives none when a + swap action in it also spends the output currency, because the step then does + not pay out all that those actions buy. A step that buys at least 0.5 WETH + with USDT and swaps 0.3 WETH of it for USDC, with WETH as its output side, + gives no `Min. received`. 3. If any swap action in the step could not be read, only `SETTLE`, `SETTLE_ALL`, `TAKE` and `TAKE_ALL` name its sides, and the step gives no `Amount` or `Min. received`. A side they did not name is unknown, and the diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 20a3920..2a00fff 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -953,7 +953,9 @@ function totalAmount(amounts) { // 2. Amount is the amountIn of the swap action that spends Token In. If // several exact-in actions spend it, each with a stated amount, it is // their sum; in any other case there is none. Min. received is the same -// for amountOutMin and the actions that buy Token Out. +// for amountOutMin and the actions that buy Token Out, except that there +// is none when a swap action in the step also spends Token Out: the step +// then does not pay out all that those actions buy. // 3. If any swap action in the step cannot be read, only SETTLE, SETTLE_ALL, // TAKE and TAKE_ALL name the sides, and there is no Amount or Min. // received. @@ -1187,11 +1189,13 @@ function decodeV4Swap(input) { const boughtOnly = currenciesNotIn(bought, spent); if (present(takeToken) || boughtOnly.length === 1) { tokenOut = present(takeToken) ? takeToken : boughtOnly[0]; - amountOutMin = totalAmount( - swaps - .filter((s) => s.tokenOut === tokenOut) - .map((s) => s.amountOutMin), - ); + if (!spent.includes(tokenOut)) { + amountOutMin = totalAmount( + swaps + .filter((s) => s.tokenOut === tokenOut) + .map((s) => s.amountOutMin), + ); + } } return { diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index b2354f2..c0e3b64 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -3730,6 +3730,88 @@ describe("uniswap decoder", () => { }, ); + // https://git.eeqj.de/sneak/AutistMask/issues/574 — a V4 step that buys + // at least 0.5 WETH with 1,000 USDT, spends some of it on USDC, and takes + // the USDC and then the WETH, so its output side is WETH. Only what is + // left of the 0.5 WETH is sure to reach the user, and the screen read + // Min. received 0.5000 WETH. + test.each([ + { + spend: "0.3 WETH for at least 594 USDC", + swap: { + id: V4_SWAP_EXACT_IN_SINGLE_ID, + param: encodeV4ExactInSingle( + WETH_ADDR, + USDC_ADDR, + 300000000000000000n, + 594000000n, + ), + }, + }, + { + spend: "0.3 WETH through DAI for at least 594 USDC", + swap: { + id: V4_SWAP_EXACT_IN, + param: encodeV4ExactIn( + WETH_ADDR, + [DAI_ADDR, USDC_ADDR], + 300000000000000000n, + 594000000n, + ), + }, + }, + { + spend: "at most 0.3 WETH for exactly 594 USDC", + swap: { + id: V4_SWAP_EXACT_OUT_SINGLE_ID, + param: encodeV4ExactOutSingle( + WETH_ADDR, + USDC_ADDR, + 594000000n, + 300000000000000000n, + ), + }, + }, + ])( + "a V4 step that buys WETH and spends $spend, with WETH as its output side, shows no Min. received", + ({ swap }) => { + const data = buildExecute( + "0x10", + [ + encodeV4Swap( + new Uint8Array([ + USDT_FOR_WETH_IN.id, + swap.id, + V4_SETTLE_ALL_ID, + V4_TAKE_ID, + V4_TAKE_ID, + ]), + [ + USDT_FOR_WETH_IN.param, + swap.param, + encodeV4SettleAll(USDT_ADDR, 1000000000n), + encodeV4Take(USDC_ADDR), + encodeV4Take(WETH_ADDR), + ], + ), + ], + 9999999999n, + ); + + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); + expect(result.name).toBe("Swap USDT → WETH"); + expect(detail(result, "Amount").value).toBe("1000.0000 USDT"); + expect(detail(result, "Token Out").address).toBe(WETH_ADDR); + expect(detail(result, "Min. received")).toBeUndefined(); + }, + ); + // A V4 step that spends WETH on USDC and on DAI. Its swap actions buy two // currencies that none of them spends, and CLOSE_CURRENCY, which the // decoder does not read, names no side, so its output side is unknown. diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js index 3018ac8..f0497b7 100644 --- a/tests/uniswapRecipient.test.js +++ b/tests/uniswapRecipient.test.js @@ -1332,6 +1332,10 @@ describe("any token left in the router is not received", () => { ), ), left: tokenLine(WETH, "WETH"), + // The second swap action spends 1 USDC of what the first buys, + // so the step states no minimum + // (https://git.eeqj.de/sneak/AutistMask/issues/574). + noMin: true, }, { kind: "a WRAP_ETH of 1 ETH into the router, then a V4 SETTLE of OPEN_DELTA from the router before the swap, which the user settles", @@ -1432,7 +1436,7 @@ describe("any token left in the router is not received", () => { ), left: { label: "Token moved into the router", value: "ETH" }, }, - ])("$kind", ({ data, left, spent, unread }) => { + ])("$kind", ({ data, left, spent, unread, noMin }) => { const result = decode(data); expect(labels(result)).toEqual([ "Protocol", @@ -1458,7 +1462,7 @@ describe("any token left in the router is not received", () => { "Token Out", "Paid to", "Token moved into the router", - "Min. paid out", + ...(noMin ? [] : ["Min. paid out"]), "Steps", "Deadline", ]); @@ -1474,7 +1478,9 @@ describe("any token left in the router is not received", () => { } expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); expect(detail(result, "Token moved into the router")).toEqual(left); - expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC"); + expect(detail(result, "Min. paid out")?.value).toBe( + noMin ? undefined : "1990.0000 USDC", + ); }); // Uniswap's own routes: a swap paid in ETH wraps into the router what its