1 Commits
Author SHA1 Message Date
sneak 08fa969cd6 fix: a V4 swap action not in the deployed shape is shown as not decoded (closes #358)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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.

Model: opus-5-5
2026-10-15 23:43:05 +00:00
7 changed files with 692 additions and 49 deletions
+23 -11
View File
@@ -1125,14 +1125,18 @@ read:
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each 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 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 that side, or `Unknown (could not be decoded)` instead when a V4 swap action in
is WETH, on mainnet or Sepolia, or when no step set the output side; a WETH the transaction could not be read (see Transaction Decoding below). A V4 step
`Min. received` figure then reads in ETH. Otherwise `Token Out` and whose swap action could not be read still sets each side as a swap step does,
`Min. received` keep the output side's own token and figure, whether the swap and a side its `SETTLE` or `TAKE` did not name is then unknown: no other step's
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then token or figure is shown for it. An `UNWRAP_WETH` step makes `Token Out` ETH
unwraps the WETH it did not spend shows USDC. The token permission warning on only when the output side is WETH, on mainnet or Sepolia, or when no step set
the signature screen has its own amount wording, including `Unknown`; the the output side; a WETH `Min. received` figure then reads in ETH. Otherwise
SignApproval section below describes it. `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. 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 `2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
@@ -1694,7 +1698,10 @@ view would leave a wallet one click from deletion.
- **When**: Transaction has been broadcast, waiting for on-chain confirmation. - **When**: Transaction has been broadcast, waiting for on-chain confirmation.
- **Elements**: - **Elements**:
- "Transaction Broadcast" heading (no back button — tx is irreversible) - "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
- To: color dot + full address + etherscan link; for a contract creation, - To: color dot + full address + etherscan link; for a contract creation,
which has no recipient, "This transaction creates a new contract. It has which has no recipient, "This transaction creates a new contract. It has
no recipient." instead no recipient." instead
@@ -1741,7 +1748,8 @@ view would leave a wallet one click from deletion.
- **When**: Transaction broadcast failed, or timed out waiting for confirmation. - **When**: Transaction broadcast failed, or timed out waiting for confirmation.
- **Elements**: - **Elements**:
- "Transaction Failed" heading - "Transaction Failed" heading
- Amount + symbol - Amount + symbol, or for a swap the approval screen showed no `Amount` for,
the same words as on WaitTx
- To: color dot + full address + etherscan link, or for a contract creation - To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx the same sentence as on WaitTx
- Error message (dashed border box) - Error message (dashed border box)
@@ -2747,7 +2755,11 @@ Currently supported:
or recipient, and amount. or recipient, and amount.
- **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g. - **Uniswap Universal Router**: `execute()` calls — shows swap direction (e.g.
"Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline. "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 ### Non-Goals Forever
+12
View File
@@ -298,6 +298,18 @@ function showTxApproval(details) {
sources, 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.
if (
decoded.details.some((d) => d.label === "Token In") &&
!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 // Carry decoded calldata info through to success/error views
+29 -16
View File
@@ -91,12 +91,17 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// native currency of txInfo.chainId, the network the transaction was sent // native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: the active network can change, in // 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 // Settings or when the user approves a site's request, while this screen
// is open or before a later popup resumes it. // is open or before a later popup resumes it. A null token is a swap whose
const symbol = // amount was not decoded (see showTxApproval() in approval.js): the amount
txInfo.token === "ETH" // says so, and no symbol follows it.
? nativeCurrencyByChainId(txInfo.chainId) let symbol = "";
: displaySymbol(txInfo.tokenSymbol || "?"); if (txInfo.token === "ETH") {
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol; 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-to").innerHTML = toAddressHtml(txInfo.to);
$("wait-tx-hash").innerHTML = txHashHtml(txHash); $("wait-tx-hash").innerHTML = txHashHtml(txHash);
attachCopyHandlers("view-wait-tx"); attachCopyHandlers("view-wait-tx");
@@ -231,10 +236,13 @@ function restoreWait() {
function showSuccess(txInfo, txHash, blockNumber) { function showSuccess(txInfo, txHash, blockNumber) {
endWait(); endWait();
const symbol = // As on the wait screen, a null token has no symbol.
txInfo.token === "ETH" let symbol = "";
? nativeCurrencyByChainId(txInfo.chainId) if (txInfo.token === "ETH") {
: displaySymbol(txInfo.tokenSymbol || "?"); symbol = nativeCurrencyByChainId(txInfo.chainId);
} else if (txInfo.token !== null) {
symbol = displaySymbol(txInfo.tokenSymbol || "?");
}
state.viewData = { state.viewData = {
amount: txInfo.amount, amount: txInfo.amount,
symbol: symbol, symbol: symbol,
@@ -294,7 +302,8 @@ function renderSuccess() {
} else { } else {
summarySection.classList.remove("hidden"); summarySection.classList.remove("hidden");
toSection.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); $("success-tx-to").innerHTML = toAddressHtml(d.to);
} }
@@ -317,10 +326,13 @@ function renderSuccess() {
function showError(txInfo, txHash, message) { function showError(txInfo, txHash, message) {
endWait(); endWait();
const symbol = // As on the wait screen, a null token has no symbol.
txInfo.token === "ETH" let symbol = "";
? nativeCurrencyByChainId(txInfo.chainId) if (txInfo.token === "ETH") {
: displaySymbol(txInfo.tokenSymbol || "?"); symbol = nativeCurrencyByChainId(txInfo.chainId);
} else if (txInfo.token !== null) {
symbol = displaySymbol(txInfo.tokenSymbol || "?");
}
state.viewData = { state.viewData = {
amount: txInfo.amount, amount: txInfo.amount,
symbol: symbol, symbol: symbol,
@@ -334,7 +346,8 @@ function showError(txInfo, txHash, message) {
function renderError() { function renderError() {
const d = state.viewData; const d = state.viewData;
if (!d || !d.message) return; 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-to").innerHTML = toAddressHtml(d.to);
$("error-tx-message").textContent = d.message; $("error-tx-message").textContent = d.message;
+69 -18
View File
@@ -53,6 +53,11 @@ function formatAmount(raw, decimals) {
// 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.
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)"; 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 // 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 // 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 // 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; 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. // Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params). // The input is ABI-encoded as (bytes actions, bytes[] params).
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions, // We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
@@ -309,6 +332,10 @@ function decodeV4Swap(input) {
let takeToken = null; let takeToken = null;
let amountIn = null; let amountIn = null;
let amountOutMin = 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++) { for (let i = 0; i < actions.length; i++) {
const actionId = actions[i]; const actionId = actions[i];
@@ -337,7 +364,7 @@ function decodeV4Swap(input) {
// tuple(address,uint24,int24,address,bytes)[] path, // tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin) // uint128 amountIn, uint128 amountOutMin)
try { try {
const s = coder.decode( const s = decodeExactly(
[ [
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
], ],
@@ -355,13 +382,13 @@ function decodeV4Swap(input) {
amountOutMin = s[0][3]; amountOutMin = s[0][3];
} }
} catch { } catch {
// Fall through — SETTLE/TAKE will provide tokens swapUndecoded = true;
} }
} else { } else {
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey, // ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData) // bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
try { try {
const s = coder.decode( const s = decodeExactly(
[ [
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
], ],
@@ -384,7 +411,7 @@ function decodeV4Swap(input) {
amountOutMin = s[0][3]; amountOutMin = s[0][3];
} }
} catch { } catch {
// Fall through swapUndecoded = true;
} }
} }
} else if ( } else if (
@@ -393,7 +420,7 @@ function decodeV4Swap(input) {
) { ) {
if (actionId === V4_SWAP_EXACT_OUT) { if (actionId === V4_SWAP_EXACT_OUT) {
try { try {
const s = coder.decode( const s = decodeExactly(
[ [
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
], ],
@@ -405,11 +432,11 @@ function decodeV4Swap(input) {
settleToken = path[path.length - 1][0]; settleToken = path[path.length - 1][0];
} }
} catch { } catch {
// Fall through swapUndecoded = true;
} }
} else { } else {
try { try {
const s = coder.decode( const s = decodeExactly(
[ [
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
], ],
@@ -426,7 +453,7 @@ function decodeV4Swap(input) {
? poolKey[1] ? poolKey[1]
: poolKey[0]; : poolKey[0];
} catch { } catch {
// Fall through swapUndecoded = true;
} }
} }
} }
@@ -440,6 +467,7 @@ function decodeV4Swap(input) {
tokenOut: takeToken, tokenOut: takeToken,
amountIn, amountIn,
amountOutMin, amountOutMin,
swapUndecoded,
}; };
} catch { } catch {
return null; return null;
@@ -465,6 +493,7 @@ function decode(data, toAddress, sources) {
let minOutput = null; let minOutput = null;
let hasUnwrapWeth = false; let hasUnwrapWeth = false;
let hasV2ExactOut = false; let hasV2ExactOut = false;
let swapUndecoded = false;
const commandNames = []; const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come // 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) { if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]); 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); setInputOnce(v4.tokenIn, v4.amountIn);
// Always update output: last swap step wins. A step // Always update output: last swap step wins. A step
// that carries the Min. received figure but decoded no // 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 // Resolve token info. A null token on either side means no step named
// named no currency for it; tokenInfo() refuses rather than calling it // a currency for it, or a V4 step whose swap action went unread left
// ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH // it undetermined; tokenInfo() refuses rather than calling it ETH.
// when the output side is WETH, or when no step set the output side. // UNWRAP_WETH turns WETH into ETH, so it makes the output ETH when the
// Any other output keeps its own token and figure: a swap that buys // output side is WETH, or when no step set the output side. A step
// USDC and then unwraps the WETH it did not spend receives USDC. // 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 = const outputIsWeth =
present(outputToken) && present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase()); WETH_ADDRESSES.includes(outputToken.toLowerCase());
const outputUnset = !present(outputToken) && !present(minOutput); const outputUnset =
!present(outputToken) && !present(minOutput) && !swapUndecoded;
const inInfo = tokenInfo(inputToken, sources); const inInfo = tokenInfo(inputToken, sources);
const outInfo = const outInfo =
hasUnwrapWeth && (outputIsWeth || outputUnset) hasUnwrapWeth && (outputIsWeth || outputUnset)
@@ -640,7 +685,10 @@ function decode(data, toAddress, sources) {
// rather than going missing or naming a token by default. Same // rather than going missing or naming a token by default. Same
// wording as the Token Out refusal below: the two sides of this // wording as the Token Out refusal below: the two sides of this
// screen must not describe the same condition in two ways. // 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)) { if (present(inputAmount)) {
@@ -703,7 +751,10 @@ function decode(data, toAddress, sources) {
// rather than going missing or naming a token by default, and a // rather than going missing or naming a token by default, and a
// Min. received figure below it is never attached to a token the // Min. received figure below it is never attached to a token the
// calldata did not state. // 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)) { if (present(minOutput)) {
+285
View File
@@ -0,0 +1,285 @@
// 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.
//
// 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]);
// 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 no ETH,
// and confirm it. The background answers the signed transaction with
// `response`.
async function approveSwap(data, response) {
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: "0x0",
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");
});
});
+269 -3
View File
@@ -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) { function detail(result, label) {
return result.details.find((d) => d.label === 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, result.details.find((d) => d.label === "Min. received").value,
).toBe("0.0002 ETH"); ).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"); const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toContain("Permit2 Permit"); expect(steps.value).toBe("Permit2 Permit → V4 Swap");
expect(steps.value).toContain("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", () => { test("decodes V2_SWAP_EXACT_IN with known tokens", () => {
@@ -548,9 +812,11 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull(); 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"); const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toBe( expect(steps.value).toBe(
"Permit2 Permit \u2192 V4 Swap \u2192 Unwrap WETH", "Permit2 Permit \u2192 V4 Swap (could not be decoded) \u2192 Unwrap WETH",
); );
}); });
+5 -1
View File
@@ -97,7 +97,11 @@ describe("an execute() whose input currency never decoded", () => {
]); ]);
test("says the input token is unknown instead of naming ETH", () => { 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", () => { test("keeps a Token In line, so the screen never omits what is paid", () => {