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

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)", and a Token In or
Token Out line that nothing named reads "Unknown (could not be decoded)",
since the sub-plan may name it.

Model: opus-5-5
This commit is contained in:
2026-10-19 03:40:18 +00:00
parent cb58e9d5f7
commit da6ca45450
3 changed files with 183 additions and 25 deletions
+13 -4
View File
@@ -1195,9 +1195,10 @@ 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, or `Unknown (could not be decoded)` instead when a V4 swap action in 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 the transaction could not be read or the transaction has an `EXECUTE_SUB_PLAN`
with a swap action always sets the output side, and sets the input side only step (see Transaction Decoding below). A V4 step with a swap action always sets
when no earlier step set it. It finds each side by three rules: the output side, and sets the input side only when no earlier step set it. It
finds each side by three rules:
1. Its input side is the currency its `SETTLE` or `SETTLE_ALL` names, or else 1. Its input side is the currency its `SETTLE` or `SETTLE_ALL` names, or else
the one currency that some swap action in the step spends and none buys. Its the one currency that some swap action in the step spends and none buys. Its
@@ -2888,7 +2889,15 @@ Currently supported:
in the parameter shape deployed on mainnet and Sepolia, not in the newer one 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 on `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in
any other shape is not read, and its step is listed as any other shape is not read, and its step is listed as
`V4 Swap (could not be decoded)`. `V4 Swap (could not be decoded)`. Each step is named by its command type,
which the decoder reads from the command byte with the mask the router at that
address uses: `0x1f` for `UniversalRouterV1`, `0x3f` for
`UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`. The allow-revert flag
(`0x80`) therefore does not change a step's name. The decoder does not read
the commands inside an `EXECUTE_SUB_PLAN` (`0x21`) step: that step is listed
as `Execute Sub-Plan (its steps are not shown)`, and what those commands spend
or receive is missing from the other lines.
### Non-Goals Forever ### Non-Goals Forever
+54 -20
View File
@@ -19,39 +19,54 @@ const ROUTER_IFACE = new Interface([
// The Universal Router deployments on each network, by chain id, copied from // The Universal Router deployments on each network, by chain id, copied from
// the deployment files Uniswap publishes, each address named by its key there. // the deployment files Uniswap publishes, each address named by its key there.
// The files also list `UnsupportedProtocol`, which is not a router. // The files also list `UnsupportedProtocol`, which is not a router.
//
// Beside each address is the mask that router reads a command's type with:
// its dispatcher runs `commandType & Commands.COMMAND_TYPE_MASK`, and the bits
// above the mask are the allow-revert flag (0x80) or ignored. The mask grew
// between releases. Each is `COMMAND_TYPE_MASK` in the release's
// `contracts/libraries/Commands.sol`, the same file as in the source verified
// for that address:
// - 0x1f, UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Commands.sol
// - 0x3f, UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/blob/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts/libraries/Commands.sol
// - 0x3f, UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/libraries/Commands.sol
// - 0x7f, UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/blob/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts/libraries/Commands.sol
// - 0x7f, UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/libraries/Commands.sol
const ROUTER_ADDRESSES = new Map([ const ROUTER_ADDRESSES = new Map([
[ [
NETWORKS.mainnet.chainId, NETWORKS.mainnet.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json // https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json
[ [
"0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", // UniversalRouterV1 ["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", 0x1f], // UniversalRouterV1
"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", // UniversalRouterV1_2_V2Support ["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
"0x66a9893cc07d91d95644aedd05d03f95e1dba8af", // UniversalRouterV2 ["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", 0x3f], // UniversalRouterV2
"0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", // UniversalRouterV2_1_1 ["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", 0x7f], // UniversalRouterV2_1_1
"0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", // UniversalRouterV2_1_2 ["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", 0x7f], // UniversalRouterV2_1_2
], ],
], ],
[ [
NETWORKS.sepolia.chainId, NETWORKS.sepolia.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json // https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json
[ [
"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", // UniversalRouterV1_2_V2Support ["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
"0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", // UniversalRouterV2 ["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", 0x3f], // UniversalRouterV2
"0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", // UniversalRouterV2_1_1 ["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", 0x7f], // UniversalRouterV2_1_1
"0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", // UniversalRouterV2_1_2 ["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", 0x7f], // UniversalRouterV2_1_2
], ],
], ],
]); ]);
// Whether `address` is a Universal Router deployment on the network with this // The mask the Universal Router deployment at `address` on the network with
// chain id. Address case is ignored. // this chain id reads a command's type with, or null when `address` is not one
function isRouterAddress(address, chainId) { // of them. Address case is ignored.
function routerCommandTypeMask(address, chainId) {
const routers = ROUTER_ADDRESSES.get(chainId) || []; const routers = ROUTER_ADDRESSES.get(chainId) || [];
const wanted = (address || "").toLowerCase(); const wanted = (address || "").toLowerCase();
return routers.some((router) => router.toLowerCase() === wanted); const router = routers.find(([a]) => a.toLowerCase() === wanted);
return router ? router[1] : null;
} }
// Universal Router command IDs (lower 5 bits of each command byte) // Universal Router command types, each read from its command byte with the
// router's mask (see ROUTER_ADDRESSES)
const COMMAND_NAMES = { const COMMAND_NAMES = {
0x00: "V3 Swap (Exact In)", 0x00: "V3 Swap (Exact In)",
0x01: "V3 Swap (Exact Out)", 0x01: "V3 Swap (Exact Out)",
@@ -90,8 +105,10 @@ function formatAmount(raw, decimals) {
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 // The same refusal when a V4 swap action could not be read (see
// decodeExactly()). That action names both of its currencies, so saying the // decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose
// calldata did not name one would be false. // commands this decoder does not read. That action names both of its
// currencies, and those commands may name one, so saying the calldata did not
// name it would be false.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; 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
@@ -603,7 +620,8 @@ function decodeV4Swap(input) {
// sent to any other contract is not a swap through the router, whatever that // sent to any other contract is not a swap through the router, whatever that
// contract does with it. // contract does with it.
function decode(data, toAddress, chainId, sources) { function decode(data, toAddress, chainId, sources) {
if (!isRouterAddress(toAddress, chainId)) return null; const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
if (commandTypeMask === null) return null;
try { try {
const parsed = ROUTER_IFACE.parseTransaction({ data }); const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null; if (!parsed) return null;
@@ -621,6 +639,7 @@ function decode(data, toAddress, chainId, sources) {
let hasUnwrapWeth = false; let hasUnwrapWeth = false;
let hasV2ExactOut = false; let hasV2ExactOut = false;
let swapUndecoded = false; let swapUndecoded = false;
let hasSubPlan = 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
@@ -660,13 +679,22 @@ function decode(data, toAddress, chainId, sources) {
}; };
for (let i = 0; i < commandsBytes.length; i++) { for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & 0x1f; const cmdId = commandsBytes[i] & commandTypeMask;
commandNames.push( commandNames.push(
COMMAND_NAMES[cmdId] || COMMAND_NAMES[cmdId] ||
"Command 0x" + cmdId.toString(16).padStart(2, "0"), "Command 0x" + cmdId.toString(16).padStart(2, "0"),
); );
try { try {
if (cmdId === 0x21) {
// EXECUTE_SUB_PLAN runs a further list of commands held in
// its input, which this decoder does not read. What those
// commands spend or receive is missing from every other
// line, so the step says so.
hasSubPlan = true;
commandNames[i] += " (its steps are not shown)";
}
if (cmdId === 0x0a) { if (cmdId === 0x0a) {
const p = decodePermit2(inputs[i]); const p = decodePermit2(inputs[i]);
// A permit states both halves itself, so it may replace an // A permit states both halves itself, so it may replace an
@@ -819,7 +847,10 @@ function decode(data, toAddress, chainId, sources) {
// screen must not describe the same condition in two ways. // screen must not describe the same condition in two ways.
details.push({ details.push({
label: "Token In", label: "Token In",
value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY, value:
swapUndecoded || hasSubPlan
? UNDECODED_CURRENCY
: UNNAMED_CURRENCY,
}); });
} }
@@ -885,7 +916,10 @@ function decode(data, toAddress, chainId, sources) {
// calldata did not state. // calldata did not state.
details.push({ details.push({
label: "Token Out", label: "Token Out",
value: swapUndecoded ? UNDECODED_CURRENCY : UNNAMED_CURRENCY, value:
swapUndecoded || hasSubPlan
? UNDECODED_CURRENCY
: UNNAMED_CURRENCY,
}); });
} }
+116 -1
View File
@@ -1,8 +1,12 @@
const { AbiCoder, Interface, solidityPacked } = require("ethers"); const { AbiCoder, Interface, solidityPacked } = require("ethers");
const uniswap = require("../src/shared/uniswap"); 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"; 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 USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
@@ -864,6 +868,117 @@ describe("uniswap decoder", () => {
); );
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/535 \u2014 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 = coder.encode(
["bytes", "bytes[]"],
[
"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 permit names the input side; the output side is in the sub-plan.
test("a permit, then an EXECUTE_SUB_PLAN step, keeps the permit's token and says the rest is not shown", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0a, 0x21]),
[encodePermit2(USDT_ADDR, 1000000n, ROUTER_ADDR), "0x"],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1");
expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Amount").value).toBe("1.0000 USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)",
);
expect(detail(result, "Steps").value).toBe(
"Permit2 Permit \u2192 Execute Sub-Plan (its steps are not shown)",
);
});
test("shows the deadline as a UTC date and time", () => { test("shows the deadline as a UTC date and time", () => {
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1"); const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1");
expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51"); expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");