diff --git a/README.md b/README.md index ef91e04..8208aec 100644 --- a/README.md +++ b/README.md @@ -2360,9 +2360,10 @@ 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, 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 `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 @@ -3112,14 +3113,22 @@ 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`. A step goes unread when its recipient does not - decode as an address, or when it is a command the decoder does not read: every - command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`, - `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`, - `SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, - `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` - (`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and - the NFT commands among them), or a type its release names no command for. + minimum reads `Min. paid out`. After the `Amount` line (after `Token In` when + there is none), each `PERMIT2_TRANSFER_FROM`, in step order, gives three + lines: `Moved from the sender`, the amount it moves, for example + `1000000.0000 USDT`; `Token moved`, giving the token's address as the + `Token In` line does; and `Moved to`, which reads `The router` for a move into + the router, `The V2 pair the next swap spends` for a hop, and `The sender` for + `address(1)` on every router but `UniversalRouterV1`, and otherwise gives the + address. These lines change neither the `Paid to` lines nor the minimum's + label. A step goes unread when its recipient does not decode as an address, or + when it is a command the decoder does not read: every command other than + `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, + `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`, + `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, + `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, + `PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among + them), or a type its release names no command for. ### Non-Goals Forever diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 7815cee..18fea9e 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -488,6 +488,21 @@ function amountText(raw, info) { }; } +// The line giving a moved token under `label`, in the shape of the Token In +// line, so the screens show it as they show that one. ETH has no address. +function tokenMovedLine(label, token, sources) { + const info = tokenInfo(token, sources); + if (!present(info.address)) { + return { label, value: info.symbol }; + } + return { + label, + value: info.symbol ? info.symbol + " (" + token + ")" : token, + address: token, + isToken: true, + }; +} + // Decode PERMIT2_PERMIT (command 0x0a) input bytes. // ABI: ((address token, uint160 amount, uint48 expiration, uint48 nonce), // address spender, uint256 sigDeadline), bytes signature @@ -1171,6 +1186,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // router, as { token, amount }. Later steps may leave part of it // there: nothing here checks that they spend all of it. const movedIntoRouter = []; + // What each PERMIT2_TRANSFER_FROM moves from the sender, in step + // order, whoever counts as paid, as { token, recipient, amount, to }: + // `to` says where it goes, the sender, the router or the V2 pair the + // next swap spends, and is null when it goes to `recipient` as + // written. + const movedFromSender = []; // Each token a step paid to the router that no later step paid out // or spent in full, by its address, lowercased, ETH as the zero // address. The router is paid what a swap step, V4 TAKE or @@ -1302,10 +1323,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) { // pays address(1) and address(2) as written (see // decodePermit2TransferFrom()). addPaidElsewhere(p.recipient); + movedFromSender.push({ ...p, to: null }); } else if (isRouter(p.recipient)) { movedIntoRouter.push(p); - } else if (!isHop(i, p.token, p.recipient)) { + movedFromSender.push({ ...p, to: "The router" }); + } else if (isHop(i, p.token, p.recipient)) { + movedFromSender.push({ + ...p, + to: "The V2 pair the next swap spends", + }); + } else { addPayee(p.recipient); + movedFromSender.push({ + ...p, + to: + p.recipient.toLowerCase() === MSG_SENDER + ? "The sender" + : null, + }); } } else if (command === "BALANCE_CHECK_ERC20") { const b = decodeBalanceCheck(inputs[i]); @@ -1675,6 +1710,26 @@ function decode(data, toAddress, chainId, sources, fromAddress) { }); } + // 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) { + details.push({ + label: "Moved from the sender", + value: amountText(m.amount, tokenInfo(m.token, sources)) + .display, + }); + details.push(tokenMovedLine("Token moved", m.token, sources)); + details.push( + present(m.to) + ? { label: "Moved to", value: m.to } + : { + label: "Moved to", + value: m.recipient, + address: m.recipient, + }, + ); + } + // Keyed on the address, not the symbol: a token absent from the // bundled list has no symbol, and gating the line on one dropped it // entirely, leaving a Min. received figure with nothing saying what is @@ -1723,24 +1778,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) { if (bridgeDeposit) { paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE }); } - // The line giving a token in the router, in the shape of the Token In - // line, so the screens show it as they show that one. ETH has no - // address. - const tokenMovedLine = (token) => { - const info = tokenInfo(token, sources); - if (!present(info.address)) { - return { - label: "Token moved into the router", - value: info.symbol, - }; - } - return { - label: "Token moved into the router", - value: info.symbol ? info.symbol + " (" + token + ")" : token, - address: token, - isToken: true, - }; - }; for (const m of movedIntoRouter) { const info = tokenInfo(m.token, sources); paidTo.push({ @@ -1750,7 +1787,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) { amountText(m.amount, info).display + " from the sender (later steps may not spend all of it)", }); - paidTo.push(tokenMovedLine(m.token)); + paidTo.push( + tokenMovedLine("Token moved into the router", m.token, sources), + ); } if (outputHeld) { paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER }); @@ -1764,7 +1803,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) { continue; } paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER }); - paidTo.push(tokenMovedLine(getAddress(token))); + paidTo.push( + tokenMovedLine( + "Token moved into the router", + getAddress(token), + sources, + ), + ); } if (payeeUnread) { paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY }); diff --git a/tests/uniswapRecipient.test.js b/tests/uniswapRecipient.test.js index cce10df..ec605cf 100644 --- a/tests/uniswapRecipient.test.js +++ b/tests/uniswapRecipient.test.js @@ -6,8 +6,10 @@ // release runs for a command type // (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose // balance a BALANCE_CHECK_ERC20 checks -// (https://git.eeqj.de/sneak/AutistMask/issues/600), and any token a swap -// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606). +// (https://git.eeqj.de/sneak/AutistMask/issues/600), any token a swap +// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606), and +// what each PERMIT2_TRANSFER_FROM moves +// (https://git.eeqj.de/sneak/AutistMask/issues/611). // // Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION, // PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and @@ -349,6 +351,13 @@ const PAID_ELSEWHERE = [ "Steps", "Deadline", ]; +// `lines` with the lines of one PERMIT2_TRANSFER_FROM after Amount. +const withMove = (lines) => + lines.flatMap((l) => + l === "Amount" + ? [l, "Moved from the sender", "Token moved", "Moved to"] + : [l], + ); // A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`. const swapToWeth = (recipient) => @@ -1154,12 +1163,12 @@ describe("each transfer command's recipient", () => { test("PERMIT2_TRANSFER_FROM: to the user it pays no one else, to another address that address is named", () => { for (const r of [MSG_SENDER, USER]) { const result = decode(movedThenSwapped(r)); - expect(labels(result)).toEqual(PAID_TO_USER); + expect(labels(result)).toEqual(withMove(PAID_TO_USER)); expect(detail(result, "Min. received").value).toBe("1.0000 WETH"); } const toOther = decode(movedThenSwapped(OTHER)); - expect(labels(toOther)).toEqual(PAID_ELSEWHERE); + expect(labels(toOther)).toEqual(withMove(PAID_ELSEWHERE)); expect(paidTo(toOther)).toEqual([OTHER]); expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH"); @@ -1183,6 +1192,9 @@ describe("each transfer command's recipient", () => { "Protocol", "Token In", "Amount", + "Moved from the sender", + "Token moved", + "Moved to", "Token Out", "Paid to", "Token moved into the router", @@ -1249,6 +1261,11 @@ describe("each transfer command's recipient", () => { for (const r of [MSG_SENDER, ADDRESS_THIS]) { const result = decode(movedThenSwapped(r), USER, ROUTER_V1); expect(paidTo(result)).toEqual([r]); + expect(detail(result, "Moved to")).toEqual({ + label: "Moved to", + value: r, + address: r, + }); expect(detail(result, "Min. received")).toBeUndefined(); } @@ -1294,6 +1311,108 @@ describe("each transfer command's recipient", () => { }); }); +// https://git.eeqj.de/sneak/AutistMask/issues/611: after the swap's own +// Amount, each PERMIT2_TRANSFER_FROM gives the amount of the sender's tokens +// it moves, the token, and where it goes, whoever counts as paid. +describe("what each PERMIT2_TRANSFER_FROM moves", () => { + // The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it. + const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"; + const USDT_1 = 1000000n; + const USDT_1000000 = 1000000000000n; + + // The lines between Amount and Token Out. + function afterAmount(result) { + const all = labels(result); + return result.details.slice( + all.indexOf("Amount") + 1, + all.indexOf("Token Out"), + ); + } + // The lines of a move of `amount` of `token`, then `to`. + const moved = (amount, token, symbol, to) => [ + { label: "Moved from the sender", value: amount }, + { + label: "Token moved", + value: symbol + " (" + token + ")", + address: token, + isToken: true, + }, + to, + ]; + const movedTo = (value) => ({ label: "Moved to", value }); + const movedToAddress = (address) => ({ + label: "Moved to", + value: address, + address, + }); + + test("1,000,000 USDT moved to another address, then a swap of 1 USDT", () => { + const result = decode( + execute( + permit2TransferFrom(USDT, OTHER, USDT_1000000), + v3SwapExactIn(MSG_SENDER, USDT_1, ONE_WETH, USDT, WETH), + ), + ); + expect(detail(result, "Amount").value).toBe("1.0000 USDT"); + expect(afterAmount(result)).toEqual( + moved("1000000.0000 USDT", USDT, "USDT", movedToAddress(OTHER)), + ); + expect(paidTo(result)).toEqual([OTHER]); + expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); + }); + + test("1 WETH moved to the pair the next V2 swap spends", () => { + const result = decode( + execute( + permit2TransferFrom(WETH, PAIR, ONE_WETH), + v2Swap(0x08, MSG_SENDER, 0n, USDC_1990, [WETH, USDC], false), + ), + ); + expect(detail(result, "Amount").value).toBe( + "Whatever an earlier step sent to the pair (V2 already paid)", + ); + expect(afterAmount(result)).toEqual( + moved( + "1.0000 WETH", + WETH, + "WETH", + movedTo("The V2 pair the next swap spends"), + ), + ); + expect(paidTo(result)).toEqual([]); + expect(detail(result, "Min. received").value).toBe("1990.0000 USDC"); + }); + + test("2,000 USDT moved into the router, then a swap of 1 USDT from the router's balance", () => { + const result = decode( + execute( + permit2TransferFrom(USDT, ADDRESS_THIS, USDT_2000), + v3SwapExactIn(MSG_SENDER, USDT_1, ONE_WETH, USDT, WETH, false), + ), + ); + expect(detail(result, "Amount").value).toBe("1.0000 USDT"); + expect(afterAmount(result)).toEqual( + moved("2000.0000 USDT", USDT, "USDT", movedTo("The router")), + ); + expect(paidTo(result)).toEqual([movedIntoRouter("2000.0000 USDT")]); + expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); + }); + + test("a move to address(1) goes to the sender, and each move is shown in step order", () => { + const result = decode( + execute( + permit2TransferFrom(USDT, MSG_SENDER, USDT_2000), + permit2TransferFrom(USDC, OTHER, USDC_1990), + swapToWeth(MSG_SENDER), + ), + ); + expect(afterAmount(result)).toEqual([ + ...moved("2000.0000 USDT", USDT, "USDT", movedTo("The sender")), + ...moved("1990.0000 USDC", USDC, "USDC", movedToAddress(OTHER)), + ]); + }); +}); + // A payout of only part of what the router holds leaves the rest there. describe("a payout of part of the router's output", () => { test.each([ @@ -2787,3 +2906,22 @@ describe("the screens show each token moved into the router as an address", () = } }); }); + +// The issue's example: 1,000,000 USDT moved to another address, then a swap +// of 1 USDT. Both screens show the amount moved and where it goes. +describe("the screens show what a PERMIT2_TRANSFER_FROM moves", () => { + const millionToOther = execute( + permit2TransferFrom(USDT, OTHER, 1000000000000n), + v3SwapExactIn(MSG_SENDER, 1000000n, ONE_WETH, USDT, WETH), + ); + + test.each([ + { screen: "approval", show: showApproval }, + { screen: "transaction detail", show: showDetail }, + ])("the $screen screen", async ({ show }) => { + const html = await show(millionToOther); + expect(html).toContain("1000000.0000 USDT"); + expect(html).toContain("Moved to"); + expect(html).toContain('data-copy="' + OTHER + '"'); + }); +});