fix: a step the router lets fail is marked, and a balance check it lets fail sets no minimum (closes #602)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

Each Universal Router release now carries the command types its dispatch()
can return a failure for instead of reverting, read from its source; only a
step of one of those with the allow-revert flag (0x80) set can fail while
the transaction goes on. Such a step is listed with "(allowed to fail)". A
BALANCE_CHECK_ERC20 the router lets fail guarantees nothing, so it is read
as a zero check and keeps an earlier step's minimum. Every swap and every
payment of ETH or an ERC-20 token succeeds or reverts the whole transaction,
so the flag on them has no effect and they read as without it.

Model: opus-5-5
This commit is contained in:
2026-10-19 12:26:03 +00:00
parent 99269675b7
commit 14794a3931
4 changed files with 400 additions and 51 deletions
+95 -35
View File
@@ -24,55 +24,104 @@ const ROUTER_IFACE = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// 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.
// The Universal Router releases deployed at the addresses below, each with two
// things read from its source at the commit linked for it:
//
// 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
// - `commandTypeMask`, the mask it 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 its addresses.
// - `allowRevert`, the command types it lets fail without reverting the
// transaction. Its `execute()`, in `contracts/UniversalRouter.sol`, runs each
// step through `dispatch()`, in `contracts/base/Dispatcher.sol`, and reverts
// the whole transaction when a step fails, unless the step's command byte
// has the allow-revert flag set: then it goes on to the next step. Only for
// these command types does `dispatch()` return a failure. Every other
// command, among them each V2, V3 and V4 swap and each payment of ETH or an
// ERC-20 token, succeeds or reverts the whole transaction itself, so the
// flag has no effect on it.
//
// UniversalRouterV1: https://github.com/Uniswap/universal-router/tree/cda697da221e24ca143bc4f55023694a2a16796f/contracts
const ROUTER_V1 = {
commandTypeMask: 0x1f,
// Its NFT market calls (0x10 to 0x14, 0x18 to 0x1c) and NFT owner checks
// (0x15, 0x16).
allowRevert: [
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
],
};
// UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/tree/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts
const ROUTER_V1_2 = {
commandTypeMask: 0x3f,
// BALANCE_CHECK_ERC20 (0x0e), its NFT market calls (0x10 to 0x13, 0x18 to
// 0x1c, 0x1e, 0x20), its NFT owner checks (0x15, 0x16) and
// EXECUTE_SUB_PLAN (0x21).
allowRevert: [
0x0e, 0x10, 0x11, 0x12, 0x13, 0x15, 0x16, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
0x1e, 0x20, 0x21,
],
};
// UniversalRouterV2: https://github.com/Uniswap/universal-router/tree/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts
const ROUTER_V2 = {
commandTypeMask: 0x3f,
// PERMIT2_PERMIT_BATCH (0x03), PERMIT2_PERMIT (0x0a), BALANCE_CHECK_ERC20
// (0x0e), V3_POSITION_MANAGER_PERMIT, V3_POSITION_MANAGER_CALL,
// V4_INITIALIZE_POOL and V4_POSITION_MANAGER_CALL (0x11 to 0x14), and
// EXECUTE_SUB_PLAN (0x21).
allowRevert: [0x03, 0x0a, 0x0e, 0x11, 0x12, 0x13, 0x14, 0x21],
};
// UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/tree/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts
// UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/tree/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts
// The three files are the same in both releases.
const ROUTER_V2_1 = {
commandTypeMask: 0x7f,
// The same as UniversalRouterV2's.
allowRevert: ROUTER_V2.allowRevert,
};
// The Universal Router deployments on each network, by chain id, copied from
// the deployment files Uniswap publishes, each address named by its key there
// and given with the release it runs. The files also list
// `UnsupportedProtocol`, which is not a router.
const ROUTER_ADDRESSES = new Map([
[
NETWORKS.mainnet.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json
[
["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", 0x1f], // UniversalRouterV1
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", 0x3f], // UniversalRouterV2
["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", 0x7f], // UniversalRouterV2_1_1
["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", 0x7f], // UniversalRouterV2_1_2
["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", ROUTER_V1], // UniversalRouterV1
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support
["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", ROUTER_V2], // UniversalRouterV2
["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", ROUTER_V2_1], // UniversalRouterV2_1_1
["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", ROUTER_V2_1], // UniversalRouterV2_1_2
],
],
[
NETWORKS.sepolia.chainId,
// https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json
[
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", 0x3f], // UniversalRouterV1_2_V2Support
["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", 0x3f], // UniversalRouterV2
["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", 0x7f], // UniversalRouterV2_1_1
["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", 0x7f], // UniversalRouterV2_1_2
["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support
["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", ROUTER_V2], // UniversalRouterV2
["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", ROUTER_V2_1], // UniversalRouterV2_1_1
["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", ROUTER_V2_1], // UniversalRouterV2_1_2
],
],
]);
// 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) {
// The release that the Universal Router deployment at `address` on the
// network with this chain id runs, or null when `address` is not one of them.
// Address case is ignored.
function routerRelease(address, chainId) {
const routers = ROUTER_ADDRESSES.get(chainId) || [];
const wanted = (address || "").toLowerCase();
const router = routers.find(([a]) => a.toLowerCase() === wanted);
return router ? router[1] : null;
}
// The allow-revert flag on a command byte (see ROUTER_V1).
const ALLOW_REVERT_FLAG = 0x80;
// The Uniswap V2 factory and pair init code hash the Universal Routers on each
// network were deployed with. A V2 swap step swaps through the pair its path
// starts at, which the router finds as `UniswapV2Library.pairFor()` does: the
@@ -123,7 +172,7 @@ function v2PairFor(chainId, tokenA, tokenB) {
}
// Universal Router command types, each read from its command byte with the
// router's mask (see ROUTER_ADDRESSES). Only the routers with the 0x7f mask
// router's mask (see ROUTER_V1). Only the routers with the 0x7f mask
// have 0x07 and 0x40: 0x40 is above the other masks, and the other routers
// revert on 0x07, so decode() names it there as it names a command not
// listed here.
@@ -856,8 +905,9 @@ 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, fromAddress) {
const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
if (commandTypeMask === null) return null;
const release = routerRelease(toAddress, chainId);
if (release === null) return null;
const { commandTypeMask, allowRevert } = release;
try {
const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null;
@@ -986,6 +1036,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & commandTypeMask;
// The router goes on to the next step if this one fails (see
// ROUTER_V1). The flag on any other command has no effect.
const allowedToFail =
(commandsBytes[i] & ALLOW_REVERT_FLAG) !== 0 &&
allowRevert.includes(cmdId);
// Only the routers with the 0x7f mask have command 0x07 (see
// COMMAND_NAMES).
const name =
@@ -1034,11 +1089,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const b = decodeBalanceCheck(inputs[i]);
// The router passes this check whenever the owner holds at
// least minBalance, so a zero one guarantees nothing and
// does not replace a minimum an earlier step stated. Any
// other minBalance sets the output side as a swap does.
// It pays nobody.
if (b && !(b.minBalance === 0n && present(minOutput))) {
setOutput(b.token, b.minBalance);
// does not replace a minimum an earlier step stated. A
// check the router lets fail guarantees nothing either, so
// it is read as a zero one. Any other minBalance sets the
// output side as a swap does. It pays nobody.
const minBalance = b && !allowedToFail ? b.minBalance : 0n;
if (b && !(minBalance === 0n && present(minOutput))) {
setOutput(b.token, minBalance);
}
} else if (cmdId === 0x00) {
const s = decodeV3SwapExactIn(inputs[i]);
@@ -1225,6 +1282,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// Skip commands we can't decode. One may pay any address.
payeeUnread = true;
}
if (allowedToFail) {
commandNames[i] += " (allowed to fail)";
}
}
// The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens