From bad6494e43e981c5442d5e749e5414dec9bc923b Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 16 Oct 2026 03:00:32 +0200 Subject: [PATCH] fix: a V4 swap action not in the deployed shape is shown as not decoded (closes #358) The four V4 swap actions are read only as the structs deployed on mainnet and Sepolia. ethers read the newer v4-periphery structs, which add minHopPriceX36, as the deployed ones, and the newer multi-hop exact-in struct showed the offset of its hop-price array as the swap amount. A decode now counts only when encoding it again gives back the same bytes. A swap action that fails that marks its step "V4 Swap (could not be decoded)", and a side its SETTLE or TAKE did not name reads "Unknown (could not be decoded)". The wait and error screens no longer show the ETH value as the amount of a swap whose amount was not read, unless the swap is paid in native ETH. Model: opus-5-5 --- README.md | 36 ++- src/popup/views/approval.js | 17 ++ src/popup/views/txStatus.js | 45 +-- src/shared/uniswap.js | 87 ++++-- tests/swapStatusAmount.test.js | 342 +++++++++++++++++++++++ tests/uniswap.test.js | 272 +++++++++++++++++- tests/uniswapUndeterminedTokenIn.test.js | 6 +- 7 files changed, 756 insertions(+), 49 deletions(-) create mode 100644 tests/swapStatusAmount.test.js diff --git a/README.md b/README.md index d08f843..f015e50 100644 --- a/README.md +++ b/README.md @@ -1125,14 +1125,18 @@ read: The swap's `Token In` and `Token Out` lines name a currency, not an amount; each reads `Unknown (not named in the calldata)` when the decoder found no token for -that side. An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side -is WETH, on mainnet or Sepolia, or when no step set the output side; a WETH -`Min. received` figure then reads in ETH. Otherwise `Token Out` and -`Min. received` keep the output side's own token and figure, whether the swap -was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then -unwraps the WETH it did not spend shows USDC. The token permission warning on -the signature screen has its own amount wording, including `Unknown`; the -SignApproval section below describes it. +that side, or `Unknown (could not be decoded)` instead when a V4 swap action in +the transaction could not be read (see Transaction Decoding below). A V4 step +whose swap action could not be read still sets each side as a swap step does, +and a side its `SETTLE` or `TAKE` did not name is then unknown: no other step's +token or figure is shown for it. An `UNWRAP_WETH` step makes `Token Out` ETH +only when the output side is WETH, on mainnet or Sepolia, or when no step set +the output side; a WETH `Min. received` figure then reads in ETH. Otherwise +`Token Out` and `Min. received` keep the output side's own token and figure, +whether the swap was paid in ETH or in a token: a V2 exact-out swap that buys +USDC and then unwraps the WETH it did not spend shows USDC. The token permission +warning on the signature screen has its own amount wording, including `Unknown`; +the SignApproval section below describes it. The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g. `2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00 @@ -1694,7 +1698,11 @@ view would leave a wallet one click from deletion. - **When**: Transaction has been broadcast, waiting for on-chain confirmation. - **Elements**: - "Transaction Broadcast" heading (no back button — tx is irreversible) - - Amount + symbol + - Amount + symbol. For a swap the approval screen showed no `Amount` for, + such as one whose V4 swap action could not be read or a V4 exact-out swap, + `Unknown (could not be decoded)` with no symbol, not the transaction's ETH + value. When that swap's `Token In` is `ETH (native)`, the transaction's + ETH value in the network's currency, as for any other transaction - To: color dot + full address + etherscan link; for a contract creation, which has no recipient, "This transaction creates a new contract. It has no recipient." instead @@ -1741,7 +1749,9 @@ view would leave a wallet one click from deletion. - **When**: Transaction broadcast failed, or timed out waiting for confirmation. - **Elements**: - "Transaction Failed" heading - - Amount + symbol + - Amount + symbol, or for a swap the approval screen showed no `Amount` for, + the same as on WaitTx: `Unknown (could not be decoded)`, unless its + `Token In` is `ETH (native)` - To: color dot + full address + etherscan link, or for a contract creation the same sentence as on WaitTx - Error message (dashed border box) @@ -2747,7 +2757,11 @@ Currently supported: or recipient, and amount. - **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g. "Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline. - Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks. + Decodes Permit2, V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap + action is read only in the parameter shape deployed on mainnet and Sepolia, + not in the newer one on `v4-periphery` `main`, which adds a `minHopPriceX36` + field. An action in any other shape is not read, and its step is listed as + `V4 Swap (could not be decoded)`. ### Non-Goals Forever diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js index 21daae4..ffdea78 100644 --- a/src/popup/views/approval.js +++ b/src/popup/views/approval.js @@ -298,6 +298,23 @@ function showTxApproval(details) { sources, ); } + // A swap the decoder read no amount for, such as one whose V4 swap + // action could not be read or a V4 exact-out swap. The wait and error + // screens say the amount could not be decoded, with no currency after + // it, rather than show the transaction's ETH value as the amount. A + // swap paid in native ETH keeps that value: it is the ETH the swap + // pays (for an exact-out swap, the most it pays), not a figure that + // had to be read from the calldata. + const tokenIn = decoded.details.find((d) => d.label === "Token In"); + if ( + tokenIn && + tokenIn.value !== "ETH (native)" && + !decoded.details.some((d) => d.label === "Amount") + ) { + pendingTxDetails.amount = "Unknown (could not be decoded)"; + pendingTxDetails.token = null; + pendingTxDetails.tokenSymbol = null; + } } // Carry decoded calldata info through to success/error views diff --git a/src/popup/views/txStatus.js b/src/popup/views/txStatus.js index fb728fb..bd31eee 100644 --- a/src/popup/views/txStatus.js +++ b/src/popup/views/txStatus.js @@ -91,12 +91,17 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) { // native currency of txInfo.chainId, the network the transaction was sent // on, not the active network's: the active network can change, in // Settings or when the user approves a site's request, while this screen - // is open or before a later popup resumes it. - const symbol = - txInfo.token === "ETH" - ? nativeCurrencyByChainId(txInfo.chainId) - : displaySymbol(txInfo.tokenSymbol || "?"); - $("wait-tx-summary").textContent = txInfo.amount + " " + symbol; + // is open or before a later popup resumes it. A null token is a swap whose + // amount was not decoded (see showTxApproval() in approval.js): the amount + // says so, and no symbol follows it. + let symbol = ""; + if (txInfo.token === "ETH") { + symbol = nativeCurrencyByChainId(txInfo.chainId); + } else if (txInfo.token !== null) { + symbol = displaySymbol(txInfo.tokenSymbol || "?"); + } + $("wait-tx-summary").textContent = + txInfo.amount + (symbol ? " " + symbol : ""); $("wait-tx-to").innerHTML = toAddressHtml(txInfo.to); $("wait-tx-hash").innerHTML = txHashHtml(txHash); attachCopyHandlers("view-wait-tx"); @@ -231,10 +236,13 @@ function restoreWait() { function showSuccess(txInfo, txHash, blockNumber) { endWait(); - const symbol = - txInfo.token === "ETH" - ? nativeCurrencyByChainId(txInfo.chainId) - : displaySymbol(txInfo.tokenSymbol || "?"); + // As on the wait screen, a null token has no symbol. + let symbol = ""; + if (txInfo.token === "ETH") { + symbol = nativeCurrencyByChainId(txInfo.chainId); + } else if (txInfo.token !== null) { + symbol = displaySymbol(txInfo.tokenSymbol || "?"); + } state.viewData = { amount: txInfo.amount, symbol: symbol, @@ -294,7 +302,8 @@ function renderSuccess() { } else { summarySection.classList.remove("hidden"); toSection.classList.remove("hidden"); - $("success-tx-summary").textContent = d.amount + " " + d.symbol; + $("success-tx-summary").textContent = + d.amount + (d.symbol ? " " + d.symbol : ""); $("success-tx-to").innerHTML = toAddressHtml(d.to); } @@ -317,10 +326,13 @@ function renderSuccess() { function showError(txInfo, txHash, message) { endWait(); - const symbol = - txInfo.token === "ETH" - ? nativeCurrencyByChainId(txInfo.chainId) - : displaySymbol(txInfo.tokenSymbol || "?"); + // As on the wait screen, a null token has no symbol. + let symbol = ""; + if (txInfo.token === "ETH") { + symbol = nativeCurrencyByChainId(txInfo.chainId); + } else if (txInfo.token !== null) { + symbol = displaySymbol(txInfo.tokenSymbol || "?"); + } state.viewData = { amount: txInfo.amount, symbol: symbol, @@ -334,7 +346,8 @@ function showError(txInfo, txHash, message) { function renderError() { const d = state.viewData; if (!d || !d.message) return; - $("error-tx-summary").textContent = d.amount + " " + d.symbol; + $("error-tx-summary").textContent = + d.amount + (d.symbol ? " " + d.symbol : ""); $("error-tx-to").innerHTML = toAddressHtml(d.to); $("error-tx-message").textContent = d.message; diff --git a/src/shared/uniswap.js b/src/shared/uniswap.js index 0b62a8c..4bcd9c9 100644 --- a/src/shared/uniswap.js +++ b/src/shared/uniswap.js @@ -53,6 +53,11 @@ function formatAmount(raw, decimals) { // on a scale — not as a token name, and not as a quantity. const UNNAMED_CURRENCY = "Unknown (not named in the calldata)"; +// The same refusal when a V4 swap action could not be read (see +// decodeExactly()). That action names both of its currencies, so saying the +// calldata did not name one would be false. +const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; + // 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 @@ -295,6 +300,24 @@ function v4ExactInAmount(raw) { return raw === 0n ? OPEN_DELTA : raw; } +// The V4 swap actions are read only in the parameter structs V4Router has +// deployed on mainnet and Sepolia. v4-periphery `main` has since added a +// `minHopPriceX36` field to all four (`src/interfaces/IV4Router.sol`), and +// reading a shape nobody has deployed would be guessing. ethers alone would +// read the newer structs anyway: it takes whatever word sits where a +// deployed field would be, ignores bytes left over, and holds back an error +// in a field until that field is used. The newer multi-hop exact-in struct +// then yields the offset of its hop-price array as `amountIn`. So a decode +// counts only when encoding the result again gives back the same bytes; +// otherwise this throws, and decodeV4Swap() marks the step as not decoded. +function decodeExactly(types, data) { + const decoded = coder.decode(types, data); + if (coder.encode(types, decoded) !== data) { + throw new Error("Not in the deployed V4 parameter shape."); + } + return decoded; +} + // Decode V4_SWAP (command 0x10) input bytes. // The input is ABI-encoded as (bytes actions, bytes[] params). // We extract token addresses from SETTLE (input) and TAKE (output) sub-actions, @@ -309,6 +332,10 @@ function decodeV4Swap(input) { let takeToken = null; let amountIn = null; let amountOutMin = null; + // Set when a swap action's parameters are not in the deployed shape + // (see decodeExactly()), so nothing was read from that action. SETTLE + // and TAKE may still name both currencies. + let swapUndecoded = false; for (let i = 0; i < actions.length; i++) { const actionId = actions[i]; @@ -337,7 +364,7 @@ function decodeV4Swap(input) { // tuple(address,uint24,int24,address,bytes)[] path, // uint128 amountIn, uint128 amountOutMin) try { - const s = coder.decode( + const s = decodeExactly( [ "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", ], @@ -355,13 +382,13 @@ function decodeV4Swap(input) { amountOutMin = s[0][3]; } } catch { - // Fall through — SETTLE/TAKE will provide tokens + swapUndecoded = true; } } else { // ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey, // bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData) try { - const s = coder.decode( + const s = decodeExactly( [ "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", ], @@ -384,7 +411,7 @@ function decodeV4Swap(input) { amountOutMin = s[0][3]; } } catch { - // Fall through + swapUndecoded = true; } } } else if ( @@ -393,7 +420,7 @@ function decodeV4Swap(input) { ) { if (actionId === V4_SWAP_EXACT_OUT) { try { - const s = coder.decode( + const s = decodeExactly( [ "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", ], @@ -405,11 +432,11 @@ function decodeV4Swap(input) { settleToken = path[path.length - 1][0]; } } catch { - // Fall through + swapUndecoded = true; } } else { try { - const s = coder.decode( + const s = decodeExactly( [ "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", ], @@ -426,7 +453,7 @@ function decodeV4Swap(input) { ? poolKey[1] : poolKey[0]; } catch { - // Fall through + swapUndecoded = true; } } } @@ -440,6 +467,7 @@ function decodeV4Swap(input) { tokenOut: takeToken, amountIn, amountOutMin, + swapUndecoded, }; } catch { return null; @@ -465,6 +493,7 @@ function decode(data, toAddress, sources) { let minOutput = null; let hasUnwrapWeth = false; let hasV2ExactOut = false; + let swapUndecoded = false; const commandNames = []; // THE INVARIANT: an amount and the token it is counted in always come @@ -567,7 +596,20 @@ function decode(data, toAddress, sources) { if (cmdId === 0x10) { const v4 = decodeV4Swap(inputs[i]); - if (v4) { + if (v4 && v4.swapUndecoded) { + // The unread swap action named this step's currencies + // and amounts, so the step is marked and still sets + // both sides as a swap step does. A side its SETTLE or + // TAKE did not name is set undetermined, not left to + // an earlier or later step's token and figure. + swapUndecoded = true; + commandNames[i] += " (could not be decoded)"; + if (!inputEstablished) { + setInput(v4.tokenIn, v4.amountIn); + } + outputToken = v4.tokenOut; + minOutput = v4.amountOutMin; + } else if (v4) { setInputOnce(v4.tokenIn, v4.amountIn); // Always update output: last swap step wins. A step // that carries the Min. received figure but decoded no @@ -591,16 +633,19 @@ function decode(data, toAddress, sources) { } } - // Resolve token info. A null token on either side means the calldata - // named no currency for it; tokenInfo() refuses rather than calling it - // ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH - // when the output side is WETH, or when no step set the output side. - // Any other output keeps its own token and figure: a swap that buys - // USDC and then unwraps the WETH it did not spend receives USDC. + // Resolve token info. A null token on either side means no step named + // a currency for it, or a V4 step whose swap action went unread left + // it undetermined; tokenInfo() refuses rather than calling it ETH. + // UNWRAP_WETH turns WETH into ETH, so it makes the output ETH when the + // output side is WETH, or when no step set the output side. A step + // whose swap action went unread did set it, to undetermined. Any other + // output keeps its own token and figure: a swap that buys USDC and + // then unwraps the WETH it did not spend receives USDC. const outputIsWeth = present(outputToken) && WETH_ADDRESSES.includes(outputToken.toLowerCase()); - const outputUnset = !present(outputToken) && !present(minOutput); + const outputUnset = + !present(outputToken) && !present(minOutput) && !swapUndecoded; const inInfo = tokenInfo(inputToken, sources); const outInfo = hasUnwrapWeth && (outputIsWeth || outputUnset) @@ -640,7 +685,10 @@ function decode(data, toAddress, sources) { // rather than going missing or naming a token by default. Same // wording as the Token Out refusal below: the two sides of this // screen must not describe the same condition in two ways. - details.push({ label: "Token In", value: UNNAMED_CURRENCY }); + details.push({ + label: "Token In", + value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY, + }); } if (present(inputAmount)) { @@ -703,7 +751,10 @@ function decode(data, toAddress, sources) { // rather than going missing or naming a token by default, and a // Min. received figure below it is never attached to a token the // calldata did not state. - details.push({ label: "Token Out", value: UNNAMED_CURRENCY }); + details.push({ + label: "Token Out", + value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY, + }); } if (present(minOutput)) { diff --git a/tests/swapStatusAmount.test.js b/tests/swapStatusAmount.test.js new file mode 100644 index 0000000..3dd8d19 --- /dev/null +++ b/tests/swapStatusAmount.test.js @@ -0,0 +1,342 @@ +// The amount the wait and error screens show for a swap whose amount the +// decoder did not read (https://git.eeqj.de/sneak/AutistMask/issues/358). +// +// A V4 swap action not in the deployed shape is not read, and a V4 exact-out +// action is read for its currencies only, so the approval screen has no +// Amount line for either. The wait and error screens then showed the +// transaction's ETH value as the amount, in the swap's input token when the +// calldata named one ("0.0000 WETH" for a swap of 1 WETH) and in ETH when it +// did not ("0.0000 ETH"). They now say the amount could not be decoded. A swap +// paid in native ETH still shows the transaction's ETH value there: that value +// is the ETH the swap pays. +// +// Driven through the real approval screen, password and Confirm included, +// against a minimal DOM stub in the shape tests/approvalNonce.test.js uses. +// The vault is mocked so that no password has to be hashed. + +jest.mock("../src/shared/vault", () => ({ + decryptWithPassword: jest.fn(), +})); + +// The wait screen polls for a receipt, which is there once mockReceipt is set. +let mockReceipt = null; +jest.mock("../src/shared/balances", () => ({ + getProvider: () => ({ getTransactionReceipt: async () => mockReceipt }), + refreshBalances: () => {}, +})); + +globalThis.chrome = { + storage: { local: { get: async () => ({}), set: async () => {} } }, +}; + +const { AbiCoder, Interface } = require("ethers"); +const { state } = require("../src/shared/state"); +const { decryptWithPassword } = require("../src/shared/vault"); +const approval = require("../src/popup/views/approval"); +const txStatus = require("../src/popup/views/txStatus"); + +// A well-known test phrase, and its first address. +const PHRASE = "test test test test test test test test test test test junk"; +const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const TX_HASH = "0x" + "4".repeat(64); + +const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; +const ZERO = "0x0000000000000000000000000000000000000000"; +// Both in the bundled list: WETH at 18 decimals, USDC at 6. +const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const ONE_WETH = 1000000000000000000n; + +const coder = AbiCoder.defaultAbiCoder(); +const routerIface = new Interface([ + "function execute(bytes commands, bytes[] inputs, uint256 deadline)", +]); + +// V4 action ids and their parameters, each swapping WETH for USDC. +const POOL_KEY = "tuple(address,address,uint24,int24,address)"; +const WETH_USDC_POOL = [WETH, USDC, 100, 1, ZERO]; +const SWAP_EXACT_IN_SINGLE = 0x06; +const SWAP_EXACT_OUT_SINGLE = 0x08; +const SETTLE = 0x0b; +const SETTLE_ALL = 0x0c; +const TAKE = 0x0e; +const TAKE_ALL = 0x0f; + +// The struct V4Router has deployed on mainnet and Sepolia. +const exactInSingle = coder.encode( + [`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`], + [[WETH_USDC_POOL, true, ONE_WETH, 990000n, "0x"]], +); +// The one v4-periphery `main` has moved to, with a `minHopPriceX36` field. +const newerExactInSingle = coder.encode( + [`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`], + [[WETH_USDC_POOL, true, ONE_WETH, 990000n, 0n, "0x"]], +); +// Deployed shape: buys 990000 base units of USDC for at most 1 WETH. +const exactOutSingle = coder.encode( + [`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`], + [[WETH_USDC_POOL, true, 990000n, ONE_WETH, "0x"]], +); +const settleWeth = coder.encode( + ["address", "uint256", "bool"], + [WETH, 0n, true], +); +const takeUsdc = coder.encode( + ["address", "address", "uint256"], + [USDC, FROM, 0n], +); +const settleAllWeth = coder.encode(["address", "uint256"], [WETH, ONE_WETH]); +const takeAllUsdc = coder.encode(["address", "uint256"], [USDC, 990000n]); + +// The same swaps paid in native ETH, which V4 names as the zero address. +const ETH_USDC_POOL = [ZERO, USDC, 500, 10, ZERO]; +const ONE_ETH = 1000000000000000000n; +const ethExactOutSingle = coder.encode( + [`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`], + [[ETH_USDC_POOL, true, 990000n, ONE_ETH, "0x"]], +); +const newerEthExactInSingle = coder.encode( + [`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`], + [[ETH_USDC_POOL, true, ONE_ETH, 990000n, 0n, "0x"]], +); +// The router pays it from the ETH the transaction sends. +const settleEth = coder.encode( + ["address", "uint256", "bool"], + [ZERO, 0n, false], +); + +// execute() with one V4_SWAP step of these actions, given as [id, params]. +function v4SwapData(actions) { + const input = coder.encode( + ["bytes", "bytes[]"], + [new Uint8Array(actions.map((a) => a[0])), actions.map((a) => a[1])], + ); + return routerIface.encodeFunctionData("execute", [ + "0x10", + [input], + 9999999999n, + ]); +} + +function makeElement(id) { + const classes = new Set(); + const el = { + id, + textContent: "", + value: "", + innerHTML: "", + disabled: false, + style: {}, + dataset: {}, + listeners: {}, + classList: { + add: (...names) => names.forEach((n) => classes.add(n)), + remove: (...names) => names.forEach((n) => classes.delete(n)), + contains: (n) => classes.has(n), + toggle: (n, force) => { + const on = force === undefined ? !classes.has(n) : force; + if (on) classes.add(n); + else classes.delete(n); + return on; + }, + }, + addEventListener: (name, fn) => { + el.listeners[name] = el.listeners[name] || []; + el.listeners[name].push(fn); + }, + querySelectorAll: () => [], + appendChild: () => {}, + }; + // Views reach for .parentElement to hide whole sections. + Object.defineProperty(el, "parentElement", { + get: () => node(id + "-parent"), + }); + return el; +} + +function makeDocument() { + const els = new Map(); + return { + getElementById(id) { + // The debug banner is created on demand by helpers.js; absent + // is the state a non-debug, non-testnet popup is in. + 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); +} + +function click(id) { + return Promise.all((node(id).listeners.click || []).map((fn) => fn())); +} + +// Open the approval screen for this calldata, sent to the router with `value` +// wei (no ETH unless given), and confirm it. The background answers the signed +// transaction with `response`. +async function approveSwap(data, response, value = 0n) { + globalThis.document = makeDocument(); + globalThis.window = { location: { search: "" }, close: () => {} }; + globalThis.chrome.runtime = { + connect: () => ({ postMessage: () => {} }), + sendMessage: (msg, reply) => { + if (!reply) return; + if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(response); + reply({ + type: "tx", + origin: "https://dapp.example", + isPhishingDomain: false, + approvedFrom: FROM, + approvedTx: { + type: 2, + from: FROM, + chainId: "0x1", + nonce: "0x7", + gasLimit: "0x30d40", + maxPriorityFeePerGas: "0x3b9aca00", + maxFeePerGas: "0x77359400", + to: ROUTER, + value: "0x" + value.toString(16), + data, + accessList: [], + }, + }); + }, + }; + state.activeAddress = FROM; + state.trackedTokens = []; + state.wallets = [ + { + type: "hd", + name: "Wallet 1", + xpub: "xpub-wallet-1", + encryptedSecret: "encrypted-secret-1", + nextIndex: 1, + addresses: [ + { address: FROM, index: 0, balance: "0", tokenBalances: [] }, + ], + }, + ]; + txStatus.init({ doRefreshAndRender: () => {} }); + approval.init({}); + await approval.show(1); + decryptWithPassword.mockResolvedValue(PHRASE); + node("approve-tx-password").value = "any password"; + await click("btn-approve-tx"); +} + +afterEach(() => { + txStatus.endWait(); + mockReceipt = null; +}); + +describe.each([ + [ + "a swap action in the deployed shape", + [ + [SWAP_EXACT_IN_SINGLE, exactInSingle], + [SETTLE, settleWeth], + [TAKE, takeUsdc], + ], + "1.0000 WETH", + ], + [ + "a swap action in the newer shape, with SETTLE naming WETH", + [ + [SWAP_EXACT_IN_SINGLE, newerExactInSingle], + [SETTLE, settleWeth], + [TAKE, takeUsdc], + ], + "Unknown (could not be decoded)", + ], + [ + "a swap action in the newer shape, with SETTLE_ALL and TAKE_ALL", + [ + [SWAP_EXACT_IN_SINGLE, newerExactInSingle], + [SETTLE_ALL, settleAllWeth], + [TAKE_ALL, takeAllUsdc], + ], + "Unknown (could not be decoded)", + ], + [ + "an exact-out swap action, which is read for its currencies only", + [ + [SWAP_EXACT_OUT_SINGLE, exactOutSingle], + [SETTLE, settleWeth], + [TAKE, takeUsdc], + ], + "Unknown (could not be decoded)", + ], +])("%s", (_name, actions, expected) => { + test("the wait screen's amount", async () => { + await approveSwap(v4SwapData(actions), { txHash: TX_HASH }); + expect(node("wait-tx-summary").textContent).toBe(expected); + }); + + test("the error screen's amount", async () => { + await approveSwap(v4SwapData(actions), { + error: "The transaction could not be sent.", + retryable: false, + }); + expect(node("error-tx-summary").textContent).toBe(expected); + }); + + // The success screen shows the approval screen's decoded lines in place + // of the amount, so the ETH value is not on it either. + test("the success screen has no amount line of its own", async () => { + await approveSwap(v4SwapData(actions), { txHash: TX_HASH }); + mockReceipt = { blockNumber: 1 }; + // A later popup resumes the wait, and the receipt is there. + txStatus.restoreWait(); + for (let i = 0; i < 10; i++) { + await new Promise((r) => setTimeout(r, 0)); + } + const summary = node("success-tx-summary").parentElement; + expect(summary.classList.contains("hidden")).toBe(true); + expect(node("success-tx-decoded").innerHTML).not.toContain("0.0000"); + }); +}); + +// A swap paid in native ETH has a Token In of "ETH (native)" and no Amount +// line, and sends 1 ETH. +describe.each([ + [ + "an exact-out swap action paid in ETH, in the deployed shape", + [ + [SWAP_EXACT_OUT_SINGLE, ethExactOutSingle], + [SETTLE, settleEth], + [TAKE, takeUsdc], + ], + ], + [ + "a swap action in the newer shape paid in ETH, with SETTLE naming ETH", + [ + [SWAP_EXACT_IN_SINGLE, newerEthExactInSingle], + [SETTLE, settleEth], + [TAKE, takeUsdc], + ], + ], +])("%s", (_name, actions) => { + test("the wait screen shows the ETH the transaction sends", async () => { + await approveSwap(v4SwapData(actions), { txHash: TX_HASH }, ONE_ETH); + expect(node("wait-tx-summary").textContent).toBe("1.0000 ETH"); + }); + + test("the error screen shows the ETH the transaction sends", async () => { + await approveSwap( + v4SwapData(actions), + { + error: "The transaction could not be sent.", + retryable: false, + }, + ONE_ETH, + ); + expect(node("error-tx-summary").textContent).toBe("1.0000 ETH"); + }); +}); diff --git a/tests/uniswap.test.js b/tests/uniswap.test.js index 952a340..8fa6606 100644 --- a/tests/uniswap.test.js +++ b/tests/uniswap.test.js @@ -163,6 +163,122 @@ function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) { ); } +// Each V4 swap action's parameters in two shapes: the struct V4Router has +// deployed on mainnet and Sepolia, and the one v4-periphery `main` has since +// moved to, which adds a `minHopPriceX36` field. Each swaps WETH for USDC. +const POOL_KEY = "tuple(address,address,uint24,int24,address)"; +const PATH_KEY = "tuple(address,uint24,int24,address,bytes)"; +const WETH_USDC_POOL = [WETH_ADDR, USDC_ADDR, 100, 1, ZERO_ADDR]; +const ONE_WETH = 1000000000000000000n; +const V4_SWAP_ACTION_SHAPES = [ + { + action: "SWAP_EXACT_IN_SINGLE", + id: 0x06, + deployed: coder.encode( + [`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`], + [[WETH_USDC_POOL, true, ONE_WETH, 990000n, "0x"]], + ), + newer: coder.encode( + [`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`], + [[WETH_USDC_POOL, true, ONE_WETH, 990000n, 0n, "0x"]], + ), + }, + { + action: "SWAP_EXACT_IN", + id: 0x07, + deployed: coder.encode( + [`tuple(address,${PATH_KEY}[],uint128,uint128)`], + [ + [ + WETH_ADDR, + [[USDC_ADDR, 100, 1, ZERO_ADDR, "0x"]], + ONE_WETH, + 990000n, + ], + ], + ), + newer: coder.encode( + [`tuple(address,${PATH_KEY}[],uint256[],uint128,uint128)`], + [ + [ + WETH_ADDR, + [[USDC_ADDR, 100, 1, ZERO_ADDR, "0x"]], + [], + ONE_WETH, + 990000n, + ], + ], + ), + }, + { + action: "SWAP_EXACT_OUT_SINGLE", + id: 0x08, + deployed: coder.encode( + [`tuple(${POOL_KEY},bool,uint128,uint128,bytes)`], + [[WETH_USDC_POOL, true, 990000n, ONE_WETH, "0x"]], + ), + newer: coder.encode( + [`tuple(${POOL_KEY},bool,uint128,uint128,uint256,bytes)`], + [[WETH_USDC_POOL, true, 990000n, ONE_WETH, 0n, "0x"]], + ), + }, + { + action: "SWAP_EXACT_OUT", + id: 0x09, + deployed: coder.encode( + [`tuple(address,${PATH_KEY}[],uint128,uint128)`], + [ + [ + USDC_ADDR, + [[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"]], + 990000n, + ONE_WETH, + ], + ], + ), + newer: coder.encode( + [`tuple(address,${PATH_KEY}[],uint256[],uint128,uint128)`], + [ + [ + USDC_ADDR, + [[WETH_ADDR, 100, 1, ZERO_ADDR, "0x"]], + [], + 990000n, + ONE_WETH, + ], + ], + ), + }, +]; + +// Helper: execute() with one V4_SWAP step holding the swap action, then +// SETTLE of WETH and TAKE of USDC. +function v4SwapCalldata(actionId, swapParams) { + return buildExecute( + "0x10", + [ + encodeV4Swap(new Uint8Array([actionId, V4_SETTLE_ID, V4_TAKE_ID]), [ + swapParams, + encodeV4Settle(WETH_ADDR), + encodeV4Take(USDC_ADDR), + ]), + ], + 9999999999n, + ); +} + +// V4 SETTLE_ALL (currency, maxAmount) and TAKE_ALL (currency, minAmount). The +// decoder reads neither, so in a V4_SWAP of a swap action and these, only the +// swap action names the currencies. +const V4_SETTLE_ALL_ID = 0x0c; +const V4_TAKE_ALL_ID = 0x0f; +function encodeV4SettleAll(currency, maxAmount) { + return coder.encode(["address", "uint256"], [currency, maxAmount]); +} +function encodeV4TakeAll(currency, minAmount) { + return coder.encode(["address", "uint256"], [currency, minAmount]); +} + function detail(result, label) { return result.details.find((d) => d.label === label); } @@ -203,9 +319,157 @@ describe("uniswap decoder", () => { result.details.find((d) => d.label === "Min. received").value, ).toBe("0.0002 ETH"); + // Real mainnet calldata is in the deployed shape, so its V4 step is + // not said to be undecoded. const steps = result.details.find((d) => d.label === "Steps"); - expect(steps.value).toContain("Permit2 Permit"); - expect(steps.value).toContain("V4 Swap"); + expect(steps.value).toBe("Permit2 Permit → V4 Swap"); + }); + + test.each(V4_SWAP_ACTION_SHAPES)( + "a V4 $action in the deployed shape is read", + ({ id, deployed }) => { + const result = uniswap.decode( + v4SwapCalldata(id, deployed), + ROUTER_ADDR, + ); + expect(detail(result, "Steps").value).toBe("V4 Swap"); + }, + ); + + // https://git.eeqj.de/sneak/AutistMask/issues/358 — before it, all four + // newer structs decoded as the deployed ones without an error, and the + // newer SWAP_EXACT_IN showed the offset of its hop-price array as Amount + // ("0.0000000000000004 WETH") and its amountIn as Min. received. + test.each(V4_SWAP_ACTION_SHAPES)( + "a V4 $action in the newer periphery shape is shown as not decoded", + ({ id, newer }) => { + const result = uniswap.decode( + v4SwapCalldata(id, newer), + ROUTER_ADDR, + ); + expect(detail(result, "Amount")).toBeUndefined(); + expect(detail(result, "Min. received")).toBeUndefined(); + expect(detail(result, "Steps").value).toBe( + "V4 Swap (could not be decoded)", + ); + // SETTLE and TAKE keep their own shape and still name both sides. + expect(detail(result, "Token In").address).toBe(WETH_ADDR); + expect(detail(result, "Token Out").address).toBe(USDC_ADDR); + }, + ); + + // Only the unread swap action names the currencies here. The token lines + // must not say the calldata did not name them. + test.each(V4_SWAP_ACTION_SHAPES)( + "a V4 $action in the newer shape, then SETTLE_ALL and TAKE_ALL, says both tokens could not be decoded", + ({ id, newer }) => { + const data = buildExecute( + "0x10", + [ + encodeV4Swap( + new Uint8Array([id, V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID]), + [ + newer, + encodeV4SettleAll(WETH_ADDR, ONE_WETH), + encodeV4TakeAll(USDC_ADDR, 990000n), + ], + ), + ], + 9999999999n, + ); + const result = uniswap.decode(data, ROUTER_ADDR); + expect(detail(result, "Steps").value).toBe( + "V4 Swap (could not be decoded)", + ); + expect(detail(result, "Token In").value).toBe( + "Unknown (could not be decoded)", + ); + expect(detail(result, "Token Out").value).toBe( + "Unknown (could not be decoded)", + ); + }, + ); + + // The user receives the V4 step's USDC, which only its unread swap + // action names. The V3 step's WETH and its Min. received figure were + // shown instead. + test.each(V4_SWAP_ACTION_SHAPES)( + "a V3 step, then a V4 $action in the newer shape and TAKE_ALL, does not show the V3 step's output", + ({ id, newer }) => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x00, 0x10]), + [ + encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [ + USDT_ADDR, + WETH_ADDR, + ]), + encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [ + newer, + encodeV4TakeAll(USDC_ADDR, 990000n), + ]), + ], + 9999999999n, + ); + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result.name).toBe("Uniswap Swap"); + expect(detail(result, "Amount").value).toBe("2.0000 USDT"); + expect(detail(result, "Token Out").value).toBe( + "Unknown (could not be decoded)", + ); + expect(detail(result, "Min. received")).toBeUndefined(); + }, + ); + + // The user pays the V4 step's WETH, which only its unread swap action + // names. The V3 step's USDC and its amount were shown instead. + test.each(V4_SWAP_ACTION_SHAPES)( + "a V4 $action in the newer shape and SETTLE_ALL, then a V3 step, does not show the V3 step's input", + ({ id, newer }) => { + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x10, 0x00]), + [ + encodeV4Swap(new Uint8Array([id, V4_SETTLE_ALL_ID]), [ + newer, + encodeV4SettleAll(WETH_ADDR, ONE_WETH), + ]), + encodeV3SwapExactIn( + USER_ADDR, + 990000n, + 980000000000000000n, + [USDC_ADDR, DAI_ADDR], + ), + ], + 9999999999n, + ); + const result = uniswap.decode(data, ROUTER_ADDR); + expect(result.name).toBe("Uniswap Swap"); + expect(detail(result, "Token In").value).toBe( + "Unknown (could not be decoded)", + ); + expect(detail(result, "Amount")).toBeUndefined(); + expect(detail(result, "Min. received").value).toBe("0.9800 DAI"); + }, + ); + + // UNWRAP_WETH makes Token Out ETH when no step set the output side. An + // unread swap action's step did set it, to undetermined. + test("an UNWRAP_WETH after an unread V4 swap action does not make Token Out ETH", () => { + const { id, newer } = V4_SWAP_ACTION_SHAPES[0]; + const data = buildExecute( + solidityPacked(["uint8", "uint8"], [0x10, 0x0c]), + [ + encodeV4Swap(new Uint8Array([id, V4_TAKE_ALL_ID]), [ + newer, + encodeV4TakeAll(USDC_ADDR, 990000n), + ]), + encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding + ], + 9999999999n, + ); + const result = uniswap.decode(data, ROUTER_ADDR); + expect(detail(result, "Token Out").value).toBe( + "Unknown (could not be decoded)", + ); }); test("decodes V2_SWAP_EXACT_IN with known tokens", () => { @@ -548,9 +812,11 @@ describe("uniswap decoder", () => { const result = uniswap.decode(data, ROUTER_ADDR); expect(result).not.toBeNull(); + // The V4 swap action's parameters are empty, so that step is said to + // be undecoded. const steps = result.details.find((d) => d.label === "Steps"); expect(steps.value).toBe( - "Permit2 Permit \u2192 V4 Swap \u2192 Unwrap WETH", + "Permit2 Permit \u2192 V4 Swap (could not be decoded) \u2192 Unwrap WETH", ); }); diff --git a/tests/uniswapUndeterminedTokenIn.test.js b/tests/uniswapUndeterminedTokenIn.test.js index ae0061d..f8266ec 100644 --- a/tests/uniswapUndeterminedTokenIn.test.js +++ b/tests/uniswapUndeterminedTokenIn.test.js @@ -97,7 +97,11 @@ describe("an execute() whose input currency never decoded", () => { ]); test("says the input token is unknown instead of naming ETH", () => { - expect(detail(data(), "Token In").value).toBe(REFUSAL); + // The swap action that would name it could not be read, so the line + // says that rather than REFUSAL's "not named in the calldata". + expect(detail(data(), "Token In").value).toBe( + "Unknown (could not be decoded)", + ); }); test("keeps a Token In line, so the screen never omits what is paid", () => {