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)", 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
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
the transaction could not be read (see Transaction Decoding below). A V4 step
with a swap action always sets the output side, and sets the input side only
when no earlier step set it. It finds each side by three rules:
the transaction could not be read or the transaction has an `EXECUTE_SUB_PLAN`
step (see Transaction Decoding below). A V4 step with a swap action always sets
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
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
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)`.
`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
+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 deployment files Uniswap publishes, each address named by its key there.
// 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([
[
NETWORKS.mainnet.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json
[
"0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", // UniversalRouterV1
"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", // UniversalRouterV1_2_V2Support
"0x66a9893cc07d91d95644aedd05d03f95e1dba8af", // UniversalRouterV2
"0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", // UniversalRouterV2_1_1
"0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", // UniversalRouterV2_1_2
["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", 0x1f], // UniversalRouterV1
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", 0x3f], // UniversalRouterV2
["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", 0x7f], // UniversalRouterV2_1_1
["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", 0x7f], // UniversalRouterV2_1_2
],
],
[
NETWORKS.sepolia.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json
[
"0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", // UniversalRouterV1_2_V2Support
"0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", // UniversalRouterV2
"0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", // UniversalRouterV2_1_1
"0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", // UniversalRouterV2_1_2
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", 0x3f], // UniversalRouterV2
["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", 0x7f], // UniversalRouterV2_1_1
["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", 0x7f], // UniversalRouterV2_1_2
],
],
]);
// Whether `address` is a Universal Router deployment on the network with this
// chain id. Address case is ignored.
function isRouterAddress(address, chainId) {
// The mask the Universal Router deployment at `address` on the network with
// this chain id reads a command's type with, or null when `address` is not one
// of them. Address case is ignored.
function routerCommandTypeMask(address, chainId) {
const routers = ROUTER_ADDRESSES.get(chainId) || [];
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 = {
0x00: "V3 Swap (Exact In)",
0x01: "V3 Swap (Exact Out)",
@@ -90,8 +105,10 @@ function formatAmount(raw, decimals) {
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.
// decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose
// 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)";
// 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
// contract does with it.
function decode(data, toAddress, chainId, sources) {
if (!isRouterAddress(toAddress, chainId)) return null;
const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
if (commandTypeMask === null) return null;
try {
const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null;
@@ -621,6 +639,7 @@ function decode(data, toAddress, chainId, sources) {
let hasUnwrapWeth = false;
let hasV2ExactOut = false;
let swapUndecoded = false;
let hasSubPlan = false;
const commandNames = [];
// 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++) {
const cmdId = commandsBytes[i] & 0x1f;
const cmdId = commandsBytes[i] & commandTypeMask;
commandNames.push(
COMMAND_NAMES[cmdId] ||
"Command 0x" + cmdId.toString(16).padStart(2, "0"),
);
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) {
const p = decodePermit2(inputs[i]);
// 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.
details.push({
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.
details.push({
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 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";
@@ -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", () => {
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1");
expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");