Degen Code

Degen Code

Share this post

Degen Code
Degen Code
Uniswap V4 — Part IV: Universal Router
Copy link
Facebook
Email
Notes
More

Uniswap V4 — Part IV: Universal Router

Men Will Literally ABI Encode Multiple Nested Byte Arrays To Swap Memecoins Instead Of Going To Therapy

Feb 14, 2025
∙ Paid
1

Share this post

Degen Code
Degen Code
Uniswap V4 — Part IV: Universal Router
Copy link
Facebook
Email
Notes
More
Share

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.

Uniswap — Universal Router

Uniswap — Universal Router

BowTiedDevil
·
March 6, 2023
Read full story

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.

Low Level EVM — Part III: Memory

Low Level EVM — Part III: Memory

BowTiedDevil
·
Jan 24
Read full story

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 two uint128 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 like address, 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 the ExactInputParams tuple is the following:
    [1 word for address]
    [1 word for offset to PoolKey array length]
    [1 word for uint128 amountIn]
    [1 word for uint128 amountOutMinimum]
    [1 word for the length of the PoolKey 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 encoded ExactInputParams starts. It does this by first identifying the offset of the calldata via params.offset. The calldata includes the encoded data for ExactInputParams, which is internally offset as described above. Thus, adding both offsets will reveal the absolute memory offset where the actual value of ExactInputParams 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):

Encoding and Decoding Calldata with eth_abi

Encoding and Decoding Calldata with eth_abi

BowTiedDevil
·
August 20, 2022
Read full story

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):

This post is for paid subscribers

Already a paid subscriber? Sign in
© 2025 BowTiedDevil
Privacy ∙ Terms ∙ Collection notice
Start writingGet the app
Substack is the home for great culture

Share

Copy link
Facebook
Email
Notes
More