Uint32
Understanding Uniswap V4 lock and callback mechanism

Understanding Uniswap V4 lock and callback mechanism

This series looks at Uniswap V4 mechanism design in detail and explores how teams are building on top of it.

In This Post:

Background knowledge

Uniswap V4 introduces hooks. Hooks allow custom code execution at specific points in the Uniswap execution lifecycle. For example, A hook can run after every swap and mint some reward tokens. Although hooks can execute arbitrary code, the PoolManager (PoolManager.sol) implements a locking mechanism to enforce atomic state changes and proper accounting.

Types of Contracts in Uniswap V4:

Core Contracts

The core consists of a singleton PoolManager contract along with core functionality libraries. The core contains all the logic and state that makes the protocol function. The singleton design of V4 is a significant upgrade on the V3 factory pattern because it is much more gas-efficient when making multiple swap via Flash accounting.

Periphery Contracts

No real change from V3; See V3 docs. Periphery contracts are contracts that support interactions with the core. Periphery contracts have no special privileges; anyone can write their own periphery. The most common use case for periphery contracts is router contracts. A router acts as an interface, allowing users to swap tokens easily from an externally owned address (EOA). Sometimes, we will use periphery and router interchangeably, but note a router is a specific type of periphery contract.

Hook Contracts

Hooks contracts have special privileges. They can also act as periphery contracts in some cases. For example you might interact directly with a hook and have the hook manage your funds and interactions with the PoolManager. A hook can operate on many pools, but a pool can only have one hook, which must be declared during the pool initialisation. A hook is permissionless by default so you can attach any third-party hook to a pool at initialisation. For this reason, it is best practice to write pool-agnostic hooks.

Hook Overview

A pool has one hook contract, but that hook can be executed at multiple places, giving a lot of flexibility to hook developers.

//Hook entery points
beforeInitialize
afterInitialize
beforeAddLiquidity
beforeRemoveLiquidity
afterAddLiquidity
afterRemoveLiquidity
beforeSwap
afterSwap
beforeDonate
afterDonate
//Custom accouting or delta entry points
beforeSwapReturnDelta
afterSwapReturnDelta
afterAddLiquidityReturnDelta
afterRemoveLiquidityReturnDelta

NoOp or Custom Accounting Hooks

The name of this one has changed a few times. Sometimes, they are called NoOp or Custom Accounting Hooks. These particular types of hooks use the delta flags to implement custom accounting. This reduces the security guarantees the lock provides but allows almost unlimited customisation. With custom accounting, you can implement things like custom fees and curves. In a sense, these hooks entirely override the core functionality of Uniswap while maintaining pool compatibility by adhering to the Uniswap interfaces. By maintaining compatibility, they can use the networks of Uniswap liquidity and solvers while writing their own AMM designs from scratch.

V4 Lock

With the background out of they way let's go back to the lock design. Conceptually, the V4 lock mechanism is similar to the V3 reentrancy lock but with added accounting checks. However, including a callback to inject custom logic into the execution is quite different. Here is how it works.

If you look at the docs, the lock mechanism part needs to be updated. The developers recently updated the lock to use transient storage, introduced in Solidity 0.8.24. This change massively simplifies the lock code, as we no longer need to keep track of locks within a mapping. Instead the transient storage only persists for the duration of the transaction. At the start of the transaction IS_UNLOCKED_SLOT is false by default. Even if set to true, the state change does not persist after the transaction, so it automatically resets to false after the transaction. That's ideal because we only care about the lock state for the current transaction.

Right now, transient storage can only be used with assembly, although we expect it to be introduced into standard Solidity in the future. lock.sol acts as a temporary library in the meantime.

//lock.sol
/// @notice This is a temporary library that allows us to use transient storage (tstore/tload)
/// TODO: This library can be deleted when we have the transient keyword support in solidity.
library Lock {
    // The slot holding the unlocked state, transiently. bytes32(uint256(keccak256("Unlocked")) - 1)
    bytes32 constant IS_UNLOCKED_SLOT = 0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;

    function unlock() internal {
        assembly {
            // unlock
            tstore(IS_UNLOCKED_SLOT, true)
        }
    }

    function lock() internal {
        assembly {
            tstore(IS_UNLOCKED_SLOT, false)
        }
    }

    function isUnlocked() internal view returns (bool unlocked) {
        assembly {
            unlocked := tload(IS_UNLOCKED_SLOT)
        }
    }
}

Back to the PoolManager contract. Functions that change balances, use the onlyWhenUnlocked() modifier, which requires the caller to unlock the PoolManager before execution.

// poolManger.sol
modifier onlyWhenUnlocked() {
    if (!Lock.isUnlocked()) ManagerLocked.selector.revertWith();
    _;
}

Unlocking the PoolManager

Calling the unlock() function unlocks the PoolManager.

// poolManger.sol
function unlock(bytes calldata data)
  external
  override
  noDelegateCall
  returns (bytes memory result) {
    if (Lock.isUnlocked()) revert AlreadyUnlocked();
  
    Lock.unlock();
    
    // The caller does everything they want inside this callback
    result = IUnlockCallback(msg.sender).unlockCallback(data);
   
    if (NonZeroDeltaCount.read() != 0) revert CurrencyNotSettled();
    Lock.lock();
}

If the lock is already unlocked, it will revert to prevent reentrancy, and at the end, we check that all balance deltas are non-zero.

if (NonZeroDeltaCount.read() != 0) revert CurrencyNotSettled();

In other words we check that the value in is equal to the value out. We will go into more detail on this later. If the balances are settled correctly, the lock is locked again, and execution is successful; if not, we revert.

To use core functions like swap, donate, and modifyLiquidity, they must be called from within the unlock() while the lock is unlocked. This is achieved using a callback mechanism. The data for execution is passed to the unlockCallback() and then called on the msg.sender which is the hook or periphery contract address.

result = IUnlockCallback(msg.sender).unlockCallback(data);

Any contract that calls the unlock function must implement the IUnlockCallback interface, including the unlockCallback() function.

interface IUnlockCallback {
    /// @notice Called by the pool manager on `msg.sender` when the manager is unlocked
    /// @param data The data that was passed to the call to unlock
    /// @return Any data that you want to be returned from the unlock call
    function unlockCallback(bytes calldata data) external returns (bytes memory);
}

For example, if you are building a router, the data passed to unlock could contain swap parameters, and you could implement a call to swap() on the poolManager inside the unlockCallback() function.

It is best practise to only to allow the PoolManager to call the unlockCallback(). Full code here.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import {IUnlockCallback} from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {ImmutableState} from "./ImmutableState.sol";

abstract contract SafeCallback is ImmutableState, IUnlockCallback {
    error NotManager();

    modifier onlyByManager() {
        if (msg.sender != address(manager)) revert NotManager();
        _;
    }

    /// @dev We force the onlyByManager modifier by exposing a virtual function after the onlyByManager check.
    function unlockCallback(bytes calldata data) external onlyByManager returns (bytes memory) {
        return _unlockCallback(data);
    }

    function _unlockCallback(bytes calldata data) internal virtual returns (bytes memory);
}
Understanding the unlock and callback pattern is essential to building hooks and router contracts. unlock simple

Above is a abstract description showing how an unlock callback can be implemented in any smart contract.

Notice that because the msg.sender must implement the callback function, externally owned addresses (EOAs) cannot directly swap on the PoolManager and must go through a periphery contract.

Below is a typical hook and router implementation. The router unlocks the PoolManager and calls swap inside the unlockCallback(). After the swap, the afterSwap() function is called, and the hook code is executed. Because it all happens within the lock, correct accounts are maintained.

Following swap flow diagram taken from @haardikkk & @AtriumAcademy

unlock simple

Summary of Uniswap V4 Mechanism Design:

  • Custom Logic in Hooks: Developers can add custom logic in hooks, which execute at specific points in the Uniswap lifecycle.
  • Lock Mechanism: Hook code is wrapped in a lock to prevent reentrancy and track balance deltas, ensuring atomic state changes.
  • Delta Tracking: Multiple balance changes can occur within a transaction, provided the deltas are zeroed out at the end.
  • Custom Delta Accounting: Special hooks can implement their own delta accounting.
  • Transient Storage: Transient storage (introduced in Solidity 0.8.24) simplifies lock management by automatically resetting the lock state after each transaction.
  • Unlock and Callback Pattern: Core functions like swap, donate, and modifyLiquidity must be called within an unlocked state via a callback. The unlock function initiates this state, and any contract using it must implement the IUnlockCallback interface with the unlockCallback() function. This ensures secure and correct execution of core functions while maintaining proper accounting.

Lock Control flow:

The unlock function follows these steps:

  1. Checks if the lock is already unlocked and reverts if true.
  2. Unlocks the contract.
  3. Executes unlockCallback(data) on the caller, enabling core function calls while unlocked.
  4. Ensures all balance deltas are zero.
  5. Relocks the contract.

We glossed over it until this point, but Uniswap V4 introduces a new type of accounting mechanism called Flash Accounting, which works with deltas. In the next post we will go through deltas and flash accounting in details.

In the meantime, hopefully, you now understand the unlock function, how it serves as the main entry point into the PoolManager and, at a high level, how you can integrate custom hooks into your pools.