diff --git a/README.md b/README.md index c388a34..ef91e04 100644 --- a/README.md +++ b/README.md @@ -2361,7 +2361,7 @@ view would leave a wallet one click from deletion. transaction" - Decoded action (if calldata is recognized): action name, token details, amounts, steps, deadline, and unless a swap pays only the From address, a - `Paid to` line for each other address it pays, or saying that its output + `Paid to` line for each other address it pays, or saying that a token stays in the router or who it pays is unknown (see Transaction Decoding) - From: color dot + full address + etherscan link - Contract: color dot + full address + etherscan link, token symbol label if @@ -3054,49 +3054,72 @@ Currently supported: such as one whose owner word has a nonzero top byte (the router reads the owner from the word's last 20 bytes), likewise sets neither, and it is listed as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or - `TAKE_PORTION` pays the router the swap's output token, the output stays in - the router unless a later step pays all the router holds of it to anyone but - the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is WETH, a - `TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or a - `PAY_PORTION_FULL_PRECISION` of 1e18 of that token, or an - `ACROSS_V4_DEPOSIT_V3` of `CONTRACT_BALANCE` of that token, or of ETH when it - sets `useNative`. The router sends ETH as written, so a `TRANSFER` of - `CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a - token to the V2 pair that the very next step spends, as a V2 exact-in of that - token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a - hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and - the router enforces that swap's own minimum. The decoder checks that the - address paid is that pair, deriving it from the V2 factory and pair init code - hash the network's routers were deployed with and the first two tokens of that - step's path; a payment to any other address is named. (Sepolia's - `UniversalRouterV1_2_V2Support` was deployed with placeholders for both - values, so no V2 swap step can succeed on that router.) Every other payment - counts, in whichever step and of whatever token, a share of the output - included: an interface fee paid to another address by `PAY_PORTION`, - `TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`, - because the swap's minimum includes the fee. The minimum reads `Min. received` - only when some step pays the transaction's sender (the From address on - TxApproval, the sender the explorer reports on TransactionDetail) and no - `Paid to` line is shown. `Paid to` lines after `Token Out` name each other - address paid; one reads + `TAKE_PORTION` pays the router any token, the swap's output or another, a + `WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step + paid it into ETH, that token stays in the router unless a later step pays out + or spends all the router holds of it: a `SWEEP` of that token to anyone but + the router, an `UNWRAP_WETH` when it is WETH, a `WRAP_ETH` of + `CONTRACT_BALANCE` when it is ETH, a `TRANSFER` of `CONTRACT_BALANCE`, a + `PAY_PORTION` of 10,000 bips or a `PAY_PORTION_FULL_PRECISION` of 1e18 of that + token to anyone but the router, an `ACROSS_V4_DEPOSIT_V3` of + `CONTRACT_BALANCE` of that token, or of ETH when it sets `useNative`, or a V2 + or V3 exact-in swap step or V4 `SETTLE` that pays `CONTRACT_BALANCE` of that + token from the router (`payerIsUser` false). V2 and V3 exact-in swap steps and + V4 `SETTLE`s that pay WETH from the router also spend all the WETH a + `WRAP_ETH` paid it once together they spend the amount the `WRAP_ETH` states, + as a swap paid in ETH does, but only when that amount is not + `CONTRACT_BALANCE` and the router held no WETH from an earlier step when the + `WRAP_ETH` ran: after two `WRAP_ETH`s of 0.5 ETH into the router, a swap of 1 + WETH from it reads as leaving the WETH there. The router pays a `SETTLE` of + `OPEN_DELTA` (zero) only what its step still owes of that token when the + `SETTLE` runs, so one from the router counts as spending the amounts the + step's swap actions state they spend of that token only when every action in + the step could be read, every swap action names the token it spends and the + token it buys (a swap action with an empty path swaps nothing), the `SETTLE` + is the step's only `SETTLE` or `SETTLE_ALL` of that token and comes after + every swap action, no swap action buys that token, and the swap actions that + spend it state a quantity together; otherwise it counts as spending nothing. + So the rest of a V4 `SETTLE` of more than its swap spends, taken to the + router, the WETH of a `WRAP_ETH`, and a token a swap pays the router on the + way to the output, each stays in the router when later steps do not spend all + of it, where anyone can take it in a later transaction. The router sends ETH + as written, so a `TRANSFER` of `CONTRACT_BALANCE` of ETH always reverts and + pays out nothing. A step paying a token to the V2 pair that the very next step + spends, as a V2 exact-in of that token whose `amountIn` is `ALREADY_PAID` + (zero) or `CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes + pay the next V2 pair directly, and the router enforces that swap's own + minimum. The decoder checks that the address paid is that pair, deriving it + from the V2 factory and pair init code hash the network's routers were + deployed with and the first two tokens of that step's path; a payment to any + other address is named. (Sepolia's `UniversalRouterV1_2_V2Support` was + deployed with placeholders for both values, so no V2 swap step can succeed on + that router.) Every other payment counts, in whichever step and of whatever + token, a share of the output included: an interface fee paid to another + address by `PAY_PORTION`, `TRANSFER` or `TAKE_PORTION` is named, and the + minimum reads `Min. paid out`, because the swap's minimum includes the fee. + The minimum reads `Min. received` only when some step pays the transaction's + sender (the From address on TxApproval, the sender the explorer reports on + TransactionDetail) and no `Paid to` line is shown. `Paid to` lines after + `Token Out` name each other address paid; one reads `The bridge, which pays the deposit's recipient on another network` for a bridge deposit; one reads, for example, `The router, 2000.0000 USDT from the sender (later steps may not spend all of it)` for each move into the router, followed by a `Token moved into the router` line that gives the moved token's address as the `Token In` line does; one - reads `The router, where it stays (no later step pays it out)` when the output - stays in the router; 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`. A step goes unread when its - recipient does not decode as an address, or when it is a command the decoder - does not read: every command other than `PERMIT2_PERMIT`, - `PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, - `V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`, - `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, - `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, - `PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among - them), or a type its release names no command for. + reads `The router, where it stays (no later step pays it out)` for each token + that stays in the router, followed, for any token but the output, by a + `Token moved into the router` line giving that token in the same way (ETH by + 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`. A step goes unread when its recipient does not + decode as an address, or when it is a command the decoder does not read: every + command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`, + `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`, + `SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, + `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` + (`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and + the NFT commands among them), or a type its release names no command for. ### Non-Goals Forever diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index fee7209..7815cee 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -5,6 +5,7 @@ const { Interface, AbiCoder, getBytes, + getAddress, formatUnits, getCreate2Address, keccak256, @@ -337,8 +338,8 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)"; // address this decoder did not read. const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; -// The Paid to line when the swap's output is paid to the router and no later -// step pays it out of the router. +// The Paid to line for each token paid to the router, the swap's output or +// another, that no later step pays out of the router. const STAYS_IN_ROUTER = "The router, where it stays (no later step pays it out)"; @@ -413,12 +414,16 @@ const NO_MINIMUM = "None (no minimum guaranteed)"; // Permit2 amounts are uint160; the maximum is Permit2's "unbounded". const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff"); -// WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia. -// Either one counts on either network. -const WETH_ADDRESSES = [ - "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", - "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", -]; +// WETH, the token WRAP_ETH turns ETH into and UNWRAP_WETH turns into ETH, on +// each network, by chain id: `weth9` in the deployment parameters +// V2_FACTORIES cites, which every router in ROUTER_ADDRESSES was deployed +// with (their constructor arguments on the explorer). +const WETH = new Map([ + [NETWORKS.mainnet.chainId, "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"], + [NETWORKS.sepolia.chainId, "0xfff9976782d46cc05630d1f6ebab18b2324d6b14"], +]); +// Either one counts on either network as the WETH UNWRAP_WETH turns into ETH. +const WETH_ADDRESSES = [...WETH.values()]; // Two recipient values the router reads as placeholders, not as addresses: // MSG_SENDER pays the caller of execute(), which is the transaction's sender, @@ -522,6 +527,7 @@ function decodeBalanceCheck(input) { // ABI: (address recipient, uint256 amountIn, uint256 amountOutMin, // address[] path, bool payerIsUser) // A zero `amountIn` is read the way the router reads it, as ALREADY_PAID. +// With `payerIsUser` false the router pays the input from its own balance. function decodeV2SwapExactIn(input) { try { const d = coder.decode( @@ -535,6 +541,7 @@ function decodeV2SwapExactIn(input) { path: d[3], tokenIn: d[3][0], tokenOut: d[3][d[3].length - 1], + payerIsUser: d[4], }; } catch { return null; @@ -574,6 +581,7 @@ function decodeV3Path(pathHex) { // Decode V3_SWAP_EXACT_IN (command 0x00) input bytes. // ABI: (address recipient, uint256 amountIn, uint256 amountOutMin, // bytes path, bool payerIsUser) +// With `payerIsUser` false the router pays the input from its own balance. function decodeV3SwapExactIn(input) { try { const d = coder.decode( @@ -588,6 +596,7 @@ function decodeV3SwapExactIn(input) { amountOutMin: d[2], tokenIn: path.tokenIn, tokenOut: path.tokenOut, + payerIsUser: d[4], }; } catch { return null; @@ -877,7 +886,10 @@ function totalAmount(amounts) { // 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. // `recipientUnread` is set when the step has a payout this decoder did not -// read. +// read. `settledFromRouter` is every SETTLE that the router pays from its own +// balance (`payerIsUser` false), as its currency and the amount it pays: a +// quantity, CONTRACT_BALANCE (all the router holds), or null when this +// decoder cannot tell (see below). function decodeV4Swap(input) { try { const d = coder.decode(["bytes", "bytes[]"], input); @@ -887,6 +899,15 @@ function decodeV4Swap(input) { let settleToken = null; let takeToken = null; const takes = []; + // Every SETTLE paid from the router, as its currency, its amount as + // written and its index among the step's actions. + const routerSettles = []; + // The currency of every SETTLE and SETTLE_ALL. + const settledCurrencies = []; + // The index of the step's last swap action, read or not, or -1. + let lastSwapAction = -1; + // Set when an action of the step could not be read. + let actionUnread = false; let recipientUnread = false; // The step's swap actions that could be read, as decodeV4SwapAction() // reads them. @@ -906,10 +927,19 @@ function decodeV4Swap(input) { params[i], ); settleToken = s[0]; + settledCurrencies.push(s[0]); + if (!s[2]) { + routerSettles.push({ + currency: s[0], + amount: s[1], + index: i, + }); + } } else if (actionId === V4_SETTLE_ALL) { // SETTLE_ALL: (address currency, uint256 maxAmount) const s = coder.decode(["address", "uint256"], params[i]); settleToken = s[0]; + settledCurrencies.push(s[0]); } else if (actionId === V4_TAKE) { // TAKE: (address currency, address recipient, uint256 amount) const t = coder.decode( @@ -936,18 +966,23 @@ function decodeV4Swap(input) { actionId === V4_SWAP_EXACT_OUT_SINGLE || actionId === V4_SWAP_EXACT_OUT ) { + lastSwapAction = i; try { swaps.push(decodeV4SwapAction(actionId, params[i])); } catch { swapUndecoded = true; + actionUnread = true; } + } else { + // V4Router reverts on any action not named above, so + // none of them pays anyone. This decoder reads none. + actionUnread = true; } - // V4Router reverts on any action not named above, so none - // of them pays anyone. } catch { // Skip sub-actions we can't decode. A TAKE, TAKE_ALL or // TAKE_PORTION among them is a payout this decoder did not // read. + actionUnread = true; if ( actionId === V4_TAKE || actionId === V4_TAKE_ALL || @@ -958,6 +993,41 @@ function decodeV4Swap(input) { } } + // What each SETTLE paid from the router pays. A quantity or + // CONTRACT_BALANCE pays as written. A SETTLE of OPEN_DELTA (zero) + // pays whatever the step still owes of its currency when it runs. + // That is what the step's swap actions state they spend of it only + // in the plain shape of Uniswap's own routes, so only then is it + // given that amount: every action in the step was read; every swap + // action names the currency it spends and the one it buys (an + // exact-in action with an empty path swaps nothing); the SETTLE is + // the step's only SETTLE or SETTLE_ALL of its currency and comes + // after every swap action; no swap action buys that currency; and + // the swap actions that spend it state a quantity together (see + // totalAmount()). Otherwise it is given null. + const settledFromRouter = routerSettles.map((s) => { + if (s.amount !== 0n) { + return { currency: s.currency, amount: s.amount }; + } + const stated = totalAmount( + swaps + .filter((w) => w.tokenIn === s.currency) + .map((w) => w.amountIn), + ); + const paysWhatSwapsSpend = + !actionUnread && + swaps.every((w) => present(w.tokenIn) && present(w.tokenOut)) && + settledCurrencies.filter((c) => c === s.currency).length === + 1 && + s.index > lastSwapAction && + !swaps.some((w) => w.tokenOut === s.currency) && + typeof stated === "bigint"; + return { + currency: s.currency, + amount: paysWhatSwapsSpend ? stated : null, + }; + }); + // Rule 3. if (swapUndecoded) { return { @@ -966,6 +1036,7 @@ function decodeV4Swap(input) { amountIn: null, amountOutMin: null, takes, + settledFromRouter, recipientUnread, swapUndecoded, hasSwapAction: true, @@ -1007,6 +1078,7 @@ function decodeV4Swap(input) { amountIn, amountOutMin, takes, + settledFromRouter, recipientUnread, swapUndecoded, hasSwapAction: swaps.length > 0, @@ -1099,16 +1171,44 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // router, as { token, amount }. Later steps may leave part of it // there: nothing here checks that they spend all of it. const movedIntoRouter = []; - // The tokens, lowercased, that a swap step or a V4 TAKE or - // TAKE_PORTION paid to the router and no later step paid out of it in - // full: a SWEEP of that token, an UNWRAP_WETH of WETH, or a TRANSFER, - // PAY_PORTION, PAY_PORTION_FULL_PRECISION or ACROSS_V4_DEPOSIT_V3 of - // all the router holds of that token. - const heldByRouter = new Set(); + // Each token a step paid to the router that no later step paid out + // or spent in full, by its address, lowercased, ETH as the zero + // address. The router is paid what a swap step, V4 TAKE or + // TAKE_PORTION pays it, the WETH of a WRAP_ETH into it, and the ETH + // of an UNWRAP_WETH into it, in place of the WETH it held. All the + // router holds of a token is paid out or spent by a SWEEP of that + // token, an UNWRAP_WETH of WETH, a WRAP_ETH of CONTRACT_BALANCE of + // ETH, a TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION or + // ACROSS_V4_DEPOSIT_V3 of all of it, or a swap step or V4 SETTLE paid + // from the router (see spendFromRouter()). The value is the amount the + // router holds, in base units, when a WRAP_ETH stated it, otherwise + // null. + const heldByRouter = new Map(); const isRouter = (recipient) => { const r = recipient.toLowerCase(); return r === ADDRESS_THIS || r === toAddress.toLowerCase(); }; + // 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 + // of it once their amounts reach that one: a swap paid in ETH wraps + // what its swap steps then spend. An amount that is not a quantity, + // such as the null decodeV4Swap() gives a SETTLE whose payment it + // cannot tell, or one of a token whose amount is not known, pays out + // nothing this decoder can see. + const spendFromRouter = (token, amount) => { + const t = token.toLowerCase(); + const held = heldByRouter.get(t); + if (amount === CONTRACT_BALANCE) { + heldByRouter.delete(t); + } else if (typeof held === "bigint" && typeof amount === "bigint") { + if (amount >= held) { + heldByRouter.delete(t); + } else { + heldByRouter.set(t, held - amount); + } + } + }; // A step pays `recipient`, an address other than the sender's or the // router's, as written. const addPaidElsewhere = (recipient) => { @@ -1122,7 +1222,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { const addPayee = (recipient, output) => { const r = recipient.toLowerCase(); if (isRouter(recipient)) { - if (present(output)) heldByRouter.add(output.toLowerCase()); + if (present(output)) { + heldByRouter.set(output.toLowerCase(), null); + } } else if (r === MSG_SENDER || r === sender) { paidToSender = true; } else { @@ -1243,6 +1345,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // the last swap step determines the final output token // and minimum received amount. setOutput(s.tokenOut, s.amountOutMin); + if (!s.payerIsUser) { + spendFromRouter(s.tokenIn, s.amountIn); + } if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); } @@ -1254,6 +1359,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if (s) { setInputOnce(s.tokenIn, s.amountIn); setOutput(s.tokenOut, s.amountOutMin); + if (!s.payerIsUser) { + spendFromRouter(s.tokenIn, s.amountIn); + } if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); } @@ -1350,12 +1458,36 @@ function decode(data, toAddress, chainId, sources, fromAddress) { w.amount, ); addPayee(w.recipient); + // CONTRACT_BALANCE wraps all the router's ETH. + if (w.amount === CONTRACT_BALANCE) { + heldByRouter.delete( + "0x0000000000000000000000000000000000000000", + ); + } + // Into the router, the router holds the WETH. Its + // amount is known when the step states it and the + // router held no WETH before. + if (isRouter(w.recipient) && w.amount > 0n) { + const weth = WETH.get(chainId); + heldByRouter.set( + weth, + w.amount === CONTRACT_BALANCE || + heldByRouter.has(weth) + ? null + : w.amount, + ); + } } else { payeeUnread = true; } } else if (command === "V4_SWAP") { const v4 = decodeV4Swap(inputs[i]); if (v4) { + // Each SETTLE paid from the router spends the amount + // decodeV4Swap() says it pays. + for (const s of v4.settledFromRouter) { + spendFromRouter(s.currency, s.amount); + } for (const t of v4.takes) { if (!isHop(i, t.currency, t.recipient)) { addPayee(t.recipient, t.currency); @@ -1400,9 +1532,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) { const u = decodeUnwrapWeth(inputs[i]); if (u) { addPayee(u.recipient); - if (!isRouter(u.recipient)) { - WETH_ADDRESSES.forEach((w) => - heldByRouter.delete(w), + // It pays out all the router's WETH, as ETH. Into the + // router, the router then holds as ETH the WETH a + // step paid it. + const heldWeth = WETH_ADDRESSES.some((w) => + heldByRouter.has(w), + ); + WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w)); + if (isRouter(u.recipient) && heldWeth) { + heldByRouter.set( + "0x0000000000000000000000000000000000000000", + null, ); } } else { @@ -1568,11 +1708,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // A Paid to line names each other address paid, and says when a // step pays the bridge, when one moves the sender's tokens into the - // router, when the output stays in the router, and when a payout - // could not be read. The output is received by the sender only when - // some step pays the sender and there is no such line. When no step - // names anyone paid, one line says so. `paidTo` holds these lines and - // the token line after each move into the router. + // router, when a token stays in the router, and when a payout could + // not be read. The output is received by the sender only when some + // step pays the sender and there is no such line. When no step names + // anyone paid, one line says so. `paidTo` holds these lines and the + // token line after each line about a token in the router. const outputHeld = present(outputToken) && heldByRouter.has(outputToken.toLowerCase()); const paidTo = paidElsewhere.map((address) => ({ @@ -1583,9 +1723,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if (bridgeDeposit) { paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE }); } - // Each move into the router is followed by a line giving the moved - // token's address, in the shape of the Token In line, so the screens - // show it as they show that one. + // The line giving a token in the router, in the shape of the Token In + // line, so the screens show it as they show that one. ETH has no + // address. + const tokenMovedLine = (token) => { + const info = tokenInfo(token, sources); + if (!present(info.address)) { + return { + label: "Token moved into the router", + value: info.symbol, + }; + } + return { + label: "Token moved into the router", + value: info.symbol ? info.symbol + " (" + token + ")" : token, + address: token, + isToken: true, + }; + }; for (const m of movedIntoRouter) { const info = tokenInfo(m.token, sources); paidTo.push({ @@ -1595,18 +1750,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) { amountText(m.amount, info).display + " from the sender (later steps may not spend all of it)", }); - paidTo.push({ - label: "Token moved into the router", - value: info.symbol - ? info.symbol + " (" + m.token + ")" - : m.token, - address: m.token, - isToken: true, - }); + paidTo.push(tokenMovedLine(m.token)); } if (outputHeld) { paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER }); } + // Any other token the router still holds after the last step, such + // as one a swap step bought on the way to the output, what a V4 + // SETTLE took beyond what the step's swap spent, or the WETH of a + // WRAP_ETH, stays there too, and its line is followed by the token's. + for (const token of heldByRouter.keys()) { + if (present(outputToken) && token === outputToken.toLowerCase()) { + continue; + } + paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER }); + paidTo.push(tokenMovedLine(getAddress(token))); + } if (payeeUnread) { paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY }); } diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 9ad6802..de5f74a 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -739,7 +739,9 @@ describe("uniswap decoder", () => { }); // Paid in ETH, with an exact-out step, the last swap step buys WETH, so - // UNWRAP_WETH makes the output ETH. + // UNWRAP_WETH makes the output ETH. That step is paid by the user, so the + // USDC the exact-out step bought stays in the router, and the minimum is + // not received (https://git.eeqj.de/sneak/AutistMask/issues/606). test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => { const data = buildExecute( solidityPacked( @@ -767,7 +769,7 @@ describe("uniswap decoder", () => { const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(detail(result, "Token Out").value).toBe("ETH"); - expect(detail(result, "Min. received").value).toBe("0.4000 ETH"); + expect(detail(result, "Min. paid out").value).toBe("0.4000 ETH"); }); // Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too. diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js index 75a21ca..cce10df 100644 --- a/tests/uniswapRecipient.test.js +++ b/tests/uniswapRecipient.test.js @@ -4,9 +4,10 @@ // on the steps that pay it // (https://git.eeqj.de/sneak/AutistMask/issues/602), the command each router // release runs for a command type -// (https://git.eeqj.de/sneak/AutistMask/issues/592), and the address whose +// (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose // balance a BALANCE_CHECK_ERC20 checks -// (https://git.eeqj.de/sneak/AutistMask/issues/600). +// (https://git.eeqj.de/sneak/AutistMask/issues/600), and any token a swap +// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606). // // Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION, // PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and @@ -546,6 +547,533 @@ describe("an output left in the router is not received", () => { }); }); +// https://git.eeqj.de/sneak/AutistMask/issues/606: any token a step pays the +// router stays there unless a later step pays out or spends all of it, not +// only the swap's output. A V4 SETTLE can take more of the user's tokens than +// the step's swap spends, a WRAP_ETH wraps the user's ETH into the router, and +// a swap can leave there the token it bought on the way to the output. +describe("any token left in the router is not received", () => { + // The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it. + const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"; + const USDT_5000 = 5000000000n; + + // V4 SETTLE of `amount` of `currency` (zero: what the step's swap + // spends), paid by the user, or by the router when `payerIsUser` is false. + function settle(currency, amount, payerIsUser) { + return [ + 0x0b, + coder.encode( + ["address", "uint256", "bool"], + [currency, amount, payerIsUser], + ), + ]; + } + // V4 SWAP_EXACT_IN_SINGLE of `amountIn` of `currencyIn` (zero: the open + // delta) for at least `amountOutMin` of `currencyOut`. + function swapExactInSingle( + currencyIn, + currencyOut, + amountIn, + amountOutMin, + ) { + return [ + 0x06, + coder.encode( + [ + "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", + ], + [ + [ + [currencyIn, currencyOut, 500, 10, ZERO], + true, + amountIn, + amountOutMin, + "0x", + ], + ], + ), + ]; + } + // V4 SWAP_EXACT_IN of `amountIn` of `currencyIn` through one pool for + // each currency of `path` in turn, for at least `amountOutMin` of the + // last. An empty path swaps nothing. + function swapExactIn(currencyIn, path, amountIn, amountOutMin) { + return [ + 0x07, + coder.encode( + [ + "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", + ], + [ + [ + currencyIn, + path.map((c) => [c, 500, 10, ZERO, "0x"]), + amountIn, + amountOutMin, + ], + ], + ), + ]; + } + // V4 SETTLE_ALL of `currency`, paid by the user. + function settleAll(currency, maxAmount) { + return [ + 0x0c, + coder.encode(["address", "uint256"], [currency, maxAmount]), + ]; + } + // The line giving a token left in the router. + const tokenLine = (token, symbol) => ({ + label: "Token moved into the router", + value: symbol + " (" + token + ")", + address: token, + isToken: true, + }); + + test.each([ + { + kind: "a V4 SETTLE of 5,000 USDT for a swap of 2,000, the rest taken to the router by TAKE", + data: execute( + v4Swap( + settle(USDT, USDT_5000, true), + SWAP_USDT_FOR_USDC, + take(USDT, ADDRESS_THIS), + takeAll(USDC), + ), + ), + left: tokenLine(USDT, "USDT"), + }, + { + kind: "a V4 SETTLE of 5,000 USDT for a swap of 2,000, the rest taken to the router by TAKE_PORTION", + data: execute( + v4Swap( + settle(USDT, USDT_5000, true), + SWAP_USDT_FOR_USDC, + takeAll(USDC), + takePortion(USDT, ADDRESS_THIS, 10000n), + ), + ), + left: tokenLine(USDT, "USDT"), + }, + { + kind: "a WRAP_ETH into the router that nothing spends", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC), + ), + left: tokenLine(WETH, "WETH"), + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, then a swap of 0.5 WETH from the router", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v3SwapExactIn( + MSG_SENDER, + ONE_WETH / 2n, + USDC_1990, + WETH, + USDC, + false, + ), + ), + left: tokenLine(WETH, "WETH"), + }, + // Only a swap the router pays from its own balance spends the WETH + // a WRAP_ETH paid it, and its stated amount is followed only when the + // router held no WETH before the WRAP_ETH. + { + kind: "a WRAP_ETH of 1 ETH into the router, then a V2 swap of 1 WETH that the user pays for", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]), + ), + left: tokenLine(WETH, "WETH"), + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, then a V3 swap of 1 WETH that the user pays for", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v3SwapExactIn(MSG_SENDER, ONE_WETH, USDC_1990, WETH, USDC), + ), + left: tokenLine(WETH, "WETH"), + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE of 1 WETH by the user", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v4Swap( + swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990), + settle(WETH, ONE_WETH, true), + takeAll(USDC), + ), + ), + left: tokenLine(WETH, "WETH"), + }, + { + kind: "a swap paying WETH to the router, a WRAP_ETH of 1 ETH into the router, then a swap of 1 WETH from the router", + data: execute( + swapToWeth(ADDRESS_THIS), + wrapEth(ADDRESS_THIS, ONE_WETH), + v3SwapExactIn( + MSG_SENDER, + ONE_WETH, + USDC_1990, + WETH, + USDC, + false, + ), + ), + left: tokenLine(WETH, "WETH"), + }, + // A SETTLE of OPEN_DELTA from the router pays only what the step + // still owes of its currency when it runs. + { + kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 0.5 WETH with two SETTLEs of OPEN_DELTA from the router", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v4Swap( + swapExactInSingle(WETH, USDC, ONE_WETH / 2n, USDC_1990), + settle(WETH, 0n, false), + settle(WETH, 0n, false), + takeAll(USDC), + ), + ), + left: tokenLine(WETH, "WETH"), + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 0.75 WETH with a SETTLE of 0.25 WETH and one of OPEN_DELTA from the router", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v4Swap( + swapExactInSingle( + WETH, + USDC, + (ONE_WETH * 3n) / 4n, + USDC_1990, + ), + settle(WETH, ONE_WETH / 4n, false), + settle(WETH, 0n, false), + takeAll(USDC), + ), + ), + left: tokenLine(WETH, "WETH"), + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, then a V4 step that also buys WETH back, with a SETTLE of OPEN_DELTA from the router", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v4Swap( + swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990), + swapExactInSingle(USDC, WETH, 1000000n, 1n), + settle(WETH, 0n, false), + takeAll(USDC), + ), + ), + left: tokenLine(WETH, "WETH"), + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, then a V4 SETTLE of OPEN_DELTA from the router before the swap, which the user settles", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v4Swap( + settle(WETH, 0n, false), + swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990), + settle(WETH, 0n, true), + takeAll(USDC), + ), + ), + left: tokenLine(WETH, "WETH"), + }, + // The user pays for the swap by a SETTLE_ALL or SETTLE whose currency + // the decoder cannot read, so the SETTLE from the router pays nothing. + { + kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE_ALL that cannot be read and a SETTLE of OPEN_DELTA from the router", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v4Swap( + swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990), + withDirtyWord(settleAll(WETH, ONE_WETH), 0), + settle(WETH, 0n, false), + takeAll(USDC), + ), + ), + left: tokenLine(WETH, "WETH"), + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE by the user that cannot be read and a SETTLE of OPEN_DELTA from the router", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v4Swap( + swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990), + withDirtyWord(settle(WETH, ONE_WETH, true), 0), + settle(WETH, 0n, false), + takeAll(USDC), + ), + ), + left: tokenLine(WETH, "WETH"), + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, then a V4 step with an exact-in of 1 WETH less 1,000 wei and an empty path, a swap of 1,000 wei, and a SETTLE of OPEN_DELTA from the router", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v4Swap( + swapExactIn(WETH, [], ONE_WETH - 1000n, 0n), + swapExactInSingle(WETH, USDC, 1000n, USDC_1990), + settle(WETH, 0n, false), + takeAll(USDC), + ), + ), + left: tokenLine(WETH, "WETH"), + }, + { + kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of all of it settled from the router, with an action the decoder does not read", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v4Swap( + swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990), + settle(WETH, 0n, false), + takeAll(USDC), + [0x14, coder.encode(["address"], [WETH])], + ), + ), + left: tokenLine(WETH, "WETH"), + }, + { + kind: "a swap paying WETH to the router, a TRANSFER of 1 wei of it to the WETH/USDC pair, then a V2 swap of what the pair holds", + data: execute( + swapToWeth(ADDRESS_THIS), + transfer(0x05, WETH, PAIR, 1n), + v2Swap(0x08, MSG_SENDER, 0n, USDC_1990, [WETH, USDC], false), + ), + left: tokenLine(WETH, "WETH"), + }, + { + kind: "a swap paying WETH to the router, then a swap of 1 wei of it from the router", + data: execute( + swapToWeth(ADDRESS_THIS), + v2Swap(0x08, MSG_SENDER, 1n, USDC_1990, [WETH, USDC], false), + ), + left: tokenLine(WETH, "WETH"), + }, + { + kind: "a swap paying WETH to the router, an UNWRAP_WETH into the router, then another swap", + data: execute( + swapToWeth(ADDRESS_THIS), + unwrapWeth(ADDRESS_THIS), + v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC), + ), + left: { label: "Token moved into the router", value: "ETH" }, + }, + ])("$kind", ({ data, left }) => { + const result = decode(data); + expect(labels(result)).toEqual([ + "Protocol", + "Token In", + "Amount", + "Token Out", + "Paid to", + "Token moved into the router", + "Min. paid out", + "Steps", + "Deadline", + ]); + expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); + expect(detail(result, "Token moved into the router")).toEqual(left); + expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC"); + }); + + // Uniswap's own routes: a swap paid in ETH wraps into the router what its + // swap steps then spend from it, and a route through several pools pays + // each token it buys on the way to the router and spends all of it in the + // next step (CONTRACT_BALANCE), turning ETH into WETH or back where the + // pools need it. + test.each([ + { + kind: "a V4 SETTLE of what its swap spends", + data: execute( + v4Swap( + settle(USDT, USDT_2000, true), + SWAP_USDT_FOR_USDC, + takeAll(USDC), + ), + ), + min: "1990.0000 USDC", + }, + { + kind: "a V4 SETTLE of more than its swap spends, the rest taken to the user", + data: execute( + v4Swap( + settle(USDT, USDT_5000, true), + SWAP_USDT_FOR_USDC, + take(USDT, MSG_SENDER), + takeAll(USDC), + ), + ), + min: "1990.0000 USDC", + }, + { + kind: "a WRAP_ETH into the router, then a swap of all of it from the router", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v3SwapExactIn( + MSG_SENDER, + ONE_WETH, + USDC_1990, + WETH, + USDC, + false, + ), + ), + min: "1990.0000 USDC", + }, + { + kind: "a WRAP_ETH into the router, then two swaps from the router that together spend all of it", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v3SwapExactIn( + MSG_SENDER, + (ONE_WETH * 3n) / 5n, + 1n, + WETH, + USDC, + false, + ), + v2Swap( + 0x08, + MSG_SENDER, + (ONE_WETH * 2n) / 5n, + USDC_1990, + [WETH, USDC], + false, + ), + ), + min: "1990.0000 USDC", + }, + { + kind: "a WRAP_ETH into the router, then a V4 swap of all of it settled from the router", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v4Swap( + swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990), + settle(WETH, 0n, false), + takeAll(USDC), + ), + ), + min: "1990.0000 USDC", + }, + { + kind: "a WRAP_ETH into the router, then a V4 swap of all of it through two pools, settled from the router", + data: execute( + wrapEth(ADDRESS_THIS, ONE_WETH), + v4Swap( + swapExactIn(WETH, [USDT, USDC], ONE_WETH, USDC_1990), + settle(WETH, 0n, false), + takeAll(USDC), + ), + ), + min: "1990.0000 USDC", + }, + { + kind: "a WRAP_ETH of all the router's ETH, then a swap of all its WETH", + data: execute( + wrapEth(ADDRESS_THIS, CONTRACT_BALANCE), + v3SwapExactIn( + MSG_SENDER, + CONTRACT_BALANCE, + USDC_1990, + WETH, + USDC, + false, + ), + ), + min: "1990.0000 USDC", + }, + { + kind: "a swap paying WETH to the router, then a V2 swap of all of it", + data: execute( + swapToWeth(ADDRESS_THIS), + v2Swap( + 0x08, + MSG_SENDER, + CONTRACT_BALANCE, + USDC_1990, + [WETH, USDC], + false, + ), + ), + min: "1990.0000 USDC", + }, + { + kind: "a swap paying WETH to the router, then a V4 swap settling all of it from the router", + data: execute( + swapToWeth(ADDRESS_THIS), + v4Swap( + settle(WETH, CONTRACT_BALANCE, false), + swapExactInSingle(WETH, USDC, 0n, USDC_1990), + takeAll(USDC), + ), + ), + min: "1990.0000 USDC", + }, + { + kind: "a V4 swap taking USDC to the router, then a V3 swap of all of it", + data: execute( + v4Swap( + SWAP_USDT_FOR_USDC, + SETTLE_USDT, + take(USDC, ADDRESS_THIS), + ), + v3SwapExactIn( + MSG_SENDER, + CONTRACT_BALANCE, + ONE_WETH, + USDC, + WETH, + false, + ), + ), + min: "1.0000 WETH", + }, + { + kind: "a V4 swap taking ETH to the router, a WRAP_ETH of all of it, then a V3 swap of all the WETH", + data: execute( + v4Swap( + SWAP_USDT_FOR_ETH, + SETTLE_USDT, + take(ZERO, ADDRESS_THIS), + ), + wrapEth(ADDRESS_THIS, CONTRACT_BALANCE), + v3SwapExactIn( + MSG_SENDER, + CONTRACT_BALANCE, + USDC_1990, + WETH, + USDC, + false, + ), + ), + 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( + swapToWeth(ADDRESS_THIS), + unwrapWeth(ADDRESS_THIS), + v4Swap( + settle(ZERO, CONTRACT_BALANCE, false), + swapExactInSingle(ZERO, USDC, 0n, USDC_1990), + 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); + }); +}); + // https://git.eeqj.de/sneak/AutistMask/issues/599: TRANSFER, PAY_PORTION, // PAY_PORTION_FULL_PRECISION and V4 TAKE_PORTION pay a token to an address, // and PERMIT2_TRANSFER_FROM moves the user's own tokens to one.