diff --git a/README.md b/README.md index 418bba9..649be8d 100644 --- a/README.md +++ b/README.md @@ -1190,14 +1190,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 @@ -2379,10 +2373,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 spends that the `Amount` line does not give, + 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 @@ -3090,37 +3085,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)` @@ -3132,8 +3130,67 @@ 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 + minimum reads `Min. paid out`. The `Amount` line gives the total the steps + take of `Token In`, from the step that sets the input side on: 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). + That step counts its own amount, whoever pays it: a V2 exact-out step 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 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. A later step + paid from the router spends what the router holds and takes nothing, and so + does a V2 exact-in whose `amountIn` is `ALREADY_PAID`. A later take of + `Token In` is added to the total while both are quantities: a V3 exact-in of + 600 USDT and a V2 exact-in of 400 USDT, both paid by the user, read + `1000.0000 USDT`. When the decoder does not know a take's amount, the total is + not known and there is no `Amount` line. `Token In` is the token of the last + `PERMIT2_PERMIT` the decoder reads, when there is one. Its amount, `Unlimited` + included, stands as the `Amount` figure in place of the total only in the + plain shape of Uniswap's own routes: it is the only `PERMIT2_PERMIT`, its + spender is the router, the router may not let it fail, no step before it takes + its token or a token the decoder does not know, and the decoder reads every + step after it in full. Permit2 then lets the router take no more of the token + than that amount, so the permit covers every take of it. A step the decoder + does not read in full may raise what Permit2 lets the router take, as a + `PERMIT2_PERMIT_BATCH` can: a command the decoder does not read (listed at the + end of this section), a step whose input it cannot read, in whole or in part, + and a step it fails on. In any other case the line gives the total, as with no + permit. When the step that sets the input side spends a token other than the + permit's, or one the decoder does not know, its amount gets lines of its own + and the total has no figure to start from: unless the permit's amount stands + there is no `Amount` line, and each take of the permit's token gets lines of + its own. Each take the total does not count gives three lines after the + `Amount` line (after `Token In` when there is none): one of a token other than + `Token In`; one of `Token In` after an amount that is not a quantity (a V4 + open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line, or + no stated amount, as of a V4 exact-out step); and one whose amount the decoder + does not know. When the step that sets the input side states no amount and + there is no `Amount` line, that step gets them too whenever another take's + lines give a figure, so that figure cannot read as all that leaves. They come + in step order, the step that sets the input side first when it has them: + `Moved from the sender`, its amount, worded as the `Amount` line words one, or + `Unknown (could not be decoded)` when the decoder does not know it; + `Token moved`, giving the token's address as the `Token In` line does; and + `Moved to`, which reads `The swap step that spends it`. A token the decoder + does not know gives the `Moved from the sender` line alone. 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; every `PERMIT2_TRANSFER_FROM_BATCH`; every `V3_SWAP_EXACT_OUT`, + which the decoder does not read, wherever it stands; every `EXECUTE_SUB_PLAN`, + wherever it stands; and every step it fails on. Each such step gives one + `Moved from the sender` line among those, reading + `Unknown (could not be decoded)`. That line, or any line for a token the + decoder does not know, leaves no `Amount` line, whose figure might be too low, + and such a step before a `PERMIT2_PERMIT` keeps the permit's amount from + standing. 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 diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index ff1173c..1770a6a 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 @@ -356,6 +357,11 @@ function present(value) { return value !== null && value !== undefined; } +// Whether `a` and `b` are both known and the same address. +function sameAddress(a, b) { + return present(a) && present(b) && a.toLowerCase() === b.toLowerCase(); +} + // Uniswap V4 spells "use the whole open delta" as an amount of zero: // v4-periphery `src/libraries/ActionConstants.sol` declares // `uint128 internal constant OPEN_DELTA = 0` ("used to signal that an action @@ -566,6 +572,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 +585,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 +909,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 +927,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 +957,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 +1014,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 +1024,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 +1055,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 +1089,9 @@ function decodeV4Swap(input) { amountOutMin: null, takes, settledFromRouter, + paidByUser, recipientUnread, + settleUnread, swapUndecoded, hasSwapAction: true, }; @@ -1094,7 +1133,9 @@ function decodeV4Swap(input) { amountOutMin, takes, settledFromRouter, + paidByUser, recipientUnread, + settleUnread, swapUndecoded, hasSwapAction: swaps.length > 0, }; @@ -1125,6 +1166,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) { let inputToken = null; let inputAmount = null; let inputEstablished = false; + // The last PERMIT2_PERMIT this decoder read, or null (see + // permitStands after the loop). + let permit = null; + // The index of each step this decoder did not read in full: a + // command it does not read, a step whose input it cannot read, in + // whole or in part, and one it fails on (see permitStands). + const stepsUnread = []; let outputToken = null; let minOutput = null; let outputEstablished = false; @@ -1135,8 +1183,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 +1200,9 @@ 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. A PERMIT2_PERMIT sets no side here: after the + // loop, the last one read names Token In, and the Amount line is + // formed there, in one place (see permitStands). const setInput = (token, amount) => { inputToken = present(token) ? token : null; inputAmount = present(amount) ? amount : null; @@ -1170,6 +1220,29 @@ function decode(data, toAddress, chainId, sources, fromAddress) { outputEstablished = true; }; + // What each step but the one that sets the input side takes from the + // sender, as { token, amount }, in step order. After the loop each + // joins the Amount line or gets lines of its own (see permitStands). + const userPaid = []; + // A swap step the user pays for (`payerIsUser` true) takes `amount` + // of `token` from the sender. A V2 exact-in of ALREADY_PAID takes + // nothing. + const addUserPaid = (token, amount) => { + if (amount !== ALREADY_PAID) userPaid.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 + // exact-in or V2 exact-out step or PERMIT2_TRANSFER_FROM whose input + // it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; a V3_SWAP_EXACT_OUT; + // an EXECUTE_SUB_PLAN; and a step it fails on. It is recorded with a + // null token and amount: one line says so, there is no Amount line, + // whose figure may then be too low, and the amount of a permit after + // it does not stand (see permitStands). + const spentUnread = () => { + userPaid.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 @@ -1300,20 +1373,40 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // EXECUTE_SUB_PLAN runs a further list of commands held in // its input, which this decoder does not read. The step // says so, and after this loop neither side is named. - // Those commands may pay any address. + // Those commands may pay any address and take from the + // sender. hasSubPlan = true; commandNames[i] += " (its steps are not shown)"; payeeUnread = true; + stepsUnread.push(i); + spentUnread(); } else if (command === "PERMIT2_PERMIT") { const p = decodePermit2(inputs[i]); - // 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) { + // Whether a step before it took its token, or may + // have: the step that set the input side or one in + // `userPaid`, when its token is the permit's or not + // known. + const mayBeItsToken = (token) => + !present(token) || sameAddress(token, p.token); + const tookBefore = + (inputEstablished && mayBeItsToken(inputToken)) || + userPaid.some((t) => mayBeItsToken(t.token)); + permit = { + ...p, + step: i, + allowedToFail, + beforeEveryTake: !tookBefore, + }; + } else { + stepsUnread.push(i); + } } else if (command === "PERMIT2_TRANSFER_FROM") { const p = decodePermit2TransferFrom(inputs[i]); if (!p) { payeeUnread = true; + stepsUnread.push(i); + spentUnread(); } else if ( commandTypeMask === 0x1f && [MSG_SENDER, ADDRESS_THIS].includes( @@ -1351,6 +1444,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // owner may be any address. It sets no side, and the // step says so. commandNames[i] += " (could not be decoded)"; + stepsUnread.push(i); } else if (b.owner.toLowerCase() !== sender) { // The router reads the owner as written (see // decodeBalanceCheck()), so address(1) and address(2) @@ -1375,7 +1469,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 +1486,25 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } else { payeeUnread = true; + stepsUnread.push(i); + spentUnread(); } + } else if (command === "V3_SWAP_EXACT_OUT") { + // This decoder does not read it + // (https://git.eeqj.de/sneak/AutistMask/issues/601). It + // may pay any address and take from the sender, so + // wherever it stands it leaves no Amount figure. + payeeUnread = true; + stepsUnread.push(i); + 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 +1514,34 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } else { payeeUnread = true; + stepsUnread.push(i); + 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; + stepsUnread.push(i); + 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; + stepsUnread.push(i); + spentUnread(); } else if (command === "SWEEP") { const s = decodeSweep(inputs[i]); if (s) { @@ -1425,6 +1551,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } else { payeeUnread = true; + stepsUnread.push(i); } } else if ( command === "TRANSFER" || @@ -1461,6 +1588,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } else { payeeUnread = true; + stepsUnread.push(i); } } else if (command === "ACROSS_V4_DEPOSIT_V3") { const a = decodeAcrossV4DepositV3(inputs[i]); @@ -1484,6 +1612,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } else { payeeUnread = true; + stepsUnread.push(i); } } else if (command === "WRAP_ETH") { const w = decodeWrapEth(inputs[i]); @@ -1514,9 +1643,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } else { payeeUnread = true; + stepsUnread.push(i); } } 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. @@ -1532,6 +1663,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if (!v4 || v4.recipientUnread) { payeeUnread = true; } + if ( + !v4 || + v4.recipientUnread || + v4.settleUnread || + v4.swapUndecoded + ) { + stepsUnread.push(i); + } if (v4 && v4.swapUndecoded) { // A swap action of this step went unread, so the step // is marked. @@ -1562,6 +1701,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]); @@ -1582,26 +1738,109 @@ function decode(data, toAddress, chainId, sources, fromAddress) { } } else { payeeUnread = true; + stepsUnread.push(i); } } 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: PERMIT2_PERMIT_BATCH and the NFT commands among + // them, and a type the release names no command for. payeeUnread = true; + stepsUnread.push(i); } } catch { - // Skip commands we can't decode. One may pay any address. + // Skip commands we can't decode. One may pay any address and + // take from the sender. payeeUnread = true; + stepsUnread.push(i); + spentUnread(); } if (allowedToFail) { commandNames[i] += " (allowed to fail)"; } } + // The Amount line, and the lines for what the steps take beyond it. + // + // The total is what the steps take of Token In: the amount of the + // step that set the input side, with each later take of that token + // added to it while both are quantities. A take that cannot be added, + // being of another token or coming after an amount that is not a + // quantity (a V4 open delta, ALREADY_PAID, or none stated), gets + // lines of its own. One whose amount is not known also leaves the + // total unknown, with no figure. + // + // The last PERMIT2_PERMIT read names Token In. Its amount stands as + // the Amount figure in place of the total only in the plain shape of + // Uniswap's own routes: it is the only PERMIT2_PERMIT, its spender is + // the router, the router may not let it fail, no step before it took + // its token or one not known, and this decoder read every step after + // it in full (see stepsUnread). Permit2 then lets the router take no + // more of the token than that amount, so the permit covers every take + // of it. A later step this decoder did not read may raise what Permit2 + // lets the router take, as a PERMIT2_PERMIT_BATCH can. A step that + // may take from the sender what this decoder did not read leaves no + // figure, permit or not (see spentUnread()), and an EXECUTE_SUB_PLAN + // step leaves both sides unknown (below). + const stepCommands = Array.from( + commandsBytes, + (b) => commands[b & commandTypeMask], + ); + const permitStands = + permit !== null && + stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 && + sameAddress(permit.spender, toAddress) && + !permit.allowedToFail && + permit.beforeEveryTake && + !stepsUnread.some((step) => step > permit.step); + // What the steps take that the Amount line does not give, as + // { token, amount }. A null token is a step this decoder did not read + // (see spentUnread()), or a token it does not know. + const spentFromSender = []; + let total = null; + // The take of the step that set the input side, when the total + // starts from it and it stated no amount (see below), or null. + let firstTakeUnstated = null; + if (permit === null || sameAddress(inputToken, permit.token)) { + total = inputAmount; + if (inputEstablished && !present(inputAmount)) { + firstTakeUnstated = { token: inputToken, amount: null }; + } + } else if (inputEstablished) { + // The step that set the input side spent a token other than the + // permit's, or one not known, so the total starts with no figure. + spentFromSender.push({ token: inputToken, amount: inputAmount }); + } + if (permit !== null) inputToken = permit.token; + for (const t of userPaid) { + const ofTokenIn = sameAddress(t.token, inputToken); + if (ofTokenIn && permitStands) continue; + if ( + ofTokenIn && + typeof total === "bigint" && + typeof t.amount === "bigint" + ) { + total += t.amount; + continue; + } + spentFromSender.push(t); + if (ofTokenIn && !present(t.amount)) total = null; + } + inputAmount = permitStands ? permit.amount : total; + // With no Amount figure, a later take's own figure could read as all + // that leaves, so the step that set the input side, when it stated + // no amount, then gets lines of its own too, first, saying its amount + // is not known. + if ( + !present(inputAmount) && + firstTakeUnstated !== null && + spentFromSender.some((s) => present(s.amount)) + ) { + spentFromSender.unshift(firstTakeUnstated); + } + // The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens // or pay the user, before or after any other step, and a permit among - // them would replace the input side. What the other steps state is + // them would name Token In. What the other steps state is // then not the whole swap, so both sides are set unknown, with no // figure, wherever the step stands. if (hasSubPlan) { @@ -1672,37 +1911,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 null token in `spentFromSender`, a step this decoder did not read + // or a token it does not know, leaves no Amount figure, which may + // then be too low. + if ( + present(inputAmount) && + spentFromSender.every((s) => present(s.token)) + ) { + const amount = spentText(inputAmount, inInfo); details.push({ label: "Amount", value: amount.display, @@ -1710,6 +1961,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..533d7eb 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -1289,6 +1289,886 @@ 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], + ]); + }); + + // The steps of an EXECUTE_SUB_PLAN may take from the sender too, so + // wherever it stands one line says so, and the figure of another + // take cannot read as all that leaves. + const SUB_PLAN = encodeSubPlan("0x00", [V3_600]); + const V2_DAI_400 = encodeV2SwapExactIn(USER_ADDR, DAI_400, 390000000n, [ + DAI_ADDR, + USDC_ADDR, + ]); + test.each([ + { + kind: "a V3 step of 600 USDT and a V2 step of 400 DAI, then an EXECUTE_SUB_PLAN", + commands: [0x00, 0x08, 0x21], + inputs: [V3_600, V2_DAI_400, SUB_PLAN], + lines: [ + ["Moved from the sender", "400.0000 DAI"], + ["Token moved", "DAI (" + DAI_ADDR + ")"], + ["Moved to", "The swap step that spends it"], + ["Moved from the sender", UNDECODED], + ], + }, + { + kind: "an EXECUTE_SUB_PLAN, then a V3 step of 600 USDT and a V2 step of 400 DAI", + commands: [0x21, 0x00, 0x08], + inputs: [SUB_PLAN, V3_600, V2_DAI_400], + lines: [ + ["Moved from the sender", UNDECODED], + ["Moved from the sender", "400.0000 DAI"], + ["Token moved", "DAI (" + DAI_ADDR + ")"], + ["Moved to", "The swap step that spends it"], + ], + }, + { + kind: "a V3 step of 600 USDT and a PERMIT2_TRANSFER_FROM of 400 DAI, then an EXECUTE_SUB_PLAN", + commands: [0x00, 0x02, 0x21], + inputs: [ + V3_600, + coder.encode( + ["address", "address", "uint160"], + [DAI_ADDR, OTHER_ADDR, DAI_400], + ), + SUB_PLAN, + ], + lines: [ + ["Moved from the sender", UNDECODED], + ["Moved from the sender", "400.0000 DAI"], + ["Token moved", "DAI (" + DAI_ADDR + ")"], + ["Moved to", OTHER_ADDR], + ], + }, + ])("$kind", ({ commands, inputs, lines }) => { + const result = decodeSteps(commands, inputs); + expect(linesBetweenTokens(result)).toEqual(lines); + }); + + // A PERMIT2_PERMIT in the plain shape of Uniswap's own routes keeps + // its own amount: Permit2 lets the router take no more of the token, + // so the steps after it 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(); + }); + + // Out of that shape the permit caps nothing the Amount line can rely + // on, so the line gives the total the steps take, as with no permit. + const ONE_USDT_PERMIT = (spender) => + encodePermit2(USDT_ADDR, 1000000n, spender); + test.each([ + { + kind: "to another spender", + commands: [0x0a], + permits: [ONE_USDT_PERMIT(OTHER_ADDR)], + }, + { + kind: "that the router lets fail", + commands: [0x8a], + permits: [ONE_USDT_PERMIT(ROUTER_ADDR)], + }, + { + kind: "followed by a second one", + commands: [0x0a, 0x0a], + permits: [ + ONE_USDT_PERMIT(ROUTER_ADDR), + ONE_USDT_PERMIT(ROUTER_ADDR), + ], + }, + ])( + "a split after a PERMIT2_PERMIT $kind shows the total the steps take", + ({ commands, permits }) => { + const result = decodeSteps( + [...commands, 0x00, 0x08], + [ + ...permits, + V3_600, + encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + ], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "1000.0000 USDT"], + ]); + }, + ); + + // A PERMIT2_PERMIT_BATCH, which this decoder does not read, may raise + // what the router may take of the permit's token again. + test("a split after a PERMIT2_PERMIT and a PERMIT2_PERMIT_BATCH shows the total the steps take", () => { + const permitBatch = coder.encode( + [ + "tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)", + "bytes", + ], + [ + [ + [[USDT_ADDR, 10000000000n, 0, 0]], // 10,000 USDT + ROUTER_ADDR, + 9999999999, + ], + "0x1234", + ], + ); + const result = decodeSteps( + [0x0a, 0x03, 0x00, 0x08], + [ + encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR), + permitBatch, + V3_600, + encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [ + USDT_ADDR, + USDC_ADDR, + ]), + ], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "10000.0000 USDT"], + ]); + }); + + // Any other step this decoder does not read may do the same, for + // example through the hook of the pool a V4_INITIALIZE_POOL sets up. + test.each([ + { + kind: "a V4_INITIALIZE_POOL", + command: 0x13, + input: coder.encode( + [POOL_KEY, "uint160"], + [ + [USDC_ADDR, USDT_ADDR, 3000, 60, OTHER_ADDR], + 79228162514264337593543950336n, + ], + ), + }, + { + kind: "a SWEEP whose input cannot be read", + command: 0x04, + input: dirtyWord( + coder.encode( + ["address", "address", "uint256"], + [USDC_ADDR, USER_ADDR, 0n], + ), + 1, + ), + }, + ])( + "a split after a PERMIT2_PERMIT, with $kind between its steps, shows the total the steps take", + ({ command, input }) => { + const result = decodeSteps( + [0x0a, 0x00, command, 0x08], + [ + encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR), + V3_600, + input, + encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [ + USDT_ADDR, + USDC_ADDR, + ]), + ], + ); + expect(linesBetweenTokens(result)).toEqual([ + ["Amount", "10000.0000 USDT"], + ]); + }, + ); + + // A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax` + // USDT for USDC. Its input is (recipient, amountOut, amountInMax, + // path, payerIsUser), laid out as encodeV3SwapExactIn() writes one; + // an exact-out path lists the token bought first. + const v3ExactOut = (amountInMax) => + encodeV3SwapExactIn(USER_ADDR, 390000000n, amountInMax, [ + USDC_ADDR, + USDT_ADDR, + ]); + // A split exact-out route: a V3_SWAP_EXACT_OUT of at most 6,000 USDT + // and a V2_SWAP_EXACT_OUT of at most 4,000 USDT, both paid by the + // user, then a SWEEP of USDC to the user. + const EXACT_OUT_SPLIT = [ + v3ExactOut(6000000000n), + encodeV2SwapExactOut(USER_ADDR, 390000000n, 4000000000n, [ + USDT_ADDR, + USDC_ADDR, + ]), + coder.encode( + ["address", "address", "uint256"], + [USDC_ADDR, USER_ADDR, 0n], + ), + ]; + // This decoder does not read a V3_SWAP_EXACT_OUT, so what one takes + // leaves no Amount figure wherever it stands, and a permit's amount + // does not stand. + test.each([ + { + kind: "a V3 exact-in of 600 USDT, then a V3_SWAP_EXACT_OUT", + commands: [0x00, 0x01], + inputs: [V3_600, v3ExactOut(USDT_400)], + }, + { + kind: "a PERMIT2_PERMIT of 10,000 USDT, then a V3_SWAP_EXACT_OUT, a V2_SWAP_EXACT_OUT and a SWEEP", + commands: [0x0a, 0x01, 0x09, 0x04], + inputs: [ + encodePermit2(USDT_ADDR, 10000000000n, ROUTER_ADDR), + ...EXACT_OUT_SPLIT, + ], + }, + { + kind: "a V3_SWAP_EXACT_OUT, a V2_SWAP_EXACT_OUT and a SWEEP", + commands: [0x01, 0x09, 0x04], + inputs: EXACT_OUT_SPLIT, + }, + { + kind: "a V3_SWAP_EXACT_OUT of at most 9,400 USDT, then a PERMIT2_PERMIT of 600 USDT and a V3 exact-in of 600 USDT", + commands: [0x01, 0x0a, 0x00], + inputs: [ + v3ExactOut(9400000000n), + encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR), + V3_600, + ], + }, + ])( + "$kind, all paid by the user, shows no Amount figure", + ({ commands, inputs }) => { + const result = decodeSteps(commands, inputs); + expect(linesBetweenTokens(result)).toEqual([ + ["Moved from the sender", UNDECODED], + ]); + }, + ); + + // A later step's amount that cannot be added to the Amount line, + // because the amount of the step that set the input side is not a + // quantity, gets lines of its own. When that step stated no amount, + // it gets lines of its own too, saying so, so the 400 USDT cannot + // read as all that leaves. + const V2_EXACT_OUT_400 = encodeV2SwapExactOut( + USER_ADDR, + 390000000n, + USDT_400, + [USDT_ADDR, USDC_ADDR], + ); + const UP_TO_400_USDT = [ + ["Moved from the sender", "Up to 400.0000 USDT"], + ["Token moved", "USDT (" + USDT_ADDR + ")"], + ["Moved to", "The swap step that spends it"], + ]; + const UNKNOWN_USDT = [ + ["Moved from the sender", UNDECODED], + ["Token moved", "USDT (" + USDT_ADDR + ")"], + ["Moved to", "The swap step that spends it"], + ]; + // A V4 exact-out step of at most 600 USDT for 1 WETH, in the shape + // of Uniswap's own routes: a SETTLE of OPEN_DELTA by the user pays + // what the swap spends. + const V4_EXACT_OUT_600 = encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_OUT_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ALL_ID, + ]), + [ + encodeV4ExactOutSingle( + USDT_ADDR, + WETH_ADDR, + ONE_WETH, + USDT_600, + ), + encodeV4Settle(USDT_ADDR), + encodeV4TakeAll(WETH_ADDR, 0n), + ], + ); + test.each([ + { + kind: "a V4 exact-out step with a SETTLE of OPEN_DELTA by the user", + commands: [0x10, 0x09], + first: V4_EXACT_OUT_600, + lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT], + }, + { + kind: "a V4 exact-in step of the open delta", + commands: [0x10, 0x09], + first: encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ALL_ID, + ]), + [ + encodeV4ExactInSingle(USDT_ADDR, USDC_ADDR, 0n, 1n), + encodeV4Settle(USDT_ADDR), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + ), + lines: [ + ["Amount", "All available (V4 open delta)"], + ...UP_TO_400_USDT, + ], + }, + { + kind: "a V2 exact-in step of ALREADY_PAID", + commands: [0x08, 0x09], + first: encodeV2SwapExactIn(USER_ADDR, 0n, 1n, [ + USDT_ADDR, + USDC_ADDR, + ]), + lines: [ + [ + "Amount", + "Whatever an earlier step sent to the pair (V2 already paid)", + ], + ...UP_TO_400_USDT, + ], + }, + ])( + "$kind, then a V2 exact-out of at most 400 USDT the user pays for, shows the 400 USDT", + ({ commands, first, lines }) => { + const result = decodeSteps(commands, [first, V2_EXACT_OUT_400]); + expect(linesBetweenTokens(result)).toEqual(lines); + }, + ); + + // The V4 exact-out route above the other way round, where the later + // take's unknown amount leaves no figure at all, and other ways the + // step that sets the input side states no amount. + test.each([ + { + kind: "a V2 exact-out of at most 400 USDT, then a V4 exact-out step of at most 600 USDT", + commands: [0x09, 0x10], + inputs: [V2_EXACT_OUT_400, V4_EXACT_OUT_600], + lines: UNKNOWN_USDT, + }, + { + kind: "a PERMIT2_PERMIT of 1 USDT to another spender, a V4 exact-out step of at most 600 USDT, then a V2 exact-out of at most 400 USDT", + commands: [0x0a, 0x10, 0x09], + inputs: [ + encodePermit2(USDT_ADDR, 1000000n, OTHER_ADDR), + V4_EXACT_OUT_600, + V2_EXACT_OUT_400, + ], + lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT], + }, + { + kind: "a V4 step whose swap action cannot be read, with a SETTLE of WETH by the user, then a V2 exact-out of at most 400 USDT", + commands: [0x10, 0x09], + inputs: [ + encodeV4Swap( + new Uint8Array([ + V4_SWAP_EXACT_IN_SINGLE_ID, + V4_SETTLE_ID, + V4_TAKE_ALL_ID, + ]), + [ + UNREAD_SWAP, + encodeV4Settle(WETH_ADDR), + encodeV4TakeAll(USDC_ADDR, 0n), + ], + ), + V2_EXACT_OUT_400, + ], + lines: [ + ["Moved from the sender", UNDECODED], + ["Token moved", "WETH (" + WETH_ADDR + ")"], + ["Moved to", "The swap step that spends it"], + ...UP_TO_400_USDT, + ], + }, + ])( + "$kind, all paid by the user, shows that an amount is not known", + ({ commands, inputs, lines }) => { + const result = decodeSteps(commands, inputs); + expect(linesBetweenTokens(result)).toEqual(lines); + }, + ); + + // 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..37c0291 100644 --- a/tests/uniswapRecipient.test.js +++ b/tests/uniswapRecipient.test.js @@ -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");