diff --git a/README.md b/README.md index c0b1e62..73b0071 100644 --- a/README.md +++ b/README.md @@ -3477,65 +3477,71 @@ Currently supported: 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 - the router spends what the router holds and takes nothing, and so does a V2 - exact-in whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is - added to the total while both are quantities: a V3 exact-in of 600 USDT and a - V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When - the decoder does not know a take's amount, the total is not known and there is - no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount, - `Unlimited` included, stands as the `Amount` figure in place of the total, - which it does only in the plain shape of Uniswap's own routes: it comes before - the step that sets the input side, it is the only `PERMIT2_PERMIT` in the - transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, - the router may not let it fail, no step before it takes its token, a - `PERMIT2_TRANSFER_FROM` of it included, or a token the decoder does not know, - and the decoder reads every step after it in full. Permit2 then lets the - router take no more of the token than that amount, so the permit covers every - take of it. A step before the permit takes under whatever allowance the router - already had, so it may take more than the permit's amount, which would then - read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of - 2,000 USDT into the router, then a permit of 600 USDT to the router and a V3 - exact-in of 2,000 USDT paid from the router, read `Amount 2000.0000 USDT`. A - later permit or batch may raise what Permit2 lets the router take, and so may - a step the decoder does not read in full: a command the decoder does not read - (listed at the end of this section), a step whose input it cannot read, in - whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, before - the step that sets the input side or after it, leaves `Token In` and the - `Amount` line as they read with no permit, unless it lets a spender other than - the router take `Token In` (see below): a permit of 5,000 DAI to another - address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with - `Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI - to the router. A permit on the zero address never stands as the `Amount` - figure, because Permit2 has no allowance of ETH. When a permit's amount stands - and the step that sets the input side spends a token other than the permit's, - or one the decoder does not know, that step's amount gets lines of its own, - unless it states no amount and the user pays it none of its input token. Each - take the total does not count gives three lines after the `Amount` line (after - `Token In` when there is none): one of a token other than `Token In`; one of - `Token In` after an amount that is not a quantity (a V4 open delta, - `ALREADY_PAID`, which keep their wording on the `Amount` line, or no stated - amount, as of a V4 exact-out step); and one whose amount the decoder does not - know. When there is no `Amount` line, each take the total counts gets them - too, so that every amount the decoder reads is shown: the amount the step that - sets the input side states, when it is a quantity and the user pays that step - (a V2 or V3 step whose `payerIsUser` is true, a V4 step the user pays some of - its input token, or a `WRAP_ETH`, whose ETH comes from the transaction's - value), and each later take of `Token In` added to it. A step that sets the - input side and is paid from the router takes nothing from the user's wallet - and gets none. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins - of 600 USDT and 400 DAI paid by the user, give lines for both amounts. When - the step that sets the input side states no amount, the user pays it some of - its input token, and there is no `Amount` line, that step gets them too - whenever another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` of - its token gives one, on the lines below or, into the router, on its `Paid to` - line, so that figure cannot read as all that leaves. When it states no amount - and the user pays it none of its input token, as when the router pays it, that - step takes none of that token from the user's wallet and never gets them, with - a permit or without. They come in step order, except that the step that sets - the input side comes first when it has them, followed, when there is no - `Amount` line, by the other takes the total counts: `Moved from the sender`, - its amount, worded as the `Amount` line words one, or + rest taken to another address, reads `5000.0000 USDT`. A later step that pays + an ERC-20 token from the router takes none of that token from the user's + wallet. A V2 exact-in whose `amountIn` is `ALREADY_PAID` takes nothing either. + 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 the decoder does not know + the amount of such a `SETTLE`, as for `CONTRACT_BALANCE`, that `SETTLE` counts + as a later step's take. When an earlier step left ETH in the router, that ETH + may pay part of a `SETTLE` of ETH paid from the router. That `SETTLE` then + gets lines of its own, with `Up to` before its amount. A later take of + `Token In` that only the user's wallet pays is added to the total while both + are quantities: a V3 exact-in of 600 USDT and a V2 exact-in of 400 USDT, both + paid by the user, read `1000.0000 USDT`. When the decoder does not know a + take's amount, the total is not known and there is no `Amount` line. A + `PERMIT2_PERMIT` names `Token In` only when its amount, `Unlimited` included, + stands as the `Amount` figure in place of the total, which it does only in the + plain shape of Uniswap's own routes: it comes before the step that sets the + input side, it is the only `PERMIT2_PERMIT` in the transaction and there is no + `PERMIT2_PERMIT_BATCH`, its spender is the router, the router may not let it + fail, no step before it takes its token, a `PERMIT2_TRANSFER_FROM` of it + included, or a token the decoder does not know, and the decoder reads every + step after it in full. Permit2 then lets the router take no more of the token + than that amount, so the permit covers every take of it. A step before the + permit takes under whatever allowance the router already had, so it may take + more than the permit's amount, which would then read lower than what leaves + the user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the router, + then a permit of 600 USDT to the router and a V3 exact-in of 2,000 USDT paid + from the router, read `Amount 2000.0000 USDT`. A later permit or batch may + raise what Permit2 lets the router take, and so may a step the decoder does + not read in full: a command the decoder does not read (listed at the end of + this section), a step whose input it cannot read, in whole or in part, and a + step it fails on. Every other `PERMIT2_PERMIT`, before the step that sets the + input side or after it, leaves `Token In` and the `Amount` line as they read + with no permit, unless it lets a spender other than the router take `Token In` + (see below): a permit of 5,000 DAI to another address and then a V3 exact-in + of 600 USDT read `Swap USDT → USDC` with `Amount 600.0000 USDT`, and so do + that exact-in and then a permit of 5,000 DAI to the router. A permit on the + zero address never stands as the `Amount` figure, because Permit2 has no + allowance of ETH. When a permit's amount stands and the step that sets the + input side spends a token other than the permit's, or one the decoder does not + know, that step's amount gets lines of its own, unless it states no amount and + the user pays it none of its input token. Each take the total does not count + gives three lines after the `Amount` line (after `Token In` when there is + none): one of a token other than `Token In`; one of `Token In` after an amount + that is not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their + wording on the `Amount` line, or no stated amount, as of a V4 exact-out step); + one of `Token In` that ETH an earlier step left in the router may pay part of; + and one whose amount the decoder does not know. When there is no `Amount` + line, each take the total counts gets them too, so that every amount the + decoder reads is shown: the amount the step that sets the input side states, + when it is a quantity and the user pays that step (a V2 or V3 step whose + `payerIsUser` is true, a V4 step the user pays some of its input token, or a + `WRAP_ETH`, whose ETH comes from the transaction's value), and each later take + of `Token In` added to it. A step that sets the input side gets none when the + user pays it none of its input token. A `PERMIT2_PERMIT` the decoder cannot + read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give lines + for both amounts. When the step that sets the input side states no amount, the + user pays it some of its input token, and there is no `Amount` line, that step + gets them too whenever another take's lines give a figure, or a + `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into + the router, on its `Paid to` line, so that figure cannot read as all that + leaves. When it states no amount and the user pays it none of its input token, + that step never gets them, with a permit or without. They come in step order, + except that the step that sets the input side comes first when it has them, + followed, when there is no `Amount` line, by the other takes the total counts: + `Moved from the sender`, its amount, worded as the `Amount` line words one, or `Unknown (could not be decoded)` when the decoder does not know it; `Token moved`, giving the token's address as the `Token In` line does; and `Moved to`, which reads `The swap step that spends it`. A token the decoder diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 5f02dda..68b8d1e 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -1240,9 +1240,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) { let inputEstablished = false; // Whether the user pays the step that set the input side: a V2 or V3 // swap step with `payerIsUser` true, a V4 step the user pays some of - // its input currency (see decodeV4Swap()), or a WRAP_ETH, whose ETH - // comes from the transaction's value. A step paid from the router - // takes nothing from the sender. + // its input currency (see paidByUser in the loop), or a WRAP_ETH, + // whose ETH comes from the transaction's value. A step that pays an + // ERC-20 token from the router takes none of that token from the + // sender. let inputPaidByUser = false; // The last PERMIT2_PERMIT this decoder read before a step set the // input side, or null (see permitStands after the loop). @@ -1338,7 +1339,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 +1818,41 @@ 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 of a known + // amount when the router holds no other ETH. + const paidByUser = v4 + ? v4.paidByUser.concat( + routerHeldEth + ? [] + : ethFromRouter.filter((s) => + present(s.amount), + ), + ) + : []; if (v4) { // Each SETTLE paid from the router spends the amount // decodeV4Swap() says it pays. @@ -1845,10 +1883,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 +1940,24 @@ 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); + } + // A SETTLE of ETH from the router that `paidByUser` leaves + // out is a take of its own, even on the step that sets + // the input side. Either this decoder does not know its + // amount, or ETH an earlier step left in the router may + // pay part of that amount (see userPaid). + for (const p of ethFromRouter) { + if (!present(p.amount)) { + addUserPaid(p.currency, null); + } else if (routerHeldEth) { + userPaid.push({ + token: p.currency, + amount: p.amount, + atMost: true, + }); } } if (!v4 || v4.settleUnread) { @@ -2013,10 +2065,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 @@ -2060,14 +2112,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) { let total = null; // The takes from the sender that `total` adds up, each a quantity, as // { token, amount }. When there is no Amount line, each gets lines of - // its own (see below). The amount of a step that set the input side - // and is paid from the router counts in `total` but is not among them. + // its own (see below). The amount of the step that set the input side + // counts in `total` whoever pays that step. That amount is among them + // only when the user pays the step (see inputPaidByUser). const inTotal = []; // Whether the step that set the input side counts as a take from the // sender, which may get lines of its own. The amount it stated counts // whoever pays it. Stating none, it counts only when the user pays it - // some of its input token: paid from the router, it takes nothing - // from the sender. + // some of its input token. const hasFirstTake = inputEstablished && (present(inputAmount) || inputPaidByUser); // The take of the step that set the input side, when the total @@ -2093,7 +2145,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 +2249,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 +2264,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 +2324,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..d6e365b 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -3439,6 +3439,129 @@ 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"], + ]); + }); + + // The WRAP_ETH sets Token In to ETH, and the UNWRAP_WETH leaves + // ETH in the router. The SETTLE may spend that ETH before the + // transaction's value, so its 1 ETH never joins the Amount line. + test("after a WRAP_ETH and an UNWRAP_WETH into the router, keeps the Amount line and shows at most 1 ETH moved from the sender", () => { + const result = decodeSteps( + [0x0b, 0x0c, 0x10], + [ + encodeWrapEth(ROUTER_ADDR, ONE_ETH / 2n), + encodeWrapEth(ROUTER_ADDR, 0n), // UNWRAP_WETH, same encoding + v4EthFromRouter(ONE_ETH), + ], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "0.5000 ETH"], + ["Moved from the sender", "Up to 1.0000 ETH"], + ["Token moved", "ETH"], + ["Moved to", "The swap step that spends it"], + ]); + }); + + // A SETTLE of CONTRACT_BALANCE pays all the router holds, an + // amount the calldata does not state. + test("of CONTRACT_BALANCE, on the step that sets the input side, shows an unknown amount moved from the sender", () => { + const result = decodeSteps( + [0x10], + [ + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ALL_ID, + ]), + [ + encodeV4ExactInSingle( + ZERO_ADDR, + USDC_ADDR, + ONE_ETH, + 1n, + ), + settle(ZERO_ADDR, 2n ** 255n, false), // CONTRACT_BALANCE + encodeV4TakeAll(USDC_ADDR, 1n), + ], + ), + ], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Moved from the sender", UNDECODED], + ["Token moved", "ETH"], + ["Moved to", "The swap step that spends it"], + ]); + }); + }); }); // 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"); }); });