From b9bb1569fddb0c83882e7cc615bf57c2e89b9634 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 21 Oct 2026 07:34:26 +0200 Subject: [PATCH] fix: a V4 step that spends some of its output currency shows no Min. received (closes #574) A V4 step's Min. received was the sum of the minimums of the swap actions that buy its output currency, even when another swap action in the same step spends some of that currency. The user then receives less than that figure. decodeV4Swap() now gives no minimum when a swap action in the step also spends the output currency, as it already does for other minimums it cannot state. A multi-hop action counts only the currency it spends and the one it ends with, so a path through an intermediate currency is not read as spending it. Model: opus-5-5 --- README.md | 6 ++- src/shared/uniswap.js | 16 ++++--- tests/uniswap.test.js | 82 ++++++++++++++++++++++++++++++++++ tests/uniswapRecipient.test.js | 12 +++-- 4 files changed, 106 insertions(+), 10 deletions(-) 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