diff --git a/README.md b/README.md index 445096e..ba1bb0a 100644 --- a/README.md +++ b/README.md @@ -1144,8 +1144,9 @@ fetched, without that cross-address check, and shows the row's figure. **Decoded amount lines on the transaction approval screen:** the `Amount` line of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a -decoded swap (see TxApproval below), do not always read as a number. They can -read: +decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless +only the user is paid, see Transaction Decoding), do not always read as a +number. They can read: - A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known: truncated to four decimals, with the floor and the zero cases described above. @@ -2288,7 +2289,9 @@ 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, steps, deadline (see Transaction Decoding) + amounts, steps, deadline, and unless a swap pays only the From address, a + `Paid to` line for each other address it pays, or saying that its output + 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 @@ -2923,7 +2926,37 @@ Currently supported: listed as `Execute Sub-Plan (its steps are not shown)`, and whatever the other steps state, the summary names no swap direction, `Token In` and `Token Out` read `Unknown (could not be decoded)`, and there is no `Amount` or - `Min. received` line. + `Min. received` line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and + V4 `TAKE` names the address it pays, and `TAKE_ALL` pays the transaction's + sender. The router reads `address(1)` as the transaction's sender, and + `address(2)` as the router itself, which holds what it is paid until a later + step pays it out; a payment to the router, as `address(2)` or by its own + address, is not counted as paid out. When a swap step or `TAKE` pays the + router the swap's output token, the output stays in the router unless a later + `SWEEP` of that token, or a later `UNWRAP_WETH` when it is WETH, pays it out. + A swap step or `TAKE` 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 as + the router does 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. Every other payment counts, in + whichever step and of whatever token. 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), no step pays any other + address, no step that may pay an address went unread, and the output does not + stay in the router. Otherwise it reads `Min. paid out`, a `Paid to` line after + `Token Out` names each other address paid, one reads + `The router, where it stays (no later step pays it out)` when the output stays + in the router, and one more reads `Unknown (could not be decoded)` when such a + step went unread, or `Unknown (not named in the calldata)` when no step pays + anyone. A step goes unread when its recipient does not decode as an address, + or when it is a command or V4 action the decoder does not read: every command + other than `PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, + `V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and + `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and + `EXECUTE_SUB_PLAN` among them), and V4 `TAKE_PORTION`. ### Non-Goals Forever diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js index 79f2294..8f26992 100644 --- a/src/popup/views/approval.js +++ b/src/popup/views/approval.js @@ -79,10 +79,10 @@ function tokenAmountText(rawAmount, decimals, symbol) { }; } -// Try to decode calldata sent to `toAddress` on the network with chain id -// `chainId`, using known ABIs. +// Try to decode calldata sent from `fromAddress` to `toAddress` on the network +// with chain id `chainId`, using known ABIs. // Returns { name, description, details } or null. -function decodeCalldata(data, toAddress, chainId) { +function decodeCalldata(data, toAddress, chainId, fromAddress) { if (!data || data === "0x" || data.length < 10) return null; // Where a token's scale is looked for, for every decoder below: the ERC-20 @@ -191,6 +191,7 @@ function decodeCalldata(data, toAddress, chainId) { toAddress, chainId, decimalsSources, + fromAddress, ); if (routerResult) return routerResult; @@ -277,11 +278,12 @@ function showTxApproval(details) { // If this is an ERC-20 call, try to extract the real recipient and amount. // Decoded for the network the transaction is for, which the Network line - // names. + // names, and the address the From line names. const decoded = decodeCalldata( approvedTx.data, toAddr || "", approvedTx.chainId, + details.approvedFrom, ); if (decoded && decoded.details) { // The asset the status summary is counted in: an ERC-20 call's Token diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index 004000d..c57a33b 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -316,11 +316,13 @@ async function loadFullTxDetails(txHash, chainId) { // Decoded against the address the transaction was sent to, as the // explorer reports it, not the history entry's `to`: for a token // transfer that is the transfer's recipient, not the token contract. - // On the active network, the one whose explorer was just asked. + // On the active network, the one whose explorer was just asked, and + // from the sender it reports. const decoded = decodeCalldata( inputData, txData.to?.hash || "", currentNetwork().chainId, + txData.from?.hash || "", ); if (decoded) { // Render decoded calldata matching approval view style diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 53b6242..4218843 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -1,7 +1,15 @@ // Decode Uniswap Universal Router execute() calldata into human-readable // swap details. Designed to be extended with other DEX decoders later. -const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers"); +const { + Interface, + AbiCoder, + getBytes, + formatUnits, + getCreate2Address, + keccak256, + solidityPacked, +} = require("ethers"); const { truncateAmountNeverZero } = require("./amountDisplay"); const { resolveTokenDecimals, @@ -65,6 +73,53 @@ function routerCommandTypeMask(address, chainId) { return router ? router[1] : null; } +// The Uniswap V2 factory and pair init code hash the Universal Routers on each +// network were deployed with. A V2 swap step swaps through the pair its path +// starts at, which the router finds as `UniswapV2Library.pairFor()` does: the +// CREATE2 address from the factory, with the hash of the path's first two +// tokens, sorted, as salt. +// - pairFor(): https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/modules/uniswap/v2/UniswapV2Library.sol +// - mainnet: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/script/deployParameters/DeployMainnet.s.sol +// - Sepolia: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/script/deployParameters/DeploySepolia.s.sol +// Every router in ROUTER_ADDRESSES was deployed with these (their constructor +// arguments on the explorer), except Sepolia's UniversalRouterV1_2_V2Support, +// whose init code hash is zero. On Sepolia neither hash is that of the +// factory's own pairs, so no contract sits at a pair any router there finds, +// and every V2 swap step they run reverts. +const V2_FACTORIES = new Map([ + [ + NETWORKS.mainnet.chainId, + { + v2Factory: "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f", + pairInitCodeHash: + "0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f", + }, + ], + [ + NETWORKS.sepolia.chainId, + { + v2Factory: "0xB7f907f7A9eBC822a80BD25E224be42Ce0A698A0", + pairInitCodeHash: + "0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f", + }, + ], +]); + +// The address of the V2 pair of `tokenA` and `tokenB` on the network with this +// chain id, found as the router finds it (see V2_FACTORIES). +function v2PairFor(chainId, tokenA, tokenB) { + const { v2Factory, pairInitCodeHash } = V2_FACTORIES.get(chainId); + const [token0, token1] = + tokenA.toLowerCase() < tokenB.toLowerCase() + ? [tokenA, tokenB] + : [tokenB, tokenA]; + return getCreate2Address( + v2Factory, + keccak256(solidityPacked(["address", "address"], [token0, token1])), + pairInitCodeHash, + ); +} + // Universal Router command types, each read from its command byte with the // router's mask (see ROUTER_ADDRESSES) const COMMAND_NAMES = { @@ -101,16 +156,23 @@ function formatAmount(raw, decimals) { // One wording for either side of the screen: a currency the calldata never // named. It reads as a refusal, the same stance unknownDecimalsAmount() takes -// on a scale — not as a token name, and not as a quantity. +// on a scale — not as a token name, and not as a quantity. The Paid to line +// uses it too, when no step names anyone paid. const UNNAMED_CURRENCY = "Unknown (not named in the calldata)"; // The same refusal when a V4 swap action could not be read (see // decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose // 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. +// name it would be false. The Paid to line uses it too, when a step may pay an +// address this decoder did not read. const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; +// The Paid to line when the swap's output is paid to the router and no later +// step pays it out of the router. +const STAYS_IN_ROUTER = + "The router, where it stays (no later step pays it out)"; + // Explicit presence, never truthiness. Every gate in this file that guards a // decoded value goes through here: an address is never falsy once set, but an // amount of 0n is, and a gate that cannot tell a genuine zero from an absent @@ -160,6 +222,11 @@ const OPEN_DELTA = Symbol("v4-open-delta"); // that nothing is swapped. const ALREADY_PAID = Symbol("v2-already-paid"); +// The router's whole-balance amount, universal-router +// `ActionConstants.CONTRACT_BALANCE`: a V2 exact-in given it as `amountIn` +// pays the pair all the router holds of the input token. +const CONTRACT_BALANCE = 2n ** 255n; + // The amount lines that state a fact instead of a quantity. Same register as // UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a // number — and deliberately not another phrasing of "not named": these say @@ -179,6 +246,21 @@ const WETH_ADDRESSES = [ "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", ]; +// Two recipient values the router reads as placeholders, not as addresses: +// MSG_SENDER pays the caller of execute(), which is the transaction's sender, +// and ADDRESS_THIS pays the router itself, which holds what it is paid until a +// later step pays it out. The swap commands, SWEEP, WRAP_ETH and UNWRAP_WETH +// map their recipient through the dispatcher's map(), and a V4 TAKE through +// V4Router's _mapRecipient(); TAKE_ALL always pays the caller. +// - constants: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Constants.sol +// - map(), UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Recipient.sol +// - map(), UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol +// - constants, V4: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/libraries/ActionConstants.sol +// - _mapRecipient(): https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/base/BaseActionsRouter.sol +// - TAKE, TAKE_ALL and TAKE_PORTION: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/V4Router.sol +const MSG_SENDER = "0x0000000000000000000000000000000000000001"; +const ADDRESS_THIS = "0x0000000000000000000000000000000000000002"; + // `decimals` is null when nothing knows this token's scale. It is not // defaulted to 18: the swap lines land on the same approval screen as the // ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as @@ -265,8 +347,10 @@ function decodeV2SwapExactIn(input) { input, ); return { + recipient: d[0], amountIn: d[1] === 0n ? ALREADY_PAID : d[1], amountOutMin: d[2], + path: d[3], tokenIn: d[3][0], tokenOut: d[3][d[3].length - 1], }; @@ -285,6 +369,7 @@ function decodeV2SwapExactOut(input) { input, ); return { + recipient: d[0], amountOut: d[1], amountInMax: d[2], tokenIn: d[3][0], @@ -316,6 +401,7 @@ function decodeV3SwapExactIn(input) { const path = decodeV3Path(d[3]); if (!path) return null; return { + recipient: d[0], amountIn: d[1], amountOutMin: d[2], tokenIn: path.tokenIn, @@ -326,12 +412,37 @@ function decodeV3SwapExactIn(input) { } } -// Decode WRAP_ETH (command 0x0b) input bytes. +// Decode SWEEP (command 0x04) input bytes. The router pays its whole balance +// of `token` to the recipient. +// ABI: (address token, address recipient, uint256 amountMin) +function decodeSweep(input) { + try { + const d = coder.decode(["address", "address", "uint256"], input); + return { token: d[0], recipient: d[1] }; + } catch { + return null; + } +} + +// Decode WRAP_ETH (command 0x0b) input bytes. The router pays the WETH to the +// recipient. // ABI: (address recipient, uint256 amount) function decodeWrapEth(input) { try { const d = coder.decode(["address", "uint256"], input); - return { amount: d[1] }; + return { recipient: d[0], amount: d[1] }; + } catch { + return null; + } +} + +// Decode UNWRAP_WETH (command 0x0c) input bytes. The router turns its whole +// WETH balance into ETH and pays it to the recipient. +// ABI: (address recipient, uint256 amountMin) +function decodeUnwrapWeth(input) { + try { + const d = coder.decode(["address", "uint256"], input); + return { recipient: d[0] }; } catch { return null; } @@ -346,6 +457,7 @@ const V4_SETTLE = 0x0b; const V4_SETTLE_ALL = 0x0c; const V4_TAKE = 0x0e; const V4_TAKE_ALL = 0x0f; +const V4_TAKE_PORTION = 0x10; // A V4 exact-in `amountIn`, read the way V4Router reads it: zero is // ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA @@ -500,6 +612,10 @@ function totalAmount(amounts) { // these rules leave unknown is set unknown, with no figure. A step with no // swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL // name. +// +// `takes` is every TAKE and TAKE_ALL, as the currency it takes and its +// recipient as written, MSG_SENDER for a TAKE_ALL. `recipientUnread` is set +// when the step has a payout this decoder did not read. function decodeV4Swap(input) { try { const d = coder.decode(["bytes", "bytes[]"], input); @@ -508,6 +624,8 @@ function decodeV4Swap(input) { let settleToken = null; let takeToken = null; + const takes = []; + let recipientUnread = false; // The step's swap actions that could be read, as decodeV4SwapAction() // reads them. const swaps = []; @@ -537,10 +655,12 @@ function decodeV4Swap(input) { params[i], ); takeToken = t[0]; + takes.push({ currency: t[0], recipient: t[1] }); } else if (actionId === V4_TAKE_ALL) { // TAKE_ALL: (address currency, uint256 minAmount) const t = coder.decode(["address", "uint256"], params[i]); takeToken = t[0]; + takes.push({ currency: t[0], recipient: MSG_SENDER }); } else if ( actionId === V4_SWAP_EXACT_IN_SINGLE || actionId === V4_SWAP_EXACT_IN || @@ -552,9 +672,19 @@ function decodeV4Swap(input) { } catch { swapUndecoded = true; } + } else if (actionId === V4_TAKE_PORTION) { + // It pays a share of a currency to an address this decoder + // does not read + // (https://git.eeqj.de/sneak/AutistMask/issues/599). + // V4Router reverts on any action not named here. + recipientUnread = true; } } catch { - // Skip sub-actions we can't decode + // Skip sub-actions we can't decode. A TAKE or TAKE_ALL among + // them is a payout this decoder did not read. + if (actionId === V4_TAKE || actionId === V4_TAKE_ALL) { + recipientUnread = true; + } } } @@ -565,6 +695,8 @@ function decodeV4Swap(input) { tokenOut: takeToken, amountIn: null, amountOutMin: null, + takes, + recipientUnread, swapUndecoded, hasSwapAction: true, }; @@ -604,6 +736,8 @@ function decodeV4Swap(input) { tokenOut, amountIn, amountOutMin, + takes, + recipientUnread, swapUndecoded, hasSwapAction: swaps.length > 0, }; @@ -612,14 +746,14 @@ function decodeV4Swap(input) { } } -// Try to decode a Universal Router execute() call sent to `toAddress` on the -// network with chain id `chainId`. +// Try to decode a Universal Router execute() call sent from `fromAddress`, the +// user, to `toAddress` on the network with chain id `chainId`. // Returns { name, description, details } matching the format used by // the approval UI, or null if the calldata is not a recognised execute() or // `toAddress` is not a Universal Router on that network: the same calldata // sent to any other contract is not a swap through the router, whatever that // contract does with it. -function decode(data, toAddress, chainId, sources) { +function decode(data, toAddress, chainId, sources, fromAddress) { const commandTypeMask = routerCommandTypeMask(toAddress, chainId); if (commandTypeMask === null) return null; try { @@ -678,6 +812,60 @@ function decode(data, toAddress, chainId, sources) { outputEstablished = true; }; + // 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 + // out. + const sender = (fromAddress || "").toLowerCase(); + let paidToSender = false; + // Each other address paid, once. + const paidElsewhere = []; + // Set when a step may pay an address this decoder did not read. + let payeeUnread = false; + // The tokens, lowercased, that a swap step or a V4 TAKE paid to the + // router and no later SWEEP of that token, or UNWRAP_WETH of WETH, + // paid out. + const heldByRouter = new Set(); + const isRouter = (recipient) => { + const r = recipient.toLowerCase(); + return r === ADDRESS_THIS || r === toAddress.toLowerCase(); + }; + // A step pays `recipient`. `output` is the token a swap step or a V4 + // TAKE pays; other steps pass none. + const addPayee = (recipient, output) => { + const r = recipient.toLowerCase(); + if (isRouter(recipient)) { + if (present(output)) heldByRouter.add(output.toLowerCase()); + } else if (r === MSG_SENDER || r === sender) { + paidToSender = true; + } else if (!paidElsewhere.some((a) => a.toLowerCase() === r)) { + paidElsewhere.push(recipient); + } + }; + // A swap step or V4 TAKE that pays `token` to `recipient` is a hop, + // not a payout, when the very next step is a V2 exact-in whose input + // is already paid, whose path starts at `token`, and whose pair (see + // v2PairFor()) is `recipient`: Uniswap's mixed routes pay the next V2 + // pair directly, and the router enforces that next swap's own minimum. + // Its input is already paid when its amountIn is ALREADY_PAID, or + // CONTRACT_BALANCE, which Uniswap's routes send and with which the + // router adds only what it holds. + const isHop = (i, token, recipient) => { + if (i + 1 >= commandsBytes.length) return false; + if ((commandsBytes[i + 1] & commandTypeMask) !== 0x08) return false; + const next = decodeV2SwapExactIn(inputs[i + 1]); + return ( + next !== null && + (next.amountIn === ALREADY_PAID || + next.amountIn === CONTRACT_BALANCE) && + present(token) && + next.path.length >= 2 && + next.tokenIn.toLowerCase() === token.toLowerCase() && + recipient.toLowerCase() === + v2PairFor(chainId, next.path[0], next.path[1]).toLowerCase() + ); + }; + for (let i = 0; i < commandsBytes.length; i++) { const cmdId = commandsBytes[i] & commandTypeMask; commandNames.push( @@ -690,30 +878,27 @@ function decode(data, toAddress, chainId, sources) { // 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. hasSubPlan = true; commandNames[i] += " (its steps are not shown)"; - } - - if (cmdId === 0x0a) { + payeeUnread = true; + } else if (cmdId === 0x0a) { 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 (cmdId === 0x0e) { + } else if (cmdId === 0x0e) { const b = decodeBalanceCheck(inputs[i]); // The router passes this check whenever the owner holds at // least minBalance, so a zero one guarantees nothing and // does not replace a minimum an earlier step stated. Any // other minBalance sets the output side as a swap does. + // It pays nobody. if (b && !(b.minBalance === 0n && present(minOutput))) { setOutput(b.token, b.minBalance); } - } - - if (cmdId === 0x00) { + } else if (cmdId === 0x00) { const s = decodeV3SwapExactIn(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountIn); @@ -721,39 +906,69 @@ function decode(data, toAddress, chainId, sources) { // the last swap step determines the final output token // and minimum received amount. setOutput(s.tokenOut, s.amountOutMin); + if (!isHop(i, s.tokenOut, s.recipient)) { + addPayee(s.recipient, s.tokenOut); + } + } else { + payeeUnread = true; } - } - - if (cmdId === 0x08) { + } else if (cmdId === 0x08) { const s = decodeV2SwapExactIn(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountIn); setOutput(s.tokenOut, s.amountOutMin); + if (!isHop(i, s.tokenOut, s.recipient)) { + addPayee(s.recipient, s.tokenOut); + } + } else { + payeeUnread = true; } - } - - if (cmdId === 0x09) { + } else if (cmdId === 0x09) { // Buys exactly amountOut and spends at most amountInMax. hasV2ExactOut = true; const s = decodeV2SwapExactOut(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountInMax); setOutput(s.tokenOut, s.amountOut); + if (!isHop(i, s.tokenOut, s.recipient)) { + addPayee(s.recipient, s.tokenOut); + } + } else { + payeeUnread = true; } - } - - if (cmdId === 0x0b) { + } else if (cmdId === 0x04) { + const s = decodeSweep(inputs[i]); + if (s) { + addPayee(s.recipient); + if (!isRouter(s.recipient)) { + heldByRouter.delete(s.token.toLowerCase()); + } + } else { + payeeUnread = true; + } + } else if (cmdId === 0x0b) { const w = decodeWrapEth(inputs[i]); if (w) { setInputOnce( "0x0000000000000000000000000000000000000000", w.amount, ); + addPayee(w.recipient); + } else { + payeeUnread = true; } - } - - if (cmdId === 0x10) { + } else if (cmdId === 0x10) { const v4 = decodeV4Swap(inputs[i]); + if (v4) { + for (const t of v4.takes) { + if (!isHop(i, t.currency, t.recipient)) { + addPayee(t.recipient, t.currency); + } + } + } + if (!v4 || v4.recipientUnread) { + payeeUnread = true; + } if (v4 && v4.swapUndecoded) { // A swap action of this step went unread, so the step // is marked. @@ -784,13 +999,29 @@ function decode(data, toAddress, chainId, sources) { setInputOnce(v4.tokenIn, v4.amountIn); setOutput(v4.tokenOut, v4.amountOutMin); } - } - - if (cmdId === 0x0c) { + } else if (cmdId === 0x0c) { hasUnwrapWeth = true; + const u = decodeUnwrapWeth(inputs[i]); + if (u) { + addPayee(u.recipient); + if (!isRouter(u.recipient)) { + WETH_ADDRESSES.forEach((w) => + heldByRouter.delete(w), + ); + } + } else { + payeeUnread = true; + } + } else { + // Any other command may pay an address this decoder does + // not read: V3_SWAP_EXACT_OUT does, and so do TRANSFER, + // PAY_PORTION and the others + // https://git.eeqj.de/sneak/AutistMask/issues/599 lists. + payeeUnread = true; } } catch { - // Skip commands we can't decode + // Skip commands we can't decode. One may pay any address. + payeeUnread = true; } } @@ -936,6 +1167,31 @@ function decode(data, toAddress, chainId, sources) { }); } + // The output is received by the sender only when some step pays the + // sender, none pays another address, none may pay an address this + // decoder did not read, and the output token is not left in the + // router. Otherwise each other address paid is named, and further + // lines say when the output stays in the router, a payout could not + // be read, or no step names anyone paid. + const outputHeld = + present(outputToken) && heldByRouter.has(outputToken.toLowerCase()); + const received = + paidToSender && + paidElsewhere.length === 0 && + !payeeUnread && + !outputHeld; + for (const address of paidElsewhere) { + details.push({ label: "Paid to", value: address, address }); + } + if (outputHeld) { + details.push({ label: "Paid to", value: STAYS_IN_ROUTER }); + } + if (payeeUnread) { + details.push({ label: "Paid to", value: UNDECODED_CURRENCY }); + } else if (!paidToSender && paidElsewhere.length === 0 && !outputHeld) { + details.push({ label: "Paid to", value: UNNAMED_CURRENCY }); + } + if (present(minOutput)) { // A zero floor is the one case the user most needs stated: the // swap guarantees nothing back. It is said in words, in the same @@ -944,7 +1200,7 @@ function decode(data, toAddress, chainId, sources) { // return nothing" — and because it is true at every scale, so it // holds even when the output token's decimals are unknown. details.push({ - label: "Min. received", + label: received ? "Min. received" : "Min. paid out", value: minOutput === 0n ? NO_MINIMUM diff --git a/tests/approvalDisplayFloor.test.js b/tests/approvalDisplayFloor.test.js index d131b3c..044e9da 100644 --- a/tests/approvalDisplayFloor.test.js +++ b/tests/approvalDisplayFloor.test.js @@ -68,10 +68,13 @@ function swapData(amountIn, amountOutMin) { } function swapDetail(amountIn, amountOutMin, label) { + // Sent by the address the swap pays, so the output is the user's. const decoded = uniswap.decode( swapData(amountIn, amountOutMin), ROUTER, "0x1", + {}, + RECIPIENT, ); return decoded.details.find((d) => d.label === label); } diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 098ff41..b839c3e 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -14,27 +14,25 @@ const DAI_ADDR = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"; const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; -// AutistMask's first-ever swap, 2026-02-27. +// AutistMask's first-ever swap, 2026-02-27, sent from USER_ADDR. // Swapped USDT for ETH via Uniswap V4 Universal Router. // https://etherscan.io/tx/0x6749f50c4e8f975b6d14780d5f539cf151d1594796ac49b7d6a5348ba0735e77 +// This is that transaction's input, as a public Ethereum node returns it. const FIRST_SWAP_CALLDATA = "0x3593564c" + - "000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0" + - "0000000000000000000000000000000000000000000000000000000069a1550f00000000000000000000000000000000000000000000000000000000000000020a10000000000000000000000000000000000000000000000000000000000000" + - "0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" + - "0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff" + - "0000000000000000000000000000000000000000000000000000000069c8daf6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af" + - "0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000041" + - "230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" + + "000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000069a1550f0000000000000000000000000000000000000000000000000000000000000002" + + "0a100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" + + "0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000069c8daf6" + + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e0" + + "0000000000000000000000000000000000000000000000000000000000000041230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003" + "070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220" + "00000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7" + - "0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e7" + - "0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064" + - "0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0" + - "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + - "dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + - "66133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012"; + "0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e70000000000000000000000000000000000000000000000000000000000000001" + + "0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000001" + + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060" + + "000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000060" + + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012"; const coder = AbiCoder.defaultAbiCoder(); const routerIface = new Interface([ @@ -332,7 +330,13 @@ describe("uniswap decoder", () => { }); test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => { - const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + FIRST_SWAP_CALLDATA, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(result).not.toBeNull(); expect(result.name).toBe("Swap USDT \u2192 ETH"); @@ -356,9 +360,11 @@ describe("uniswap decoder", () => { expect(result.details.find((d) => d.label === "Amount").value).toBe( "Unlimited", ); + // Its TAKE pays the From address, so the minimum is received. expect( result.details.find((d) => d.label === "Min. received").value, ).toBe("0.0002 ETH"); + expect(labels).not.toContain("Paid to"); // Real mainnet calldata is in the deployed shape, so its V4 step is // not said to be undecoded. @@ -373,6 +379,8 @@ describe("uniswap decoder", () => { v4SwapCalldata(id, deployed), ROUTER_ADDR, "0x1", + {}, + USER_ADDR, ); expect(detail(result, "Steps").value).toBe("V4 Swap"); }, @@ -389,6 +397,8 @@ describe("uniswap decoder", () => { v4SwapCalldata(id, newer), ROUTER_ADDR, "0x1", + {}, + USER_ADDR, ); expect(detail(result, "Amount")).toBeUndefined(); expect(detail(result, "Min. received")).toBeUndefined(); @@ -419,7 +429,13 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(detail(result, "Steps").value).toBe( "V4 Swap (could not be decoded)", ); @@ -440,7 +456,13 @@ describe("uniswap decoder", () => { [encodeV4Swap(new Uint8Array([id]), [newer])], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(detail(result, "Steps").value).toBe( "V4 Swap (could not be decoded)", ); @@ -470,7 +492,13 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Amount").value).toBe("2.0000 USDT"); expect(detail(result, "Token Out").value).toBe( @@ -498,7 +526,13 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Token In").value).toBe( "Unknown (could not be decoded)", @@ -520,7 +554,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(detail(result, "Token Out").value).toBe( "Unknown (could not be decoded)", ); @@ -540,7 +574,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(result.name).toBe("Swap USDT \u2192 WETH"); @@ -567,7 +601,7 @@ describe("uniswap decoder", () => { 1767225600n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Swap USDC → WETH"); expect(detail(result, "Token In").address).toBe(USDC_ADDR); expect(detail(result, "Token Out").address).toBe(WETH_ADDR); @@ -599,7 +633,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Swap ETH → USDC"); const amount = detail(result, "Amount"); @@ -628,7 +662,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Swap USDC → ETH"); expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC"); expect(detail(result, "Token Out").value).toBe("ETH"); @@ -671,7 +705,13 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(detail(result, "Token Out").address).toBe(tokenOut); expect(detail(result, "Min. received").value).toBe(minReceived); }, @@ -692,7 +732,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Swap USDT → USDC"); expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Min. received").value).toBe("1.9000 USDC"); @@ -725,7 +765,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(detail(result, "Token Out").value).toBe("ETH"); expect(detail(result, "Min. received").value).toBe("0.4000 ETH"); }); @@ -744,7 +784,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(detail(result, "Token Out").value).toBe("ETH"); expect(detail(result, "Min. received").value).toBe("0.0005 ETH"); }); @@ -769,7 +809,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(detail(result, "Token Out").value).toBe( "Unknown (not named in the calldata)", ); @@ -792,7 +832,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(result.name).toBe("Swap USDT \u2192 WETH"); }); @@ -804,7 +844,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); const tokenIn = result.details.find((d) => d.label === "Token In"); @@ -827,12 +867,13 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); // UNWRAP_WETH means output is native ETH expect(result.name).toBe("Swap USDT \u2192 ETH"); }); + // No step pays anyone, so the minimum is not said to be received. test("decodes BALANCE_CHECK_ERC20 for min output", () => { const data = buildExecute( solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), @@ -843,10 +884,10 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); - const minOut = result.details.find((d) => d.label === "Min. received"); + const minOut = result.details.find((d) => d.label === "Min. paid out"); expect(minOut).toBeDefined(); expect(minOut.value).toContain("2.0000"); expect(minOut.value).toContain("USDT"); @@ -863,7 +904,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); // The V4 swap action's parameters are empty, so that step is said to @@ -943,7 +984,13 @@ describe("uniswap decoder", () => { [subPlan], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Steps").value).toBe( "Execute Sub-Plan (its steps are not shown)", @@ -1031,7 +1078,13 @@ describe("uniswap decoder", () => { "$beside an EXECUTE_SUB_PLAN step does not name either side", ({ commands, inputs, steps }) => { const data = buildExecute(commands, inputs, 9999999999n); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(detail(result, "Steps").value).toBe(steps); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Token In").value).toBe( @@ -1046,7 +1099,13 @@ describe("uniswap decoder", () => { ); test("shows the deadline as a UTC date and time", () => { - const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + FIRST_SWAP_CALLDATA, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51"); }); @@ -1064,7 +1123,7 @@ describe("uniswap decoder", () => { 2n ** 256n - 1n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(result.name).toBe("Swap USDT \u2192 WETH"); expect(detail(result, "Deadline").value).toBe( @@ -1079,7 +1138,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); const amount = result.details.find((d) => d.label === "Amount"); @@ -1154,7 +1213,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); // Before decodeV4Swap fix: name would be "Swap USDT → ETH" or "Uniswap Swap" // After fix: correctly identifies both tokens from V4 sub-actions @@ -1188,7 +1247,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); // The amount and the token it is counted in come from the same hop. @@ -1228,7 +1287,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toBe( @@ -1273,7 +1332,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("USDC"); @@ -1302,7 +1361,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("USDC"); @@ -1339,7 +1398,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token In").value).toContain("WETH"); @@ -1356,9 +1415,12 @@ describe("uniswap decoder", () => { // are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL, // SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the // sides to the swap actions alone. + // `minLabel` is the minimum's line: with no TAKE or TAKE_ALL, no step + // pays the user, so it is not said to be received. const V4_STEP_ENDINGS = [ { ending: "SETTLE_ALL and TAKE_ALL", + minLabel: "Min. received", ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID], params: [ encodeV4SettleAll(USDT_ADDR, 1000000000n), @@ -1367,11 +1429,13 @@ describe("uniswap decoder", () => { }, { ending: "SETTLE and TAKE", + minLabel: "Min. received", ids: [V4_SETTLE_ID, V4_TAKE_ID], params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)], }, { ending: "CLOSE_CURRENCY", + minLabel: "Min. paid out", ids: [ V4_CLOSE_CURRENCY_ID, V4_CLOSE_CURRENCY_ID, @@ -1520,7 +1584,7 @@ describe("uniswap decoder", () => { ), )( "a V4 step of $step, then $ending, reads Swap USDT → USDC", - ({ swaps, ids, params, amount, min }) => { + ({ swaps, ids, params, amount, min, minLabel }) => { const data = buildExecute( "0x10", [ @@ -1532,12 +1596,18 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(result.name).toBe("Swap USDT → USDC"); expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Amount")?.value).toBe(amount); - expect(detail(result, "Min. received")?.value).toBe(min); + expect(detail(result, minLabel)?.value).toBe(min); }, ); @@ -1584,7 +1654,13 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode( + data, + ROUTER_ADDR, + "0x1", + {}, + USER_ADDR, + ); expect(detail(result, "Steps").value).toBe( "V4 Swap (could not be decoded)", ); @@ -1643,7 +1719,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Token In").address).toBe( USDT_ADDR.toLowerCase(), @@ -1699,7 +1775,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Uniswap Swap"); expect(detail(result, "Token In").value).toBe( "Unknown (not named in the calldata)", @@ -1722,7 +1798,7 @@ describe("uniswap decoder", () => { ], 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(detail(result, "Token Out").value).toBe( "Unknown (not named in the calldata)", ); @@ -1756,7 +1832,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result.name).toBe("Swap USDT → USDC"); expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token Out").address).toBe(USDC_ADDR); @@ -1780,7 +1856,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token In").value).toContain("USDT"); @@ -1811,7 +1887,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("WETH"); @@ -1832,7 +1908,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("USDC"); @@ -1840,7 +1916,8 @@ describe("uniswap decoder", () => { }); // With no minimum stated before it, a zero balance check is what sets the - // output side, and it guarantees nothing. + // output side, and it guarantees nothing. No step pays anyone, so the + // minimum is not said to be received. test("a zero balance check with no earlier minimum states no minimum", () => { const data = buildExecute( solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), @@ -1851,11 +1928,11 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(detail(result, "Token Out").value).toContain("USDT"); - expect(detail(result, "Min. received").value).toBe( + expect(detail(result, "Min. paid out").value).toBe( "None (no minimum guaranteed)", ); }); @@ -1871,7 +1948,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(result.name).toBe("Uniswap Swap"); @@ -1891,7 +1968,7 @@ describe("uniswap decoder", () => { 9999999999n, ); - const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); + const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); expect(result).not.toBeNull(); expect(result.name).toBe("Uniswap Swap"); diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js new file mode 100644 index 0000000..a08ad72 --- /dev/null +++ b/tests/uniswapRecipient.test.js @@ -0,0 +1,953 @@ +// The address a swap's output is paid to +// (https://git.eeqj.de/sneak/AutistMask/issues/594). +// +// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH and V4 TAKE names the +// address it pays, and TAKE_ALL pays the sender. The decoder read none of +// them: a swap whose output went to another address still read "Min. +// received", as if the user received it. The router reads address(1) as the +// sender and address(2) as the router itself, which holds what it is paid +// until a later step pays it out. +// +// The screens are driven against a minimal DOM stub in the shape +// tests/uniswapRouterAddress.test.js uses. + +const { AbiCoder, Interface, solidityPacked } = require("ethers"); + +jest.mock("../src/shared/log", () => ({ + log: { + debugf: () => {}, + infof: () => {}, + warnf: () => {}, + errorf: () => {}, + }, + debugFetch: async (url) => ({ + ok: true, + json: async () => mockExplorerAnswer(url), + }), + setRuntimeDebug: () => {}, + isDebug: () => false, +})); + +globalThis.chrome = { + storage: { local: { get: async () => ({}), set: async () => {} } }, +}; + +const uniswap = require("../src/shared/uniswap"); +const { state } = require("../src/shared/state"); +const approval = require("../src/popup/views/approval"); +const transactionDetail = require("../src/popup/views/transactionDetail"); + +// UniversalRouterV2 on Ethereum Mainnet. +const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; +// UniversalRouterV2_1_2 on Ethereum Mainnet, whose mask reads 0x07 and 0x40. +const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85"; +const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; +const OTHER = "0x7777777777777777777777777777777777777777"; +const MSG_SENDER = "0x0000000000000000000000000000000000000001"; +const ADDRESS_THIS = "0x0000000000000000000000000000000000000002"; +const ZERO = "0x0000000000000000000000000000000000000000"; + +const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; +const ONE_WETH = 1000000000000000000n; +const USDT_2000 = 2000000000n; +const USDC_1990 = 1990000000n; + +// The Paid to line's value when no address can be named. +const UNDECODED = "Unknown (could not be decoded)"; +const UNNAMED = "Unknown (not named in the calldata)"; +const STAYS_IN_ROUTER = + "The router, where it stays (no later step pays it out)"; + +const coder = AbiCoder.defaultAbiCoder(); +const routerIface = new Interface([ + "function execute(bytes commands, bytes[] inputs, uint256 deadline)", +]); + +// execute() of `steps`, each [command, input]. +function execute(...steps) { + const commands = steps.map(([command]) => command); + return routerIface.encodeFunctionData("execute", [ + solidityPacked( + commands.map(() => "uint8"), + commands, + ), + steps.map(([, input]) => input), + 9999999999n, + ]); +} + +// V2_SWAP_EXACT_IN (0x08) and V2_SWAP_EXACT_OUT (0x09) share one layout. +function v2Swap(command, recipient, amount, limit, path, payerIsUser = true) { + return [ + command, + coder.encode( + ["address", "uint256", "uint256", "address[]", "bool"], + [recipient, amount, limit, path, payerIsUser], + ), + ]; +} + +function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) { + return [ + 0x00, + coder.encode( + ["address", "uint256", "uint256", "bytes", "bool"], + [ + recipient, + amountIn, + amountOutMin, + solidityPacked( + ["address", "uint24", "address"], + [tokenIn, 3000, tokenOut], + ), + true, + ], + ), + ]; +} + +function sweep(token, recipient) { + return [ + 0x04, + coder.encode(["address", "address", "uint256"], [token, recipient, 0n]), + ]; +} + +function wrapEth(recipient, amount) { + return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])]; +} + +function unwrapWeth(recipient) { + return [0x0c, coder.encode(["address", "uint256"], [recipient, 0n])]; +} + +function balanceCheck(token, minBalance) { + return [ + 0x0e, + coder.encode( + ["address", "address", "uint256"], + [USER, token, minBalance], + ), + ]; +} + +// `step`, a [command or action, input] pair, with the top byte of the input's +// word `index` set. The decoder cannot read that word as an address. +function withDirtyWord([id, input], index) { + const at = 2 + index * 64; + return [id, input.slice(0, at) + "ff" + input.slice(at + 2)]; +} + +// The V4 actions a step below is built from. +const SWAP_USDT_FOR_USDC = [ + 0x06, // SWAP_EXACT_IN_SINGLE + coder.encode( + [ + "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", + ], + [[[USDT, USDC, 100, 1, ZERO], true, USDT_2000, USDC_1990, "0x"]], + ), +]; +// SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read. +const UNREADABLE_SWAP = [0x06, "0x"]; +const SETTLE_USDT = [ + 0x0b, + coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]), +]; +function take(currency, recipient) { + return [ + 0x0e, + coder.encode( + ["address", "address", "uint256"], + [currency, recipient, 0n], + ), + ]; +} +function takeAll(currency) { + return [0x0f, coder.encode(["address", "uint256"], [currency, 0n])]; +} + +// A V4_SWAP step of `actions`, each [action, params]. +function v4Swap(...actions) { + return [ + 0x10, + coder.encode( + ["bytes", "bytes[]"], + [ + solidityPacked( + actions.map(() => "uint8"), + actions.map(([action]) => action), + ), + actions.map(([, params]) => params), + ], + ), + ]; +} + +function decode(data, sender = USER, router = ROUTER) { + return uniswap.decode(data, router, "0x1", {}, sender); +} + +function labels(result) { + return result.details.map((d) => d.label); +} + +function detail(result, label) { + return result.details.find((d) => d.label === label); +} + +function paidTo(result) { + return result.details + .filter((d) => d.label === "Paid to") + .map((d) => d.value); +} + +// A swap paid to the user, the lines it has always had. +const PAID_TO_USER = [ + "Protocol", + "Token In", + "Amount", + "Token Out", + "Min. received", + "Steps", + "Deadline", +]; +const PAID_ELSEWHERE = [ + "Protocol", + "Token In", + "Amount", + "Token Out", + "Paid to", + "Min. paid out", + "Steps", + "Deadline", +]; + +// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`. +const swapToWeth = (recipient) => + v3SwapExactIn(recipient, USDT_2000, ONE_WETH, USDT, WETH); + +beforeEach(() => { + state.trackedTokens = []; + state.wallets = []; +}); + +describe("each swap command's recipient", () => { + test.each([ + { + kind: "V2_SWAP_EXACT_IN", + data: (r) => + execute(v2Swap(0x08, r, USDT_2000, ONE_WETH, [USDT, WETH])), + min: "1.0000 WETH", + }, + { + kind: "V2_SWAP_EXACT_OUT", + data: (r) => + execute(v2Swap(0x09, r, ONE_WETH, USDT_2000, [USDT, WETH])), + min: "1.0000 WETH", + }, + { + kind: "V3_SWAP_EXACT_IN", + data: (r) => execute(swapToWeth(r)), + min: "1.0000 WETH", + }, + { + kind: "V4_SWAP with TAKE", + data: (r) => + execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, r))), + min: "1990.0000 USDC", + }, + ])( + "$kind: paid to the user it reads as before; paid to another address it names that address", + ({ data, min }) => { + const viaPlaceholder = decode(data(MSG_SENDER)); + expect(labels(viaPlaceholder)).toEqual(PAID_TO_USER); + expect(detail(viaPlaceholder, "Min. received").value).toBe(min); + // The sender's own address, in any case, reads the same. + expect(decode(data(USER), USER.toLowerCase()).details).toEqual( + viaPlaceholder.details, + ); + + const toOther = decode(data(OTHER)); + expect(labels(toOther)).toEqual(PAID_ELSEWHERE); + expect(paidTo(toOther)).toEqual([OTHER]); + expect(detail(toOther, "Min. paid out").value).toBe(min); + }, + ); +}); + +describe("an output the router holds", () => { + const toRouter = v3SwapExactIn( + ADDRESS_THIS, + USDT_2000, + USDC_1990, + USDT, + USDC, + ); + + test("SWEEP pays it to the SWEEP's recipient", () => { + const direct = decode( + execute( + v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC), + ), + ); + const toUser = decode(execute(toRouter, sweep(USDC, MSG_SENDER))); + expect(labels(toUser)).toEqual(PAID_TO_USER); + const withoutSteps = (r) => + r.details.filter((d) => d.label !== "Steps"); + expect(withoutSteps(toUser)).toEqual(withoutSteps(direct)); + + const toOther = decode(execute(toRouter, sweep(USDC, OTHER))); + expect(labels(toOther)).toEqual(PAID_ELSEWHERE); + expect(paidTo(toOther)).toEqual([OTHER]); + expect(detail(toOther, "Min. paid out").value).toBe("1990.0000 USDC"); + }); + + test("with nothing paying it out, it stays in the router", () => { + const result = decode(execute(toRouter)); + expect(labels(result)).toEqual(PAID_ELSEWHERE); + expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); + }); + + test("the router's own address as a recipient is read as address(2) is", () => { + const toRouterAddress = v3SwapExactIn( + ROUTER, + USDT_2000, + USDC_1990, + USDT, + USDC, + ); + expect(paidTo(decode(execute(toRouterAddress)))).toEqual([ + STAYS_IN_ROUTER, + ]); + expect( + labels(decode(execute(toRouterAddress, sweep(USDC, MSG_SENDER)))), + ).toEqual(PAID_TO_USER); + }); + + test("UNWRAP_WETH pays its WETH, as ETH, to the UNWRAP_WETH's recipient", () => { + const toWeth = swapToWeth(ADDRESS_THIS); + + const toUser = decode(execute(toWeth, unwrapWeth(MSG_SENDER))); + expect(labels(toUser)).toEqual(PAID_TO_USER); + expect(detail(toUser, "Token Out").value).toBe("ETH"); + expect(detail(toUser, "Min. received").value).toBe("1.0000 ETH"); + + const toOther = decode(execute(toWeth, unwrapWeth(OTHER))); + expect(labels(toOther)).toEqual(PAID_ELSEWHERE); + expect(paidTo(toOther)).toEqual([OTHER]); + expect(detail(toOther, "Min. paid out").value).toBe("1.0000 ETH"); + }); + + test("WRAP_ETH pays its WETH to the WRAP_ETH's recipient", () => { + const toUser = decode( + execute(wrapEth(ADDRESS_THIS, ONE_WETH), unwrapWeth(MSG_SENDER)), + ); + expect(detail(toUser, "Token Out").value).toBe("ETH"); + expect(paidTo(toUser)).toEqual([]); + + const toOther = decode( + execute(wrapEth(OTHER, ONE_WETH), unwrapWeth(MSG_SENDER)), + ); + expect(paidTo(toOther)).toEqual([OTHER]); + }); + + test("a V4 TAKE to the router, then SWEEP, pays the SWEEP's recipient", () => { + const step = v4Swap( + SWAP_USDT_FOR_USDC, + SETTLE_USDT, + take(USDC, ADDRESS_THIS), + ); + + const toUser = decode(execute(step, sweep(USDC, MSG_SENDER))); + expect(labels(toUser)).toEqual(PAID_TO_USER); + + const toOther = decode(execute(step, sweep(USDC, OTHER))); + expect(labels(toOther)).toEqual(PAID_ELSEWHERE); + expect(paidTo(toOther)).toEqual([OTHER]); + }); +}); + +// Only a later SWEEP of the output token, or UNWRAP_WETH of WETH, pays the +// router's output out. A payment to the user of another token, of nothing, or +// before the swap leaves the output in the router. +describe("an output left in the router is not received", () => { + const toRouter = swapToWeth(ADDRESS_THIS); + + test.each([ + { + kind: "a SWEEP of WETH to the user, then the swap", + data: execute(sweep(WETH, MSG_SENDER), toRouter), + min: "1.0000 WETH", + }, + { + kind: "an UNWRAP_WETH to the user, then the swap", + data: execute(unwrapWeth(MSG_SENDER), toRouter), + min: "1.0000 ETH", + }, + { + kind: "the swap, then a WRAP_ETH of zero to the user", + data: execute(toRouter, wrapEth(MSG_SENDER, 0n)), + min: "1.0000 WETH", + }, + { + kind: "the swap, then a SWEEP of a token it does not buy to the user", + data: execute(toRouter, sweep(USDC, MSG_SENDER)), + min: "1.0000 WETH", + }, + ])("$kind", ({ data, min }) => { + const result = decode(data); + expect(labels(result)).toEqual(PAID_ELSEWHERE); + expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); + expect(detail(result, "Min. paid out").value).toBe(min); + }); + + test("one V4 step: a TAKE of what it buys to the router, then a TAKE_ALL of it, which takes nothing", () => { + const result = decode( + execute( + v4Swap( + SWAP_USDT_FOR_USDC, + SETTLE_USDT, + take(USDC, ADDRESS_THIS), + takeAll(USDC), + ), + ), + ); + expect(detail(result, "Steps").value).toBe("V4 Swap"); + expect(labels(result)).toEqual(PAID_ELSEWHERE); + expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]); + expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC"); + }); +}); + +// Uniswap's mixed routes pay a V2 pair directly when the next step swaps +// through it. That V2 swap spends what the pair holds: its amountIn is +// CONTRACT_BALANCE, paid from the router, or ALREADY_PAID. The pairs below +// are the addresses the routers find for the first two tokens of that V2 +// swap's path. +describe("a swap paid to the pair the next V2 swap spends", () => { + // The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it. + const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"; + const CONTRACT_BALANCE = 2n ** 255n; + const AMOUNTS_IN = [ + { kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE }, + { kind: "ALREADY_PAID", amountIn: 0n }, + ]; + const swapTo = (recipient) => + v3SwapExactIn(recipient, USDT_2000, 0n, USDT, WETH); + const swapToPair = swapTo(PAIR); + const pairSpends = (amountIn, path) => + v2Swap(0x08, MSG_SENDER, amountIn, USDC_1990, path, false); + + test.each(AMOUNTS_IN)( + "a V3 swap, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user", + ({ amountIn }) => { + const result = decode( + execute(swapToPair, pairSpends(amountIn, [WETH, USDC])), + ); + expect(result.name).toBe("Swap USDT → USDC"); + expect(labels(result)).toEqual(PAID_TO_USER); + expect(detail(result, "Amount").value).toBe("2000.0000 USDT"); + expect(detail(result, "Min. received").value).toBe( + "1990.0000 USDC", + ); + }, + ); + + test.each(AMOUNTS_IN)( + "a V3 swap paid to another address, then a V2 swap whose amountIn is $kind, names that address", + ({ amountIn }) => { + const result = decode( + execute(swapTo(OTHER), pairSpends(amountIn, [WETH, USDC])), + ); + expect(labels(result)).toEqual(PAID_ELSEWHERE); + expect(paidTo(result)).toEqual([OTHER]); + expect(detail(result, "Min. paid out").value).toBe( + "1990.0000 USDC", + ); + }, + ); + + test.each([ + { recipient: PAIR, lines: PAID_TO_USER, paid: [] }, + { recipient: OTHER, lines: PAID_ELSEWHERE, paid: [OTHER] }, + ])( + "a V4 step whose TAKE pays $recipient, then the V2 swap", + ({ recipient, lines, paid }) => { + const result = decode( + execute( + v4Swap( + SWAP_USDT_FOR_USDC, + SETTLE_USDT, + take(USDC, recipient), + ), + v2Swap( + 0x08, + MSG_SENDER, + CONTRACT_BALANCE, + ONE_WETH, + [USDC, WETH], + false, + ), + ), + ); + expect(labels(result)).toEqual(lines); + expect(paidTo(result)).toEqual(paid); + }, + ); + + describe("on Sepolia", () => { + const SEPOLIA = "0xaa36a7"; + // UniversalRouterV2_1_2 on Sepolia. + const SEPOLIA_ROUTER = "0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3"; + // The WETH/USDC pair the routers on Sepolia find. Sepolia's V2 + // factory reports another address for it, and no contract sits at + // this one (see V2_FACTORIES in src/shared/uniswap.js). + const SEPOLIA_PAIR = "0x541b50d32B73E769ac42F0FddD86D15FcAa861Fb"; + const SEPOLIA_UNI = "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984"; + const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"; + const SEPOLIA_USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"; + // UNI for WETH, paid to `recipient`, then WETH for USDC through the + // pair. + const route = (recipient, amountIn) => + execute( + v3SwapExactIn( + recipient, + ONE_WETH, + 0n, + SEPOLIA_UNI, + SEPOLIA_WETH, + ), + v2Swap( + 0x08, + MSG_SENDER, + amountIn, + USDC_1990, + [SEPOLIA_WETH, SEPOLIA_USDC], + false, + ), + ); + + test.each(AMOUNTS_IN)( + "paid to the pair, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user", + ({ amountIn }) => { + const result = uniswap.decode( + route(SEPOLIA_PAIR, amountIn), + SEPOLIA_ROUTER, + SEPOLIA, + {}, + USER, + ); + expect(labels(result)).toEqual(PAID_TO_USER); + }, + ); + + test.each(AMOUNTS_IN)( + "paid to another address, then a V2 swap whose amountIn is $kind, names that address", + ({ amountIn }) => { + const result = uniswap.decode( + route(OTHER, amountIn), + SEPOLIA_ROUTER, + SEPOLIA, + {}, + USER, + ); + expect(labels(result)).toEqual(PAID_ELSEWHERE); + expect(paidTo(result)).toEqual([OTHER]); + }, + ); + + test("the same route on Ethereum Mainnet names the Sepolia pair", () => { + const result = decode(route(SEPOLIA_PAIR, 0n)); + expect(paidTo(result)).toEqual([SEPOLIA_PAIR]); + }); + }); + + test.each([ + { + kind: "takes its input from the user", + next: v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]), + }, + { + kind: "spends another token", + next: pairSpends(0n, [USDC, WETH]), + }, + ])("when the V2 swap $kind, the pair is named", ({ next }) => { + const result = decode(execute(swapToPair, next)); + expect(paidTo(result)).toEqual([PAIR]); + expect(detail(result, "Min. received")).toBeUndefined(); + }); +}); + +describe("every step's payee counts, whichever step sets the output", () => { + test("TAKE_ALL pays the user; a TAKE to another address beside it is named", () => { + const toUser = decode( + execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))), + ); + expect(labels(toUser)).toEqual(PAID_TO_USER); + + // TAKE with an amount of zero takes the whole open credit, so + // TAKE_ALL after it pays the user nothing. + const split = decode( + execute( + v4Swap( + SWAP_USDT_FOR_USDC, + SETTLE_USDT, + take(USDC, OTHER), + takeAll(USDC), + ), + ), + ); + expect(paidTo(split)).toEqual([OTHER]); + expect(detail(split, "Min. received")).toBeUndefined(); + expect(detail(split, "Min. paid out").value).toBe("1990.0000 USDC"); + }); + + test("a swap paid to another address, then a V4 step that only takes the same token", () => { + const emptyStep = v4Swap(takeAll(WETH)); + + const result = decode(execute(swapToWeth(OTHER), emptyStep)); + expect(paidTo(result)).toEqual([OTHER]); + expect(detail(result, "Min. received")).toBeUndefined(); + + const checked = decode( + execute(swapToWeth(OTHER), emptyStep, balanceCheck(WETH, ONE_WETH)), + ); + expect(paidTo(checked)).toEqual([OTHER]); + expect(detail(checked, "Min. received")).toBeUndefined(); + expect(detail(checked, "Min. paid out").value).toBe("1.0000 WETH"); + }); + + test("a swap paid to the router, a V4 step that only takes, then SWEEP to another address", () => { + const result = decode( + execute( + v3SwapExactIn(ADDRESS_THIS, USDT_2000, USDC_1990, USDT, USDC), + v4Swap(takeAll(USDC)), + sweep(USDC, OTHER), + ), + ); + expect(paidTo(result)).toEqual([OTHER]); + expect(detail(result, "Min. received")).toBeUndefined(); + }); + + test("a swap paid to another address, then a small swap paid to the user", () => { + const result = decode( + execute( + swapToWeth(OTHER), + v2Swap(0x08, MSG_SENDER, 1000000n, 1n, [USDT, WETH]), + ), + ); + expect(paidTo(result)).toEqual([OTHER]); + expect(detail(result, "Min. received")).toBeUndefined(); + expect(detail(result, "Min. paid out")).toBeDefined(); + }); + + test("one V4 step: a TAKE of what it buys to another address, then TAKE_ALL of another currency", () => { + const result = decode( + execute( + v4Swap( + SWAP_USDT_FOR_USDC, + SETTLE_USDT, + take(USDC, OTHER), + takeAll(USDT), + ), + ), + ); + expect(paidTo(result)).toEqual([OTHER]); + expect(detail(result, "Min. received")).toBeUndefined(); + }); +}); + +describe("a payout that cannot be read is never said to be received", () => { + test("a V4 TAKE whose recipient word has its top byte set", () => { + const result = decode( + execute( + v4Swap( + SWAP_USDT_FOR_USDC, + SETTLE_USDT, + withDirtyWord(take(USDC, OTHER), 1), + ), + ), + ); + expect(paidTo(result)).toEqual([UNDECODED]); + expect(detail(result, "Min. received")).toBeUndefined(); + expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC"); + }); + + test.each([ + { + kind: "V2_SWAP_EXACT_IN", + step: v2Swap(0x08, OTHER, 1n, 1n, [USDT, WETH]), + }, + { + kind: "V2_SWAP_EXACT_OUT", + step: v2Swap(0x09, OTHER, 1n, 1n, [USDT, WETH]), + }, + { kind: "V3_SWAP_EXACT_IN", step: swapToWeth(OTHER) }, + { kind: "SWEEP", step: sweep(WETH, OTHER), word: 1 }, + { kind: "WRAP_ETH", step: wrapEth(OTHER, 1n) }, + { kind: "UNWRAP_WETH", step: unwrapWeth(OTHER) }, + ])( + "a $kind whose recipient word has its top byte set", + ({ step, word = 0 }) => { + const result = decode( + execute(swapToWeth(MSG_SENDER), withDirtyWord(step, word)), + ); + expect(paidTo(result)).toEqual([UNDECODED]); + expect(detail(result, "Min. received")).toBeUndefined(); + expect(detail(result, "Min. paid out")).toBeDefined(); + }, + ); + + // The router reads only the low 32 bits of the action list's length, so + // the step still runs, and may pay anyone. + test("a V4_SWAP whose action list's length word has its top byte set", () => { + const step = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, OTHER)); + const result = decode( + execute(swapToWeth(MSG_SENDER), withDirtyWord(step, 2)), + ); + expect(paidTo(result)).toEqual([UNDECODED]); + expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); + }); + + test("an output set only by BALANCE_CHECK_ERC20", () => { + const result = decode(execute(balanceCheck(WETH, ONE_WETH))); + expect(paidTo(result)).toEqual([UNNAMED]); + expect(detail(result, "Min. received")).toBeUndefined(); + expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); + }); + + // https://git.eeqj.de/sneak/AutistMask/issues/599 is to read these. Until + // then each is a payout this decoder does not read. + const transferShape = (command) => [ + command, + coder.encode(["address", "address", "uint256"], [WETH, OTHER, 1n]), + ]; + test.each([ + { kind: "PERMIT2_TRANSFER_FROM", step: transferShape(0x02) }, + { kind: "TRANSFER", step: transferShape(0x05) }, + { kind: "PAY_PORTION", step: transferShape(0x06) }, + { kind: "PAY_PORTION_FULL_PRECISION", step: transferShape(0x07) }, + { + kind: "PERMIT2_TRANSFER_FROM_BATCH", + step: [ + 0x0d, + coder.encode( + ["tuple(address,address,uint160,address)[]"], + [[[USER, OTHER, 1n, WETH]]], + ), + ], + }, + { + kind: "V3_SWAP_EXACT_OUT", + step: [0x01, swapToWeth(OTHER)[1]], + }, + { + kind: "EXECUTE_SUB_PLAN", + step: [ + 0x21, + coder.encode( + ["bytes", "bytes[]"], + ["0x05", [transferShape(0x05)[1]]], + ), + ], + }, + { + kind: "ACROSS_V4_DEPOSIT_V3", + step: [0x40, coder.encode(["address"], [OTHER])], + }, + { + kind: "V4 TAKE_PORTION", + step: v4Swap([ + 0x10, + coder.encode( + ["address", "address", "uint256"], + [WETH, OTHER, 10000n], + ), + ]), + }, + ])("a swap paid to the user, then $kind", ({ step }) => { + const result = decode( + execute(swapToWeth(MSG_SENDER), step), + USER, + ROUTER_V2_1_2, + ); + expect(paidTo(result)).toEqual([UNDECODED]); + expect(detail(result, "Min. received")).toBeUndefined(); + }); +}); + +describe("paths whose recipients were untested", () => { + test("BALANCE_CHECK_ERC20 after a swap leaves who is paid as the swap says", () => { + const check = balanceCheck(WETH, ONE_WETH); + + const toUser = decode(execute(swapToWeth(MSG_SENDER), check)); + expect(paidTo(toUser)).toEqual([]); + expect(detail(toUser, "Min. received").value).toBe("1.0000 WETH"); + + const toOther = decode(execute(swapToWeth(OTHER), check)); + expect(paidTo(toOther)).toEqual([OTHER]); + expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH"); + }); + + test("a V4 step whose swap action cannot be read still has its TAKE read", () => { + const step = (r) => v4Swap(UNREADABLE_SWAP, SETTLE_USDT, take(USDC, r)); + + const toUser = decode(execute(step(MSG_SENDER))); + expect(detail(toUser, "Steps").value).toBe( + "V4 Swap (could not be decoded)", + ); + expect(paidTo(toUser)).toEqual([]); + + expect(paidTo(decode(execute(step(OTHER))))).toEqual([OTHER]); + }); +}); + +// The DOM stub both screens are drawn into. +function makeElement(id) { + const classes = new Set(); + const el = { + id, + textContent: "", + value: "", + innerHTML: "", + style: {}, + dataset: {}, + classList: { + add: (...names) => names.forEach((n) => classes.add(n)), + remove: (...names) => names.forEach((n) => classes.delete(n)), + contains: (n) => classes.has(n), + toggle: () => false, + }, + addEventListener: () => {}, + querySelectorAll: () => [], + appendChild: () => {}, + }; + Object.defineProperty(el, "parentElement", { + get: () => node(id + "-parent"), + }); + return el; +} + +function makeDocument() { + const els = new Map(); + return { + getElementById(id) { + if (id === "debug-banner") return null; + if (!els.has(id)) els.set(id, makeElement(id)); + return els.get(id); + }, + createElement: () => makeElement("created"), + body: { prepend: () => {} }, + }; +} + +function node(id) { + return globalThis.document.getElementById(id); +} + +// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`. +const swapPaidTo = (recipient) => execute(swapToWeth(recipient)); + +// Open the approval screen for a transaction from USER sending `data` to the +// router. +async function showApproval(data) { + globalThis.document = makeDocument(); + globalThis.window = { location: { search: "" }, close: () => {} }; + globalThis.chrome.runtime = { + connect: () => ({ postMessage: () => {} }), + sendMessage: (_msg, reply) => + reply({ + type: "tx", + origin: "https://dapp.example", + isPhishingDomain: false, + approvedFrom: USER, + approvedTx: { + type: 2, + from: USER, + chainId: "0x1", + nonce: "0x7", + gasLimit: "0x30d40", + maxPriorityFeePerGas: "0x3b9aca00", + maxFeePerGas: "0x77359400", + to: ROUTER, + value: "0x0", + data, + accessList: [], + }, + }), + }; + state.networkId = "mainnet"; + state.currentView = null; + await approval.show(1); + return node("approve-tx-decoded-details").innerHTML; +} + +// The transaction the explorer reports: from USER, sending `mockSentData` to +// the router. +const TX_HASH = "0x" + "5".repeat(64); +let mockSentData = null; + +function mockExplorerAnswer(url) { + if (url.endsWith("/transactions/" + TX_HASH)) { + return { + from: { hash: USER }, + to: { hash: ROUTER }, + raw_input: mockSentData, + method: "execute", + }; + } + return {}; +} + +// Open the transaction detail screen for that transaction, sending `data`. +async function showDetail(data) { + mockSentData = data; + globalThis.document = makeDocument(); + globalThis.window = { location: { search: "" } }; + state.networkId = "mainnet"; + state.blockscoutUrl = "https://explorer.example/api/v2"; + state.viewData = {}; + state.viewStack = []; + state.currentView = null; + transactionDetail.show({ + hash: TX_HASH, + from: USER, + to: ROUTER, + value: "", + symbol: "ETH", + timestamp: 1790000000, + isError: false, + direction: "contract", + directionLabel: "Swap", + method: "execute", + chainId: "0x1", + }); + for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); + return node("tx-detail-calldata-details").innerHTML; +} + +describe("the screens judge the output against the transaction's sender", () => { + test("the approval screen, against its From address", async () => { + const toUser = await showApproval(swapPaidTo(USER)); + expect(toUser).toContain("Min. received"); + expect(toUser).not.toContain("Paid to"); + + const toOther = await showApproval(swapPaidTo(OTHER)); + expect(toOther).toContain("Paid to"); + expect(toOther).toContain(OTHER); + expect(toOther).not.toContain("Min. received"); + }); + + test("the transaction detail screen, against the sender the explorer reports", async () => { + const toUser = await showDetail(swapPaidTo(USER)); + expect(toUser).toContain("Min. received"); + expect(toUser).not.toContain("Paid to"); + + const toOther = await showDetail(swapPaidTo(OTHER)); + expect(toOther).toContain("Paid to"); + expect(toOther).not.toContain("Min. received"); + }); +}); diff --git a/tests/uniswapTokenOut.test.js b/tests/uniswapTokenOut.test.js index a3a7aa4..007ea87 100644 --- a/tests/uniswapTokenOut.test.js +++ b/tests/uniswapTokenOut.test.js @@ -53,7 +53,14 @@ function unwrapData() { } function detail(data, label, sources) { - const decoded = uniswap.decode(data, ROUTER, "0x1", sources || {}); + // Sent by the address the swap pays, so the output is the user's. + const decoded = uniswap.decode( + data, + ROUTER, + "0x1", + sources || {}, + RECIPIENT, + ); return decoded.details.find((d) => d.label === label); } diff --git a/tests/uniswapUndeterminedTokenIn.test.js b/tests/uniswapUndeterminedTokenIn.test.js index 4d078b2..bb70995 100644 --- a/tests/uniswapUndeterminedTokenIn.test.js +++ b/tests/uniswapUndeterminedTokenIn.test.js @@ -87,7 +87,7 @@ function detail(data, label) { describe("an execute() whose input currency never decoded", () => { // A V4_SWAP whose sub-action params do not decode — an action encoding // this decoder does not know — leaves every V4 token null. The - // BALANCE_CHECK still names the output, so the screen has a Min. received + // BALANCE_CHECK still names the output, so the screen has a minimum // figure and a Token Out, and previously claimed the user was paying ETH // for them. const data = () => @@ -132,7 +132,8 @@ describe("an execute() that names only an output token", () => { test("still states the output it did establish", () => { expect(detail(data(), "Token Out").value).toContain("USDT"); - expect(detail(data(), "Min. received").value).toBe("2.0000 USDT"); + // No step pays anyone, so the minimum is not said to be received. + expect(detail(data(), "Min. paid out").value).toBe("2.0000 USDT"); }); }); diff --git a/tests/uniswapUndeterminedTokenOut.test.js b/tests/uniswapUndeterminedTokenOut.test.js index 8f0a687..7192bb2 100644 --- a/tests/uniswapUndeterminedTokenOut.test.js +++ b/tests/uniswapUndeterminedTokenOut.test.js @@ -105,7 +105,7 @@ function v3ExactIn(tokenIn, tokenOut, amountIn, amountOutMin) { } function detail(data, label) { - const decoded = uniswap.decode(data, ROUTER, "0x1", {}); + const decoded = uniswap.decode(data, ROUTER, "0x1", {}, USER); expect(decoded).not.toBeNull(); return decoded.details.find((d) => d.label === label); } @@ -134,7 +134,9 @@ describe("a V4 step that states a Min. received but no output currency", () => { }); test("refuses to scale Min. received rather than assuming 18", () => { - expect(detail(data(), "Min. received").value).toBe( + // With no TAKE, no step pays the user either, so the line is not + // labelled received. + expect(detail(data(), "Min. paid out").value).toBe( unknownDecimalsAmount(MIN_OUT), ); }); diff --git a/tests/uniswapUnknownDecimals.test.js b/tests/uniswapUnknownDecimals.test.js index 0abeb22..af91a2e 100644 --- a/tests/uniswapUnknownDecimals.test.js +++ b/tests/uniswapUnknownDecimals.test.js @@ -82,7 +82,8 @@ function walletsHolding(token, decimals) { // decodeCalldata() rather than uniswap.decode() directly, because the scale // sources the screen supplies are part of what is under test. function swapDetail(data, label) { - const decoded = decodeCalldata(data, ROUTER, "0x1"); + // Sent by the address the swap pays, so the output is the user's. + const decoded = decodeCalldata(data, ROUTER, "0x1", RECIPIENT); return decoded.details.find((d) => d.label === label); }