diff --git a/README.md b/README.md index 0268c4b..eccafec 100644 --- a/README.md +++ b/README.md @@ -1365,9 +1365,14 @@ when both tokens are known. The lines are: the sender that the `Amount` line does not include. The same three lines show what a `PERMIT2_TRANSFER_FROM` moves and where. A `WRAP_ETH` can pay its WETH to a recipient other than the router. When its amount gets these lines, its - `Moved to` line names that recipient, or reads `The sender`. ETH that the - sender pays a V4 step can get these three lines when the router may hold ETH - from an earlier step. When the decoder knows the amount, its + `Moved to` line names that recipient, or reads `The sender`. A `WRAP_ETH` + after the step that sets `Token In` takes the amount it states from the + sender. When it pays anyone but the router, that amount gets these lines. When + it pays the router, that amount joins the `Amount` line if `Token In` is ETH, + and gets these lines if not. A later `WRAP_ETH` of `CONTRACT_BALANCE` states + no amount, so it gets no lines. ETH that the sender pays a V4 step or a + `WRAP_ETH` of a stated amount can get these three lines when the router may + hold ETH from an earlier step. When the decoder knows the amount, its `Moved from the sender` line reads `Up to` and the amount. - `Permit2 allowance`, `Allowance token` and `Allowance spender`: each allowance that a `PERMIT2_PERMIT` or `PERMIT2_PERMIT_BATCH` grants, unless the permit's diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 8da031a..21c4935 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -1368,7 +1368,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // After the loop each joins the Amount line or gets lines of its own // (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. + // "Up to" and the amount. A take with a `recipient`, the ETH of a + // later WRAP_ETH that pays anyone but the router, never joins the + // Amount line either: its Moved to line names that recipient. const userPaid = []; // A swap step the user pays for (`payerIsUser` true) takes `amount` // of `token` from the sender, or at most that when `atMost` is set. A @@ -1380,8 +1382,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { }; // A step may take from the sender what this decoder did not read: a // V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it - // cannot read; a V2 or V3 swap step or PERMIT2_TRANSFER_FROM whose - // input it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; an + // cannot read; a V2 or V3 swap step, PERMIT2_TRANSFER_FROM or WRAP_ETH + // whose input it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; an // EXECUTE_SUB_PLAN; and a step it fails on. It is recorded with a null // token and amount: one line says so, there is no Amount line, whose // figure may then be too low, and the amount of a permit after it @@ -1907,6 +1909,25 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if (!isRouter(w.recipient)) { wrapEthRecipient = w.recipient; } + } else if ( + w.amount > 0n && + w.amount !== CONTRACT_BALANCE + ) { + // The ETH of a stated amount comes from the + // transaction's value. ETH an earlier step left in + // the router may pay part of it, and the sender + // then pays at most that amount. CONTRACT_BALANCE + // states no amount to take. + userPaid.push({ + token: "0x0000000000000000000000000000000000000000", + amount: w.amount, + atMost: heldByRouter.has( + "0x0000000000000000000000000000000000000000", + ), + recipient: isRouter(w.recipient) + ? null + : w.recipient, + }); } addPayee(w.recipient); // CONTRACT_BALANCE wraps all the router's ETH. @@ -1931,6 +1952,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else { payeeUnread = true; stepsUnread.push(i); + spentUnread(); } } else if (command === "V4_SWAP") { const v4 = decodeV4Swap(inputs[i]); @@ -2203,8 +2225,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // added to it while both are quantities. A take that cannot be added, // 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. + // `atMost` or a `recipient` 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 @@ -2245,7 +2267,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // { token, amount, atMost, recipient }. A null token is a step this // decoder did not read (see spentUnread()), or a token it does not // know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH that - // pays elsewhere has a recipient (see wrapEthRecipient). + // pays elsewhere has a recipient (see wrapEthRecipient and userPaid). const spentFromSender = []; let total = null; // The takes from the sender that `total` adds up, each a quantity, in @@ -2292,7 +2314,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { ofTokenIn && typeof total === "bigint" && typeof t.amount === "bigint" && - !t.atMost + !t.atMost && + !present(t.recipient) ) { total += t.amount; inTotal.push(t); diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 76d9308..76fde00 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -2593,6 +2593,9 @@ describe("uniswap decoder", () => { ], lines: [ ["Amount", "1.0000 ETH"], + ["Moved from the sender", "Up to 1.0000 ETH"], + ["Token moved", "ETH"], + ["Moved to", OTHER_ADDR], ...ethLines("Up to 1.0000 ETH"), ], }, @@ -2622,6 +2625,7 @@ describe("uniswap decoder", () => { ], lines: [ ["Amount", "600.0000 USDT"], + ...wrapLines("The swap step that spends it"), ...ethLines("Up to 1.0000 ETH"), ], }, @@ -2753,6 +2757,172 @@ describe("uniswap decoder", () => { expect(linesBetweenTokens(result)).toEqual(lines); }); + // https://git.eeqj.de/sneak/AutistMask/issues/721 — a WRAP_ETH that + // does not set the input side takes the amount it states from the + // sender. On next no line gave that ETH. The rows marked "reads as on + // next" state no amount to take from the sender. Uniswap's own routes + // wrap CONTRACT_BALANCE after their first step, as the last two rows + // do. + const CONTRACT_BALANCE = 2n ** 255n; + const WRAP_TO_OTHER = encodeWrapEth(OTHER_ADDR, ONE_ETH); + test.each([ + { + kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to another address", + commands: [0x00, 0x0b], + inputs: [V3_600, WRAP_TO_OTHER], + lines: [["Amount", "600.0000 USDT"], ...wrapLines(OTHER_ADDR)], + }, + { + kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to address(1)", + commands: [0x00, 0x0b], + inputs: [V3_600, encodeWrapEth(MSG_SENDER, ONE_ETH)], + lines: [ + ["Amount", "600.0000 USDT"], + ...wrapLines("The sender"), + ], + }, + { + kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to address(2)", + commands: [0x00, 0x0b], + inputs: [V3_600, WRAP_INTO_ROUTER], + lines: [ + ["Amount", "600.0000 USDT"], + ...wrapLines("The swap step that spends it"), + ], + }, + { + kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to the router by its own address", + commands: [0x00, 0x0b], + inputs: [V3_600, encodeWrapEth(ROUTER_ADDR, ONE_ETH)], + lines: [ + ["Amount", "600.0000 USDT"], + ...wrapLines("The swap step that spends it"), + ], + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, then a WRAP_ETH of 1 ETH to another address", + commands: [0x0b, 0x0b], + inputs: [WRAP_INTO_ROUTER, WRAP_TO_OTHER], + lines: [["Amount", "1.0000 ETH"], ...wrapLines(OTHER_ADDR)], + }, + { + kind: "two WRAP_ETHs of 1 ETH into the router, which the Amount line adds up", + commands: [0x0b, 0x0b], + inputs: [WRAP_INTO_ROUTER, WRAP_INTO_ROUTER], + lines: [["Amount", "2.0000 ETH"]], + }, + { + kind: "a V3 exact-in of 600 USDT for WETH paid to the router, an UNWRAP_WETH into the router, then a WRAP_ETH of 1 ETH to another address", + commands: [0x00, 0x0c, 0x0b], + inputs: [ + encodeV3SwapExactIn(ADDRESS_THIS, USDT_600, 1n, [ + USDT_ADDR, + WETH_ADDR, + ]), + UNWRAP_INTO_ROUTER, + WRAP_TO_OTHER, + ], + lines: [ + ["Amount", "600.0000 USDT"], + ["Moved from the sender", "Up to 1.0000 ETH"], + ["Token moved", "ETH"], + ["Moved to", OTHER_ADDR], + ], + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a WRAP_ETH of 1 ETH into the router", + commands: [0x0b, 0x0c, 0x0b], + inputs: [ + WRAP_INTO_ROUTER, + UNWRAP_INTO_ROUTER, + WRAP_INTO_ROUTER, + ], + lines: [ + ["Amount", "1.0000 ETH"], + ...ethLines("Up to 1.0000 ETH"), + ], + }, + { + kind: "a PERMIT2_PERMIT of 5,000 USDT to the router, a V3 exact-in of 600 USDT, then a WRAP_ETH of 1 ETH to another address", + commands: [0x0a, 0x00, 0x0b], + inputs: [ + encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR), + V3_600, + WRAP_TO_OTHER, + ], + lines: [["Amount", "5000.0000 USDT"], ...wrapLines(OTHER_ADDR)], + }, + { + kind: "a V3 exact-in of 600 USDT, a WRAP_ETH of 1 ETH to another address, then an EXECUTE_SUB_PLAN", + commands: [0x00, 0x0b, 0x21], + inputs: [V3_600, WRAP_TO_OTHER, SUB_PLAN], + lines: [ + ...USDT_600_GROUP, + ...wrapLines(OTHER_ADDR), + ["Moved from the sender", UNDECODED], + ], + }, + { + kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH whose input this decoder cannot read", + commands: [0x00, 0x0b], + inputs: [V3_600, "0x1234"], + lines: [ + ...USDT_600_GROUP, + ["Moved from the sender", UNDECODED], + ], + }, + { + kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of nothing to another address, reads as on next", + commands: [0x00, 0x0b], + inputs: [V3_600, encodeWrapEth(OTHER_ADDR, 0n)], + lines: [["Amount", "600.0000 USDT"]], + }, + { + kind: "a V3 exact-in of 600 USDT, then a WRAP_ETH of CONTRACT_BALANCE to the sender, reads as on next", + commands: [0x00, 0x0b], + inputs: [V3_600, encodeWrapEth(MSG_SENDER, CONTRACT_BALANCE)], + lines: [["Amount", "600.0000 USDT"]], + }, + { + kind: "a V4 swap of 600 USDT that takes ETH into the router, a WRAP_ETH of CONTRACT_BALANCE into the router, then a V3 exact-in of it, reads as on next", + commands: [0x10, 0x0b, 0x00], + inputs: [ + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ID, + ]), + [ + encodeV4ExactInSingle( + USDT_ADDR, + ZERO_ADDR, + USDT_600, + 1n, + ), + settle(USDT_ADDR, 0n, true), + take(ZERO_ADDR, ADDRESS_THIS, 0n), + ], + ), + encodeWrapEth(ADDRESS_THIS, CONTRACT_BALANCE), + coder.encode( + ["address", "uint256", "uint256", "bytes", "bool"], + [ + USER_ADDR, + CONTRACT_BALANCE, + 1n, + encodeV3Path([WETH_ADDR, USDC_ADDR]), + false, + ], + ), + ], + lines: [["Amount", "600.0000 USDT"]], + }, + ])("$kind", ({ commands, inputs, lines }) => { + const result = decodeSteps(commands, inputs); + expect(linesBetweenTokens(result)).toEqual(lines); + }); + // A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax` // USDT for USDC. const v3ExactOut = (amountInMax) => diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js index 548ee97..2d1c033 100644 --- a/tests/uniswapRecipient.test.js +++ b/tests/uniswapRecipient.test.js @@ -370,7 +370,8 @@ const PAID_ELSEWHERE = [ "Steps", "Deadline", ]; -// `lines` with the lines of one PERMIT2_TRANSFER_FROM after Amount. +// `lines` with the lines of one PERMIT2_TRANSFER_FROM or later WRAP_ETH after +// Amount. const withMove = (lines) => lines.flatMap((l) => l === "Amount" @@ -985,7 +986,7 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => { ); // The SWEEP pays the user the swap's WETH, so the UNWRAP_WETH unwraps the - // WRAP_ETH's. + // WRAP_ETH's. The WRAP_ETH's 1 ETH gets lines of its own. test("a SWEEP of the swap's WETH to the user, then a WRAP_ETH into the router, reads as ETH with no minimum", () => { const result = decode( execute( @@ -996,7 +997,10 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => { ), ); expect(result.name).toBe("Swap USDT → ETH"); - expect(labels(result)).toEqual(NO_MINIMUM_TO_USER); + expect(labels(result)).toEqual(withMove(NO_MINIMUM_TO_USER)); + expect(detail(result, "Moved from the sender").value).toBe( + "1.0000 ETH", + ); }); test("an UNWRAP_WETH into the router, then a WRAP_ETH of all its ETH to the user, reads as WETH with no minimum", () => { @@ -1097,7 +1101,9 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => { }, ); - // The user receives 0.5 WETH and the rest of the swap's output as ETH. + // The user receives 0.5 WETH and the rest of the swap's output as ETH. The + // router holds ETH when the WRAP_ETH runs, so the sender pays at most its + // 0.5 ETH. test("an UNWRAP_WETH into the router, then a WRAP_ETH of 0.5 ETH to the user, reads as WETH with no minimum", () => { const result = decode( execute( @@ -1108,7 +1114,11 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => { ), ); expect(result.name).toBe("Swap USDT → WETH"); - expect(labels(result)).toEqual(NO_MINIMUM_TO_USER); + expect(labels(result)).toEqual(withMove(NO_MINIMUM_TO_USER)); + expect(detail(result, "Moved from the sender").value).toBe( + "Up to 0.5000 ETH", + ); + expect(detail(result, "Moved to").value).toBe("The sender"); }); // The UNWRAP_WETH pays the user the swap's WETH as ETH; the WRAP_ETH wraps @@ -1348,6 +1358,7 @@ describe("any token left in the router is not received", () => { ), ), left: tokenLine(WETH, "WETH"), + spent: "1.0000 ETH", }, // A SETTLE of OPEN_DELTA from the router pays only what the step // still owes of its currency when it runs. @@ -1516,8 +1527,8 @@ describe("any token left in the router is not received", () => { "Moved from the sender", ] : ["Amount"]), - // A later swap step the user pays for spends `spent` of a token - // other than Token In. + // A later swap step or WRAP_ETH the user pays for spends `spent` + // of a token other than Token In. ...(spent ? ["Moved from the sender", "Token moved", "Moved to"] : []),