Uniswap V4 — Part IV: Universal Router
Men Will Literally ABI Encode Multiple Nested Byte Arrays To Swap Memecoins Instead Of Going To Therapy
Given the complexity of swapping through the PoolManager, you might choose to swap with the Router instead and just let the gigabrains at Uniswap Labs handle the details. This lesson covers the new functionality of the Universal Router with respect to V4 functionality.
Universal Router Architecture
The Router contracts provided for Uniswap V2 and V3 were tightly coupled to their associated deployment. External functions of the V2 & V3 Routers were essentially wrappers that executed a series of scripted actions on behalf of the user, often calling the underlying Factory and Pool contracts directly.
Universal Router handles operations differently. Instead of wrapping “canned” sequences of operations, it provides a flexible interface for the caller to define the actions and the ordering of their execution.
Universal Router is built to provide a backwards-compatible interface to earlier deployments. Universal Router was introduced after V3, and supported V2 and V3 at launch.
I covered the V3 Universal Router here before, so check it out if you need a refresher.
The V4 deployment comes with an upgrade of the Universal Router which now supports V2, V3, and V4.
The V4-specific Router functionality can be found in the src folder of the v4-periphery Github repository.
V4Router
The top level contract is V4Router.sol, which inherits from several libraries and interfaces. It is declared as an abstract contract, it cannot be deployed by itself. Its functions and methods are only available to contracts that inherit from it:
/// @title UniswapV4Router
/// @notice Abstract contract that contains all internal logic needed
/// for routing through Uniswap v4 pools
/// @dev the entry point to executing actions in this contract is
/// calling `BaseActionsRouter._executeActions`
/// An inheriting contract should call _executeActions at the point that
/// they wish actions to be executed
abstract contract V4Router is IV4Router, BaseActionsRouter, DeltaResolver {
using SafeCast for *;
using CalldataDecoder for bytes;
using BipsLibrary for uint256;
constructor(
IPoolManager _poolManager
) BaseActionsRouter(_poolManager) {}
[...]
}
IV4Router
The interface contract IV4Router.sol defines four key structs:
/// @notice Parameters for a single-hop exact-input swap
struct ExactInputSingleParams {
PoolKey poolKey;
bool zeroForOne;
uint128 amountIn;
uint128 amountOutMinimum;
bytes hookData;
}
/// @notice Parameters for a multi-hop exact-input swap
struct ExactInputParams {
Currency currencyIn;
PathKey[] path;
uint128 amountIn;
uint128 amountOutMinimum;
}
/// @notice Parameters for a single-hop exact-output swap
struct ExactOutputSingleParams {
PoolKey poolKey;
bool zeroForOne;
uint128 amountOut;
uint128 amountInMaximum;
bytes hookData;
}
/// @notice Parameters for a multi-hop exact-output swap
struct ExactOutputParams {
Currency currencyOut;
PathKey[] path;
uint128 amountOut;
uint128 amountInMaximum;
}
Actions
The contract inherits from BaseActionsRouter.sol, which defines some errors, some functions for executing V4-specific operations, and an _unlockCallback
function that can decode the required actions passed as calldata and execute them.
Calldata Decoding
The decoding of calldata is defined in the library contract CalldataDecoder.sol. The functions in the library are broadly concerned with unpacking bytes into higher level user-defined types and/or values. I will not cover each one here, but will give an example of how one works — all of the decoding functions operate on a similar philosophy, but the structure of the calldata and the associated operations being decoded will differ.
/// @dev equivalent to: abi.decode(params, (IV4Router.ExactInputParams))
function decodeSwapExactInParams(bytes calldata params)
internal
pure
returns (IV4Router.ExactInputParams calldata swapParams)
{
// ExactInputParams is a variable length struct so we just have to
// look up its location
assembly ("memory-safe") {
// only safety checks for the minimum length, where path is
// empty
// 0xa0 = 5 * 0x20 -> 3 elements, path offset, and path length 0
if lt(params.length, 0xa0) {
mstore(0, SLICE_ERROR_SELECTOR)
revert(0x1c, 4)
}
swapParams := add(params.offset, calldataload(params.offset))
}
}
The decodeSwapExactInParams
function will decode a bytes array to the ExactInputParams
struct, which is defined in the IV4Router
interface listed above.
It decodes the bytes array using inline assembly, which you can probably parse if you’ve read the Low Level EVM Series entry for Memory.
Here is a summary of the steps taken by this function:
Check if the length of the
params
bytes array is less than the minimum for a minimally defined exact input swap of two currencies, an empty path array, and twouint128
values. A dynamic data type like a tuple is ABI encoded as a bytes array with minimum length of 2 words — 1 word for the offset where the encoded data starts, 1 word for the length of the encoded data, and 1 word for the encoded data (if present). Values are encoded in order — value types likeaddress
,uint
,bool
, etc. are encoded directly in successive 32-byte chunks, and dynamic sub-types are marked with an offset. After all offsets and values have been encoded, the dynamic sub-types are encoded at their offsets.
ABI encoding of theExactInputParams
tuple is the following:
[1 word foraddress
]
[1 word for offset toPoolKey
array length]
[1 word foruint128
amountIn]
[1 word foruint128
amountOutMinimum]
[1 word for the length of thePoolKey
array]
[empty]
Therefore the minimum length is 5 words. 5 * 32 bytes = 160 = 0xa0 in hex.If the length is less than the minimum, store the
SLICE_ERROR_SELECTOR
offset in memory and then revert with the associated message.Otherwise, create a memory variable
swapParams
that represents the memory offset where the encodedExactInputParams
starts. It does this by first identifying the offset of the calldata viaparams.offset
. The calldata includes the encoded data forExactInputParams
, which is internally offset as described above. Thus, adding both offsets will reveal the absolute memory offset where the actual value ofExactInputParams
begins.The Solidity function then returns the
ExactInputParams
data starting at this offset.
If the last point was confusing to you, please refer to the Solidity documentation on Assembly which describes how local variables hold a pointer to a memory offset, not the value itself. This is why the Yul manipulation of swapParams
results in the encoded value being returned, and not the offset itself as the ADD
opcode would suggest.
The process above is a heavily gas-optimized method that allows a higher level function, _handleAction, to extract only the ExactInputParams
struct from a generic calldata bytes array.
The _handleAction
function will receive ABI-encoded calldata with the function selector, a uint256 value representing a specific action, and a blob of bytes for the action parameters. Each code block below will identify the action, decode the parameters for that action in the special handling block as demonstrated above, then perform the action with the decoded parameters.
function _handleAction(uint256 action, bytes calldata params)
internal override {
// swap actions and payment actions in different blocks for gas
// efficiency
if (action < Actions.SETTLE) {
if (action == Actions.SWAP_EXACT_IN) {
IV4Router.ExactInputParams calldata swapParams = (
params.decodeSwapExactInParams();
)
_swapExactInput(swapParams);
return;
} else if (action == Actions.SWAP_EXACT_IN_SINGLE) {
IV4Router.ExactInputSingleParams calldata swapParams = (
params.decodeSwapExactInSingleParams();
)
_swapExactInputSingle(swapParams);
return;
} else if (action == Actions.SWAP_EXACT_OUT) {
IV4Router.ExactOutputParams calldata swapParams = (
params.decodeSwapExactOutParams();
)
_swapExactOutput(swapParams);
return;
} else if (action == Actions.SWAP_EXACT_OUT_SINGLE) {
IV4Router.ExactOutputSingleParams calldata swapParams = (
params.decodeSwapExactOutSingleParams();
)
_swapExactOutputSingle(swapParams);
return;
}
[...]
}
Luckily for us, the purpose of each decoder is encapsulated in the dev comment above each function definition:
/// @dev equivalent to: abi.decode(params, (IV4Router.ExactInputParams))
Instead of inspecting and rewriting Yul by hand, we can simply use the definitions with the familiar eth_abi module to encode or decode the parameters.
If this sounds like gibberish to you, refer to my lesson on Encoding and Decoding Calldata (because you’re going to need it):
V4 Swapping With Universal Router
To this point I’ve focused the study on V4Router.sol, but we need to zoom out to consider the ultimate consumer of this abstract contract, UniversalRouter.sol, which is held in the contracts directory of the univeral-router Github repository.
UniversalRouter.sol inherits from several other contracts that give it the ability to interact with the pools and factory contracts that comprise Uniswap V2, V3, and V4:
contract UniversalRouter is IUniversalRouter, Dispatcher {
constructor(RouterParameters memory params)
UniswapImmutables(
UniswapParameters(
params.v2Factory,
params.v3Factory,
params.pairInitCodeHash,
params.poolInitCodeHash
)
)
V4SwapRouter(params.v4PoolManager)
PaymentsImmutables(
PaymentsParameters(
params.permit2,
params.weth9
)
)
MigratorImmutables(
MigratorParameters(
params.v3NFTPositionManager,
params.v4PositionManager
)
)
{}
[...]
}
The UniswapImmutables contract provides methods to deploy and interact with V2 & V3 pools, V4SwapRouter provides the methods described above, PaymentsImmutables provides methods to wrap & unwrap WETH and perform Permit2 approvals, and MigratorImmutables provides methods to move liquidity positions from V3 to V4.
The V2 & V3 functionality is not relevant to today’s purpose and not covered here. That functionality is largely unchanged from V3, so any work you’ve done with that version can be ported to this one.
execute
The main points of interaction for Universal Router are the execute functions (1, 2):
Keep reading with a 7-day free trial
Subscribe to Degen Code to keep reading this post and get 7 days of free access to the full post archives.