diff --git a/README.md b/README.md index 1fd1ff2..b17ac2b 100644 --- a/README.md +++ b/README.md @@ -1189,14 +1189,8 @@ number. They can read: `amountOut`, the exact amount it buys. - `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. The line shows the - amount of the step that sets the input side: the last `PERMIT2_PERMIT` step if - there is one, otherwise the first V2 or V3 exact-in, V2 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 step set it, even when it names - neither, as described below. A V2 exact-out step gives its `amountInMax`, and - a V4 swap step the `amountIn` of the swap action that spends its input - currency. + whole open delta", so the calldata states no quantity. Transaction Decoding + below gives the steps whose amounts the line shows. - `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's `Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero. The router reads that zero as "the pair already holds the input tokens": the @@ -2374,10 +2368,11 @@ view would leave a wallet one click from deletion. - Site origin (bold, scheme and port included) + "wants to send a transaction" - Decoded action (if calldata is recognized): action name, token details, - amounts, what each `PERMIT2_TRANSFER_FROM` moves and where to, steps, - deadline, and unless a swap pays only the From address, a `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) + amounts, what each swap step the user pays for spends of a token other + than `Token In`, what each `PERMIT2_TRANSFER_FROM` moves and where to, + steps, deadline, and unless a swap pays only the From address, a `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 known; for a contract creation, the same sentence as on WaitTx @@ -3085,37 +3080,40 @@ Currently supported: 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 + WETH from it reads as leaving the WETH there. A `SETTLE` of `OPEN_DELTA` + (zero), and a `SETTLE_ALL`, pay only what the step still owes of their token + when they run. The decoder takes that to be 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` or + `SETTLE_ALL` is the step's only `SETTLE` or `SETTLE_ALL` of that token and + comes after every swap action, no swap action buys that token, no `TAKE`, + `TAKE_ALL` or `TAKE_PORTION` in the step takes it (a `TAKE` of a stated amount + adds to what the step owes), and the swap actions that spend it state a + quantity together; otherwise it does not know the amount, and such a `SETTLE` + from the router 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)` @@ -3127,11 +3125,48 @@ 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`. After the `Amount` line (after `Token In` when - there is none), each `PERMIT2_TRANSFER_FROM` but one into the router, in step - order, gives three lines: `Moved from the sender`, the amount it moves, for - example `1000000.0000 USDT`; `Token moved`, giving the token's address as the - `Token In` line does; and `Moved to`, which reads + minimum reads `Min. paid out`. The `Amount` line gives the amount of the step + that sets the input side: the last `PERMIT2_PERMIT` step if there is one, + otherwise the first V2 or V3 exact-in, V2 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 step set it, even when it names neither (see + Display Consistency). A V2 exact-out step gives its `amountInMax`, and a V4 + swap step the `amountIn` of the swap action that spends its input currency. + Each later V2 or V3 exact-in or V2 exact-out step whose `payerIsUser` is true + also takes its amount, found in the same way, from the user's wallet. So does + every V4 step but the one that sets the input side, with a swap action or + without: it takes what its `SETTLE`s paid by the user (`payerIsUser` true) and + its `SETTLE_ALL`s pay, for each token on its own. 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. What a step takes of + `Token In` is added to the line's, unless a `PERMIT2_PERMIT` set the input + side, whose amount the line keeps. A permit whose spender is the router and + which the router does not let fail caps what the router may take of its token + after it; any other permit caps nothing the router takes. So 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 either amount is not a quantity (a V4 open delta, + `ALREADY_PAID`, or an amount the decoder does not know), the total is not + known and there is no `Amount` line. A step paid from the router spends what + the router holds and adds nothing, and so does a V2 exact-in whose `amountIn` + is `ALREADY_PAID`, which takes nothing from the user. What a step takes of a + token other than `Token In`, or of `Token In` when the decoder does not know + the amount or when a permit that caps nothing set the input side, gives three + lines after the `Amount` line (after `Token In` when there is none), in step + order: `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 step may also take + from the user's wallet what the decoder did not read: a V4 step, other than + the one that sets the input side, whose input or one of whose `SETTLE`s or + `SETTLE_ALL`s it cannot read, which the router still runs; a V2 or V3 exact-in + or V2 exact-out step or a `PERMIT2_TRANSFER_FROM` whose input it cannot read; + and every `PERMIT2_TRANSFER_FROM_BATCH`. Each such step gives one + `Moved from the sender` line, among those lines in step order, reading + `Unknown (could not be decoded)`, and there is then no `Amount` line, whose + figure might be too low. Then each `PERMIT2_TRANSFER_FROM` but one into the + router, in step order, gives three lines: `Moved from the sender`, the amount + it moves, for example `1000000.0000 USDT`; `Token moved`, giving the token's + address as the `Token In` line does; and `Moved to`, which reads `The V2 pair the next swap spends` for a hop and `The sender` for `address(1)` on every router but `UniversalRouterV1`, and otherwise gives the address. A move into the router needs none of them, because its `Paid to` line and diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index ff1173c..358c3df 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -335,7 +335,8 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)"; // commands this decoder does not read. That action names both of its // currencies, and those commands may name one, so saying the calldata did not // name it would be false. The Paid to line uses it too, when a step may pay an -// address this decoder did not read. +// address this decoder did not read, and so do the lines for what a step +// takes from the sender, when this decoder does not know it. const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; // The Paid to line for each token paid to the router, the swap's output or @@ -566,6 +567,7 @@ function decodeV2SwapExactIn(input) { // Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes. // ABI: (address recipient, uint256 amountOut, uint256 amountInMax, // address[] path, bool payerIsUser) +// With `payerIsUser` false the router pays the input from its own balance. function decodeV2SwapExactOut(input) { try { const d = coder.decode( @@ -578,6 +580,7 @@ function decodeV2SwapExactOut(input) { amountInMax: d[2], tokenIn: d[3][0], tokenOut: d[3][d[3].length - 1], + payerIsUser: d[4], }; } catch { return null; @@ -901,10 +904,15 @@ 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. `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). +// read, and `settleUnread` when it has a SETTLE or SETTLE_ALL this decoder +// did not 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). `paidByUser` is what the user pays +// the step, once per currency, as that currency and the amount: the total of +// every SETTLE paid by the user (`payerIsUser` true) and every SETTLE_ALL, +// which V4Router always has the user pay, of that currency, or null when +// this decoder cannot tell what one of them pays. function decodeV4Swap(input) { try { const d = coder.decode(["bytes", "bytes[]"], input); @@ -914,16 +922,18 @@ 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 = []; + // Every SETTLE and SETTLE_ALL, as its currency, its amount as + // written, whether the user pays it, and its index among the step's + // actions. A SETTLE_ALL pays what a SETTLE of OPEN_DELTA (zero) paid + // by the user pays, all the step still owes of its currency, so it + // is kept as one; its maxAmount only caps that. + const settles = []; // 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; + let settleUnread = false; // The step's swap actions that could be read, as decodeV4SwapAction() // reads them. const swaps = []; @@ -942,19 +952,22 @@ 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, - }); - } + settles.push({ + currency: s[0], + amount: s[1], + payerIsUser: s[2], + 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]); + settles.push({ + currency: s[0], + amount: 0n, + payerIsUser: true, + index: i, + }); } else if (actionId === V4_TAKE) { // TAKE: (address currency, address recipient, uint256 amount) const t = coder.decode( @@ -996,7 +1009,8 @@ function decodeV4Swap(input) { } 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. + // read, and a SETTLE or SETTLE_ALL may take from the user + // what it did not read: the router still runs both. actionUnread = true; if ( actionId === V4_TAKE || @@ -1005,25 +1019,29 @@ function decodeV4Swap(input) { ) { recipientUnread = true; } + if (actionId === V4_SETTLE || actionId === V4_SETTLE_ALL) { + settleUnread = true; + } } } - // 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 }; - } + // What a SETTLE or SETTLE_ALL pays. A SETTLE of any amount but + // OPEN_DELTA (zero) pays as written: a quantity, or, for + // CONTRACT_BALANCE, as much as the router holds. A SETTLE of + // OPEN_DELTA, and a SETTLE_ALL, pay whatever the step still owes of + // the currency when they run. 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); it 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 nothing in the step takes it (a TAKE of a stated + // amount adds to what the step owes); and the swap actions that spend + // it state a quantity together (see totalAmount()). Otherwise it is + // given null. + const pays = (s) => { + if (s.amount !== 0n) return s.amount; const stated = totalAmount( swaps .filter((w) => w.tokenIn === s.currency) @@ -1032,16 +1050,30 @@ function decodeV4Swap(input) { const paysWhatSwapsSpend = !actionUnread && swaps.every((w) => present(w.tokenIn) && present(w.tokenOut)) && - settledCurrencies.filter((c) => c === s.currency).length === - 1 && + settles.filter((c) => c.currency === s.currency).length === 1 && s.index > lastSwapAction && !swaps.some((w) => w.tokenOut === s.currency) && + !takes.some((t) => t.currency === s.currency) && typeof stated === "bigint"; - return { - currency: s.currency, - amount: paysWhatSwapsSpend ? stated : null, - }; - }); + return paysWhatSwapsSpend ? stated : null; + }; + const settledFromRouter = settles + .filter((s) => !s.payerIsUser) + .map((s) => ({ currency: s.currency, amount: pays(s) })); + const paidByUser = []; + for (const s of settles.filter((c) => c.payerIsUser)) { + const amount = pays(s); + const paid = paidByUser.find((p) => p.currency === s.currency); + if (!paid) { + paidByUser.push({ currency: s.currency, amount }); + } else { + paid.amount = + typeof paid.amount === "bigint" && + typeof amount === "bigint" + ? paid.amount + amount + : null; + } + } // Rule 3. if (swapUndecoded) { @@ -1052,7 +1084,9 @@ function decodeV4Swap(input) { amountOutMin: null, takes, settledFromRouter, + paidByUser, recipientUnread, + settleUnread, swapUndecoded, hasSwapAction: true, }; @@ -1094,7 +1128,9 @@ function decodeV4Swap(input) { amountOutMin, takes, settledFromRouter, + paidByUser, recipientUnread, + settleUnread, swapUndecoded, hasSwapAction: swaps.length > 0, }; @@ -1125,6 +1161,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { let inputToken = null; let inputAmount = null; let inputEstablished = false; + let inputFromPermit = false; + let permitCapsInput = false; let outputToken = null; let minOutput = null; let outputEstablished = false; @@ -1135,8 +1173,8 @@ 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. 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. 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 @@ -1152,7 +1190,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // states neither half: the output side always, and the input side // when no earlier hop set it. A half the establishing hop did not // state stays null and the line says so, rather than being filled in - // from a different hop. + // from a different hop. Each later swap step the user pays for adds + // what it spends, in addUserPaid(), the one place the total is formed, + // and a step that may take what this decoder did not read leaves no + // total at all (see spentUnread()). const setInput = (token, amount) => { inputToken = present(token) ? token : null; inputAmount = present(amount) ? amount : null; @@ -1170,6 +1211,55 @@ function decode(data, toAddress, chainId, sources, fromAddress) { outputEstablished = true; }; + // What steps take from the sender that the Amount line does not give, + // as { token, amount }, in step order (see addUserPaid()). A step that + // may take from the sender what this decoder did not read is here + // with a null token (see spentUnread()). + const spentFromSender = []; + // A swap step the user pays for (`payerIsUser` true), after the input + // side is set, takes `amount` of `token` from the sender. What it + // takes of the input side's token is added to the input side's + // amount, so the Amount line is what the steps spend of it together; + // when either is not a quantity the total is not known (null), and an + // amount not known also goes in `spentFromSender`, so the screen says + // so. What it takes of another token goes in `spentFromSender`. A V2 + // exact-in of ALREADY_PAID takes nothing. When a permit set the input + // side, the line keeps the permit's amount: a permit that caps what + // the router takes of its token (`permitCapsInput`) covers the take, + // and after any other permit the take goes in `spentFromSender`. + const addUserPaid = (token, amount) => { + if (amount === ALREADY_PAID) return; + const sameToken = + present(inputToken) && + token.toLowerCase() === inputToken.toLowerCase(); + if (sameToken && inputFromPermit) { + if (!permitCapsInput) { + spentFromSender.push({ token, amount }); + } + return; + } + if (!sameToken) { + spentFromSender.push({ token, amount }); + return; + } + inputAmount = + typeof inputAmount === "bigint" && typeof amount === "bigint" + ? inputAmount + amount + : null; + if (!present(amount)) { + spentFromSender.push({ token, amount }); + } + }; + // A step may take from the sender what this decoder did not read: a + // V4 step, other than the one that sets the input side, 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; or a + // PERMIT2_TRANSFER_FROM_BATCH. One line says so, and there is no + // Amount line, whose figure may then be too low. + const spentUnread = () => { + spentFromSender.push({ token: null, amount: null }); + }; + // Who the steps pay, in whichever step and of whatever token. // MSG_SENDER is the sender. The router itself, as ADDRESS_THIS or by // its own address, holds what it is paid until a later step pays it @@ -1309,11 +1399,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // A permit states both halves itself, so it may replace an // input side an earlier hop established without breaking // the invariant. - if (p) setInput(p.token, p.amount); + if (p) { + setInput(p.token, p.amount); + inputFromPermit = true; + // Permit2 lets the spender take no more of the token + // than the permit's amount. Only a permit to this + // router that the router does not let fail caps what + // the later steps take (see addUserPaid()). + permitCapsInput = + !allowedToFail && + p.spender.toLowerCase() === toAddress.toLowerCase(); + } } else if (command === "PERMIT2_TRANSFER_FROM") { const p = decodePermit2TransferFrom(inputs[i]); if (!p) { payeeUnread = true; + spentUnread(); } else if ( commandTypeMask === 0x1f && [MSG_SENDER, ADDRESS_THIS].includes( @@ -1375,7 +1476,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } else if (command === "V3_SWAP_EXACT_IN") { const s = decodeV3SwapExactIn(inputs[i]); if (s) { - setInputOnce(s.tokenIn, s.amountIn); + if (!inputEstablished) { + setInputOnce(s.tokenIn, s.amountIn); + } else if (s.payerIsUser) { + addUserPaid(s.tokenIn, s.amountIn); + } // Always update output: in multi-step swaps (V3 → V4), // the last swap step determines the final output token // and minimum received amount. @@ -1388,11 +1493,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } else { payeeUnread = true; + spentUnread(); } } else if (command === "V2_SWAP_EXACT_IN") { const s = decodeV2SwapExactIn(inputs[i]); if (s) { - setInputOnce(s.tokenIn, s.amountIn); + if (!inputEstablished) { + setInputOnce(s.tokenIn, s.amountIn); + } else if (s.payerIsUser) { + addUserPaid(s.tokenIn, s.amountIn); + } setOutput(s.tokenOut, s.amountOutMin); if (!s.payerIsUser) { spendFromRouter(s.tokenIn, s.amountIn); @@ -1402,20 +1512,31 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } else { payeeUnread = true; + spentUnread(); } } else if (command === "V2_SWAP_EXACT_OUT") { // Buys exactly amountOut and spends at most amountInMax. hasV2ExactOut = true; const s = decodeV2SwapExactOut(inputs[i]); if (s) { - setInputOnce(s.tokenIn, s.amountInMax); + if (!inputEstablished) { + setInputOnce(s.tokenIn, s.amountInMax); + } else if (s.payerIsUser) { + addUserPaid(s.tokenIn, s.amountInMax); + } setOutput(s.tokenOut, s.amountOut); if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); } } else { payeeUnread = true; + spentUnread(); } + } else if (command === "PERMIT2_TRANSFER_FROM_BATCH") { + // It moves the sender's tokens to any addresses, and this + // decoder does not read it. + payeeUnread = true; + spentUnread(); } else if (command === "SWEEP") { const s = decodeSweep(inputs[i]); if (s) { @@ -1517,6 +1638,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } else if (command === "V4_SWAP") { const v4 = decodeV4Swap(inputs[i]); + const inputSetBefore = inputEstablished; if (v4) { // Each SETTLE paid from the router spends the amount // decodeV4Swap() says it pays. @@ -1562,6 +1684,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) { setInputOnce(v4.tokenIn, v4.amountIn); setOutput(v4.tokenOut, v4.amountOutMin); } + // Every V4 step but the one that sets the input side, + // with a swap action or without, takes from the sender + // what the user pays it (an amount an unread action may + // change is null). One whose input, or one of whose + // SETTLEs or SETTLE_ALLs, this decoder could not read may + // take anything: the router still runs it. + const setInputHere = inputEstablished && !inputSetBefore; + if (!setInputHere) { + if (v4) { + for (const p of v4.paidByUser) { + addUserPaid(p.currency, p.amount); + } + } + if (!v4 || v4.settleUnread) { + spentUnread(); + } + } } else if (command === "UNWRAP_WETH") { hasUnwrapWeth = true; const u = decodeUnwrapWeth(inputs[i]); @@ -1585,9 +1724,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } else { // Any other step may pay an address this decoder does not - // read: V3_SWAP_EXACT_OUT, PERMIT2_TRANSFER_FROM_BATCH and - // the NFT commands among them, and a type the release - // names no command for. + // read: V3_SWAP_EXACT_OUT and the NFT commands among them, + // and a type the release names no command for. payeeUnread = true; } } catch { @@ -1672,37 +1810,49 @@ function decode(data, toAddress, chainId, sources, fromAddress) { }); } - if (present(inputAmount)) { - // Three amounts need no scale to describe and are named rather - // than formatted: V4's open delta and 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. - let amount; - if (inputAmount === OPEN_DELTA) { - amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; - } else if (inputAmount === ALREADY_PAID) { - amount = { + // The text of an amount the swap steps spend, for the Amount line and + // the lines for other tokens below it. Three amounts need no scale to + // describe and are named rather than formatted: V4's open delta and + // 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) => { + if (amount === OPEN_DELTA) { + return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; + } + if (amount === ALREADY_PAID) { + return { raw: ALREADY_PAID_AMOUNT, display: ALREADY_PAID_AMOUNT, }; - } else if (inputAmount >= MAX_UINT160) { - amount = { raw: "Unlimited", display: "Unlimited" }; - } else if (hasV2ExactOut) { + } + if (amount >= MAX_UINT160) { + return { raw: "Unlimited", display: "Unlimited" }; + } + if (hasV2ExactOut) { // A V2 exact-out swap spends at most this figure, whichever - // step set the line (its amountInMax, the WRAP_ETH of a swap - // paid in ETH, a permit), so it is said to be a maximum, in - // `raw` too: the wait, success and error screens show `raw` as - // the transaction's amount. - const most = amountText(inputAmount, inInfo); - amount = { + // steps gave it (an amountInMax, the WRAP_ETH of a swap paid + // in ETH, a permit, or several steps' total), so it is said to + // be a maximum, in `raw` too: the wait, success and error + // screens show `raw` as the transaction's amount. + const most = amountText(amount, info); + return { raw: "Up to " + most.raw, display: "Up to " + most.display, }; - } else { - amount = amountText(inputAmount, inInfo); } + return amountText(amount, info); + }; + + // A step that may take from the sender what this decoder did not read, + // a null token in `spentFromSender`, leaves no Amount figure (see + // spentUnread()). + if ( + present(inputAmount) && + spentFromSender.every((s) => present(s.token)) + ) { + const amount = spentText(inputAmount, inInfo); details.push({ label: "Amount", value: amount.display, @@ -1710,6 +1860,26 @@ function decode(data, toAddress, chainId, sources, fromAddress) { }); } + // What steps take from the sender beyond the Amount line, in the shape + // of the lines below for a PERMIT2_TRANSFER_FROM. An amount this + // decoder does not know is said so, and a step it did not read gets + // that one line alone. + for (const s of spentFromSender) { + details.push({ + label: "Moved from the sender", + value: present(s.amount) + ? spentText(s.amount, tokenInfo(s.token, sources)).display + : UNDECODED_CURRENCY, + }); + if (present(s.token)) { + details.push(tokenMovedLine("Token moved", s.token, sources)); + details.push({ + label: "Moved to", + value: "The swap step that spends it", + }); + } + } + // What each PERMIT2_TRANSFER_FROM moves: its amount, its token and // where it goes, an address on a line of its own. for (const m of movedFromSender) { diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index de5f74a..e0cda8d 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -1289,6 +1289,543 @@ describe("uniswap decoder", () => { expect(detail(result, "Amount").value).not.toContain("500000000000"); }); + // https://git.eeqj.de/sneak/AutistMask/issues/625 — a route split across + // two swap steps the user pays for, with no PERMIT2_PERMIT, takes both + // steps' amounts from the wallet. On next at 671b4d3 the Amount line read + // only the first step's 600.0000 USDT. + describe("a swap split across steps the user pays for", () => { + const USDT_600 = 600000000n; + const USDT_400 = 400000000n; + const DAI_400 = 400000000000000000000n; + const OTHER_ADDR = "0x1111111111111111111111111111111111111111"; + const UNDECODED = "Unknown (could not be decoded)"; + + // The lines between Token In and Token Out, as [label, value]. + function linesBetweenTokens(result) { + const lines = result.details.map((d) => [d.label, d.value]); + const from = lines.findIndex(([label]) => label === "Token In"); + const to = lines.findIndex(([label]) => label === "Token Out"); + return lines.slice(from + 1, to); + } + + // execute() of these command bytes and inputs, decoded as sent by + // the user. + function decodeSteps(commands, inputs) { + const data = buildExecute( + solidityPacked( + commands.map(() => "uint8"), + commands, + ), + inputs, + 9999999999n, + ); + return uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); + } + + // The first step: a V3 exact-in of 600 USDT for USDC, paid by the + // user, which sets the input side. + const V3_600 = encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [ + USDT_ADDR, + USDC_ADDR, + ]); + // V4 SETTLE of `amount` of `currency`, and TAKE of `amount` of + // `currency` to `recipient`. + const settle = (currency, amount, payerIsUser) => + coder.encode( + ["address", "uint256", "bool"], + [currency, amount, payerIsUser], + ); + const take = (currency, recipient, amount) => + coder.encode( + ["address", "address", "uint256"], + [currency, recipient, amount], + ); + // `input` with the top byte of its word at `index` set. This decoder + // cannot read such a word as an address or a length; the router reads + // its low bytes and runs the step. + const dirtyWord = (input, index) => { + const at = 2 + index * 64; + return input.slice(0, at) + "ff" + input.slice(at + 2); + }; + + test("a split across a V3 and a V2 step the user pays for shows the total", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x00, 0x08]), + [ + encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + ], + 9999999999n, + ); + + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); + expect(result).not.toBeNull(); + + expect(detail(result, "Token In").address.toLowerCase()).toBe( + USDT_ADDR.toLowerCase(), + ); + expect(detail(result, "Amount").value).toBe("1000.0000 USDT"); + expect(detail(result, "Amount").rawValue).toBe("1000.0000"); + expect(detail(result, "Moved from the sender")).toBeUndefined(); + }); + + // In the order of Uniswap's own V4 routes: the swap, then a SETTLE of + // OPEN_DELTA by the user, which pays what the swap spends. + test("a split across a V3 step and a V4 step the user pays for shows the total", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x00, 0x10]), + [ + encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ID, + ]), + [ + encodeV4ExactInSingle( + USDT_ADDR, + USDC_ADDR, + USDT_400, + 390000000n, + ), + encodeV4Settle(USDT_ADDR), // paid by the user + encodeV4Take(USDC_ADDR), + ], + ), + ], + 9999999999n, + ); + + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); + expect(result).not.toBeNull(); + + expect(detail(result, "Amount").value).toBe("1000.0000 USDT"); + expect(detail(result, "Moved from the sender")).toBeUndefined(); + }); + + test("a second step the user pays for that spends another token shows that token's amount", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x00, 0x08]), + [ + encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + encodeV2SwapExactIn( + USER_ADDR, + 400000000000000000000n, // 400 DAI + 390000000n, + [DAI_ADDR, USDC_ADDR], + ), + ], + 9999999999n, + ); + + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); + expect(result).not.toBeNull(); + + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "600.0000 USDT"], + ["Moved from the sender", "400.0000 DAI"], + ["Token moved", "DAI (" + DAI_ADDR + ")"], + ["Moved to", "The swap step that spends it"], + ]); + }); + + // Every later V4 step takes from the sender what its SETTLEs paid by + // the user and its SETTLE_ALLs pay, each currency on its own, with a + // swap action or without. Each of these read 600.0000 USDT alone + // before. + const USDT_FOR_USDC_400 = encodeV4ExactInSingle( + USDT_ADDR, + USDC_ADDR, + USDT_400, + 390000000n, + ); + const DAI_FOR_USDC_400 = encodeV4ExactInSingle( + DAI_ADDR, + USDC_ADDR, + DAI_400, + 390000000n, + ); + const DAI_GROUP = [ + ["Moved from the sender", "400.0000 DAI"], + ["Token moved", "DAI (" + DAI_ADDR + ")"], + ["Moved to", "The swap step that spends it"], + ]; + test.each([ + { + kind: "a V4 step paying USDT and DAI by a SETTLE_ALL of each", + actions: [ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ALL_ID, + V4_SETTLE_ALL_ID, + V4_TAKE_ALL_ID, + ], + params: [ + USDT_FOR_USDC_400, + DAI_FOR_USDC_400, + encodeV4SettleAll(USDT_ADDR, USDT_400), + encodeV4SettleAll(DAI_ADDR, DAI_400), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + lines: [["Amount", "1000.0000 USDT"], ...DAI_GROUP], + }, + { + kind: "a V4 step with a SETTLE of USDT by the user, then a SETTLE of WETH from the router", + actions: [ + V4_SETTLE_ID, + V4_SETTLE_ID, + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_TAKE_ALL_ID, + ], + params: [ + settle(USDT_ADDR, USDT_400, true), + settle(WETH_ADDR, 2n ** 255n, false), // CONTRACT_BALANCE + USDT_FOR_USDC_400, + encodeV4ExactInSingle(WETH_ADDR, USDC_ADDR, 0n, 1n), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + lines: [["Amount", "1000.0000 USDT"]], + }, + { + kind: "a V4 step with no swap action: a SETTLE of 400 USDT by the user and a TAKE of it to another address", + actions: [V4_SETTLE_ID, V4_TAKE_ID], + params: [ + settle(USDT_ADDR, USDT_400, true), + take(USDT_ADDR, OTHER_ADDR, USDT_400), + ], + lines: [["Amount", "1000.0000 USDT"]], + }, + // The SETTLE's amount, not the swap's, is what the user pays. + { + kind: "a V4 step swapping 400 USDT with a SETTLE of 5,000 USDT by the user, the rest taken to another address", + actions: [ + V4_SETTLE_ID, + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_TAKE_ID, + V4_TAKE_ALL_ID, + ], + params: [ + settle(USDT_ADDR, 5000000000n, true), + USDT_FOR_USDC_400, + take(USDT_ADDR, OTHER_ADDR, 0n), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + lines: [["Amount", "5600.0000 USDT"]], + }, + // A SETTLE_ALL pays all the step owes, here what the TAKE takes as + // well as what the swap spends, so its amount is not known. + { + kind: "a V4 step with no swap action: a TAKE of 400 USDT to another address, then a SETTLE_ALL of USDT", + actions: [V4_TAKE_ID, V4_SETTLE_ALL_ID], + params: [ + take(USDT_ADDR, OTHER_ADDR, USDT_400), + encodeV4SettleAll(USDT_ADDR, USDT_400), + ], + lines: [ + ["Moved from the sender", UNDECODED], + ["Token moved", "USDT (" + USDT_ADDR + ")"], + ["Moved to", "The swap step that spends it"], + ], + }, + { + kind: "a V4 step swapping 400 USDT, with a TAKE of 400 USDT to another address before its SETTLE_ALL of USDT", + actions: [ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_TAKE_ID, + V4_SETTLE_ALL_ID, + V4_TAKE_ALL_ID, + ], + params: [ + USDT_FOR_USDC_400, + take(USDT_ADDR, OTHER_ADDR, USDT_400), + encodeV4SettleAll(USDT_ADDR, 800000000n), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + lines: [ + ["Moved from the sender", UNDECODED], + ["Token moved", "USDT (" + USDT_ADDR + ")"], + ["Moved to", "The swap step that spends it"], + ], + }, + ])( + "a V3 step of 600 USDT, then $kind", + ({ actions, params, lines }) => { + const result = decodeSteps( + [0x00, 0x10], + [V3_600, encodeV4Swap(new Uint8Array(actions), params)], + ); + expect(linesBetweenTokens(result)).toEqual(lines); + }, + ); + + // A step that may take from the sender what the decoder did not read + // leaves no Amount figure, which may be too low, and one line says so. + const UNREAD_SWAP = V4_SWAP_ACTION_SHAPES[0].newer; + test.each([ + { + kind: "a V4 step whose SETTLE by the user cannot be read", + actions: [ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ALL_ID, + ], + params: [ + USDT_FOR_USDC_400, + dirtyWord(settle(USDT_ADDR, USDT_400, true), 0), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + }, + { + kind: "a V4 step whose SETTLE_ALL cannot be read", + actions: [ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ALL_ID, + V4_TAKE_ALL_ID, + ], + params: [ + USDT_FOR_USDC_400, + dirtyWord(encodeV4SettleAll(USDT_ADDR, USDT_400), 0), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + }, + { + kind: "a V4 step whose swap action and SETTLE_ALL cannot be read", + actions: [ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ALL_ID, + V4_TAKE_ALL_ID, + ], + params: [ + UNREAD_SWAP, + dirtyWord(encodeV4SettleAll(WETH_ADDR, ONE_WETH), 0), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + }, + ])("a V3 step of 600 USDT, then $kind", ({ actions, params }) => { + const result = decodeSteps( + [0x00, 0x10], + [V3_600, encodeV4Swap(new Uint8Array(actions), params)], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Moved from the sender", UNDECODED], + ]); + }); + + test.each([ + { + kind: "a V3 step of 600 USDT, then a V4 step whose input cannot be read", + commands: [0x00, 0x10], + inputs: [ + V3_600, + dirtyWord( + encodeV4Swap(new Uint8Array([V4_SETTLE_ID]), [ + settle(USDT_ADDR, USDT_400, true), + ]), + 2, + ), + ], + }, + { + kind: "a V4 step with no swap action whose SETTLE_ALL cannot be read, then a V3 step of 600 USDT", + commands: [0x10, 0x00], + inputs: [ + encodeV4Swap( + new Uint8Array([V4_TAKE_ID, V4_SETTLE_ALL_ID]), + [ + take(USDT_ADDR, OTHER_ADDR, USDT_400), + dirtyWord( + encodeV4SettleAll(USDT_ADDR, USDT_400), + 0, + ), + ], + ), + V3_600, + ], + }, + { + kind: "a V3 step of 600 USDT, then a V2 step the user pays for whose input cannot be read", + commands: [0x00, 0x08], + inputs: [ + V3_600, + dirtyWord( + encodeV2SwapExactIn(OTHER_ADDR, USDT_400, 1n, [ + USDT_ADDR, + USDC_ADDR, + ]), + 0, + ), + ], + }, + { + kind: "a V3 step of 600 USDT, then a PERMIT2_TRANSFER_FROM whose input cannot be read", + commands: [0x00, 0x02], + inputs: [ + V3_600, + dirtyWord( + coder.encode( + ["address", "address", "uint160"], + [USDT_ADDR, OTHER_ADDR, USDT_400], + ), + 1, + ), + ], + }, + { + kind: "a V3 step of 600 USDT, then a PERMIT2_TRANSFER_FROM_BATCH", + commands: [0x00, 0x0d], + inputs: [ + V3_600, + coder.encode( + ["tuple(address,address,uint160,address)[]"], + [[[USER_ADDR, OTHER_ADDR, USDT_400, USDT_ADDR]]], + ), + ], + }, + ])("$kind", ({ commands, inputs }) => { + const result = decodeSteps(commands, inputs); + expect(linesBetweenTokens(result)).toEqual([ + ["Moved from the sender", UNDECODED], + ]); + }); + + // A PERMIT2_PERMIT that sets the input side keeps its own amount: the + // steps it lets the router spend do not add to it. + test("a split after a PERMIT2_PERMIT shows the permit's amount", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x00, 0x08]), + [ + encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR), // 5000 USDT + encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + ], + 9999999999n, + ); + + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); + expect(result).not.toBeNull(); + + expect(detail(result, "Amount").value).toBe("5000.0000 USDT"); + expect(detail(result, "Moved from the sender")).toBeUndefined(); + }); + + // A permit to another spender, or one the router lets fail (0x80 on + // the command byte), caps nothing the router takes, so what each + // later step takes of its token gets its own lines. + test.each([ + { + kind: "to another spender", + command: 0x0a, + spender: OTHER_ADDR, + }, + { + kind: "that the router lets fail", + command: 0x8a, + spender: ROUTER_ADDR, + }, + ])( + "a split after a PERMIT2_PERMIT $kind shows what each step takes", + ({ command, spender }) => { + const result = decodeSteps( + [command, 0x00, 0x08], + [ + encodePermit2(USDT_ADDR, 1000000n, spender), // 1 USDT + V3_600, + encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + ], + ); + // The V3 step names its token as its path spells it, in + // lower case. + const moved = ["Moved to", "The swap step that spends it"]; + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "1.0000 USDT"], + ["Moved from the sender", "600.0000 USDT"], + ["Token moved", "USDT (" + USDT_ADDR.toLowerCase() + ")"], + moved, + ["Moved from the sender", "400.0000 USDT"], + ["Token moved", "USDT (" + USDT_ADDR + ")"], + moved, + ]); + }, + ); + + // A V2 exact-out step makes every amount the swap steps spend a maximum, + // the total included. + test("a split with a V2 exact-out step the user pays for shows the total as a maximum", () => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x00, 0x09]), + [ + encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + encodeV2SwapExactOut(USER_ADDR, 390000000n, USDT_400, [ + USDT_ADDR, + USDC_ADDR, + ]), + ], + 9999999999n, + ); + + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); + expect(result).not.toBeNull(); + + expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT"); + }); + }); + // https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in // the shape the issue measured: a V4 step that states a minimum of zero // and names no output currency. diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js index 364f348..00ea24c 100644 --- a/tests/uniswapRecipient.test.js +++ b/tests/uniswapRecipient.test.js @@ -160,8 +160,8 @@ function transfer(command, token, recipient, amount) { ]; } -// PERMIT2_PERMIT (0x0a) of `amount` of `token`, for the router to spend. -function permit2Permit(token, amount) { +// PERMIT2_PERMIT (0x0a) of `amount` of `token`, for `spender` to spend. +function permit2Permit(token, amount, spender) { return [ 0x0a, coder.encode( @@ -169,7 +169,7 @@ function permit2Permit(token, amount) { "tuple(tuple(address,uint160,uint48,uint48),address,uint256)", "bytes", ], - [[[token, amount, 0, 0], ROUTER, 0n], "0x"], + [[[token, amount, 0, 0], spender, 0n], "0x"], ), ]; } @@ -671,6 +671,7 @@ describe("any token left in the router is not received", () => { v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC), ), left: tokenLine(WETH, "WETH"), + spent: "2000.0000 USDT", }, { kind: "a WRAP_ETH of 1 ETH into the router, then a swap of 0.5 WETH from the router", @@ -697,6 +698,7 @@ describe("any token left in the router is not received", () => { v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]), ), left: tokenLine(WETH, "WETH"), + spent: "1.0000 WETH", }, { kind: "a WRAP_ETH of 1 ETH into the router, then a V3 swap of 1 WETH that the user pays for", @@ -705,6 +707,7 @@ describe("any token left in the router is not received", () => { v3SwapExactIn(MSG_SENDER, ONE_WETH, USDC_1990, WETH, USDC), ), left: tokenLine(WETH, "WETH"), + spent: "1.0000 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", @@ -717,6 +720,7 @@ describe("any token left in the router is not received", () => { ), ), left: tokenLine(WETH, "WETH"), + spent: "1.0000 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", @@ -792,9 +796,13 @@ describe("any token left in the router is not received", () => { ), ), left: tokenLine(WETH, "WETH"), + // The step has two SETTLEs of WETH, so what the user pays is not + // known. + spent: UNDECODED, }, // 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. + // the decoder cannot read, so the SETTLE from the router pays nothing, + // and what the user pays is not known. { 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( @@ -807,6 +815,7 @@ describe("any token left in the router is not received", () => { ), ), left: tokenLine(WETH, "WETH"), + unread: true, }, { 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", @@ -820,6 +829,7 @@ describe("any token left in the router is not received", () => { ), ), left: tokenLine(WETH, "WETH"), + unread: true, }, { 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", @@ -873,12 +883,20 @@ describe("any token left in the router is not received", () => { ), left: { label: "Token moved into the router", value: "ETH" }, }, - ])("$kind", ({ data, left }) => { + ])("$kind", ({ data, left, spent, unread }) => { const result = decode(data); expect(labels(result)).toEqual([ "Protocol", "Token In", - "Amount", + // A later step whose SETTLE or SETTLE_ALL the decoder cannot read + // may take anything from the sender: one line says so, in place + // of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625). + unread ? "Moved from the sender" : "Amount", + // A later swap step the user pays for spends `spent` of a token + // other than Token In. + ...(spent + ? ["Moved from the sender", "Token moved", "Moved to"] + : []), "Token Out", "Paid to", "Token moved into the router", @@ -886,6 +904,14 @@ describe("any token left in the router is not received", () => { "Steps", "Deadline", ]); + if (spent) { + expect(detail(result, "Moved from the sender").value).toBe(spent); + } + if (unread) { + expect(detail(result, "Moved from the sender").value).toBe( + UNDECODED, + ); + } 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"); @@ -2229,6 +2255,10 @@ describe("the allow-revert flag", () => { }); // Only the routers from UniversalRouterV2 on let a PERMIT2_PERMIT fail. + // A permit to the router caps what the router takes of its token after + // it, unless the router lets it fail: then what the swap takes gets its + // own lines (https://git.eeqj.de/sneak/AutistMask/issues/625). The swap + // otherwise reads as without the flag. test.each([ { router: "UniversalRouterV1", address: ROUTER_V1, mark: "" }, { @@ -2240,17 +2270,19 @@ describe("the allow-revert flag", () => { router: "UniversalRouterV2", address: ROUTER, mark: " (allowed to fail)", + taken: "2000.0000 USDT", }, { router: "UniversalRouterV2_1_2", address: ROUTER_V2_1_2, mark: " (allowed to fail)", + taken: "2000.0000 USDT", }, ])( - "on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark', and the swap reads as without the flag", - ({ address, mark }) => { + "on $router, a PERMIT2_PERMIT with the flag is listed as 'Permit2 Permit$mark'", + ({ address, mark, taken }) => { const steps = [ - permit2Permit(USDT, USDT_2000), + permit2Permit(USDT, USDT_2000, address), swapToWeth(MSG_SENDER), ]; const plain = decode(execute(...steps), USER, address); @@ -2262,9 +2294,20 @@ describe("the allow-revert flag", () => { expect(detail(withFlag, "Steps").value).toBe( "Permit2 Permit" + mark + " → V3 Swap (Exact In)", ); - const withoutSteps = (r) => - r.details.filter((d) => d.label !== "Steps"); - expect(withoutSteps(withFlag)).toEqual(withoutSteps(plain)); + const takenLines = [ + "Moved from the sender", + "Token moved", + "Moved to", + ]; + const rest = (r) => + r.details.filter( + (d) => d.label !== "Steps" && !takenLines.includes(d.label), + ); + expect(rest(withFlag)).toEqual(rest(plain)); + expect(detail(plain, "Moved from the sender")).toBeUndefined(); + expect(detail(withFlag, "Moved from the sender")?.value).toBe( + taken, + ); expect(detail(withFlag, "Min. received").value).toBe("1.0000 WETH"); }, );