fix: the swap decoder reads each command's type with its router's mask (closes #535)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

Each Universal Router address now carries the mask its own source reads a
command's type with: 0x1f for UniversalRouterV1, 0x3f for V1_2 and V2, 0x7f
for V2_1_1 and V2_1_2. The fixed 0x1f dropped the 0x20 bit, so
EXECUTE_SUB_PLAN (0x21) was listed as a V3 exact-out swap. A sub-plan step is
now listed as "Execute Sub-Plan (its steps are not shown)". Its commands are
not read and may spend from or pay the user wherever the step stands, so with
one the summary names no swap direction, Token In and Token Out read
"Unknown (could not be decoded)", and there is no Amount or Min. received.

Model: opus-5-5
This commit was merged in pull request #591.
This commit is contained in:
2026-10-19 07:13:22 +02:00
parent f8b050f501
commit b5d2cedb57
3 changed files with 272 additions and 32 deletions
+182 -1
View File
@@ -1,8 +1,12 @@
const { AbiCoder, Interface, solidityPacked } = require("ethers");
const uniswap = require("../src/shared/uniswap");
// A Universal Router on Ethereum Mainnet, chain id 0x1.
// A Universal Router on Ethereum Mainnet, chain id 0x1: UniversalRouterV2,
// which reads a command's type with the mask 0x3f.
const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
// Two more there, which read it with 0x1f and 0x7f.
const ROUTER_V1_ADDR = "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B";
const ROUTER_V2_1_2_ADDR = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85";
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
@@ -105,6 +109,12 @@ function encodeV4Swap(actions, params) {
return coder.encode(["bytes", "bytes[]"], [actions, params]);
}
// Helper: encode an EXECUTE_SUB_PLAN input (command 0x21), which holds the
// sub-plan's own commands and inputs
function encodeSubPlan(commands, inputs) {
return coder.encode(["bytes", "bytes[]"], [commands, inputs]);
}
// V4 inner action IDs, as src/shared/uniswap.js names them.
const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06;
@@ -864,6 +874,177 @@ describe("uniswap decoder", () => {
);
});
// https://git.eeqj.de/sneak/AutistMask/issues/535 — every command's type
// was read from its low five bits, as only UniversalRouterV1 reads it, so
// the later routers listed 0x21 as a V3 exact-out swap, and
// UniversalRouterV2_1_2 listed 0x40 as a V3 exact-in swap.
test.each([
{
router: "UniversalRouterV1",
address: ROUTER_V1_ADDR,
command: "0x21",
steps: "V3 Swap (Exact Out)",
},
{
router: "UniversalRouterV2",
address: ROUTER_ADDR,
command: "0x21",
steps: "Execute Sub-Plan (its steps are not shown)",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2_ADDR,
command: "0x21",
steps: "Execute Sub-Plan (its steps are not shown)",
},
{
router: "UniversalRouterV1",
address: ROUTER_V1_ADDR,
command: "0x40",
steps: "V3 Swap (Exact In)",
},
{
router: "UniversalRouterV2",
address: ROUTER_ADDR,
command: "0x40",
steps: "V3 Swap (Exact In)",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2_ADDR,
command: "0x40",
steps: "Command 0x40",
},
])(
"command $command sent to $router is listed as $steps",
({ address, command, steps }) => {
const data = buildExecute(command, ["0x"], 9999999999n);
const result = uniswap.decode(data, address, "0x1");
expect(detail(result, "Steps").value).toBe(steps);
},
);
// A sub-plan holding a V3 swap of USDT for WETH, which the decoder does
// not read. With the allow-revert flag (0x80) the command byte is 0xa1.
test.each([
{ flag: "without", command: 0x21 },
{ flag: "with", command: 0xa1 },
])(
"an EXECUTE_SUB_PLAN step $flag the allow-revert flag says its steps are not shown",
({ command }) => {
const subPlan = encodeSubPlan("0x00", [
encodeV3SwapExactIn(USER_ADDR, 2000000n, ONE_WETH, [
USDT_ADDR,
WETH_ADDR,
]),
]);
const data = buildExecute(
solidityPacked(["uint8"], [command]),
[subPlan],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Steps").value).toBe(
"Execute Sub-Plan (its steps are not shown)",
);
// The sub-plan names both currencies, so the lines must not say
// the calldata did not.
expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
},
);
// The steps inside a sub-plan may spend the user's tokens or pay the user,
// so the steps read beside it are not the whole swap, whether they come
// before it or after it. No side is named and no figure is given.
test.each([
{
// 2,000 USDT for at least 1 WETH, paid to the router; the sub-plan
// swaps that WETH for USDC.
beside: "a swap before",
commands: "0x0021",
inputs: [
encodeV3SwapExactIn(ROUTER_ADDR, 2000000000n, ONE_WETH, [
USDT_ADDR,
WETH_ADDR,
]),
encodeSubPlan("0x00", [
encodeV3SwapExactIn(USER_ADDR, ONE_WETH, 2000000000n, [
WETH_ADDR,
USDC_ADDR,
]),
]),
],
steps: "V3 Swap (Exact In) \u2192 Execute Sub-Plan (its steps are not shown)",
},
{
// The sub-plan buys WETH with the user's USDC; 1 WETH is then
// swapped for at least 2,000 DAI.
beside: "a swap after",
commands: "0x2100",
inputs: [
encodeSubPlan("0x00", [
encodeV3SwapExactIn(ROUTER_ADDR, 2000000000n, ONE_WETH, [
USDC_ADDR,
WETH_ADDR,
]),
]),
encodeV3SwapExactIn(USER_ADDR, ONE_WETH, 2000n * ONE_WETH, [
WETH_ADDR,
DAI_ADDR,
]),
],
steps: "Execute Sub-Plan (its steps are not shown) \u2192 V3 Swap (Exact In)",
},
{
// A permit among the sub-plan's steps would replace this one.
beside: "a permit before",
commands: "0x0a21",
inputs: [encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR), "0x"],
steps: "Permit2 Permit \u2192 Execute Sub-Plan (its steps are not shown)",
},
{
// The sub-plan buys USDC for the user; an UNWRAP_WETH of 0
// follows. No step outside the sub-plan sets the output side, and
// UNWRAP_WETH would then make Token Out ETH.
beside: "an UNWRAP_WETH after",
commands: "0x210c",
inputs: [
encodeSubPlan("0x00", [
encodeV3SwapExactIn(USER_ADDR, 2000000000n, 1900000000n, [
USDT_ADDR,
USDC_ADDR,
]),
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH, same encoding
],
steps: "Execute Sub-Plan (its steps are not shown) \u2192 Unwrap WETH",
},
])(
"$beside an EXECUTE_SUB_PLAN step does not name either side",
({ commands, inputs, steps }) => {
const data = buildExecute(commands, inputs, 9999999999n);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(detail(result, "Steps").value).toBe(steps);
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, "Token Out").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Min. received")).toBeUndefined();
},
);
test("shows the deadline as a UTC date and time", () => {
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1");
expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");