From 53111a975ea0a56d6ade38f7d4082b9baa1d7e00 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 27 Oct 2026 08:17:49 +0000 Subject: [PATCH] fix: a split swap's Min. received is the sum of its steps' minimums (closes #647) The step that sets the output side gives the minimum. When that step and earlier swap steps each pay the output token to the sender, the minimum is now the sum of their minimums. There is no figure when the decoder reads none from one of them, or when one of them pays part of that token elsewhere. There is also none when the decoder did not read one of them, or a step between them, in full. These steps drop earlier steps from the sum, so a multi-hop route reads as before: - a V2 or V3 swap step that spends the output token; - a V4 step with a swap action that spends that token, or with a SETTLE or SETTLE_ALL of that token; - a PERMIT2_TRANSFER_FROM of that token. Model: opus-5-5 --- README.md | 14 ++- src/shared/uniswap.js | 120 +++++++++++++++++-- tests/uniswap.test.js | 268 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 387 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e20ba2b..40466b9 100644 --- a/README.md +++ b/README.md @@ -1266,7 +1266,8 @@ see Transaction Decoding), do not always read as a number. They can read: the same words. `Unlimited`, `All available (V4 open delta)` and `Whatever an earlier step sent to the pair (V2 already paid)` keep their wording. When a V2 or V3 exact-out step sets `Min. received`, that line shows - its `amountOut`, the exact amount it buys. + its `amountOut`, the exact amount it buys. When earlier swap steps also pay + that token to the user, the line shows the sum (see Transaction Decoding). - `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. Transaction Decoding @@ -3459,7 +3460,16 @@ Currently supported: name); and one more reads `Unknown (could not be decoded)` when a step that may pay an address went unread. When no step pays anyone, a single line reads `Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the - minimum reads `Min. paid out`. The `Amount` line gives the total the steps + minimum reads `Min. paid out`. The step that sets the output side gives the + minimum. When that step and earlier swap steps each pay the output token to + the transaction's sender, the minimum is the sum of their minimums. There is + no minimum when the decoder reads none from one of them, or when one of them + pays part of that token elsewhere. There is also no minimum when the decoder + did not read one of them, or a step between them, in full. A swap step that + spends the output token drops earlier steps from the sum. A V4 step spends + each token that its swap actions spend. A V4 `SETTLE` or `SETTLE_ALL` of the + output token also drops earlier steps. A `PERMIT2_TRANSFER_FROM` of the output + token drops earlier steps too. The `Amount` line gives the total the steps take of `Token In`, from the step that sets the input side on: the first V2 or V3 exact-in or exact-out, `WRAP_ETH` or V4 swap step that names an input token or amount. A V4 step with a swap action sets the input side when no earlier diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 6e140b7..ca59ae3 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -988,6 +988,9 @@ function totalAmount(amounts) { // pays it of Token In as the step's amount in place of the rules' Amount, or // no amount when this decoder cannot tell what that is. // +// `spent` lists the currency that each swap action spends, for each swap +// action that this decoder could read. +// // `takes` is every TAKE, TAKE_ALL and TAKE_PORTION, as the currency it takes // and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes // a share of the currency, so it does not name the output side as TAKE does. @@ -1163,6 +1166,8 @@ function decodeV4Swap(input) { } } + const spent = swaps.map((s) => s.tokenIn); + // Rule 3. if (swapUndecoded) { return { @@ -1170,6 +1175,7 @@ function decodeV4Swap(input) { tokenOut: takeToken, amountIn: null, amountOutMin: null, + spent, takes, settledFromRouter, paidByUser, @@ -1180,7 +1186,6 @@ function decodeV4Swap(input) { }; } - const spent = swaps.map((s) => s.tokenIn); const bought = swaps.map((s) => s.tokenOut); // Rules 1 and 2 for Token In. @@ -1216,6 +1221,7 @@ function decodeV4Swap(input) { tokenOut, amountIn, amountOutMin, + spent, takes, settledFromRouter, paidByUser, @@ -1307,8 +1313,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { const commandNames = []; // THE INVARIANT: an amount and the token it is counted in always come - // from the same hop, or from hops that all spend that token. A figure - // is never rendered against a token that did not supply it. + // from the same hop, or from hops that all spend that token or all buy + // it. A figure is never rendered against a token that did not supply + // it. // // Both sides are therefore set as a PAIR — never field by field, and // never on truthiness. An address is never falsy once set but an @@ -1423,6 +1430,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) { const r = recipient.toLowerCase(); return r === ADDRESS_THIS || r === toAddress.toLowerCase(); }; + const isSender = (recipient) => { + const r = recipient.toLowerCase(); + return r === MSG_SENDER || r === sender; + }; // A swap step or V4 SETTLE pays `amount` of `token` from the router's // own balance. CONTRACT_BALANCE pays all of it. Any other quantity // counts against the amount a WRAP_ETH stated, and the steps pay all @@ -1455,17 +1466,69 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // A step pays `recipient`. `output` is the token a swap step or a V4 // TAKE or TAKE_PORTION pays; other steps pass none. const addPayee = (recipient, output) => { - const r = recipient.toLowerCase(); if (isRouter(recipient)) { if (present(output)) { heldByRouter.set(output.toLowerCase(), null); } - } else if (r === MSG_SENDER || r === sender) { + } else if (isSender(recipient)) { paidToSender = true; } else { addPaidElsewhere(recipient); } }; + // Each swap step that paid the sender some of the token it bought, + // as { step, token, amount, onlyToSender }: the step's index, that + // token, the step's stated minimum, and whether the step paid all of + // that token to the sender. A later step that spends that token takes + // the step off this list (see dropFromSum()). + let swapsPayingSender = []; + // The step being read spends `token`, so it may spend what an earlier + // swap step paid the sender. Each step on swapsPayingSender that + // bought `token` leaves the list. These steps spend a token: + // - a V2 or V3 swap step spends its input token; + // - a V4 step spends each token that one of its swap actions spends, + // and each token that one of its SETTLEs or SETTLE_ALLs pays; + // - a PERMIT2_TRANSFER_FROM spends the token it moves. + const dropFromSum = (token) => { + swapsPayingSender = swapsPayingSender.filter( + (s) => !sameAddress(s.token, token), + ); + }; + // The minimum the output side shows for the swap step at `step`. The + // step buys `token` with the stated minimum `amount`, and pays + // `token` to `recipients`. The minimum is the step's own, unless this + // step and an earlier one on swapsPayingSender both pay `token` to + // the sender, as a route split across steps does. The minimum is then + // the total of all such steps' minimums. There is none when this + // decoder reads no minimum from one of them, or when one of them pays + // some of `token` elsewhere. There is also none when this decoder did + // not read one of them, or a step between them, in full (see + // stepsUnread). + const swapMinimum = (step, token, amount, recipients) => { + const toSender = recipients.filter( + (r) => present(r) && isSender(r), + ); + if (toSender.length === 0) return amount; + swapsPayingSender.push({ + step, + token, + amount, + onlyToSender: toSender.length === recipients.length, + }); + const split = swapsPayingSender.filter((s) => + sameAddress(s.token, token), + ); + if (split.length < 2) return amount; + if ( + split.some( + (s) => !s.onlyToSender || typeof s.amount !== "bigint", + ) || + stepsUnread.some((unread) => unread >= split[0].step) + ) { + return null; + } + return split.reduce((sum, s) => sum + s.amount, 0n); + }; // A step that pays `token` to `recipient` is a hop, not a payout, // when the very next step is a V2 exact-in whose input is already // paid, whose path starts at `token`, and whose pair (see v2PairFor()) @@ -1607,6 +1670,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { : null, }); } + if (p) dropFromSum(p.token); } else if (command === "BALANCE_CHECK_ERC20") { const b = decodeBalanceCheck(inputs[i]); if (!b) { @@ -1649,10 +1713,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (s.payerIsUser) { addUserPaid(s.tokenIn, s.amountIn); } + dropFromSum(s.tokenIn); // Always update output: in multi-step swaps (V3 → V4), - // the last swap step determines the final output token - // and minimum received amount. - setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]); + // the last swap step determines the final output + // token. swapMinimum() gives the minimum. + setOutput( + i, + s.tokenOut, + swapMinimum(i, s.tokenOut, s.amountOutMin, [ + s.recipient, + ]), + [s.recipient], + ); if (!s.payerIsUser) { spendFromRouter(s.tokenIn, s.amountIn); } @@ -1673,7 +1745,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (s.payerIsUser) { addUserPaid(s.tokenIn, s.amountIn); } - setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]); + dropFromSum(s.tokenIn); + setOutput( + i, + s.tokenOut, + swapMinimum(i, s.tokenOut, s.amountOutMin, [ + s.recipient, + ]), + [s.recipient], + ); if (!s.payerIsUser) { spendFromRouter(s.tokenIn, s.amountIn); } @@ -1705,7 +1785,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (s.payerIsUser) { addUserPaid(s.tokenIn, s.amountInMax); } - setOutput(i, s.tokenOut, s.amountOut, [s.recipient]); + dropFromSum(s.tokenIn); + setOutput( + i, + s.tokenOut, + swapMinimum(i, s.tokenOut, s.amountOut, [ + s.recipient, + ]), + [s.recipient], + ); if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); } @@ -1877,6 +1965,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) { addPayee(t.recipient, t.currency); } } + for (const token of v4.spent) dropFromSum(token); + for (const s of v4.paidByUser) dropFromSum(s.currency); + for (const s of v4.settledFromRouter) { + dropFromSum(s.currency); + } } if (!v4 || v4.recipientUnread) { payeeUnread = true; @@ -1925,7 +2018,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) { setInput(v4.tokenIn, amountIn); } outputToken = v4.tokenOut; - minOutput = v4.amountOutMin; + minOutput = swapMinimum( + i, + v4.tokenOut, + v4.amountOutMin, + takeRecipients, + ); outputEstablished = true; outputUnwrapped = false; unwrapIntoRouterStep = null; diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 511f3ca..28311f6 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -2871,8 +2871,9 @@ describe("uniswap decoder", () => { ...USDT_400_GROUP, ...ONE_USDT_LINES(OTHER_ADDR), ], - // The last swap step's minimum. - minimum: "390.0000 USDC", + // Both swap steps pay USDC to the user, so the minimum + // is the sum of their two minimums. + minimum: "980.0000 USDC", }, ])( "$kind shows each take's own lines and the allowances, and no Amount figure or Min. received", @@ -3638,6 +3639,269 @@ describe("uniswap decoder", () => { ]); }); }); + + // https://git.eeqj.de/sneak/AutistMask/issues/647 — on next at + // f550d8c, a split whose steps each pay WETH to the user showed only + // the last step's minimum. + describe("the minimum of a split that pays WETH to the user", () => { + const WETH_01 = 100000000000000000n; + const WETH_02 = 200000000000000000n; + const WETH_03 = 300000000000000000n; + // A V3 exact-in of 600 USDT for at least 0.3 WETH, and a V2 + // exact-in of 400 USDT for at least 0.2 WETH, each paid to the + // user. + const V3_TO_WETH = encodeV3SwapExactIn( + USER_ADDR, + USDT_600, + WETH_03, + [USDT_ADDR, WETH_ADDR], + ); + const V2_TO_WETH = encodeV2SwapExactIn( + USER_ADDR, + USDT_400, + WETH_02, + [USDT_ADDR, WETH_ADDR], + ); + // A V4 step that swaps 400 USDT for at least 0.2 WETH, with these + // TAKEs of the WETH. + const v4ToWeth = (...takes) => + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ID, + ...takes.map(() => V4_TAKE_ID), + ]), + [ + encodeV4ExactInSingle( + USDT_ADDR, + WETH_ADDR, + USDT_400, + WETH_02, + ), + encodeV4Settle(USDT_ADDR), // paid by the user + ...takes, + ], + ); + // The swap's minimum line as [label, value], or undefined. + const minimum = (result) => + result.details + .filter( + (d) => + d.label === "Min. received" || + d.label === "Min. paid out", + ) + .map((d) => [d.label, d.value])[0]; + + test("a V3 and a V2 exact-in show the total of their minimums", () => { + const result = decodeSteps( + [0x00, 0x08], + [V3_TO_WETH, V2_TO_WETH], + ); + expect(minimum(result)).toEqual([ + "Min. received", + "0.5000 WETH", + ]); + }); + + test("a V3 exact-in and a V4 step show the total of their minimums", () => { + const result = decodeSteps( + [0x00, 0x10], + [V3_TO_WETH, v4ToWeth(encodeV4Take(WETH_ADDR))], + ); + expect(minimum(result)).toEqual([ + "Min. received", + "0.5000 WETH", + ]); + }); + + // The second route buys DAI for the router, then swaps all of it + // from the router for at least 0.2 WETH. + test("a route that hops through DAI counts its last hop's minimum", () => { + const result = decodeSteps( + [0x00, 0x08, 0x00], + [ + V3_TO_WETH, + encodeV2SwapExactIn(ROUTER_ADDR, USDT_400, DAI_400, [ + USDT_ADDR, + DAI_ADDR, + ]), + coder.encode( + ["address", "uint256", "uint256", "bytes", "bool"], + [ + USER_ADDR, + 2n ** 255n, // CONTRACT_BALANCE + WETH_02, + encodeV3Path([DAI_ADDR, WETH_ADDR]), + false, // paid from the router + ], + ), + ], + ); + expect(minimum(result)).toEqual([ + "Min. received", + "0.5000 WETH", + ]); + }); + + // The middle step spends the user's WETH, so the count of WETH + // starts again after it. + test("a step that spends WETH between them leaves the last step's minimum", () => { + const result = decodeSteps( + [0x00, 0x00, 0x08], + [ + V3_TO_WETH, + encodeV3SwapExactIn(USER_ADDR, WETH_03, 1n, [ + WETH_ADDR, + USDC_ADDR, + ]), + V2_TO_WETH, + ], + ); + expect(minimum(result)).toEqual([ + "Min. received", + "0.2000 WETH", + ]); + }); + + // The middle step swaps 0.3 WETH and 400 DAI for USDC, both paid + // by the user. It spends WETH whichever SETTLE comes last. + test.each([ + { order: "WETH first", settled: [WETH_ADDR, DAI_ADDR] }, + { order: "DAI first", settled: [DAI_ADDR, WETH_ADDR] }, + ])( + "a V4 step that spends WETH and DAI between them leaves the last step's minimum, with the SETTLE of $order", + ({ settled }) => { + const wethAndDaiForUsdc = encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ID, + V4_SETTLE_ID, + V4_TAKE_ALL_ID, + ]), + [ + encodeV4ExactInSingle( + WETH_ADDR, + USDC_ADDR, + WETH_03, + 1n, + ), + encodeV4ExactInSingle( + DAI_ADDR, + USDC_ADDR, + DAI_400, + 1n, + ), + ...settled.map((token) => encodeV4Settle(token)), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + ); + const result = decodeSteps( + [0x00, 0x10, 0x08], + [V3_TO_WETH, wethAndDaiForUsdc, V2_TO_WETH], + ); + expect(minimum(result)).toEqual([ + "Min. received", + "0.2000 WETH", + ]); + }, + ); + + // The middle step has no swap action. Its SETTLE pays 0.3 WETH + // from the user, and its TAKE pays that WETH to another address. + test("a V4 step whose SETTLE pays WETH between them leaves the last step's minimum", () => { + const wethToOther = encodeV4Swap( + new Uint8Array([V4_SETTLE_ID, V4_TAKE_ID]), + [ + settle(WETH_ADDR, WETH_03, true), + take(WETH_ADDR, OTHER_ADDR, 0n), + ], + ); + const result = decodeSteps( + [0x00, 0x10, 0x08], + [V3_TO_WETH, wethToOther, V2_TO_WETH], + ); + expect(minimum(result)).toEqual([ + "Min. paid out", + "0.2000 WETH", + ]); + }); + + test("a PERMIT2_TRANSFER_FROM of WETH between them leaves the last step's minimum", () => { + const result = decodeSteps( + [0x00, 0x02, 0x08], + [ + V3_TO_WETH, + permit2TransferFrom(WETH_ADDR, OTHER_ADDR, WETH_03), + V2_TO_WETH, + ], + ); + expect(minimum(result)).toEqual([ + "Min. paid out", + "0.2000 WETH", + ]); + }); + + // The decoder reads no minimum from a V4 exact-out action. + test("a step that states no minimum leaves no figure", () => { + const exactOut = encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_OUT_SINGLE_ID, + V4_SETTLE_ALL_ID, + V4_TAKE_ALL_ID, + ]), + [ + encodeV4ExactOutSingle( + USDT_ADDR, + WETH_ADDR, + WETH_02, + USDT_400, + ), + encodeV4SettleAll(USDT_ADDR, USDT_400), + encodeV4TakeAll(WETH_ADDR, 0n), + ], + ); + const result = decodeSteps( + [0x10, 0x00], + [exactOut, V3_TO_WETH], + ); + expect(minimum(result)).toBeUndefined(); + }); + + test("a step that pays some of its WETH to the router leaves no figure", () => { + const result = decodeSteps( + [0x00, 0x10], + [ + V3_TO_WETH, + v4ToWeth( + take(WETH_ADDR, USER_ADDR, WETH_01), + take(WETH_ADDR, ROUTER_ADDR, 0n), + ), + ], + ); + expect(minimum(result)).toBeUndefined(); + }); + + // The router still runs a swap step whose recipient cannot be + // read, and that step may spend the WETH the first step paid. + test("a step between them that cannot be read leaves no figure", () => { + const result = decodeSteps( + [0x00, 0x00, 0x08], + [ + V3_TO_WETH, + dirtyWord( + encodeV3SwapExactIn(USER_ADDR, WETH_03, 1n, [ + WETH_ADDR, + USDC_ADDR, + ]), + 0, + ), + V2_TO_WETH, + ], + ); + expect(minimum(result)).toBeUndefined(); + }); + }); }); // https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in