diff --git a/README.md b/README.md index e20ba2b..e127b09 100644 --- a/README.md +++ b/README.md @@ -3459,7 +3459,12 @@ 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`. When two or more swap steps pay the output + token to the transaction's sender, the minimum is the sum of their minimums. + There is no minimum when one of them states none or 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. 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..74bbb2a 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -1307,8 +1307,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 +1424,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 +1460,59 @@ 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 swap step that spends that token + // takes the step off this list. + let swapsPayingSender = []; + // The minimum the output side shows for the swap step at `step`. The + // step spends `tokenIn`, 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 one of them states no minimum or pays + // some of `token` elsewhere, or when this decoder did not read in full + // one of them or a step between them (see stepsUnread). + const swapMinimum = (step, tokenIn, token, amount, recipients) => { + swapsPayingSender = swapsPayingSender.filter( + (s) => !sameAddress(s.token, tokenIn), + ); + 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()) @@ -1650,9 +1697,20 @@ function decode(data, toAddress, chainId, sources, fromAddress) { addUserPaid(s.tokenIn, s.amountIn); } // 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.tokenIn, + s.tokenOut, + s.amountOutMin, + [s.recipient], + ), + [s.recipient], + ); if (!s.payerIsUser) { spendFromRouter(s.tokenIn, s.amountIn); } @@ -1673,7 +1731,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (s.payerIsUser) { addUserPaid(s.tokenIn, s.amountIn); } - setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]); + setOutput( + i, + s.tokenOut, + swapMinimum( + i, + s.tokenIn, + s.tokenOut, + s.amountOutMin, + [s.recipient], + ), + [s.recipient], + ); if (!s.payerIsUser) { spendFromRouter(s.tokenIn, s.amountIn); } @@ -1705,7 +1774,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (s.payerIsUser) { addUserPaid(s.tokenIn, s.amountInMax); } - setOutput(i, s.tokenOut, s.amountOut, [s.recipient]); + setOutput( + i, + s.tokenOut, + swapMinimum(i, s.tokenIn, s.tokenOut, s.amountOut, [ + s.recipient, + ]), + [s.recipient], + ); if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); } @@ -1925,7 +2001,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) { setInput(v4.tokenIn, amountIn); } outputToken = v4.tokenOut; - minOutput = v4.amountOutMin; + minOutput = swapMinimum( + i, + v4.tokenIn, + 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..345cb66 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,190 @@ 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 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