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