From 182742152d2a4bee6b69033248310229a50edc48 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 27 Oct 2026 04:29:57 +0000 Subject: [PATCH] fix: a V4 SETTLE of ETH paid from the router reads as moved from the sender (closes #682) V4Router pays a SETTLE of ETH from the router's own ETH, whichever payer it names. The router holds the transaction's value. So a SETTLE of ETH that names the router as payer now counts as paid by the user. When an earlier step left ETH in the router, such a SETTLE gets lines of its own. Its line reads "Up to" and its amount, and that amount never joins the Amount total. A SETTLE of CONTRACT_BALANCE reads as an amount the decoder does not know. One existing row in tests/uniswapRecipient.test.js now expects the ETH lines. Its SETTLE spends all the router's ETH, the transaction's value included. Model: opus-5-5 --- README.md | 7 ++- src/shared/uniswap.js | 79 ++++++++++++++++++++++++++-------- tests/uniswap.test.js | 71 ++++++++++++++++++++++++++++++ tests/uniswapRecipient.test.js | 38 ++++++++++++---- 4 files changed, 166 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index c0b1e62..79e4521 100644 --- a/README.md +++ b/README.md @@ -3473,8 +3473,11 @@ Currently supported: the user's wallet. Every V4 step, with a swap action or without, takes from the user's wallet what its `SETTLE`s paid by the user (`payerIsUser` true) and its `SETTLE_ALL`s pay, for each token on its own; what the step that sets the - input side takes of any other token counts as a later step's take. A `SETTLE` - of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of + input side takes of any other token counts as a later step's take. A V4 + `SETTLE` of ETH paid from the router still takes from the user's wallet, since + the router holds the transaction's value. When an earlier step left ETH in the + router, such a `SETTLE` gets lines of its own, with `Up to` before its amount. + A `SETTLE` of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of `OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the rest taken to another address, reads `5000.0000 USDT`. A later step paid from diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 5f02dda..5542319 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -1338,7 +1338,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // What the steps take from the sender beyond the amount of the step // that sets the input side, as { token, amount }, in step order. // After the loop each joins the Amount line or gets lines of its own - // (see permitStands). + // (see permitStands). A take with `atMost` set may take less than + // `amount`: it never joins the Amount line, and its line reads + // "Up to" and the amount. const userPaid = []; // A swap step the user pays for (`payerIsUser` true) takes `amount` // of `token` from the sender. A V2 exact-in of ALREADY_PAID takes @@ -1815,6 +1817,37 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (command === "V4_SWAP") { const v4 = decodeV4Swap(inputs[i]); const inputSetBefore = inputEstablished; + // V4Router pays a SETTLE of ETH from the router's own + // ETH, whichever payer the SETTLE names. The router holds + // the transaction's value, and any ETH an earlier step + // left it (see heldByRouter). So the user also pays a + // SETTLE of ETH that names the router as payer. The user + // pays all of its amount when the router holds no other + // ETH, and up to its amount otherwise. CONTRACT_BALANCE + // pays all the router holds, an amount the calldata does + // not state. + const routerHeldEth = heldByRouter.has( + "0x0000000000000000000000000000000000000000", + ); + const ethFromRouter = (v4?.settledFromRouter ?? []) + .filter( + (s) => + s.currency === + "0x0000000000000000000000000000000000000000", + ) + .map((s) => ({ + currency: s.currency, + amount: + s.amount === CONTRACT_BALANCE ? null : s.amount, + })); + // What the user pays the step: what decodeV4Swap() says, + // and the ETH of each SETTLE from the router when the + // router holds no other ETH. + const paidByUser = v4 + ? v4.paidByUser.concat( + routerHeldEth ? [] : ethFromRouter, + ) + : []; if (v4) { // Each SETTLE paid from the router spends the amount // decodeV4Swap() says it pays. @@ -1845,10 +1878,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) { commandNames[i] += " (could not be decoded)"; } // The step's amount, if it sets the input side: what the - // user pays it of its input currency (see - // decodeV4Swap()), or, when the user pays none of it, - // what its swap actions spend of it, whoever pays that. - const paidIn = v4?.paidByUser.find( + // user pays it of its input currency, or, when the user + // pays none of it, what its swap actions spend of it, + // whoever pays that. + const paidIn = paidByUser.find( (p) => p.currency === v4.tokenIn, ); const amountIn = paidIn ? paidIn.amount : v4?.amountIn; @@ -1902,10 +1935,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // router still runs it. const setInputHere = inputEstablished && !inputSetBefore; if (setInputHere) inputPaidByUser = paidIn !== undefined; - if (v4) { - for (const p of v4.paidByUser) { - if (setInputHere && p === paidIn) continue; - addUserPaid(p.currency, p.amount); + for (const p of paidByUser) { + if (setInputHere && p === paidIn) continue; + addUserPaid(p.currency, p.amount); + } + if (routerHeldEth) { + for (const p of ethFromRouter) { + userPaid.push({ + token: p.currency, + amount: p.amount, + atMost: true, + }); } } if (!v4 || v4.settleUnread) { @@ -2013,10 +2053,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // The total is what the steps take of Token In: the amount of the // step that set the input side, with each later take of that token // added to it while both are quantities. A take that cannot be added, - // being of another token or coming after an amount that is not a - // quantity (a V4 open delta, ALREADY_PAID, or none stated), gets - // lines of its own. One whose amount is not known also leaves the - // total unknown, with no figure. + // being of another token, coming after an amount that is not a + // quantity (a V4 open delta, ALREADY_PAID, or none stated), or having + // `atMost` set, gets lines of its own. One whose amount is not known + // also leaves the total unknown, with no figure. // // A PERMIT2_PERMIT names Token In only when its amount stands as the // Amount figure in place of the total, which it does only in the @@ -2093,7 +2133,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if ( ofTokenIn && typeof total === "bigint" && - typeof t.amount === "bigint" + typeof t.amount === "bigint" && + !t.atMost ) { total += t.amount; inTotal.push(t); @@ -2196,8 +2237,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // V2's already-paid zero, neither of which is a quantity at all (see // OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two // tests come first — the sentinels are not bigints and cannot be - // compared with one. - const spentText = (amount, info) => { + // compared with one. `atMost` marks an amount that is only the most a + // take may be (see userPaid). + const spentText = (amount, info, atMost) => { if (amount === OPEN_DELTA) { return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; } @@ -2210,7 +2252,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if (amount >= MAX_UINT160) { return { raw: "Unlimited", display: "Unlimited" }; } - if (hasV2OrV3ExactOut) { + if (hasV2OrV3ExactOut || atMost) { // A V2 or V3 exact-out swap spends at most this figure, // whichever steps gave it (an amountInMax, the WRAP_ETH of a // swap paid in ETH, a permit, or several steps' total), so it @@ -2270,7 +2312,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { details.push({ label: "Moved from the sender", value: present(s.amount) - ? spentText(s.amount, tokenInfo(s.token, sources)).display + ? spentText(s.amount, tokenInfo(s.token, sources), s.atMost) + .display : UNDECODED_CURRENCY, }); if (present(s.token)) { diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 46d7ff9..e638101 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -3439,6 +3439,77 @@ describe("uniswap decoder", () => { ]); }); }); + + // https://git.eeqj.de/sneak/AutistMask/issues/682 — V4 pays a SETTLE + // of ETH from the router's own ETH, whichever payer the SETTLE names, + // and the router holds the transaction's value. On next at 36b2002 a + // SETTLE of 1 ETH that names the router as payer gave no ETH line. + describe("a V4 SETTLE of ETH that names the router as payer", () => { + const ONE_ETH = 1000000000000000000n; + // A V4 swap of `amount` of ETH for USDC. The SETTLE names the + // router as payer. + const v4EthFromRouter = (amount) => + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ALL_ID, + ]), + [ + encodeV4ExactInSingle(ZERO_ADDR, USDC_ADDR, amount, 1n), + settle(ZERO_ADDR, amount, false), + encodeV4TakeAll(USDC_ADDR, 1n), + ], + ); + + test("after a V3 exact-in of 600 USDT, shows the 1 ETH moved from the sender", () => { + const result = decodeSteps( + [0x00, 0x10], + [V3_600, v4EthFromRouter(ONE_ETH)], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "600.0000 USDT"], + ["Moved from the sender", "1.0000 ETH"], + ["Token moved", "ETH"], + ["Moved to", "The swap step that spends it"], + ]); + }); + + // The UNWRAP_WETH leaves ETH in the router. The SETTLE may spend + // that ETH before the transaction's value. + test("after an UNWRAP_WETH into the router, shows at most 1 ETH moved from the sender", () => { + const result = decodeSteps( + [0x00, 0x0c, 0x10], + [ + encodeV3SwapExactIn(ROUTER_ADDR, USDT_600, 1n, [ + USDT_ADDR, + WETH_ADDR, + ]), + encodeWrapEth(ROUTER_ADDR, 0n), // UNWRAP_WETH, same encoding + v4EthFromRouter(ONE_ETH), + ], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "600.0000 USDT"], + ["Moved from the sender", "Up to 1.0000 ETH"], + ["Token moved", "ETH"], + ["Moved to", "The swap step that spends it"], + ]); + }); + + test("after a WRAP_ETH of 0.5 ETH, adds the SETTLE's 0.5 ETH to the Amount line", () => { + const result = decodeSteps( + [0x0b, 0x10], + [ + encodeWrapEth(ROUTER_ADDR, ONE_ETH / 2n), + v4EthFromRouter(ONE_ETH / 2n), + ], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "1.0000 ETH"], + ]); + }); + }); }); // https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js index e7d620a..4e9b9e6 100644 --- a/tests/uniswapRecipient.test.js +++ b/tests/uniswapRecipient.test.js @@ -1661,9 +1661,18 @@ describe("any token left in the router is not received", () => { ), min: "1990.0000 USDC", }, - { - kind: "a swap paying WETH to the router, an UNWRAP_WETH into the router, then a V4 swap settling all the ETH from the router", - data: execute( + ])("$kind reads as a swap paid to the user", ({ data, min }) => { + const result = decode(data); + expect(labels(result)).toEqual(PAID_TO_USER); + expect(detail(result, "Min. received").value).toBe(min); + }); + + // https://git.eeqj.de/sneak/AutistMask/issues/682 — the SETTLE spends all + // the router's ETH, the transaction's value included, so the sender pays + // an amount the calldata does not state. + test("a swap paying WETH to the router, an UNWRAP_WETH into the router, then a V4 swap settling all the ETH from the router reads as a swap paid to the user", () => { + const result = decode( + execute( swapToWeth(ADDRESS_THIS), unwrapWeth(ADDRESS_THIS), v4Swap( @@ -1672,12 +1681,23 @@ describe("any token left in the router is not received", () => { takeAll(USDC), ), ), - min: "1990.0000 USDC", - }, - ])("$kind reads as a swap paid to the user", ({ data, min }) => { - const result = decode(data); - expect(labels(result)).toEqual(PAID_TO_USER); - expect(detail(result, "Min. received").value).toBe(min); + ); + expect(labels(result)).toEqual([ + "Protocol", + "Token In", + "Amount", + "Moved from the sender", + "Token moved", + "Moved to", + "Token Out", + "Min. received", + "Steps", + "Deadline", + ]); + expect(detail(result, "Moved from the sender").value).toBe( + "Unknown (could not be decoded)", + ); + expect(detail(result, "Min. received").value).toBe("1990.0000 USDC"); }); });