If you didn’t click the link to read the Universal Router Press Release in the previous post, please do that now.
We’re no strangers to routers (you know the rules and so do I) so this post will be dedicated to exploring the new router and studying how to decode transactions that pass through it.
It’s a relatively straightforward contract, designed as a generic interface to allow V2/V3 AMM swaps in addition to NFT interactions with Opensea, Blur, X2Y2 etc.
I don’t have a lot of interest in the NFT side (yet), but do have avested interest in decoding the V2/V3 interactions so that my mempool bot and the UniswapTransaction
helper can process swaps running through the Universal Router.
The Universal Router is deployed to Ethereum mainnet at address 0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B. One very simple thing about this contract is that it only has one external function: execute
. This function accepts a byte string of commands, followed by a dynamic payloads array of bytes.
Inside the execute function, you’ll find a very simple loop that sequentially processes each command and input:
/// @inheritdoc Dispatcher
function execute(bytes calldata commands, bytes[] calldata inputs) public payable override isNotLocked {
bool success;
bytes memory output;
uint256 numCommands = commands.length;
if (inputs.length != numCommands) revert LengthMismatch();
// loop through all given commands, execute them and
// pass along outputs as defined
for (uint256 commandIndex = 0; commandIndex < numCommands;) {
bytes1 command = commands[commandIndex];
bytes calldata input = inputs[commandIndex];
(success, output) = dispatch(command, input);
if (!success && successRequired(command)) {
revert ExecutionFailed(
{commandIndex: commandIndex, message: output}
);
}
unchecked {
commandIndex++;
}
}
}
All we need to do is figure out how to decode commands
and inputs
, then we’re done!
Decoding Commands
This contract determines the number of commands by checking the length of commands
. Then, starting at zero, it peels off each command (a bytes1
) and executes it. Given that a single byte is 8 bits, the total number of unique commands is 2^8 = 256.
The commands are defined in a library named Commands.sol. From that contract, here are four commands that interest us:
uint256 constant V3_SWAP_EXACT_IN = 0x00;
uint256 constant V3_SWAP_EXACT_OUT = 0x01;
uint256 constant V2_SWAP_EXACT_IN = 0x08;
uint256 constant V2_SWAP_EXACT_OUT = 0x09;
Decoding Inputs
The inputs are a bit harder to decode, but still familiar given that we’ve already learned how to decode multicall payloads sent to the V3 router.
The individual command payloads are defined in a contract called Dispatcher.sol.
It does some inline Yul assembly to optimize the decoding of calldata, but we can simply take the helpful comments and pass them to eth_abi.decode
almost without thinking.
For example, let’s start with command 0x08
, aka V2_SWAP_EXACT_IN
. The contract defines that as:
if (command == Commands.V2_SWAP_EXACT_IN) {
// equivalent: abi.decode(inputs, (address, uint256, uint256, bytes, bool))
address recipient;
uint256 amountIn;
uint256 amountOutMin;
bool payerIsUser;
assembly {
recipient := calldataload(inputs.offset)
amountIn := calldataload(add(inputs.offset, 0x20))
amountOutMin := calldataload(add(inputs.offset, 0x40))
// 0x60 offset is the path, decoded below
payerIsUser := calldataload(add(inputs.offset, 0x80))
}
address[] calldata path = inputs.toAddressArray(3);
address payer = payerIsUser ? lockedBy : address(this);
v2SwapExactInput(
map(recipient), amountIn, amountOutMin, path, payer
);
}
You can basically ignore everything and look at the comment. Assuming that we are using the same variable name, the appropriate Python equivalent is:
if command == "V2_SWAP_EXACT_IN":
(
recipient,
amountIn,
amountOutMin,
path,
payerIsUser,
) = eth_abi.decode(
[
"address",
"uint256",
"uint256",
"address[]",
"bool",
],
inputs,
)
Simple! In fact it’s so simple I’ve already added the four V2/V3 functions to the degenbot UniswapTransaction helper, and included placeholders for the others.
Demo
If you feel like drinking from the fire hose of pending transactions to the Universal Router, run this simple watcher and see the swaps pour in:
universal_router_tx_watcher.py