Author SHA1 Message Date
sneak ec1254204c 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 parameter structs deployed
on mainnet and Sepolia. ethers decoded the newer v4-periphery structs,
which add minHopPriceX36, as the deployed ones without an error, 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. When a swap action fails that, its step is listed as
"V4 Swap (could not be decoded)", and a token line left without a
currency reads "Unknown (could not be decoded)" rather than "not named in
the calldata". The README records both.

Model: opus-5-5
2026-10-14 09:03:49 +00:00
4 changed files with 259 additions and 29 deletions
+15 -9
View File
@@ -1110,14 +1110,16 @@ 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). An
`Min. received` figure then reads in ETH. Otherwise `Token Out` and `UNWRAP_WETH` step makes `Token Out` ETH only when the output side is WETH, on
`Min. received` keep the output side's own token and figure, whether the swap mainnet or Sepolia, or when no step set the output side; a WETH `Min. received`
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then figure then reads in ETH. Otherwise `Token Out` and `Min. received` keep the
unwraps the WETH it did not spend shows USDC. The token permission warning on output side's own token and figure, whether the swap was paid in ETH or in a
the signature screen has its own amount wording, including `Unknown`; the token: a V2 exact-out swap that buys USDC and then unwraps the WETH it did not
SignApproval section below describes it. 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
@@ -2690,7 +2692,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
+58 -16
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
@@ -568,6 +597,12 @@ 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) {
// Without this, a swap action that went unread would
// look like one that had nothing to show.
if (v4.swapUndecoded) {
swapUndecoded = true;
commandNames[i] += " (could not be decoded)";
}
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,12 +626,13 @@ 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 that
// named no currency for it; tokenInfo() refuses rather than calling it // was read named a currency for it; tokenInfo() refuses rather than
// ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH // calling it ETH. UNWRAP_WETH turns WETH into ETH, so it makes the
// when the output side is WETH, or when no step set the output side. // output ETH when the output side is WETH, or when no step set the
// Any other output keeps its own token and figure: a swap that buys // output side. Any other output keeps its own token and figure: a swap
// USDC and then unwraps the WETH it did not spend receives USDC. // 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());
@@ -640,7 +676,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 +742,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)) {
+181 -3
View File
@@ -163,6 +163,110 @@ 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,
);
}
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,11 +307,83 @@ 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);
},
);
// The decoder does not read SETTLE_ALL or TAKE_ALL, so here only the
// unread swap action names the currencies. 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",
[
// 0x0c is SETTLE_ALL (currency, maxAmount), 0x0f is
// TAKE_ALL (currency, minAmount).
encodeV4Swap(new Uint8Array([id, 0x0c, 0x0f]), [
newer,
coder.encode(
["address", "uint256"],
[WETH_ADDR, ONE_WETH],
),
coder.encode(
["address", "uint256"],
[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)",
);
},
);
test("decodes V2_SWAP_EXACT_IN with known tokens", () => { test("decodes V2_SWAP_EXACT_IN with known tokens", () => {
const data = buildExecute( const data = buildExecute(
"0x08", // V2_SWAP_EXACT_IN "0x08", // V2_SWAP_EXACT_IN
@@ -548,9 +724,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", () => {